Files
esh-pfi-infrastructure/docs/runbooks/althing-deploy.md
T
vh e9605df6ff docs(althing): the fast outage probe never touched the timeout it claimed to test
My smoke test used a refused port on a live host and presented it as the
check for the 2 s budget. Measured: it returns in 79 ms, because a refused
port answers instantly with an RST. It verifies the glyph and nothing about
the deadline. An address that black-holes — an unrouted LAN host — takes
2.065 s and is the one that exercises it. Both are now in the page with what
each actually proves, because letting the fast case imply the slow one is how
a status line that hangs the prompt ships with a green test beside it.

Found by forseti in their own copy of the same probe and confirmed here
rather than adopted.

The smoke test also moves from expected-values-in-trailing-comments to
printing `got [x] wanted [y]` on one line. The comment form is the shape that
produced the false pass that caught two agents inside an hour, and the
expected-value column is what caught it — so the page should use the thing it
recommends.
2026-09-02 10:36:49 -07:00

20 KiB

althing deploy — six surfaces

scripts/deploy-althing.sh does all four. --check reports drift without touching anything. This page is the why.

Deploying althing means updating four independent surfaces on nh3-dev. Three were known; the fourth had no step in any runbook and drifted for five days before anyone noticed, on 2026-09-01.

# surface what it is how it drifts
1 binaries uv tool install --force . — the 7 console scripts silently, see below
2 herald systemctl --user restart althing-po-herald new guards live here; nothing takes effect until it restarts
3 skill scripts/sync_skill.sh~/.agents/skills/althing/SKILL.md covered by its own --check
4 plugin repo plugin/ → marketplace dir → Claude Code cache two hops, neither was automated
5 per-seat route althing-route declare on each seat a channel change needs a re-declare; the plugin's SessionStart hook does it for CC seats
6 ~/.claude/settings.json crossSessionInbound: "accept" outside the althing repo entirely — no version can carry it

uv tool install . without --force is a silent no-op

$ uv tool install .
`althing-core @ file:///home/lkraven/development/althing` is already installed
$ echo $?
0

uv matches on the source spec, not its contents. On a box that already installed from that path it declines and reports success. An operator following a runbook literally would restart the herald, see everything green, and wonder why the new binary was missing — with every command exiting 0.

⚠ Surface 4 is the one that bites, and it ate a hook

The chain is:

repo plugin/  →  ~/.local/share/althing-plugin/  →  ~/.claude/plugins/cache/althing/althing/<ver>/
              ^^^ nothing synced this hop

On 2026-09-01 the marketplace directory was a frozen copy from Aug 28:

deployed 0.0.1   hooks: ['UserPromptSubmit']
repo     0.1.1   hooks: ['UserPromptSubmit', 'SessionStart', 'SessionEnd']
                 + scripts/pane-route.sh   (absent from the deployment entirely)

So "CC seats re-declare their pane route automatically at next SessionStart" was never true on this box. The hook existed and worked upstream; it was never deployed. That is why every seat — including infra-ops' own Claude Code seat — had to be hand-declared with a pid someone measured by hand, and why the idle_cursor pin from 3.2.1 would not have self-applied either.

⚠ Compare the HOOK LIST, not the version string

A version number cannot tell you what a stale plugin actually cost you. 0.0.1 and 0.1.1 differ by two hooks and a script:

