0076 — A wake reaches the session that owns the work and never a human's input box: workers wait on their own signal, owners get coalesced notices, and quiet holds everything
- Status: Accepted 2026-10-29. Slices land one PR at a time (§6); this record is written with the first, as ADR-0004 requires, not after the last.
- Date: 2026-10-29
- Deciders: Gera, in conversation on 2026-10-29 after the night of 2026-10-28/29 (§2), four rulings: a wake for a worker goes straight to that worker, fleet-wide, never through the main session; nothing is ever typed over a human; I must be able to silence everything ("quiet") and lift it with one digest; ship it fleet-wide tonight, after proving it on my session first. The open questions of the design review were answered by him the same night (§5). Recorded by the lane that implements it.
- Design review: the read-only review this ADR condenses measured the night, verified the native channels against the Claude Code 2.1.312 docs and binary, and ran two live probes; it is quoted where it matters and not duplicated.
- Systems:
- review-webhook (dotclaude
services/review-webhook/listener.js): the only daemon that types into panes today; loses that path (§4). - quiet (local-bin
quiet, dotclaude listener hold): new entry inSYSTEMS.md. - review-sub (local-bin): subscriptions grow a kind,
waiter|owner(§3.2, §3.4). - notices (dotclaude
hooks/notices.sh, generalisingwt-merged-inbox.sh): the attended-session channel (§3.1). - deliver (local-bin
lib/deliver.py): the one writer every injector calls (§3.1, §3.6). - pr-stuck-wake, stuck-sweep, fleet-medic, nudge, wt-merged-notify, wake-session, msg (local-bin / dotclaude): the other injectors; each moves onto
deliver()and loses itssend-keys(§3.6). - guard-bash rule 20: widened (§3.6).
- review-webhook (dotclaude
- Supersedes: ADR-0039 §3 ("a sub-agent's PR wakes its PARENT, and that is the decision, not a defect") and the delivery mechanics of its 2026-10-07/08 amendments. Its stage table and its dedupe rule (keys in the status file) stand.
- Amends: ADR-0002: rule 1 applies to daemons as well as agents; the listener's
send-keyspath, never on ADR-0002's legacy inventory, is added to it and deleted; the enforcement row that inventory left open is filled (§3.6). Rule 2 (own-pane process management) is untouched. - Relation: ADR-0030 (push, not poll) stands; nothing here polls GitHub. ADR-0003: this is rung 1-2 reflex work, files and hooks, not Temporal. ADR-0009: why
quietcarries no per-event liveness signal.
1. The decision
- A wake for a worker goes straight to that worker, never through the main session — every session, every Driver, fleet-wide. Mechanism: the worker arms its own subscription (a waiter: no pane) and its own background wait on the listener's signal file, and the harness resumes the worker when the file appears. Measured 2026-10-29: a returned sub-agent was re-invoked by the harness 35 s after its background task finished, and again after 722 s, with no parent turn and no keystrokes.
- No process types into a session's pane to communicate. ADR-0002 rule 1 reaches daemons. The listener's
send-keyspath is deleted, not kept as a fallback. Own-pane process management (start-driver,ccswitch,session-exit,ra) stays. - Notice vs wake. A notice is information; it rides the recipient's next action (
additionalContexton its next tool call or prompt) and never starts a turn. A wake starts a turn, and only in a session that has nobody to start one: a worker parked on its PR (its own wait), or an unattended Driver (a cross-session message, never keystrokes). An attended session is never woken by a machine. In an attended session a notice appears under the next prompt. - Quiet is a fact on disk, distinct from PAUSED: PAUSED means the model stands down; quiet means humans talk and machines do not. Set by the human in one word; held events are kept; one digest on lift.
quiet --allsilences machines only and does not pause Drivers. - One subscription per (repo, pr, owner); one notice per owner per 30 s window across all their PRs; a deploy is one line. "Live in production" is one coalesced owner-notice line per deploy.
- The delivery library is one path.
deliver(sid, text, kind)checks, in order, quiet → PAUSED → attended → channel, and is the only writer any injector calls. There is no transport switch and no typed fallback ("a fix is ONE path, never a switch"). - Channels (
claude --channels, research preview) are named as the intended successor transport for every machine→session event once out of preview. Not adopted tonight; nothing here depends on it.
2. What happened, measured (2026-10-29 00:00-02:40Z)
From ~/.claude/services/review-webhook/listener.log and the main session's transcript (57769700, tmux claude-fresh-1792611180):
| Measure | Value |
|---|---|
| Wakes typed by the listener fleet-wide | 229 |
| Of those, into Gera's main pane | 110 (55 "pr stage", 29 "review landed", 26 "merged") |
User-role turns in the main session containing [webhook] |
274 of 787 |
Subscriptions moved aside to stop it (~/.claude/review-subs-paused-57769700/) |
75, for 63 PRs; 12 PRs with two subs each (--auto + --lane); 59 of 75 were proxy wakes "opened by your sub-agent X: forward this to it" |
| One production deploy at 02:11:52Z | 11 PRs, up to 11 "live in production" lines for one event |
| What else was stopped | 9 sub-agents, ad hoc, with no record a resume could read |
The transcript shows the mechanism exactly: can [webhook] PR #23355 ... is live in production ...u s is Gera's keystrokes with a wake typed into the middle of them. The listener's dialog gate (dotclaude, 2026-10-28) held wakes while a picker was open and released them in a burst (waitedMs up to 488 s); it did not and cannot know a human is typing.
3. Why this shape
3.1 The push model is right; the transport and the addressee were wrong
- Push, not poll stands.
tmux send-keyshas no addressee, no queue and no turn boundary: it delivers to whatever the pane shows, which that night was a human's half-typed sentence. Every safeguard added in October (two-call send, exact=name:target, dialog gate, paused park, wake cap) was a patch on a channel that cannot be made safe, because the channel is the human's input box. - The addressee was a relay. 59 of 75 subscriptions woke the Driver so it could
SendMessagethe sub-agent by hand. ADR-0039 §3 settled that on the claim "there is no channel into an Agent-tool sub-agent except its parent's SendMessage" — true for pushing in from outside, false as a reason for a relay, because a sub-agent can arrange its own resume before it returns, and the harness honours it (measured twice, §1.1). - One assumption was wrong underneath: that a wake must start a turn. Most of the 110 wakes were information ("live in production", "merged") that needed no turn. Separating notice from wake gives each recipient the right channel:
| Recipient | What it gets | Channel | Starts a turn? |
|---|---|---|---|
| Worker (Agent-tool sub-agent parked on its PR) | the event itself | its own background wait on the signal file → harness resumes it | yes, in the worker only |
| Attended session (a human is attached) | a coalesced notice | pending file → additionalContext on its next tool call or prompt |
never |
| Unattended session (idle Driver, nobody attached) | a coalesced notice as a wake | the session's inbox socket, or until that is probed a claude -p helper SendMessage |
yes, as a cross-session message, never keystrokes |
Attended means a tmux client is attached to the session's pane, or a human-origin prompt landed in the last 30 min (fleet-medic's definition).
3.2 Worker-direct wake
review-sub arm --repo R --pr N --lane ITEM --brief PATH # a WAITER sub: no pane, exit 0
SIG=$(review-sub signal N) # the three signal basenames, as a glob
Bash(run_in_background): until the glob exists (deadline = the sub's TTL); then consume by rename
→ the worker ends its turn "parked on #N" (a RETURN, not a completion)
→ the listener writes the signal → the harness resumes the worker with the task notification
→ the worker reads the verdict, pushes the fix, re-arms, parks again
arm --laneno longer refuses without a pane; for a waiter no pane is the healthy state (kind: "waiter", diagnosedWAITING). The "THE WAKE LANDS ON, NOT ON YOU" banner goes. - The wait carries a deadline: a reviewer at its weekly cap sends no signal ever, and silence resumes the worker with a non-zero exit it can act on.
- Parked is not done. A Driver receives a completion notification for a lane that has only parked; a return whose last line says
parked on #Nis not a completion and is not re-dispatched or counted. - A parked lane lives as long as its parent process;
list's WAITING checks the owner session is live, and a successor Driver re-dispatches from the brief, which is why--briefstays required. - With worker-direct wakes, quiet never needs to stop a lane: the 9 lanes stopped that night were stopped only because their wakes went through Gera.
3.3 Quiet
Markers:
<jobs>/quiet/ALL,<jobs>/quiet/<sid>(JSON: by, at, reason, pane), and<jobs>/quiet/pane/<tmux>— the pane a subscription would type into, written beside the sid marker as a belt for a subscription whose sid does not resolve. Measured before choosing: all 83 subscriptions on Gera's pane that night resolved to his sid, so the pane marker is a belt, not the braces.Set by
quiet on|off|status [--sid S|--all](local-bin),/quietand/quiet off, and the bare wordquiet/resumeas a whole prompt (a UserPromptSubmit hook; hook config is snapshotted at session start, so the word works from the next session, the CLI at once).Honoured by the listener first (checked before PAUSED and before any tmux, local and remote alike, since the markers live on the listener's box), then by
deliver()for every injector. A hold that cannot be appended fails closed: nothing is typed; the stage is still in the status file. Workers are untouched: their signal files are still written and they still resume.Lifting: the marker goes first, then the inbox is renamed aside, then the held lines become ONE digest — one clause per deploy, per kind, per verdict — printed and written to the notice inbox, delivered on the session's next action:
[quiet lifted 02:50Z; held 02:12Z-02:51Z; 7 event(s)] 3 PRs live in production (02:11Z): #23352 #23355 #23357 · merged: #23353 · checks failed: #23380(GITHUB_ACTIONS) · review BLOCKING: #23359No per-event liveness signal on quiet: it would read stale exactly when healthy (ADR-0009). Its catalog signal is the holder running.
3.4 Dedupe and coalescing
- One owner-notice subscription per (repo, pr, owner-sid):
--autoand--lanederiving two ids for one PR with the same wake target is what made 12 duplicates. Waiter subs (worker-owned, file only, N per PR is fine) are a different kind from owner-notice subs (one per repo/pr/owner;armers[]accumulates). - Per owner, per 30 s window, across all their PRs: one notice. The per-PR 30 s window is why one deploy became 11 lines.
- The
(opened by your sub-agent X: forward this to it with SendMessage)clause is deleted: the sub-agent already has it. - Dedupe keys stay in the status file (ADR-0039 §4).
3.5 Resume
resume-wakes [--from <paused-dir>] lifts quiet for the sid, restores paused subscriptions whose PR is still open and whose TTL has not run out, re-keyed to the (repo, pr, owner) shape, and prints the sub-agents only a parent can resume. It exists only after the typed path is gone (§6): restoring a subscription that still carries a wakeTmux while send-keys exists re-creates that night. The 75 subscriptions paused on 2026-10-29 are not restored by anything; they expire on their TTL, every stage they would have announced is in the status files, and most of their lanes were stopped anyway.
3.6 Inventory and removal (ADR-0004 part 3)
| Surface | Disposition |
|---|---|
listener.js wakeSession / buildTmuxWakeArgs / remote ssh wake / wake cap |
deleted; deliverSignal stays; owner notices go through deliver() |
dialog-gate.js, ask-wake-guard.py's post-wake answer block, jobs/wake-marks |
dead once nothing types; deleted in the same slice |
review-sub wakeTmux, pane_reachable, DEAF / UNREACHABLE / proxy diagnoses |
replaced by `kind: waiter |
pr-stuck-wake, stuck-sweep STALLED, fleet-medic NUDGE_IDLE, nudge --_watch, wt-merged-notify msg fallback, wake-session, msg's pane path |
call deliver(); their send-keys goes |
| guard-bash rule 20 "raw-send-keys deny" | widened from Drivers to any agent process into any non-self pane — the enforcement row ADR-0002's inventory left open |
start-driver, start-architect, ccswitch, session-exit, ra self-pane sends |
untouched: own-pane process management, ADR-0002 rule 2 |
4. What is not proposed, and why
- Keeping
send-keysbehind a dialog gate, a typing detector or a "human quiet for N s" check: the composer is shared state with a human; any detector is a race. - Routing worker wakes through the Driver's
SendMessagefaster: the relay is the defect, not its latency. - Wrapping any of this in Temporal: rung 1-2 reflex work (ADR-0003).
5. Open questions, answered (Gera, 2026-10-29)
crossSessionInbound: acceptfleet-wide: yes, if the socket probe shows delivery works; otherwise theclaude -phelper path carries unattended wakes.- Attended sessions: a notice appears under the next prompt.
- "Live in production": one coalesced owner-notice line per deploy.
- Channels: not tonight.
quiet --all: silences machines only; does not pause Drivers.
6. Slices (vertical, each works alone, ≤300 substantive lines, all off origin/main)
| # | Slice | Repo | Ships |
|---|---|---|---|
| 1 | Quiet marker + listener hold + CLI | dotclaude#346, local-bin#704 | quiet on → the listener parks every wake for that sid (or ALL) into the inbox, types nothing; quiet off prints and files the digest |
| 0 | This ADR + quiet in SYSTEMS.md |
agentflow | written with slice 1 |
| 2 | Notice inbox hook | dotclaude | jobs/notices/pending/<sid>.jsonl → additionalContext; /quiet, /quiet off; the one-word rule |
| 3 | Worker-direct wait | local-bin, skills | arm --lane writes a waiter, review-sub signal N, list says WAITING, worker skill: arm, background wait, park |
| 4 | Owner notices replace typed wakes | dotclaude, local-bin lib/deliver.py |
wakeSession → deliver(); one notice per owner per 30 s; proxy clause gone; one sub per (repo, pr, owner); dialog gate deleted |
| 5 | Every other injector onto deliver() |
local-bin | one PR each; then guard-bash rule 20 widened |
| 6 | resume-wakes |
local-bin, commands | only after 4 |
Order: 1 → 0 → 2 → 3 → 4 → 5 → 6 — slice 1 (the hold, firm and probe-free) goes before slice 0 (this record) on purpose, so the night's pane is protected first and the ADR ships with it; the numbers are the design review's and are kept so its citations stay true. Each slice says in its PR what it proved live.
7. Risks, and what each slice proves before it is called live
- Background wait lifetime: measured to 722 s; re-arm on the notification if a wait must exceed it.
- Socket delivery to a bypass receiver (slice 4): probed against a throwaway bypass session from a non-child process before anything depends on it.
- Attended discriminator false negatives (a human attached through Agent view, not a tmux client): the 30-min human-prompt rule covers it.
- Signal overwrite between write and read: consume by rename.
- A worker that never re-arms never hears the next verdict; the Driver's
notify_when_idleon the lane is the backstop, and the stuck sweep still writes the status file. - Nothing here changes the webhook, the signature check or the status files.
8. Receipts, 2026-10-29 (appended as the slices landed; nothing above is edited)
| Slice | PR(s) | Proof |
|---|---|---|
| 1 quiet hold + CLI | gera-propflow/dotclaude#346 (merged 03:44Z, live 03:47Z), PropFlow-Technologies/local-bin#704 (merged 03:53Z, live 04:07Z) | quiet on --sid 57769700… at 03:47:21Z on Gera's session; first held event 03:50:56Z (quiet.held … merged #23381), then review landed #23395; 0 wake_session.sent into his pane after the restart, 7 in the hour before; 4 held lines in quiet/pane-claude-fresh-1792611180.inbox.jsonl by 03:51Z. |
| 0 this record | agentflow-relay#456 (merged 03:39Z) | — |
| 2 notices hook | dotclaude#347 (merged ~04:04Z; hand-applied to the live checkout) | tests/notices-hook-delivers-on-next-action.sh 39/39; the word quiet / quiet off and /quiet wired; 3 MB tool payload costs 88 ms on the no-notice path. |
| 3 waiter subs | local-bin#706 (merged 04:06Z, live 04:08Z), #709 (follow-up), dotclaude#348 (merged 04:08Z) | Five review verdicts reached the lane that opened the PR through its own background wait, with zero typed wakes: #704 signal.wrote 03:29:35Z → wait returned 03:29:36Z; agentflow-relay#456 03:35:47Z → :48Z; #706 03:58:30Z; #708 04:12:12Z; #709 04:12:16Z. |
4a deliver |
local-bin#708 | tests/deliver-never-types-and-picks-the-channel-in-order.py 21/21 against a real unix socket. |
| 4b owner notices | dotclaude#350 | listener −573/+166; the typed path, dialog gate, wake cap and ssh wake deleted; one deliver call per owner per 30 s across PRs; a deploy of N PRs is one line (test). Live receipt pending the restart. |
| Q1 socket probe | dotclaude#349 | A raw post to a throwaway bypass session's /tmp/cc-socks/<pid>.sock was HELD behind a picker (no-mode-asserted) with the default and with a project-level accept (a repo setting may only tighten); with user-level accept the same post was delivered as a cross-session message and answered RECEIVED, no picker, no keystrokes (04:01-04:04Z). crossSessionInbound: accept is therefore fleet-wide, per §5 Q1. |
Decisions made for Gera overnight (his to adjust in the morning):
crossSessionInbound: acceptset in the live user settings and tracked (dotclaude#349), on his conditional yes.- His session 57769700 was put on quiet at 03:47Z for the live proof and is left on quiet:
quiet off --sid 57769700-3b7e-4dc4-b826-20972fc8d979(or the wordquiet offin a fresh session) lifts it with one digest. - The bare word
resumeis NOT a quiet lever (it is an ordinary instruction);quiet offis. §3.3 above saidresume; this supersedes it. - The live
~/.local/bincheckout carried staged edits identical to already-merged commits and an untrackedsmith-row-fenceolder than main's; the identical edits were restored and the file renamed aside (smith-row-fence.pre-ff-<epoch>.untracked) so doctrine-pull could advance. Nothing of anyone's was lost. - The live
~/.claude/settings.jsondiverges from main (model: haiku,enabledMcpjsonServersmoved), soupdate-dotclauderefuses every merge that touches it; slices 2 and 4b were applied to the live checkout by hand. Reconciling that file is a person's call.
Still open after tonight: slice 5 (pr-stuck-wake, stuck-sweep STALLED, fleet-medic NUDGE_IDLE, nudge --_watch, wt-merged-notify's msg fallback, wake-session, msg's pane path → deliver; then guard-bash rule 20 widened); slice 6 resume-wakes; hooks/ask-wake-guard.py's post-wake answer block is dead and still present; the 75 subscriptions paused on 2026-10-29 expire on their TTL, unrestored (§3.5).
8b. Receipts, 05:13-05:16Z (the typed path is gone from the live listener)
| What | Receipt |
|---|---|
| Slice 4b live | dotclaude#350 merged 9ad845b, hand-applied, listener restarted 05:13:36Z: deliver.resolved {"bin":"~/.local/bin/deliver","window_ms":30000}. 0 wake_session.sent anywhere fleet-wide since (the word no longer exists in the code). |
| First coalesced owner notice | 05:15:07Z notice.delivered {claudeSid: 57769700…, kind: wake, events: 2, prs: [23422, 23436], channel: "notice tmux client attached to claude-fresh-1792611180"} — two PRs' events in one window → ONE deliver call → a human was attached, so a notice for his next prompt, not a turn. |
| Quiet lifted for real | 05:14Z quiet off --sid 57769700…: 88 held events → one digest line (6 PRs live in production at 05:09Z in one clause; 12 merged; 11 checks failed; 4+4+8 verdicts by kind), filed as one notice for his next prompt. Decision 2 above is therefore superseded: his session is NOT left on quiet; every later event reaches him as a notice, never a turn, because he is attached. |
| Slice 5 parts 1-2 live | local-bin#710 (pr-stuck-wake) and #711 (wt-merged-notify) merged and pulled by 04:52Z. 04:57:00Z stuck-wake CLEARED … told=57769700… went through deliver and was HELD for his quiet — the same event the old code had typed at 04:51:07Z. |
| What still got typed into his pane tonight, stated | Three [pr-stuck] lines, at 04:21:47Z, 04:27:42Z and 04:51:07Z, by the OLD pr-stuck-wake before #710 was live (its own send-keys path, outside the listener's quiet hold). The listener itself typed nothing into his pane after 03:47Z. |
| Follow-ups merged | local-bin#712 (an armer-shaped waiter reads WAITING), #714 (deliver holds/parks one line per event), #715 (the digest's #None label) — the last two driven to the queue at the time of writing. |
| Review rounds | dotclaude#350 took four fresh-context reads (🔴🔴🔴🟡); every finding was fixed or filed (local-bin#714). Past the three-round rule it was merged on the first non-blocking read, per the standing rule. |
Still open after this record (§8c closes the MacBook rollout, slice 5 parts 3+ and rule 20 widened; slice 6 is deliberately not built, §8c decision 6; the mini's ~/.claude checkout is still behind its edited settings.json, hand-applied only): the MacBook carries neither deliver nor hooks/notices.sh (its local-bin and dotclaude checkouts are behind), so a Mac-hosted owner would get only its signal file plus a line under jobs/notices/remote-pending/<host>/ on the mini; there were zero Mac-hosted subscriptions tonight, and the first notice.remote_filed is the signal to do that rollout. Slice 5 parts 3+ (fleet-medic, nudge, wake-session, msg's pane path, find-pane --send) and guard-bash rule 20 widened; slice 6 resume-wakes; the mini's runtime ~/.claude checkout stuck behind its locally edited settings.json (slices 2 and 4b hand-applied).
8c. Receipts, 2026-10-29 05:30-16:00Z — slice 5 complete: every injector is on deliver, the guard is fleet-wide, the MacBook is at parity
Written by the lane that finished slice 5 (wake-fix-2), driving alone overnight on Gera's instruction "decisions, not questions". Nothing above is edited except the pointer on §8b's still-open line; every decision taken for him is listed at the end so he can reverse it in the morning.
| Injector | PR(s) | What changed | Proof |
|---|---|---|---|
find-pane --send |
local-bin#716 (merged 06:07Z) | deliver --sid <full> --kind wake --source find-pane; 8 characters resolve from the matched pane's @claude_session_id stamp or refuse (6); a full id with no pane is still delivered; a slash command is refused (5) because a notice is not a composer; a failing deliver exits 7 |
sandboxed tmux + deliver recorders: six cases, zero send-keys, the recorder's capture-pane/show-option as the control; fails on the old tool |
fleet-medic (the one nudger) |
local-bin#717 (06:03Z), #718 (06:22Z) | type_line → tell() through deliver; no Esc into a question (the picker stays open; the nudge rides the next prompt or the socket); the wedge Esc PROBE stays as process management; grading follows the channel (wake-socket graded, notice/held/parked never a strike, capped at max_pokes_without_turn 3, re-armed by a human prompt) |
pulse suite 0 failures with every tmux call recorded (zero send-keys); live-tmux suite 99 ok — its fake Claude's keys.log still sees the wedge Esc and sees nothing on a poke. Live: no poke has fired since the pull (pokes=0 on every tick 06:48Z-15:30Z, nobody idle in needs-kickoff); the first one will log via <channel> |
wake-session |
local-bin#719 (06:17Z) | the stamped message goes to deliver; no pane, no flock, no submit check (−200 lines); exit alphabet kept as a subset 0/2/3/6 (4 and 5 never emitted); a slash command is refused (6) | tests/wake-session-goes-through-deliver-never-types.sh all pass; the two lock-era suites deleted; decision_block.py and decision-hook/listener.js read the same codes |
nudge |
local-bin#720 (06:29Z), #721 | four send-keys sites and the agent-send switch → deliver_wake; fire()'s 60-minute wait-for-idle gone; the watcher's typed /compact gone (the session's own context-budget.py compacts it, ADR-0002 rule 2) |
nudge-provenance.sh 26/26 with the pane as the instrument (§2 types into it by hand, §1/§9 assert it stayed empty while the recorder got the stamped payload); control-35 11/11. Live: 7 watchers armed before the merge kept the old code in memory and typed [msg from … via nudge] into Driver panes until 15:23Z; re-armed through the new nudge at 15:3xZ (receipt below) |
msg (agent-smith agent-tools/msg) |
agent-smith#1730 (open at the time of writing) | ra.send_then_enter unreachable from msg; a busy target is delivered (read between tool calls), --force is a no-op, a target off the roster is still delivered, a refused deliver parks (deliver-failed); --all reaches every roster session but this one; the --json receipt keeps its keys with unverified/failed/no_pane 0 by construction |
the one changed test file 174 passed; ruff + mypy clean; test_the_delay_is_defined_once_for_the_whole_fleet now pins that msg's deliver is NOT send_then_enter |
pr-stuck-wake, wt-merged-notify |
local-bin#710, #711 (§8b) | — | — |
The measured limit of the channel. /compact keep: PROBE-OK posted raw over a throwaway bypass session's messaging socket (05:59-06:02Z, mini) arrived as a wrapped cross-session message and the session answered "I can't run /compact from inside the conversation" — nothing compacted. So a slash command cannot ride deliver (a notice is not a composer either): wake-session, find-pane and nudge refuse one; the mechanical compact stays the session's OWN context-budget.py writing into its own pane (ADR-0002 rule 2), and the wake-session rung it used to try first now falls through to that path. The same probe, one step earlier, is the first live receipt for deliver --kind wake to an unattended session: wake-socket sent → a turn started → PROBE-OK on screen, nothing typed.
Guard, fleet-wide. dotclaude#351 (rule 20b): a raw tmux send-keys is refused for EVERY session unless the command IS one plain send to the session's own pane ($TMUX_PANE itself, or the bare name of a one-pane tmux session stamped with its id). Chains, \; chains, wrapped forms (bash -c, eval, xargs, interpreters), tmux's own runners (run-shell, if-shell, source-file), window/pane suffixes, $variables, other servers and a tmux that cannot be asked are all refused; a mention (a commit message) is allowed only when driver-reach --broad confirms nothing runs. Three fresh-context reads so far (🔴 chains and the first--t bypass; 🔴 wrapped forms behind a msg, tmux's runners, the active-pane trap; the third was in flight when this was written — its verdict is in the PR); 45/45 cases, with the control that the suite fails against the pre-change hook. The swarm and handoff-auto skills stop teaching send-keys. The live mini ~/.claude cannot fast-forward (its settings.json is hand-edited; a person's call), so once merged the hook is hand-applied to the live checkout like slices 2 and 4b — the receipt of that apply is a follow-up line here, not yet written.
MacBook parity (16:0xZ). ~/.local/bin fast-forwarded 28 commits (deliver, lib/deliver.py present); ~/.claude merged main (its normal shape — the checkout carries two local commits of Gera's, settings.json among them, and the merge was clean): hooks/notices.sh present, wired in settings.json, crossSessionInbound: accept. Proof, end to end, on a Mac session born after the merge: deliver --sid <mac sid> --kind notice "NOTICE-PROBE-MAC-1 …" filed the notice; deliver --kind wake "run date and report any notice" reached it over its socket; the session reported the notice verbatim, delivered as "UserPromptSubmit hook additional context" on the incoming message — nothing typed, zero send-keys. Pre-existing Mac sessions see the hook from their next start.
Decisions made for Gera overnight (his to adjust):
- A stuck AskUserQuestion is no longer cancelled by Esc (fleet-medic): with nobody attached, the picker stays open until a human arrives; the only trace is a
POKE-STOPPED … started no turnline in~/.claude/jobs/medic/log, which nothing alarms on today. Reader: whoever runs the morningagent-audit/ reads the pulse; the closing condition is either a typing-free dismiss (Channels, §1.7) or amedic ALARMon that line — not decided here. msg --forcedoes nothing (a busy session reads a cross-session message at its own boundary). Its park-on-busy, pane proof and question gate are gone with the keystrokes; the inbox still drains.- The watcher's
/compactand nudge's daemon-socket (agent-send) branch are deleted, not kept behind a door. - The seven pre-merge nudge watchers were re-armed in place (same sid, text and interval) so the live fleet runs the new code; the one armed after the merge already did.
- agent-smith#1724 (a duplicate KNOWN_DIVERGENCES prune, needed only to pass the pre-push gate) was closed in favour of #1723/#1726; the msg PR carries that prune until one lands, then is rebased.
- Slice 6
resume-wakesis not built: the 75 paused subscriptions expire by design (§3.5), the lanes they would have resumed are gone, and nothing since slice 4 stops a lane. If a human stops lanes by hand again, that is the day to build it.
Still open: fleet-medic's first live poke through deliver — nobody has been idle in needs-kickoff since the pull, and pokes=0 on every tick also describes a dead poke path, so the proof is the first STUCK idle … via <channel> line in the medic log, not the absence; agent-smith#1730 to merge; dotclaude#351 to merge and be hand-applied (receipt above); hooks/ask-wake-guard.py's dead post-wake block still carries an uncommitted local edit in the mini's checkout; control-71 (nudge) shows five standalone failures — compared line by line against origin/main's nudge in the same harness (same five assertion lines, same order, none exercising deliver_wake), so they predate this work, but no baseline commit is named and nobody has root-caused them; tmux respawn-pane -k / display-popup / command-prompt into another pane are not refused by rule 20b (they type nothing; respawn-pane -k kills what runs there) — filed for its own rule in the dotclaude#351 thread, no PR yet.
9. Amendment, 2026-11-05 — an ALERT starts the owner's turn even when attended (Gera)
Ruling. Gera, 2026-11-05: "we need to have a webhook for everybody… As soon as it fails, you should get the webhook. So, something's broken there. We need to fix that." This narrows decision 3 ("an attended session is never woken by a machine") for one class of event.
What broke. propflowai #25298 got a review verdict at 01:50Z and failed checks at 01:54Z. The listener logged notice.delivered … channel: notice tmux client attached to <pane> both times: Gera's terminal was attached to the owner's pane, so deliver filed each as a notice that rides the session's next action. The owner session was idle, got no turn, and the PR sat ~35 minutes until Gera typed. Attendance had become a reason for the work to stop, not a reason to avoid typing.
Why this does not reopen the 10-29 harm. The harm in §1 was KEYSTROKES landing in a sentence Gera was typing. Since slice 5 nothing types: a wake is a cross-session message over the session's socket, the same channel a finished background task uses to start a turn while a human is attached. Quiet and PAUSED (§3.3) still hold every alert, so "silence everything and resume in one step" is unchanged.
The rule now. deliver(sid, text, kind) has three kinds:
| kind | quiet / PAUSED | attended | unattended |
|---|---|---|---|
notice |
held / parked | notice | notice |
wake |
held / parked | notice | socket turn |
alert |
held / parked | socket turn | socket turn |
An alert is an event the owner must act on now: a review verdict, checks failed at head, a merge-queue kick-out, a merge, a PR stuck at one head. The review-webhook listener sends those as alert (information-only windows, such as a production deploy alone, stay notice), and pr-stuck-wake sends alert. Every other injector keeps wake. The kinds that wait for nobody to be attached are an explicit set in lib/deliver.py (KINDS_THAT_WAIT_FOR_NOBODY = {"wake"}), so a new kind must choose.
Landed by: PropFlow-Technologies/local-bin#740 (deliver --kind alert, pr-stuck-wake), gera-propflow/dotclaude#364 (the listener sends alerts). Order: #740 first; before it, the listener's existing failure path files the notice (notice.filed_fallback), so nothing is lost either way.