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

1. The decision

  1. 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.
  2. No process types into a session's pane to communicate. ADR-0002 rule 1 reaches daemons. The listener's send-keys path is deleted, not kept as a fallback. Own-pane process management (start-driver, ccswitch, session-exit, ra) stays.
  3. Notice vs wake. A notice is information; it rides the recipient's next action (additionalContext on 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.
  4. 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 --all silences machines only and does not pause Drivers.
  5. 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.
  6. 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").
  7. 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

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

3.3 Quiet

3.4 Dedupe and coalescing

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

5. Open questions, answered (Gera, 2026-10-29)

  1. crossSessionInbound: accept fleet-wide: yes, if the socket probe shows delivery works; otherwise the claude -p helper path carries unattended wakes.
  2. Attended sessions: a notice appears under the next prompt.
  3. "Live in production": one coalesced owner-notice line per deploy.
  4. Channels: not tonight.
  5. 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

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):

  1. crossSessionInbound: accept set in the live user settings and tracked (dotclaude#349), on his conditional yes.
  2. 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 word quiet off in a fresh session) lifts it with one digest.
  3. The bare word resume is NOT a quiet lever (it is an ordinary instruction); quiet off is. §3.3 above said resume; this supersedes it.
  4. The live ~/.local/bin checkout carried staged edits identical to already-merged commits and an untracked smith-row-fence older 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.
  5. The live ~/.claude/settings.json diverges from main (model: haiku, enabledMcpjsonServers moved), so update-dotclaude refuses 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):

  1. 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 turn line in ~/.claude/jobs/medic/log, which nothing alarms on today. Reader: whoever runs the morning agent-audit / reads the pulse; the closing condition is either a typing-free dismiss (Channels, §1.7) or a medic ALARM on that line — not decided here.
  2. msg --force does 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.
  3. The watcher's /compact and nudge's daemon-socket (agent-send) branch are deleted, not kept behind a door.
  4. 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.
  5. 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.
  6. Slice 6 resume-wakes is 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.