for d in ~/.claude/plugins/cache/althing/althing/*/; do
  printf "  %-8s hooks: %s  pane-route.sh: %s\n" "$(basename "$d")" \
    "$(python3 -c "import json;print(list(json.load(open('$d/.claude-plugin/plugin.json'))['hooks'].keys()))")" \
    "$(test -f "$d/scripts/pane-route.sh" && echo yes || echo NO)"
done

Expect ['UserPromptSubmit', 'SessionStart', 'SessionEnd'] and pane-route.sh: yes. Anything less and pane routes are not being declared or released automatically.

⚠ WHY THIS HID FOR FIVE DAYS — the shape to recognise

A missing deploy surface does not present as an error. It presents as "the migration needs manual work" — and we had a ready explanation for that, because four of five seats were non-Claude and genuinely did need hand-holding.

The seat that falsified the story was infra-ops' own: a Claude Code seat that should have self-declared and didn't, and it looked exactly like the other four. Neither infra-ops nor forseti asked why the automatic path had not fired for the one seat it was supposed to work on.

When a migration needs manual intervention, check whether the automatic path was ever deployed before concluding it does not apply to your case.

Use the supported CLI for the second hop. claude plugin update althing (also install / uninstall / list / details / validate / marketplace). ⚠ Do not hand-edit ~/.claude/plugins/installed_plugins.json or fabricate a cache directory — that is Claude Code's own bookkeeping, and a subtle mistake there breaks the plugin in a way that looks like an upstream bug.

A Claude Code restart is required for new plugin hooks. They are read at session start; a running session keeps the old ones.

Verifying a seat is actually reachable

postbox status reports mode: push, but until 3.2.4 a failed push declaration was silent — _declare_push devnulled both streams with check=False, so a missing binary, a non-zero exit and a server-side rejection rendered identically as nothing. A seat could have been silently pull-only since 3.1.2.

Audit for "looks armed but is not", cross-referencing local waiter locks against what the post office believes:

for f in ~/.althing/wake-listener-*.lock; do
  h=$(basename "$f" .lock); h=${h#wake-listener-}
  pid=$(cat "$f" 2>/dev/null)
  [ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] || continue
  # IDENTITY, not liveness: a lock left by a reaped listener names a pid the
  # kernel is free to hand to anything, so `kill -0` alone reports a stranger
  # as a live waiter. Match the same cmdline segment `althing-listen --stop`
  # requires before it will signal anything.
  ours=0
  while IFS= read -r -d '' seg; do [ "$seg" = "--_route=$h" ] && ours=1; done \
    < "/proc/$pid/cmdline"
  [ "$ours" = 1 ] || { printf "  %-24s pid %-8s STRANGER (recycled pid)\n" "$h" "$pid"; continue; }
  printf "  %-24s waiter %-8s mode: %s\n" "$h" "$pid" \
    "$(postbox status --handle "$h" 2>/dev/null | grep -oP 'mode: \K\w+')"
done

A live waiter reporting mode: pull is a seat that will never be poked. From 3.2.4 onward $ALTHING_ROOT/listen.log records failed declarations directly.

Liveness is not identity, and this loop is the place that gets it wrong. The earlier kill -0 form would print a phantom waiter for any handle whose stale lock happens to name a recycled pid — and a phantom waiter is exactly what sends a false "you are unreachable" notice to a seat that is fine. session_listener.sh refuses to SIGTERM on liveness alone for precisely this reason; an audit that only reads has no excuse for a weaker standard than the one that kills. (Adopted 2026-09-02 after the regin-smithy-dev cross-reference, where the pid happened to be genuine and the weaker check happened to be right.)

A stale wake-listener-*.lock is NOT a fault. The gate is flock -n on an open fd, which the kernel releases when the holder dies, so a lock file left by a reaped listener is inert and exit 3 only fires against a genuinely live holder. The pid in the file is read by --stop alone. (Recorded because infra-ops claimed the opposite, untested, on 2026-09-01; forseti measured it.)

⚠ The plugin cache does not take a content-only change

claude plugin update althing matches on the version in plugin.json and declines when it is unchanged. A release that edits hook or script CONTENT without bumping the plugin version therefore leaves the Claude Code cache stale while every version check reports success — the marketplace dir gets the new bytes, the cache does not, and update says "already at the latest version".

Measured on the 3.3.0 deploy (2026-09-02): marketplace and live cache both read 0.1.1, and pane-route.sh + README.md differed. That delta was documentation-only and harmless. The next one need not be.

scripts/deploy-althing.sh now diffs the marketplace tree against the live cache dir on every run and on --check, so the drift is reported rather than inferred. The fix is a plugin version bump upstream, not a hand-edit~/.claude/plugins/installed_plugins.json and the cache directory are Claude Code's own bookkeeping.

The 2026-09-01 lesson was compare the hook list, not the version string. This is the same lesson one turn deeper: here the hook list was identical too, and only the file contents moved.

A seat already running when the plugin lands never auto-declares — leave it

The SessionStart hook fires at session start and nowhere else, so any session that was already running when a plugin deploy landed keeps its old channel until it restarts. After the 3.3.1 deploy that was eight of seventeen live seats, all still on fifo.

Operator ruling 2026-09-02: leave them. They are reachable now, the post office holds mail regardless, and each self-corrects at its next session start. The residual exposure is that a fifo waiter is what Claude Code 2.1.257 reaps — one seat was reaped three times in a morning — and a reap converts a healthy seat into a silent one until the next restart. That costs timeliness, never mail.

Do not mass-message the affected seats. Standing operator directive: no unsolicited fleet notification, ever, unless asked. Direct correspondence with a counterparty about work in hand is fine; sweeping the handle list is not. Audit it, report the count, and let him decide — as he did here.

The one-liner, for a seat that asks or an operator who wants one moved:

althing-route declare --discover-pid

⚠ The cc channel is an undocumented interface, taken deliberately

From 3.3.0 a Claude Code seat is poked over its own message socket ($XDG_RUNTIME_DIR/cc-socks/<pid>.sock) rather than by typing into its pane. No process to reap, nothing near the input line, and delivery lands at the receiver's next turn boundary.

It is not a published interface and is expected to break on some future Claude Code release. Accepted on the operator's reasoning: the FIFO poker it replaces was also an unsanctioned hack of the background-watcher system, so this is a better instance of a dependency class we already had, not a new one.

When it breaks the failure mode is a seat going pull-only with a logged reason — not lost mail; the post office holds it either way. Recovery is one command:

althing-route declare --pid <pid>     # prefers cc, falls back to pane

Why cc is worth an undocumented dependency, in one measurement. A pane poke is typed into the session and submitted, so Claude Code records it in the operator's prompt history exactly as if he had written it — 8 herald-written entries accumulated across three projects, permanently in up-arrow recall. A cc poke enters none: 2 delivered, 0 entries. Verified from the herald log against the history on 2026-09-02. Channel sequence on one seat that day, all three in order:

09-01 21:44  poked via fifo            the old way, later reaped
09-02 08:34  could NOT reach via pane  guard 4 refusing: he was typing
09-02 09:07  poked via pane            typed into his input line
09-02 09:14  poked via cc              arrived as a peer message
09-02 09:31  poked via cc              after crossSessionInbound accept

Count herald pokes by the whole display string, not by grepping ALTHING-HERALD. The substring matches the operator's own messages when he pastes a hold notice into one, so the count runs high and the excess reads as new activity. See the identity-not-resemblance note in the audit section.

Ordering constraint at any upgrade that changes the channel set: a pre-3.3.0 herald refuses channel=cc at parse, so a seat that declares the new channel before the herald restarts goes silently pull-only. uv tool install --force then the herald restart, and only then let anything re-declare. deploy-althing.sh already runs them in that order and updates the plugin (whose SessionStart hook declares the route) last, which is what keeps the window to a couple of seconds.

⚠ Surface 6 — crossSessionInbound, and it is not in the althing repo

A cc poke to a default-configured seat is HELD, not delivered. Claude Code auto-delivers an inbound cross-session message only when the sender's permission-mode class matches the receiver's, and a sender that asserts no class is held. The herald is a daemon and asserts none, deliberately. What the operator sees instead of a delivery:

Held peer message — from an unidentified session [verified pid <herald>];
preview: «ALTHING-HERALD althing: you have mail ...» — not delivered to
Claude (1 held). The sender did not attest its permission mode and this
session bypasses prompts.

Claude Code verified the herald through SO_PEERCRED and then correctly declined to let it speak, because it would not say what it was.

The fix is one key in ~/.claude/settings.json:

"crossSessionInbound": "accept"

Set on nh3-dev 2026-09-02. The operator's reasoning is the part to keep: the herald can only reach local seats, so bypass is the correct authorization type; when the guard was not there, that was our default posture, including the pane poke. A pane poke types into a session and presses Enter — bypass-level access by any measure, and what we had been doing all along. The socket channel is strictly narrower than what it replaces, so accepting here states the existing trust boundary rather than widening it.

There is no attestation the herald could send instead. Four probes established that Claude Code identifies a sender by verified pid against the session registry and reads that session's live runtime permission mode; a daemon has none and the registry has no field for one. A from_mode field on a type:"user" frame is not consulted at all — it belongs to the control actions. Adding it would be shipping a field nothing reads.

Cost without the setting is smaller than it looks: the correspondent record is in-memory session state, so the hold is first-contact, not per-message — one approval per seat per session lifetime, and a long-lived pane pays it once.

Why this surface is the dangerous one. A seat without it is declared, reachable and green, and its pokes go to a human instead of to the session. That is the same shape as the SessionStart hook that was never deployed: everything reports success, nothing arrives. deploy-althing.sh reads the key and reports it on every run and on --check — and deliberately never sets it. That file is the operator's permission configuration; a deploy script that edits it is a deploy script granting itself trust.

The status-line segment belongs to althing, not to this repo

From althing 3.4.0 the segment is a console script, althing-statusline, installed on PATH by the same uv tool install --force as the other seven executables. scripts/claude-statusline-command.sh calls it and does not reimplement it:

althing=$(printf '%s' "$input" | timeout 5 althing-statusline 2>/dev/null)

Keep that outer timeout LOOSER than the program's own 2 s budget. If the outer one fires first the segment comes back empty, which renders as "not an althing directory" — the outage conflation, reintroduced by the guard meant to prevent a hang.

Why a console script rather than the sync_skill.sh canonical-file pattern: a script, unlike a document, has somewhere to be installed. Installing it makes drift impossible instead of merely visible.

Why it needed solving at all. On 2026-09-02 the same twelve lines existed in three places — althing's plugin/scripts/statusline.sh, the operator's wired ~/.claude/statusline-command.sh, and this repo's copy — and one audit found a different defect in each. All three were fixed separately, by hand, the same day. Two of the defects were in this repo's copy and are worth knowing because they are both silent:

  • the handle was resolved as the most recent launch in the directory, so a directory hosting both a claude and a codex seat (~/development/Worldtree) reports the codex handle's unread count and reachability to a Claude session the moment the codex pane relaunches last;
  • the post-office address was hardcoded, so it survives exactly until the post office moves and then reports an outage that is really a stale constant.

Deploying it

scripts/claude-statusline-command.sh is the tracked copy of what Claude Code actually runs at ~/.claude/statusline-command.sh (settings.jsonstatusLine.command). Copies, not symlinks — same rule as stacks/.

diff -u scripts/claude-statusline-command.sh ~/.claude/statusline-command.sh
cp scripts/claude-statusline-command.sh ~/.claude/statusline-command.sh

Diff before you copy, in both directions. On 2026-09-02 the live path was edited directly and was ahead of the tracked copy; a reflexive cp from the repo would have destroyed the better version. The tracked copy is intent, the live path is reality, and reality is sometimes right.

Smoke test — strip the althing env vars, or you will test your own session's identity instead of the resolution logic. A hand-run inherits the exported ALTHING_HANDLE from the session you run it in, so the seat's own directory and a directory with no althing identity render identically and both look right. That false pass caught two agents within one hour on 2026-09-02.

Open question — whether Claude Code's own invocation inherits the environment is NOT established, and nothing here depends on it. An earlier version of this page asserted a "bare shell"; that claim was never verified and the evidence points the other way. Measured 2026-09-02: this box's Claude Code process carries ALTHING_HANDLE and ALTHING_POST_OFFICE in its own environ (inherited from the dev-launch shell), and a spawned child would normally inherit both — observed independently on two seats. Whether Claude Code scrubs the environment before spawning the status-line command was not observed either way. Write code that does not depend on the environment being present, which althing-statusline already does: environment first, launch history second, and a seat started outside dev-launch has no handle in either place. Test with the variables stripped regardless, because that is the harder case:

probe() {  # $1 = cwd, $2 = what you expect
  got=$(printf '{"workspace":{"current_dir":"%s"}}' "$1" \
        | env -u ALTHING_HANDLE -u ALTHING_POST_OFFICE \
          bash scripts/claude-statusline-command.sh)
  printf 'got [%s]  wanted [%s]\n' "$got" "$2"
}
probe /tmp                                    ''      # no althing identity
probe /home/lkraven/development/eshpfi-management '🔔 …'

Print what you got beside what you wanted, on the same line. Expected values in trailing comments are what produced the false pass that caught two agents inside an hour on 2026-09-02: /tmp rendered 🔔 and both of us read it as "fine" rather than "that directory has no identity and should be empty." A plausible value in the right shape is the hardest kind of wrong to see, and the only thing that reliably catches it is the wanted value sitting on the same line as the got value.

⚠ The outage probe: a refused port does NOT test the timeout

📵 has two causes and they take different paths. Measured 2026-09-02 on nh3-dev:

address elapsed what it proves
http://10.100.50.40:9999 — refused port on a live host 0.079 s the glyph, and nothing about the deadline
http://10.100.50.199:8390unrouted host, black-holes 2.065 s the 2 s budget actually fires

A refused port answers instantly with an RST, so the fast probe returns 📵 without ever approaching the timeout. Use both, and know which is which — letting the fast case imply the slow one is how a status line that hangs the prompt ships with a green test beside it.

Rollback

uv tool install althing-core==3.1.2

Routes written by later versions stay parseable — the old reader ignores unknown keys — so nothing is stranded.