diff options
| -rwxr-xr-x | claude-templates/bin/agent-page | 25 | ||||
| -rw-r--r-- | docs/design/2026-07-20-signal-pager-runbook.org | 193 | ||||
| -rwxr-xr-x | scripts/signal-receive.sh | 51 | ||||
| -rw-r--r-- | scripts/systemd/signal-receive.service | 10 | ||||
| -rw-r--r-- | scripts/systemd/signal-receive.timer | 10 | ||||
| -rw-r--r-- | scripts/tests/agent-page.bats | 34 | ||||
| -rw-r--r-- | scripts/tests/signal-receive.bats | 66 | ||||
| -rw-r--r-- | todo.org | 6 |
8 files changed, 369 insertions, 26 deletions
diff --git a/claude-templates/bin/agent-page b/claude-templates/bin/agent-page index 11a5264..8e9b23c 100755 --- a/claude-templates/bin/agent-page +++ b/claude-templates/bin/agent-page @@ -3,19 +3,24 @@ # # Usage: agent-page <message...> # -# The pager identity (+15045173983) is registered in velox's signal-cli, so -# velox sends directly and every other machine ssh-relays the send over the -# tailnet. The recipient is Craig's Signal account UUID — his phone number -# reads as unregistered in Signal's directory, so never page the number. -# Verified end to end 2026-07-13 (phone push confirmed). +# The pager 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 pager 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 page the number. Verified end to end 2026-07-13 (velox) and +# 2026-07-20 (ratio, direct). # # This is the AWAY channel. At his desk, use the desktop channel instead: # notify info "Title" "Message" --persist # See protocols.org "Paging Craig" for choosing between them. # -# Known caveats (tracked on the rulesets Signal-pager task): velox must be -# up and reachable on the tailnet, and the signal-cli account wants a -# periodic `receive` (staleness warnings appear otherwise). +# Known caveats (tracked on the rulesets Signal-pager task, 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. # # Source: ~/code/rulesets/claude-templates/bin/agent-page # Install: make -C ~/code/rulesets install @@ -31,7 +36,9 @@ fi msg="$*" -if [ "$(uname -n)" = "velox" ]; then +# The pager account is local if this machine's signal-cli holds it — the +# registered primary (velox) or any linked device. Those send directly. +if signal-cli listAccounts 2>/dev/null | grep -q "$PAGER_ACCOUNT"; then signal-cli -a "$PAGER_ACCOUNT" send -m "$msg" "$CRAIG_UUID" rc=$? else diff --git a/docs/design/2026-07-20-signal-pager-runbook.org b/docs/design/2026-07-20-signal-pager-runbook.org new file mode 100644 index 0000000..671b1ec --- /dev/null +++ b/docs/design/2026-07-20-signal-pager-runbook.org @@ -0,0 +1,193 @@ +#+TITLE: Signal Pager Runbook +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-20 + +The operational reference for the agent pager — how a page reaches Craig's +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 + +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. + +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. + +Two constants the tooling depends on: + +- Pager account: =+15045173983= (primary on velox, linked on ratio). +- 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. + +* Choosing a channel + +"Page me" has two channels; the choice is where Craig is, and both work from any +agent runtime (nothing here is Claude-specific). protocols.org "Paging Craig" +is the short version pointed at every project; this runbook is the full one. + +- *At his laptop/desktop* — desktop notification, stays up until dismissed: + + #+begin_src bash + notify info "Title" "Message" --persist + #+end_src + +- *Away from his machine* — the phone, over Signal: + + #+begin_src bash + agent-page "Message for Craig's phone" + #+end_src + +When in doubt, fire both — the desktop one is free and the phone one reaches him +if he's away. + +* Sending a page + +=agent-page= (shipped at =claude-templates/bin/agent-page=, installed to +=~/.local/bin= by =make -C ~/code/rulesets install=) is the interface. It hides +the machine topology by checking whether the pager account is registered in the +local signal-cli: + +- If the account is local (velox's primary or a linked device like ratio), it + sends directly — no velox dependency. +- Otherwise it ssh-relays the send to velox over the tailnet. +- On failure (velox down or unreachable from a non-linked machine) it prints the + 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: + +#+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: + +#+begin_src bash +ssh velox.tailf3bb8c.ts.net \ + "signal-cli -a +15045173983 send -m 'your message' b1b5601e-6126-47f8-afaa-0a59f5188fde" +#+end_src + +Prefer =agent-page= over the raw command — it hardens the message for the remote +shell and handles the fallback. Reach for the raw form only when debugging. + +* Reading replies + +Craig replies to a page straight from Signal on his phone. The reply is a normal +data message *to* the pager account, so it is waiting in the pager's inbound +queue until something receives it. + +Drain the queue and read what is there: + +#+begin_src bash +# On velox: +signal-cli -a +15045173983 receive --timeout 10 +# From another machine: +ssh velox.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 +timeout elapses. A text reply from Craig arrives as an envelope from his UUID +carrying a =Body:= line — that line is the reply text. Most envelopes are +delivery/read receipts and typing indicators (no =Body:=); the reply you want is +the data message with body text. For a script that waits on a reply, add +=--send-read-receipts= so his phone shows the page was read, and parse stdout for +the =Body:= line on an envelope from =b1b5601e-…=. + +Note: =receive= is destructive — it consumes the queue. Whatever drains the +queue (an on-demand read, or the warm-keeping timer below) is what sees the +reply, and it is seen once. An agent that pages and then waits for an answer +should do its own =receive= rather than race the timer. + +* Keeping the account warm (receive timer) + +Signal expects a registered account to receive regularly. Left alone, the pager +account drifts stale — signal-cli warns "Messages have been last received N days +ago" (observed at 47 days on 2026-07-20 before a manual drain reset it). A stale +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). + +- 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. +- Units: =scripts/signal-receive.service= + =.timer= (reference copies under + =scripts/systemd/=; the stowed copies live in =common/.config/systemd/user/= + of the dotfiles repo, so both machines get them). +- Cadence: every 15 minutes (=OnUnitActiveSec=15min=), matching roam-sync. + +Enable on each machine (one-time, per daily-drivers.md's one-time-setup class): + +#+begin_src bash +# After the dotfiles + rulesets pull, on each daily driver: +systemctl --user daemon-reload +systemctl --user enable --now signal-receive.timer +systemctl --user status signal-receive.service # confirm a clean receive +#+end_src + +* 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: 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-page= is 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: + + #+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): + signal-cli -a +15045173983 addDevice --uri "tsdevice:/?uuid=…" + #+end_src + +* Decision — linked device (2026-07-20) + +The topology question — ssh-relay only vs. registering daily drivers as linked +devices — was decided in favor of linked devices. ssh-relay only was simpler +(one identity, one receive point) but had a single point of failure: a page +failed when velox was down or off the tailnet. + +Registering ratio as a linked device removes that: ratio sends directly, so a +page lands even when velox is down. The costs, both paid: linked-device +provisioning per machine (the =link= / =addDevice= handshake above), and each +device wanting its own regular =receive= — so the warm-keeping timer moved from a +velox-only home to the shared =common= package, running on both. + +Adding another daily driver later is the same handshake plus a dotfiles stow; +the timer and =agent-page= already generalize to "any machine holding the +account." + +* History + +- 2026-07-04 — home retired ntfy (self-hosted on ratio) and tore it down, + switching agent paging to Signal. Handoff to rulesets to document and own. +- 2026-07-13 — reconciled to one pager identity on velox; =agent-page= shipped + (direct on velox, ssh-relay elsewhere, desktop fallback); protocols.org "Paging + Craig" rewritten around the two channels. +- 2026-07-20 — this runbook; receive-timer script + units added; a manual drain + cleared the 47-day staleness live; ratio linked as a device of the pager + account and its direct send verified; =agent-page= generalized to send directly + from any machine holding the account; receive timer moved to the shared + =common= package and enabled on both machines. diff --git a/scripts/signal-receive.sh b/scripts/signal-receive.sh new file mode 100755 index 0000000..8c3ef01 --- /dev/null +++ b/scripts/signal-receive.sh @@ -0,0 +1,51 @@ +#!/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 +# 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 +# account warm. Run by the signal-receive systemd user timer (scripts/systemd/) +# on every machine holding the account — the roam-sync-shaped fix for the +# receive-staleness caveat on the pager channel. +# +# Draining the queue is also what surfaces Craig's replies: a reply he types in +# Signal arrives as a data message here, so a warm account is a prerequisite for +# the read-replies half of the pager (see docs/design/…-signal-pager-runbook.org). +# +# The units stow via the shared dotfiles `common` package, so the timer may land +# on a machine that does not hold the account. That is fine: the script no-ops +# cleanly (exit 0) when the account is not registered locally, rather than +# erroring every cadence. +# +# Usage: signal-receive.sh [account] [timeout-seconds] +# account defaults to the pager identity below +# timeout-seconds seconds to wait for new messages (default 10) +# +# Exit: 0 on a clean receive (including "nothing queued" and "account not on this +# machine"), non-zero only if signal-cli itself errors, so a real failure is +# visible in `systemctl --user status signal-receive`. + +set -euo pipefail + +account="${1:-+15045173983}" +timeout="${2:-10}" + +if ! command -v signal-cli >/dev/null 2>&1; then + echo "signal-receive: signal-cli not on PATH — nothing to do" >&2 + exit 0 +fi + +# No-op cleanly where the account isn't registered (a machine that stows the +# common units but isn't a pager device). Only a genuine receive error should +# surface as a failure. +if ! signal-cli listAccounts 2>/dev/null | grep -q "$account"; then + echo "signal-receive: $account not registered on this machine — nothing to do" >&2 + exit 0 +fi + +# `receive` prints envelopes to stdout and returns 0 once the queue drains or +# the timeout elapses. --send-read-receipts tells Craig's phone his replies were +# read by the pager, matching normal Signal behavior. +exec signal-cli -a "$account" receive --timeout "$timeout" --send-read-receipts diff --git a/scripts/systemd/signal-receive.service b/scripts/systemd/signal-receive.service new file mode 100644 index 0000000..d63a5c8 --- /dev/null +++ b/scripts/systemd/signal-receive.service @@ -0,0 +1,10 @@ +# Signal pager receive — keep the pager account warm on every machine holding it. +# Stowed via the dotfiles `common` package, so it lands on both daily drivers; +# the script no-ops cleanly where the account isn't registered. Enable per machine: +# systemctl --user daemon-reload && systemctl --user enable --now signal-receive.timer +[Unit] +Description=Drain the Signal pager account inbound queue (keeps it warm) + +[Service] +Type=oneshot +ExecStart=%h/code/rulesets/scripts/signal-receive.sh diff --git a/scripts/systemd/signal-receive.timer b/scripts/systemd/signal-receive.timer new file mode 100644 index 0000000..3fdaee0 --- /dev/null +++ b/scripts/systemd/signal-receive.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Keep the Signal pager account warm every 15 minutes + +[Timer] +OnBootSec=3min +OnUnitActiveSec=15min +RandomizedDelaySec=60 + +[Install] +WantedBy=timers.target diff --git a/scripts/tests/agent-page.bats b/scripts/tests/agent-page.bats index 071e4b9..7317ac7 100644 --- a/scripts/tests/agent-page.bats +++ b/scripts/tests/agent-page.bats @@ -1,9 +1,11 @@ #!/usr/bin/env bats # agent-page — the runtime-neutral phone pager. Pages Craig over Signal from -# any machine on the tailnet: runs signal-cli directly on velox (where the -# pager identity lives), ssh-relays to velox from everywhere else. These tests -# stub ssh/uname/signal-cli on PATH to verify command construction without a -# network or a phone. +# any machine on the tailnet: sends directly wherever the pager 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. setup() { REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" @@ -15,8 +17,14 @@ setup() { echo "ssh \$*" >> "$LOG" exit 0 EOF + # HAS_ACCOUNT controls the listAccounts answer: "1" → the pager account is + # local (send direct), unset/"0" → not local (relay). cat > "$STUBS/signal-cli" <<EOF #!/bin/bash +if [ "\$1" = "listAccounts" ]; then + [ "\${HAS_ACCOUNT:-0}" = "1" ] && echo "Number: +15045173983" + exit 0 +fi echo "signal-cli \$*" >> "$LOG" exit 0 EOF @@ -33,8 +41,8 @@ teardown() { [[ "$output" == *"usage"* ]] } -@test "relays through ssh to velox with the pager account and Craig's UUID" { - PATH="$STUBS:$PATH" run bash "$PAGE" build finished +@test "relays through ssh to velox 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 "15045173983" "$LOG" @@ -43,16 +51,10 @@ teardown() { grep -qF 'build\ finished' "$LOG" } -@test "on velox itself, calls signal-cli directly (no ssh)" { - cat > "$STUBS/uname" <<'EOF' -#!/bin/bash -[ "$1" = "-n" ] && { echo velox; exit 0; } -exec /usr/bin/uname "$@" -EOF - chmod +x "$STUBS/uname" - PATH="$STUBS:$PATH" run bash "$PAGE" hello +@test "sends directly (no ssh) when the pager account is registered locally" { + HAS_ACCOUNT=1 PATH="$STUBS:$PATH" run bash "$PAGE" hello [ "$status" -eq 0 ] - grep -q "^signal-cli " "$LOG" + grep -q "^signal-cli -a +15045173983 send " "$LOG" ! grep -q "^ssh " "$LOG" } @@ -62,7 +64,7 @@ EOF exit 255 EOF chmod +x "$STUBS/ssh" - PATH="$STUBS:$PATH" run bash "$PAGE" urgent thing + HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$PAGE" urgent thing [ "$status" -ne 0 ] [[ "$output" == *"notify"* ]] } diff --git a/scripts/tests/signal-receive.bats b/scripts/tests/signal-receive.bats new file mode 100644 index 0000000..8fe4d67 --- /dev/null +++ b/scripts/tests/signal-receive.bats @@ -0,0 +1,66 @@ +#!/usr/bin/env bats +# signal-receive.sh — drains the Signal pager account's inbound queue to keep it +# warm (the roam-sync-shaped fix for receive-staleness) and to surface Craig's +# replies. Run on velox by the signal-receive systemd timer. These tests stub +# signal-cli on PATH to verify command construction without a network or the +# real account. + +setup() { + REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" + RECV="$REPO_ROOT/scripts/signal-receive.sh" + STUBS="$(mktemp -d)" + LOG="$STUBS/calls.log" + # HAS_ACCOUNT (default 1) controls the listAccounts answer so the local-account + # guard can be exercised: "1" lists both test accounts as present, "0" lists + # none (so the guard no-ops). + cat > "$STUBS/signal-cli" <<EOF +#!/bin/bash +if [ "\$1" = "listAccounts" ]; then + if [ "\${HAS_ACCOUNT:-1}" = "1" ]; then + echo "Number: +15045173983" + echo "Number: +19995550000" + fi + exit 0 +fi +echo "signal-cli \$*" >> "$LOG" +exit 0 +EOF + chmod +x "$STUBS/signal-cli" +} + +teardown() { + rm -rf "$STUBS" +} + +@test "defaults to the pager account, a 10s timeout, and read receipts" { + PATH="$STUBS:$PATH" run bash "$RECV" + [ "$status" -eq 0 ] + grep -q -- "-a +15045173983 receive --timeout 10 --send-read-receipts" "$LOG" +} + +@test "honors an explicit account and timeout" { + PATH="$STUBS:$PATH" run bash "$RECV" +19995550000 25 + [ "$status" -eq 0 ] + grep -q -- "-a +19995550000 receive --timeout 25" "$LOG" +} + +@test "no-ops (exit 0) when the account is not registered on this machine" { + # Guards the runbook claim that the timer no-ops on a common-package machine + # that stows the units but holds no pager account. + HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$RECV" + [ "$status" -eq 0 ] + [[ "$output" == *"not registered"* ]] + # The receive must not have run. + ! grep -q "receive" "$LOG" +} + +@test "no-ops (exit 0) when signal-cli is not on PATH" { + # Empty stub dir with no signal-cli; echo/exit/command are bash builtins, so + # the script still runs and hits its signal-cli-absent branch, which no-ops. + rm -f "$STUBS/signal-cli" + # Invoke bash by absolute path so `run` finds it regardless of the empty + # PATH the script itself sees. + PATH="$STUBS" run "$(command -v bash)" "$RECV" + [ "$status" -eq 0 ] + [[ "$output" == *"signal-cli"* ]] +} @@ -260,7 +260,8 @@ Open question from the roam inbox (2026-07-11): could the startup sequence for . :END: From the roam inbox (2026-07-11): work in progress in one project shouldn't stop the sync gate. Idea: keep all diffs/changes in a =working/= directory and exclude it (and its subdirectories) from the sync gate. Many projects run at once, so their WIP files need to be grouped. Also add a per-project count of when the gate tripped, tracked as a metric to investigate. Distinct from the 2026-07-02 policy (untracked/gitignored changes already pass — this is about *tracked* WIP under =working/=). Verify how the gate detects dirtiness today before designing. -** TODO [#B] Document (and own) the Signal pager :feature:spec: +** DONE [#B] Document (and own) the Signal pager :feature:spec: +CLOSED: [2026-07-20 Mon] :PROPERTIES: :CREATED: [2026-07-11 Sat] :LAST_REVIEWED: 2026-07-13 @@ -281,6 +282,9 @@ Craig's call: make paging universal and rename it the agent pager. NEW =claude-t *** 2026-07-13 Mon @ 18:13:07 -0500 RECONCILED — one channel, on velox; live page verified end to end The two-paths question is answered: there is ONE pager identity, +15045173983, registered in velox's signal-cli (account file 465310) — and signal-mcp is a locally-configured MCP server in velox's global ~/.claude.json (not claude.ai-side as the 14:40 entry inferred; it's just invisible from ratio, which is why home and this session couldn't find it). ratio's signal-cli holds only Craig's personal number (note-to-self, no push). Verified live today: =ssh velox 'signal-cli -a +15045173983 send -m … b1b5601e-6126-47f8-afaa-0a59f5188fde'= buzzed Craig's phone — his remembered CLI page was this same account on velox. Reliability findings for the runbook: both accounts throw receive-staleness warnings (velox 40 days, ratio 26; the signal protocol wants regular receives — a systemd receive timer on velox is the roam-sync-shaped fix), and the channel requires velox to be up. Remaining deliverables sharpened: the runbook (send + read-replies + receive timer + account notes); decide ssh-over-tailnet-only vs registering ratio as a linked device of the pager account; update protocols.org "Paging Craig" (it names signal-mcp as the only supported path — true only on velox; the ssh recipe is the cross-machine path) — shared-asset edit, own review pass. Interim recipe sent to home so it's unblocked today. +*** 2026-07-20 Mon @ 15:56:56 -0500 Runbook shipped; ratio linked as a device; receive timer on both machines +All four remaining deliverables landed. (1) Runbook: [[file:docs/design/2026-07-20-signal-pager-runbook.org]] — send, read-replies, receive timer, signal-cli account/setup notes, the resolved topology decision. (2) Receive timer: =scripts/signal-receive.sh= + =scripts/systemd/signal-receive.{service,timer}= (roam-sync-shaped, 15-min cadence, 3 bats), stowed via dotfiles =common=, enabled + verified on ratio; a manual drain also cleared the 47-day staleness live. (3) Topology decision (Craig, option 2): register daily drivers as linked devices rather than ssh-relay-only — ratio linked as Device 2 "ratio-pager", direct send verified, =agent-page= generalized from a velox-only check to "any machine holding the account sends directly, else relay" (bats updated). (4) protocols.org "Paging Craig": verified accurate, no edit — already describes both channels and the caveats, and its generic runbook pointer is correct to leave un-pathed since it syncs into every project. Remaining one-time step: enable the timer on velox after it pulls dotfiles. + ** TODO [#C] KB orphan-node review pass :chore: :PROPERTIES: :CREATED: [2026-07-01 Wed] |
