aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ai/protocols.org10
-rw-r--r--claude-templates/.ai/protocols.org10
-rwxr-xr-xclaude-templates/bin/agent-text62
-rw-r--r--docs/design/2026-07-20-signal-pager-runbook.org113
-rw-r--r--docs/design/2026-08-19-agent-text-relay-fallback-decision.org155
-rwxr-xr-xscripts/signal-receive.sh6
-rw-r--r--scripts/tests/agent-text.bats101
7 files changed, 388 insertions, 69 deletions
diff --git a/.ai/protocols.org b/.ai/protocols.org
index 3e32074..bf0e6f7 100644
--- a/.ai/protocols.org
+++ b/.ai/protocols.org
@@ -469,13 +469,17 @@ Two channels, two trigger words. "page me" is the desktop, "text me" is the phon
agent-text "Message for Craig's phone"
#+end_src
- =agent-text= (in =~/.local/bin= via the rulesets install) sends from the dedicated Signal identity (+15045173983) to Craig's Signal account UUID, firing a normal mobile push. The account is registered on velox (primary) and ratio (linked device), so either sends directly; a machine without it ssh-relays to velox. Verified end to end 2026-07-13 (velox) and 2026-07-20 (ratio). Never target Craig's phone *number* (it reads as unregistered in Signal's directory); the script targets the UUID.
+ =agent-text= (in =~/.local/bin= via the rulesets install) sends from the dedicated Signal identity (+15045173983) to Craig's Signal account UUID, firing a normal mobile push. Never target Craig's phone *number* (it reads as unregistered in Signal's directory); the script targets the UUID.
- Caveats: a relay from a non-linked machine needs velox up on the tailnet, and each device holding the account wants a periodic =receive= (the signal-receive timer handles that). The full runbook lives in rulesets =docs/design/=.
+ *Which machine holds the account is a runtime fact, not a fixed one — derive it, don't assume it.* Any machine whose local =signal-cli= holds the identity sends directly, whether as the registered primary or as a linked device; a machine without it ssh-relays to the first relay host that does. Check the local half with =signal-cli listAccounts=. I write it this way because the old wording named velox as the permanent primary, and velox's 2026-08-13 reinstall wiped that registration — which broke the doc and the script together, since =agent-text= had the same host hardcoded as its only relay target.
+
+ The relay list lives in =AGENT_TEXT_RELAYS= at the top of the script, tried in order, and is overridable for a one-off (=AGENT_TEXT_RELAYS="host1 host2" agent-text "..."=). It skips any candidate whose short name matches =uname -n=: reaching the relay branch means the account is *not* local, so an ssh round trip to this machine lands on the same empty store and cannot succeed.
+
+ Caveats: a relay needs at least one listed host up on the tailnet *and* holding the account, and each device holding it wants a periodic =receive= (the signal-receive timer handles that). A relay to a host that is reachable but unregistered fails cleanly rather than silently — =signal-cli send= exits 1 with "User <number> is not registered", so the loop moves on to the next candidate (verified 2026-08-19). The full runbook lives in rulesets =docs/design/=.
- *"text and page me" — both.* Fire =agent-text= and =notify= together. The phone reaches him now, the desktop note waits for his return. This is the default when a run can't tell whether he's away.
-On velox, Claude sessions may also have the *signal-mcp* tool (=send_message_to_user=, same identity), fine to use there, but it exists only in velox's local MCP config, so =agent-text= is the portable habit. The tool was named =agent-page= before 2026-07-20; a deprecated =agent-page= shim still delegates to =agent-text=. Do *not* use the old =page-signal= shell script (removed 2026-06-12).
+A *signal-mcp* tool (=send_message_to_user=, same identity) has existed in one machine's local MCP config before now. Treat it as a nicety that may or may not be registered on the machine you're on — it was gone from velox after the 2026-08-13 reinstall — and use =agent-text= as the habit, since that one travels with the rulesets install. The tool was named =agent-page= before 2026-07-20; a deprecated =agent-page= shim still delegates to =agent-text=. Do *not* use the old =page-signal= shell script (removed 2026-06-12).
* Session Protocols
diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org
index 3e32074..bf0e6f7 100644
--- a/claude-templates/.ai/protocols.org
+++ b/claude-templates/.ai/protocols.org
@@ -469,13 +469,17 @@ Two channels, two trigger words. "page me" is the desktop, "text me" is the phon
agent-text "Message for Craig's phone"
#+end_src
- =agent-text= (in =~/.local/bin= via the rulesets install) sends from the dedicated Signal identity (+15045173983) to Craig's Signal account UUID, firing a normal mobile push. The account is registered on velox (primary) and ratio (linked device), so either sends directly; a machine without it ssh-relays to velox. Verified end to end 2026-07-13 (velox) and 2026-07-20 (ratio). Never target Craig's phone *number* (it reads as unregistered in Signal's directory); the script targets the UUID.
+ =agent-text= (in =~/.local/bin= via the rulesets install) sends from the dedicated Signal identity (+15045173983) to Craig's Signal account UUID, firing a normal mobile push. Never target Craig's phone *number* (it reads as unregistered in Signal's directory); the script targets the UUID.
- Caveats: a relay from a non-linked machine needs velox up on the tailnet, and each device holding the account wants a periodic =receive= (the signal-receive timer handles that). The full runbook lives in rulesets =docs/design/=.
+ *Which machine holds the account is a runtime fact, not a fixed one — derive it, don't assume it.* Any machine whose local =signal-cli= holds the identity sends directly, whether as the registered primary or as a linked device; a machine without it ssh-relays to the first relay host that does. Check the local half with =signal-cli listAccounts=. I write it this way because the old wording named velox as the permanent primary, and velox's 2026-08-13 reinstall wiped that registration — which broke the doc and the script together, since =agent-text= had the same host hardcoded as its only relay target.
+
+ The relay list lives in =AGENT_TEXT_RELAYS= at the top of the script, tried in order, and is overridable for a one-off (=AGENT_TEXT_RELAYS="host1 host2" agent-text "..."=). It skips any candidate whose short name matches =uname -n=: reaching the relay branch means the account is *not* local, so an ssh round trip to this machine lands on the same empty store and cannot succeed.
+
+ Caveats: a relay needs at least one listed host up on the tailnet *and* holding the account, and each device holding it wants a periodic =receive= (the signal-receive timer handles that). A relay to a host that is reachable but unregistered fails cleanly rather than silently — =signal-cli send= exits 1 with "User <number> is not registered", so the loop moves on to the next candidate (verified 2026-08-19). The full runbook lives in rulesets =docs/design/=.
- *"text and page me" — both.* Fire =agent-text= and =notify= together. The phone reaches him now, the desktop note waits for his return. This is the default when a run can't tell whether he's away.
-On velox, Claude sessions may also have the *signal-mcp* tool (=send_message_to_user=, same identity), fine to use there, but it exists only in velox's local MCP config, so =agent-text= is the portable habit. The tool was named =agent-page= before 2026-07-20; a deprecated =agent-page= shim still delegates to =agent-text=. Do *not* use the old =page-signal= shell script (removed 2026-06-12).
+A *signal-mcp* tool (=send_message_to_user=, same identity) has existed in one machine's local MCP config before now. Treat it as a nicety that may or may not be registered on the machine you're on — it was gone from velox after the 2026-08-13 reinstall — and use =agent-text= as the habit, since that one travels with the rulesets install. The tool was named =agent-page= before 2026-07-20; a deprecated =agent-page= shim still delegates to =agent-text=. Do *not* use the old =page-signal= shell script (removed 2026-06-12).
* Session Protocols
diff --git a/claude-templates/bin/agent-text b/claude-templates/bin/agent-text
index 86aa933..2eccb2e 100755
--- a/claude-templates/bin/agent-text
+++ b/claude-templates/bin/agent-text
@@ -5,31 +5,38 @@
#
# Usage: agent-text <message...>
#
-# The Signal identity (+15045173983) is registered in velox's signal-cli, and
-# any daily driver linked as a device of that account (ratio, 2026-07-20) can
-# send directly too. So the dispatch is: if the account is registered in the
-# local signal-cli, send directly; otherwise ssh-relay the send to velox over
-# the tailnet. A direct send from a linked device still lands when velox is
-# down (the reason ratio was linked). The recipient is Craig's Signal account
-# UUID; his phone number reads as unregistered in Signal's directory, so never
-# target the number. Verified end to end 2026-07-13 (velox) and 2026-07-20
-# (ratio, direct).
+# The Signal identity (+15045173983) is held by any daily driver that has it in
+# its local signal-cli, whether as the registered primary or as a linked device
+# (ratio, 2026-07-20). So the dispatch is: if the account is registered locally,
+# send directly; otherwise ssh-relay the send to the first relay host that
+# actually holds it — a reachable but unregistered host fails and the loop
+# advances. The recipient is Craig's Signal account UUID; his phone
+# number reads as unregistered in Signal's directory, so never target the
+# number. Verified end to end 2026-07-13 (velox) and 2026-07-20 (ratio,
+# direct). The relay path was verified velox -> ratio on 2026-08-16 by
+# archsetup, who reported the exact relay command returning a Signal
+# timestamp with rc 0; that one is on report, not re-run here.
#
# This is the AWAY channel. At his desk, use the desktop channel instead:
# notify info "Title" "Message" --persist
# See protocols.org "Reaching Craig" for choosing between them.
#
-# Known caveats (full runbook in rulesets docs/design/): a relay from a
-# non-linked machine needs velox up on the tailnet, and each device holding the
-# account wants a periodic `receive` (staleness warnings appear otherwise); the
-# signal-receive timer handles that.
+# Known caveats (full runbook in rulesets docs/design/): a relay needs at least
+# one listed host that is both up on the tailnet AND holding the account -- a
+# reachable host without it fails and the loop moves on. Each device holding the
+# account wants a
+# periodic `receive` (staleness warnings appear otherwise); the signal-receive
+# timer handles that.
#
# Source: ~/code/rulesets/claude-templates/bin/agent-text
# Install: make -C ~/code/rulesets install
SIGNAL_ACCOUNT="+15045173983"
CRAIG_UUID="b1b5601e-6126-47f8-afaa-0a59f5188fde"
-VELOX_HOST="velox.tailf3bb8c.ts.net"
+# Relay hosts, tried in order until one sends. ratio leads because it is the
+# always-on desktop at home, while velox is the laptop that travels and sleeps.
+# Override for a one-off with AGENT_TEXT_RELAYS="host1 host2".
+AGENT_TEXT_RELAYS="${AGENT_TEXT_RELAYS:-ratio.tailf3bb8c.ts.net velox.tailf3bb8c.ts.net}"
if [ $# -eq 0 ]; then
echo "usage: agent-text <message...>" >&2
@@ -37,21 +44,34 @@ if [ $# -eq 0 ]; then
fi
msg="$*"
+self="$(uname -n)"
# The account is local if this machine's signal-cli holds it: the registered
-# primary (velox) or any linked device. Those send directly.
+# primary or any linked device. Those send directly.
if signal-cli listAccounts 2>/dev/null | grep -q "$SIGNAL_ACCOUNT"; then
signal-cli -a "$SIGNAL_ACCOUNT" send -m "$msg" "$CRAIG_UUID"
rc=$?
+ why="local signal-cli send failed"
else
- # printf %q hardens the message for the remote shell.
- ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
- "$VELOX_HOST" \
- "signal-cli -a $SIGNAL_ACCOUNT send -m $(printf '%q' "$msg") $CRAIG_UUID"
- rc=$?
+ rc=1
+ why="no relay reachable in: $AGENT_TEXT_RELAYS"
+ for host in $AGENT_TEXT_RELAYS; do
+ # Never relay to this machine. Reaching this branch means the account is
+ # NOT local, so an ssh round trip to ourselves lands on the same empty
+ # signal-cli and cannot succeed. velox hit exactly that after its
+ # 2026-08-13 reinstall wiped the registration: the only relay target was
+ # velox, so the fallback pointed at the one machine guaranteed to fail.
+ [ "${host%%.*}" = "${self%%.*}" ] && continue
+ # printf %q hardens the message for the remote shell.
+ ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
+ "$host" \
+ "signal-cli -a $SIGNAL_ACCOUNT send -m $(printf '%q' "$msg") $CRAIG_UUID"
+ rc=$?
+ [ "$rc" -eq 0 ] && break
+ done
fi
if [ "$rc" -ne 0 ]; then
- echo "agent-text: phone message failed (velox down or unreachable?); fall back to the desktop channel: notify info 'Message' '<message>' --persist" >&2
+ echo "agent-text: phone message failed ($why); fall back to the desktop channel: notify info 'Message' '<message>' --persist" >&2
fi
exit "$rc"
diff --git a/docs/design/2026-07-20-signal-pager-runbook.org b/docs/design/2026-07-20-signal-pager-runbook.org
index f31ed18..393c1c6 100644
--- a/docs/design/2026-07-20-signal-pager-runbook.org
+++ b/docs/design/2026-07-20-signal-pager-runbook.org
@@ -7,26 +7,42 @@ phone, how his replies come back, how the account stays healthy, and the
signal-cli setup behind it. This is the Signal successor to the retired ntfy
runbook. Canonical home is rulesets because the pager is cross-machine tooling.
-* What the pager is
+* SUPERSEDED 2026-08-19 — the topology below is out of date
+
+velox's 2026-08-13 reinstall wiped its signal-cli registration, so everything
+this runbook says about velox holding the primary is now false. I am flagging
+it here rather than rewriting the whole document. Treat everything below as
+predating the 2026-08-19 change except where a dated note says otherwise: the
+send, reply-reading and setup sections all name velox specifically, and I have
+marked each one inline rather than asking you to remember this banner four
+screens later.
+
+Current, and stated as a derivation rather than a snapshot, since the snapshot
+form is exactly what rotted: *any* machine whose local signal-cli holds the
+identity sends directly, and a machine without it ssh-relays to the first
+listed host that does. Check with =signal-cli listAccounts=. As of 2026-08-19
+that is ratio only. See
+[[file:2026-08-19-agent-text-relay-fallback-decision.org]] for what broke, why
+re-registering velox was not the fix, and the measurements behind the current
+=agent-text= dispatch.
-One Signal identity, =+15045173983=, registered in *velox's* signal-cli
-(account file 465310, velox is the primary device). It is a dedicated pager
-number, not Craig's personal Signal. Pages go *from* that identity *to* Craig's
-own Signal account, which fires a normal mobile push on his phone.
+* What the pager is
-As of 2026-07-20 the identity spans two devices: velox (primary) and ratio
-(linked device "ratio-pager"). Any machine holding the account sends directly;
-a machine that doesn't relays to velox over the tailnet.
+One Signal identity, =+15045173983=. It is a dedicated pager number, not
+Craig's personal Signal. Pages go *from* that identity *to* Craig's own Signal
+account, which fires a normal mobile push on his phone.
Two constants the tooling depends on:
-- Pager account: =+15045173983= (primary on velox, linked on ratio).
+- Pager account: =+15045173983=.
- Recipient: Craig's Signal account UUID =b1b5601e-6126-47f8-afaa-0a59f5188fde=.
His phone *number* reads as unregistered in Signal's directory — always
target the UUID, never the number.
-velox is the laptop that travels with Craig, so the pager account rides with
-him; ratio holds it too, so a page still lands when velox is down.
+Which machines hold it is runtime state, not a constant — see the superseded
+note above. Historical record: as of 2026-07-20 the identity spanned velox
+(primary, account file 465310) and ratio (linked device "ratio-pager"). velox's
+half is gone.
* Choosing a channel
@@ -63,16 +79,26 @@ local signal-cli:
desktop fallback line and exits non-zero, so a caller can tell the page did not
land.
-The raw command it runs, for reference or a manual send from velox:
+*Superseded 2026-08-19.* The relay and failure bullets above (the last two)
+describe the single-host dispatch that was replaced. =agent-text= now walks an ordered list (=AGENT_TEXT_RELAYS=,
+ratio first), skips any candidate matching =uname -n=, and advances past a host
+that is reachable but does not hold the account. The failure line no longer
+blames velox by name and distinguishes a local send failure from relay
+exhaustion.
+
+The raw command it runs, for reference or a manual send from a machine that
+holds the account:
#+begin_src bash
signal-cli -a +15045173983 send -m "your message" b1b5601e-6126-47f8-afaa-0a59f5188fde
#+end_src
-From another machine, the same send relayed over the tailnet:
+From another machine, the same send relayed over the tailnet — *substitute a
+host that actually holds the account* (=signal-cli listAccounts=); as of
+2026-08-19 velox does not, so the example below is written against ratio:
#+begin_src bash
-ssh velox.tailf3bb8c.ts.net \
+ssh ratio.tailf3bb8c.ts.net \
"signal-cli -a +15045173983 send -m 'your message' b1b5601e-6126-47f8-afaa-0a59f5188fde"
#+end_src
@@ -87,11 +113,15 @@ queue until something receives it.
Drain the queue and read what is there:
+*Superseded 2026-08-19:* these named velox, which no longer holds the account.
+Run the drain on a machine that does — =signal-cli listAccounts= names it; as of
+2026-08-19 that is ratio.
+
#+begin_src bash
-# On velox:
+# On the machine holding the account:
signal-cli -a +15045173983 receive --timeout 10
-# From another machine:
-ssh velox.tailf3bb8c.ts.net "signal-cli -a +15045173983 receive --timeout 10"
+# From any other machine:
+ssh ratio.tailf3bb8c.ts.net "signal-cli -a +15045173983 receive --timeout 10"
#+end_src
=receive= prints every queued envelope and exits 0 once the queue drains or the
@@ -116,10 +146,21 @@ account is a reliability risk on the one channel that reaches Craig when he is
away.
The fix mirrors roam-sync: a systemd user timer that drains the queue on a
-cadence, keeping the account warm and, as a bonus, picking up async replies. With
-the account linked on both machines, each device wants its own regular receive,
-so the timer runs on *both* velox and ratio (the shared =common= dotfiles
-package, same home as roam-sync).
+cadence, keeping the account warm and, as a bonus, picking up async replies. Each
+device *holding* the account wants its own regular receive, so the timer runs on
+every daily driver (the shared =common= dotfiles package, same home as
+roam-sync). It no-ops cleanly where the account is *genuinely* absent, which is why velox
+running it since 2026-08-13 is harmless rather than an error — the 2026-07-20
+rationale here said "linked on both machines", which no longer holds even though
+the arrangement it justifies still does.
+
+*Do not read that no-op as proof of health.* The guard is
+=signal-cli listAccounts 2>/dev/null | grep -q=, which cannot tell "the account
+is not on this machine" from "listAccounts failed" — the redirect discards the
+error and grep simply finds nothing either way. On 2026-08-19 that was masking a
+real outage on ratio, the only machine holding the account: a PATH version skew
+made the timer run signal-cli 0.14.5 against a database 0.14.7 had upgraded, so
+every 15-minute fire reported success while receiving nothing, for 17 days.
- Script: =scripts/signal-receive.sh= (rulesets, so both machines get it on
=git pull=). It no-ops cleanly on a machine that lacks the account.
@@ -140,25 +181,35 @@ systemctl --user status signal-receive.service # confirm a clean receive
* signal-cli setup notes
- *Version:* signal-cli 0.14.5 on velox (2026-07-20).
-- *Accounts:* velox's signal-cli holds the pager identity =+15045173983= as the
- registered primary (account file 465310). ratio's signal-cli holds two
+- *Accounts:* /as of 2026-07-20; velox's half is gone — its 2026-08-13 reinstall
+ wiped the registration./ velox's signal-cli holds the pager identity
+ =+15045173983= as the registered primary (account file 465310). ratio's signal-cli holds two
accounts: Craig's personal number =+15103169357= (its own primary,
note-to-self only — no phone push) and the pager identity as a *linked device*
(Device 2, "ratio-pager", linked 2026-07-20). Both accounts coexist; target
the pager with =-a +15045173983=. A future daily driver joins the same way.
-- *signal-mcp:* on velox, Claude sessions may also expose a =signal-mcp= tool
- (=send_message_to_user=, same pager identity) configured in velox's global
- =~/.claude.json=. It works there but is invisible from any other machine and
- from non-Claude runtimes, so =agent-text= is the portable habit. The old
+- *signal-mcp:* /superseded 2026-08-19 — this said the tool is configured in
+ velox's global Claude config and "works there". It is not registered there any
+ more; that config now lists only =linear= and =slack-deepsat=./ Treat
+ =signal-mcp= as a per-machine nicety that may or may not be present, and use
+ =agent-text= as the portable habit. The old
=page-signal= shell script was removed 2026-06-12 — do not resurrect it.
-- *Linking a device:* to add a second signal-cli as a linked device of the pager
- account (see the open decision below), provision it from the new machine and
- approve the link from the account holder:
+- *Linking a device:* /superseded 2026-08-19 — nobody can run this today./ The
+ recipe below needs the *primary* to approve the link, and the primary lived on
+ velox whose keys the reinstall destroyed. A linked device cannot authorize
+ another, so ratio cannot approve one either. Until the number is re-registered
+ there is no machine that can execute this, and re-registering is not free — it
+ mints a new primary, drops ratio's existing link, and needs an SMS or voice
+ code delivered to the pager number, which is unrecoverable if that code cannot
+ be received. Read
+ [[file:2026-08-19-agent-text-relay-fallback-decision.org]] before attempting
+ it; it carries that tradeoff in full. Kept here as the procedure to follow
+ *once* a primary exists again:
#+begin_src bash
# On the new machine — prints a tsdevice:/ URI (render as QR to approve):
signal-cli link -n "ratio-pager"
- # Approve from velox (the primary device):
+ # Approve from the machine holding the PRIMARY registration:
signal-cli -a +15045173983 addDevice --uri "tsdevice:/?uuid=…"
#+end_src
diff --git a/docs/design/2026-08-19-agent-text-relay-fallback-decision.org b/docs/design/2026-08-19-agent-text-relay-fallback-decision.org
new file mode 100644
index 0000000..48dd572
--- /dev/null
+++ b/docs/design/2026-08-19-agent-text-relay-fallback-decision.org
@@ -0,0 +1,155 @@
+#+TITLE: agent-text relay fallback — decision record
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-08-19
+
+* What broke
+
+velox's 2026-08-13 reinstall wiped its =signal-cli= registration. Its account
+store came back empty.
+
+That alone would have cost velox one channel. =agent-text= made it cost the
+fleet, because the script hardcoded =VELOX_HOST= as its single ssh relay
+target. So a send from velox relayed to velox, hit the same empty store, and
+failed; and any third machine without a local account relayed into the same
+dead end. Only ratio still worked, and only because it holds the account as a
+linked device and takes the direct branch.
+
+The failure text said "velox down or unreachable?", which was actively
+misleading. velox was up and on the tailnet the whole time. It simply was not
+registered any more.
+
+* Why re-registering velox was not the fix
+
+velox held the *primary* registration, not a linked device, and its keys are
+gone. In Signal a linked device cannot authorize another device, so ratio
+(device 2) cannot link velox back. There is no QR to scan.
+
+Re-registering the number from velox would work, but it mints a new primary,
+drops ratio's link, and needs an SMS or voice code delivered to
++15045173983 — and if that code cannot be received, the identity is
+unrecoverable. That is real downside risk taken on to restore exactly the
+single-primary topology that had just proved fragile. I chose the relay
+repoint instead.
+
+* Two proposals, one chosen
+
+Both =.emacs.d= (2026-08-14) and archsetup (2026-08-16) diagnosed this
+independently and proposed replacing the constant with an ordered relay list.
+I took archsetup's, on one difference.
+
+=.emacs.d='s version tries each host in order and stops at the first success.
+On velox that works — but only because ratio happens to be listed first. It
+never skips *self*, so the failure it fixes is still reachable: a fleet whose
+list led with velox would relay velox to velox exactly as before. It repairs
+the instance rather than the defect.
+
+archsetup's adds the guard:
+
+: [ "${host%%.*}" = "$self" ] && continue
+
+Reaching the relay branch *means* the account is not local. So an ssh round
+trip to this machine lands on the same store that just came back empty and
+cannot succeed, whatever the list order. That is the defect; the ordering was
+the symptom.
+
+archsetup's also makes the list overridable (=AGENT_TEXT_RELAYS=), which is a
+genuine improvement over both the old constant and a fixed array — the fleet
+assumption can be stepped around per invocation without editing the script.
+
+* The question neither proposal had answered
+
+=.emacs.d= raised the sharpest point in either handoff and left it open: a
+relay host that is *reachable but unregistered* looks identical to success
+from the caller's side unless the exit code is checked. The loop breaks on
+=rc -eq 0=, so if =signal-cli send= exits zero against an empty account store,
+the loop reports success for a message that went nowhere — the worst possible
+outcome for a channel whose entire job is reaching me when I am away.
+
+Answered empirically before applying, on velox, which is itself the
+reachable-but-unregistered case:
+
+: $ signal-cli -a +15045173983 send -m ... <uuid>
+: User +15045173983 is not registered.
+: exit code: 1
+
+So the failure is clean and the loop advances correctly. Recorded in
+=protocols.org= so the next reader does not have to re-derive it.
+
+* What else was stale
+
+Fixing the script exposed that =protocols.org= "Reaching Craig" asserted the
+same dead fact — the account "is registered on velox (primary) and ratio
+(linked device)". Rewritten to state the *derivation* (any machine whose local
+=signal-cli= holds it sends directly; check with =signal-cli listAccounts=)
+rather than the snapshot, per the host-identity rule. The snapshot form is
+what rotted; a second snapshot would rot the same way at the next rebuild.
+
+The same section's =signal-mcp= claim was also false by then: velox's global
+=~/.claude.json= carries only =linear= and =slack-deepsat= after the reinstall.
+Softened to "may or may not be registered on the machine you're on".
+
+* What the review caught
+
+The guard I chose this proposal *for* had the same shape of flaw I rejected the
+other proposal over, and I did not see it. It read:
+
+: [ "${host%%.*}" = "$self" ] && continue
+
+That strips the domain off the candidate host but not off =uname -n=. Both
+machines return a short nodename today, so it worked — and nodename is just
+whatever =/etc/hostname= holds. One =hostnamectl set-hostname= to an FQDN on
+any machine and the guard silently stops firing, restoring the 2026-08-13 bug
+on the away channel with nothing to announce it. So the defect fix was itself
+instance-dependent, which is the exact criticism I had made of .emacs.d's
+version one section above. Both sides are stripped now, and the test that used
+to assert the direction that already worked was repointed at the direction that
+was broken.
+
+Three more, all correct and all fixed here:
+
+- The failure message named the relay list on *both* branches, so a local
+ =signal-cli= failure on the machine that actually holds the account — ratio,
+ the fleet's only working sender — would send a debugger chasing the tailnet
+ while the fault sat on the box in front of them. That is the same misdirection
+ as the old "velox down or unreachable?" text that this change exists to
+ remove. It now carries a per-branch reason.
+- The header's "Verified end to end ... 2026-08-16 (velox -> ratio, relay)" was
+ archsetup's verification, not mine, sitting in a list of two I could vouch for
+ and inheriting their credibility. Their handoff reports running the exact
+ relay command from velox and getting a Signal timestamp with rc 0. That is
+ real, and it is on report — the header now says so.
+- =protocols.org= and the script header both point at the runbook in
+ =docs/design/= as authoritative, and that runbook still asserted the retired
+ topology in four places. Correcting two surfaces and leaving the one they
+ route to is worse than not having started: the contradiction reads as a
+ disagreement between sources rather than one uniformly old story. The runbook
+ now opens with a dated superseded note, and =signal-receive.sh='s header
+ comment moved to the derivation form.
+
+* Verification
+
+- =bash -n= and =shellcheck -S warning= clean.
+- =agent-text.bats= 5 tests → 10, all green. The suite now stubs =uname= so the
+ self-skip is exercised deterministically instead of depending on which
+ machine runs it.
+- Mutation-proven rather than assumed. Deleting the self-relay guard reds tests
+ 3, 4 and 9; making the loop break unconditionally reds test 5; seeding =rc=0=
+ instead of =1= reds test 9. Each mutation is caught by a distinct test, so
+ the suite guards the behavior rather than merely covering the lines.
+- Full suite: only the pre-existing =ai-launcher-runtime.bats= codex failure
+ remains, which is a velox environment gap tracked separately.
+- The two review fixes are mutation-proven the same way rather than assumed:
+ reverting the self-side strip reds the FQDN test, and making the failure
+ message branch-blind again reds the local-failure test. Suite is 11 tests.
+
+* Provenance of the relay verification
+
+Recorded here because the working directory was filed away and this is the only
+surviving copy. archsetup's 2026-08-16 handoff states: "Verified from velox
+before proposing: signal-cli is at /usr/bin/signal-cli on ratio's
+non-interactive PATH, and the exact relay command agent-text would run returned
+a Signal timestamp with rc 0. Direct send from ratio also verified. bash -n and
+shellcheck -S warning are both clean on the attached file."
+
+That is the source for the header's 2026-08-16 line. I did not re-run a live
+relay send, because doing so delivers a real message to Craig's phone.
diff --git a/scripts/signal-receive.sh b/scripts/signal-receive.sh
index 8c3ef01..ffe292c 100755
--- a/scripts/signal-receive.sh
+++ b/scripts/signal-receive.sh
@@ -1,8 +1,10 @@
#!/usr/bin/env bash
# signal-receive.sh — drain the Signal pager account's inbound queue.
#
-# The pager identity (+15045173983) lives on velox (primary) and any linked
-# device (ratio). The Signal protocol expects a registered account to receive
+# The pager identity (+15045173983) lives on whichever machines hold it in their
+# local signal-cli — check with `signal-cli listAccounts` rather than assuming;
+# velox's 2026-08-13 reinstall wiped its registration and the old comment here
+# named it as primary. The Signal protocol expects a registered account to receive
# regularly; when it goes quiet, signal-cli prints a staleness warning
# ("Messages have been last received N days ago") and the account drifts toward
# an unhealthy state. This script pulls anything queued and exits, keeping the
diff --git a/scripts/tests/agent-text.bats b/scripts/tests/agent-text.bats
index e5d80fc..d4c5f9b 100644
--- a/scripts/tests/agent-text.bats
+++ b/scripts/tests/agent-text.bats
@@ -1,11 +1,14 @@
#!/usr/bin/env bats
# agent-text — the runtime-neutral Signal phone messenger ("text me"). Reaches
# Craig over Signal from any machine on the tailnet: sends directly wherever the
-# account is registered locally (velox, or any linked device), and ssh-relays to
-# velox from a machine that doesn't hold the account. These tests stub
-# ssh/signal-cli on PATH to verify command construction without a network or a
-# phone. The signal-cli stub answers `listAccounts` to control which branch the
-# dispatch takes. The final test covers the deprecated agent-page shim.
+# account is registered locally (any machine holding it, as primary or as a
+# linked device), and otherwise ssh-relays to the first relay host that holds
+# it. These tests stub ssh/signal-cli/uname on PATH to verify command
+# construction without a network or a phone. The signal-cli stub answers
+# `listAccounts` to control which branch the dispatch takes; the uname stub
+# fixes the machine identity, so the self-relay skip is exercised deterministically
+# rather than depending on which machine runs the suite. The final test covers
+# the deprecated agent-page shim.
setup() {
REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
@@ -29,7 +32,14 @@ fi
echo "signal-cli \$*" >> "$LOG"
exit 0
EOF
- chmod +x "$STUBS/ssh" "$STUBS/signal-cli"
+ # SELF_HOST fixes what the script sees as this machine. Defaults to a name
+ # in neither relay list, so the plain relay tests reach the first host.
+ cat > "$STUBS/uname" <<EOF
+#!/bin/bash
+[ "\$1" = "-n" ] && { echo "\${SELF_HOST:-somewhere-else}"; exit 0; }
+exec /usr/bin/uname "\$@"
+EOF
+ chmod +x "$STUBS/ssh" "$STUBS/signal-cli" "$STUBS/uname"
}
teardown() {
@@ -42,14 +52,59 @@ teardown() {
[[ "$output" == *"usage"* ]]
}
-@test "relays through ssh to velox when the account is not local" {
+@test "relays to the first relay host when the account is not local" {
HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$PAGE" build finished
[ "$status" -eq 0 ]
- grep -q "^ssh .*velox" "$LOG"
+ grep -q "^ssh .*ratio" "$LOG"
grep -q "15045173983" "$LOG"
grep -q "b1b5601e-6126-47f8-afaa-0a59f5188fde" "$LOG"
# printf %q escapes the space, so the relayed message reads build\ finished.
grep -qF 'build\ finished' "$LOG"
+ # First host answered, so the second is never tried.
+ ! grep -q "^ssh .*velox" "$LOG"
+}
+
+@test "never relays to this machine — the self host is skipped" {
+ # On ratio, relaying to ratio cannot work: reaching this branch means the
+ # account is not local, so the round trip lands on the same empty store.
+ SELF_HOST=ratio HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$PAGE" hello
+ [ "$status" -eq 0 ]
+ ! grep -q "^ssh .*ratio" "$LOG"
+ grep -q "^ssh .*velox" "$LOG"
+}
+
+@test "the self skip survives an FQDN nodename on either side" {
+ # uname -n returns whatever /etc/hostname holds, which may be fully
+ # qualified. Both sides are stripped, so the guard must still fire -- if it
+ # compares a stripped candidate against an unqualified self, an FQDN
+ # nodename silently restores the self-relay bug on the away channel.
+ SELF_HOST=ratio.tailf3bb8c.ts.net HAS_ACCOUNT=0 PATH="$STUBS:$PATH" \
+ run bash "$PAGE" hi
+ [ "$status" -eq 0 ]
+ ! grep -q "^ssh .*ratio" "$LOG"
+ grep -q "^ssh .*velox" "$LOG"
+}
+
+@test "falls through to the next host when the first relay fails" {
+ cat > "$STUBS/ssh" <<EOF
+#!/bin/bash
+echo "ssh \$*" >> "$LOG"
+case "\$*" in *ratio*) exit 255 ;; esac
+exit 0
+EOF
+ chmod +x "$STUBS/ssh"
+ HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$PAGE" retry me
+ [ "$status" -eq 0 ]
+ grep -q "^ssh .*ratio" "$LOG"
+ grep -q "^ssh .*velox" "$LOG"
+}
+
+@test "AGENT_TEXT_RELAYS overrides the default relay list" {
+ AGENT_TEXT_RELAYS="spare.example.net" HAS_ACCOUNT=0 PATH="$STUBS:$PATH" \
+ run bash "$PAGE" custom
+ [ "$status" -eq 0 ]
+ grep -q "^ssh .*spare.example.net" "$LOG"
+ ! grep -q "^ssh .*ratio" "$LOG"
}
@test "sends directly (no ssh) when the pager account is registered locally" {
@@ -59,7 +114,7 @@ teardown() {
! grep -q "^ssh " "$LOG"
}
-@test "a failed relay reports the desktop fallback and propagates failure" {
+@test "every relay failing reports the desktop fallback and propagates failure" {
cat > "$STUBS/ssh" <<'EOF'
#!/bin/bash
exit 255
@@ -70,6 +125,34 @@ EOF
[[ "$output" == *"notify"* ]]
}
+@test "no reachable relay at all still fails rather than reporting success" {
+ # Every candidate is this machine, so the loop body never runs. rc must stay
+ # at its non-zero seed: a skipped loop is not a delivered message.
+ AGENT_TEXT_RELAYS="ratio.tailf3bb8c.ts.net" SELF_HOST=ratio HAS_ACCOUNT=0 \
+ PATH="$STUBS:$PATH" run bash "$PAGE" nowhere to go
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"notify"* ]]
+ ! grep -q "^ssh " "$LOG"
+}
+
+@test "a local send failure blames the local send, not the relay list" {
+ # On the machine that holds the account, no relay is ever consulted. A
+ # message naming the relay list sends whoever is debugging it off chasing
+ # the tailnet while the fault is on the box in front of them.
+ cat > "$STUBS/signal-cli" <<EOF
+#!/bin/bash
+if [ "\$1" = "listAccounts" ]; then echo "Number: +15045173983"; exit 0; fi
+exit 1
+EOF
+ chmod +x "$STUBS/signal-cli"
+ # This stub reports the account unconditionally, so it drives the direct
+ # branch on its own -- HAS_ACCOUNT would be decorative here.
+ PATH="$STUBS:$PATH" run bash "$PAGE" boom
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"local signal-cli send failed"* ]]
+ [[ "$output" != *"no relay reachable"* ]]
+}
+
@test "the deprecated agent-page shim delegates to agent-text" {
HAS_ACCOUNT=1 PATH="$STUBS:$PATH" run bash "$SHIM" via shim
[ "$status" -eq 0 ]