radio stop-hook: flush queued messages when the agent goes idle (#163)#172
Conversation
`radio send` makes exactly one wake attempt, at send time — a message
sent while the recipient is busy/awaiting queues with zero redelivery,
and the old Stop hook (`radio ready && radio check`) couldn't help:
Stop-hook stdout goes to the hook subshell, never to the model.
Add `radio stop-hook` and switch the Stop hook to it:
- reads the Stop-hook JSON payload from stdin
- marks the role idle
- inbox non-empty -> emits {"decision": "block", ...} on stdout so
Claude Code forces the agent to continue and drain the queue
- stop_hook_active=true never re-blocks (no infinite continue loop)
- no $TASK_FORCE_ROLE -> silent exit 0 (#93 semantics)
Wiring: this repo's .claude/settings.json + all four claude-* task-init
installers, with an in-place jq migration that upgrades the legacy
`radio ready && radio check` entry when task-init is re-run. Also fix
the `radio ready` usage string (it never processed pending messages).
Closes #163
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review — PR #172 (spec: #163)Verdict: changes requested — 1 CI blocker, 2 doc/release gaps, 4 correctness findings, plus optional cleanups. The core mechanism is right and well-tested (the block-JSON contract, the #93 no-role gate, TDD coverage are all solid); the findings below are about edges of the state machine, the migration, and the docs. Blocker
Correctness
Docs / release
Optional (non-blocking, note or defer to #164/#168)
Findings 1, 6, 7 are mechanical; 2-5 are small diffs in one function + one jq block. Happy to re-review the delta. |
- shellcheck: SC2016 directive on its own line (CI blocker) - restructure cmd_stop_hook (review finding 2): count inbox first in pure bash — empty inbox marks idle and returns with zero cat/jq spawns; when blocking, mark the role BUSY (the agent is about to continue; painting idle opened a window where cmd_send wakes a running pane mid-drain) - fail safe without jq (finding 4): stop_hook_active is unreadable, so never block — degrade to the old queue-only behavior - log the stranded count on the stop_hook_active=true path (finding 3), with a breadcrumb that #164/#168 close that gap from other directions - task-init x4 (finding 5): post-merge verification warns loudly when a radio-prefixed Stop command that isn't the new stop_cmd survives the merge (non-byte-exact legacy variants are preserved, not stranded silently); upgrade_stop no longer injects hooks:[] into matcher-only user entries - README (finding 6): command table, wake-up walkthrough, hooks table, and workflow step 5 migrated off `radio ready && radio check` - CHANGELOG (finding 7): [Unreleased] entry with the load-bearing "re-run task-init <loadout>" upgrading note - kiro-* task-inits: one-line breadcrumb that keeping the legacy agentStop wiring is deliberate (no block-JSON mechanism; revisit with #146) - steering docs: describe the busy-while-draining state truthfully Tests: busy-when-blocking, jq-absent fail-safe (isolated no-jq PATH), stranded-count log, stray-variant warning, matcher-only preservation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review round 1 addressed in 205835b — all seven findings taken, mostly as suggested. Blocker (1): SC2016 directive added (on its own line — prose after the directive is SC1125 and gets ignored, which may be why it looked clean locally at first). Verified against the exact CI invocation: Correctness (2, 4): adopted the restructure wholesale — inbox counted first in pure bash (empty inbox: mark idle, return, zero spawns), jq-absent: log + mark idle + no block (fail safe), Finding 3: took the minimum (log the stranded count + code breadcrumb to #164/#168) rather than the Finding 5: post-merge verification in all four task-inits — if a Docs (6, 7): README migrated in all four spots (command table now lists Deferred to #164/#168 (as offered): the Full suite: 821/821 pass (clean env). |
Re-review — commit 205835bAll seven round-1 findings verified fixed:
Independently verified: read the restructured function in full, checked the jq program, ran |
… injection (#164) — round-2 supplemental - cmd_prompt_hook flips busy FIRST again, before any inbox work (guarded with || true — fail-open is preserved): deferring the flip re-opened the #172 idle-window race where cmd_send write-chars into a pane whose turn has already started. - _hook_role_gate notes an ignored invalid-role toggle on stderr when stdin is a TTY (manual `radio busy`), still exit 0; hook invocations pipe stdin and stay silent. - README: PostToolUse row documents why it deliberately stays `radio busy` (not prompt-hook); kiro-parity sentence corrected — kiro keeps plain `radio busy` on userPromptSubmit (no injection mechanism; #146). - workflow docs (.claude/gh-workflow.md + 4 steering templates): companion paragraph telling agents the injected `[radio] N unread message(s): …` line is the canonical radio channel, not user-typed text. 840/840 tests, drift clean (21 groups). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… injection (#164) (#173) * radio prompt-hook: surface pending inbox via UserPromptSubmit context injection (#164) An idle agent fires no hooks, so when the send-time zellij wake fails (no writable pane, stale TAB_ID, missing session file) queued messages stay invisible until a later wake happens to succeed. UserPromptSubmit hook stdout IS injected into the model's context (unlike Stop), so: - add `radio prompt-hook`: marks the role busy (old `radio busy` behavior preserved), then prints a one-line summary of any unread inbox — id, from, intent, pr/issue per message. Empty inbox prints nothing (zero context noise); missing $TASK_FORCE_ROLE is a silent exit 0 (#93 semantics). - switch the UserPromptSubmit hook from `radio busy` to it in this repo's .claude/settings.json and the four claude-* task-init installers; PostToolUse keeps plain `radio busy`. - generalize the #163 upgrade_stop jq migration to upgrade_cmd and use it to rewrite legacy `radio busy` UserPromptSubmit hooks in place on task-init re-runs; extend the #172 stray-hook warning to UserPromptSubmit. Closes #164 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * radio prompt-hook: surface pending inbox via UserPromptSubmit context injection (#164) — review round 2 PR #173 review findings: - fail open on every prompt-hook path (a nonzero UserPromptSubmit exit blocks AND erases the user's typed prompt): _update_state snapshots the session file before the awk rewrite (the #140 cascade could unlink it mid-flight — observed live as `awk: can't open file` — and _atomic_write would resurrect an empty file), _hook_role_gate extends the #93 no-role gate to set-but-invalid roles for hook entrypoints, and the state flip in cmd_prompt_hook is best-effort after the summary is printed. - fence the frontmatter parse: new _inbox_summary helper does one awk pass per message scoped to the --- block, so body lines can't fabricate optional pr=/issue= fields; values are clamped to a safe charset so a crafted intent can't forge extra summary entries. 1 fork per message instead of 5. - extract _hook_payload_field/_read_hook_payload (shared by stop-hook and unregister; #168 needs both) per the issue notes. - CHANGELOG entry with the load-bearing "re-run task-init" upgrading note. - drift-guard the 4-way installer hook block: radio-install-hooks group, 3 sentinel regions + manifest entries; stray-hook check parameterized per-event. - tests: invalid-role gates, body-line pr: leak, forged-intent clamp, unreadable-session-file fail-open, stranded-message sequence (stop_hook_active=true → next prompt surfaces), single-run variant warning. 840/840 pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * radio prompt-hook: surface pending inbox via UserPromptSubmit context injection (#164) — round-2 supplemental - cmd_prompt_hook flips busy FIRST again, before any inbox work (guarded with || true — fail-open is preserved): deferring the flip re-opened the #172 idle-window race where cmd_send write-chars into a pane whose turn has already started. - _hook_role_gate notes an ignored invalid-role toggle on stderr when stdin is a TTY (manual `radio busy`), still exit 0; hook invocations pipe stdin and stay silent. - README: PostToolUse row documents why it deliberately stays `radio busy` (not prompt-hook); kiro-parity sentence corrected — kiro keeps plain `radio busy` on userPromptSubmit (no injection mechanism; #146). - workflow docs (.claude/gh-workflow.md + 4 steering templates): companion paragraph telling agents the injected `[radio] N unread message(s): …` line is the canonical radio channel, not user-typed text. 840/840 tests, drift clean (21 groups). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Closes #163
Problem
radio sendmakes exactly one wake attempt, at send time. If the recipient isbusy/awaiting(the common case for a working agent), the message queues with zero redelivery. The Stop hook (radio ready && radio check) was useless for delivery: Stop-hook stdout goes to the hook subshell, never to the model. Live evidence: 101busy … queuedlog entries with no redelivery; 23 messages dangling in the pm inbox.Solution
New
radio stop-hooksubcommand, and the Stop hook switched to it:stop_hook_active=true→ just mark idle, exit 0 (prevents block loops).{"decision": "block", "reason": "radio: N unread message(s) — …"}on stdout with exit 0, so Claude Code forces the agent to continue and drain the queue the moment its turn ends.Also fixed the
radio readyusage string — it claimed "(and process pending)" but only ever flipped STATE.Wiring
.claude/settings.json: Stop hook →radio stop-hook.task-initinstallers: newstop_cmd, plus an in-place jq migration (upgrade_stop) that rewrites a legacyradio ready && radio checkStop entry whentask-initis re-run —add_radioalone would have left the stale command behind.Acceptance criteria
stop_hook_active=truenever re-blocks$TASK_FORCE_ROLE→ silent exit 0 (Radio hooks must no-op when $TASK_FORCE_ROLE is unset (don't block plainclaudesessions) #93 semantics)Verification
tests/radio_stop_hook.bats: 10 tests (no-role gate, idle transition, block JSON validity/content, inbox not consumed, no-payload manual invocation, active-guard, usage honesty).tests/claude_gh_task_init.bats: Stop-hook wiring + legacy-upgrade migration tests.env -u TASK_FORCE_AUTO_SUBMIT ./run_tests.sh— this worker tab exportsTASK_FORCE_AUTO_SUBMIT=1, which 4 pre-existing auto-submit tests are sensitive to; unrelated to this change).🤖 Generated with Claude Code