Skip to content

radio prompt-hook: surface pending inbox via UserPromptSubmit context injection (#164)#173

Merged
martin-conur merged 3 commits into
mainfrom
task/issue-164
Jul 7, 2026
Merged

radio prompt-hook: surface pending inbox via UserPromptSubmit context injection (#164)#173
martin-conur merged 3 commits into
mainfrom
task/issue-164

Conversation

@martin-conur

Copy link
Copy Markdown
Owner

Closes #164

Problem

An idle agent fires no hooks, so if the send-time zellij wake fails (no writable pane, stale TAB_ID, missing session file) the queued message is invisible until a later wake happens to succeed — landing precisely on recipients that are ready to work.

Changes

Verification

  • tests/radio_prompt_hook.bats (new, 10 tests): both queued messages listed in one line; empty inbox → zero stdout; no-role → silent exit 0; busy transition; inbox not consumed; issue=/pr= field handling; missing-session-file resilience; usage.
  • tests/claude_gh_task_init.bats (+3): UserPromptSubmit = radio prompt-hook; legacy radio busy upgraded in place while PostToolUse is untouched; non-byte-exact variant preserved with a loud WARNING.
  • Full suite: 834/834 pass (env -u TASK_FORCE_AUTO_SUBMIT ./run_tests.sh; the env-unset is needed because this worker session itself runs under task-work --auto, which leaks TASK_FORCE_AUTO_SUBMIT=1 into three pre-existing radio_auto_submit assertions — unrelated to this change).
  • tools/check-drift.sh: 18 groups clean.

🤖 Generated with Claude Code

… 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>
@martin-conur

Copy link
Copy Markdown
Owner Author

Review — PR #173 (spec: #164)

Verdict: changes requested — 2 correctness findings, 1 release gap, 2 explicitly-requested items from the issue notes that were skipped, plus optional hardening.

First, credit where due: upgrade_cmd is genuinely general (event-agnostic (arr; old; new) — verified the jq scopes the migration to UserPromptSubmit only, PostToolUse keeps plain radio busy); the .claude/settings.json change applied via jq after the permission-guard block is byte-identical to the spec'd one-liner (audited — right call, and right to disclose it); empty-inbox fast path is spawn-clean; shellcheck directives correctly placed; CI green ×4.

Correctness

  1. bin/radio cmd_prompt_hook — three paths exit nonzero, and on UserPromptSubmit a nonzero exit BLOCKS AND ERASES the user's typed prompt. This hook's failure cost is strictly worse than stop-hook's, and the contract in your own tests says every path exits 0 — but under set -euo pipefail:

    • _update_state busy runs bare: the Radio session corruption: unregister cascade → re-seed loses TAB_ID + LOADOUT (breaks --auto wake-up) #140/Complete #140: analyze cascade-payload logs, expand SessionEnd skip-list, restore LOADOUT/AGENT preservation #151 unregister cascade (which this ticket's own description cites, and which hit your session mid-task) can unlink <role>.info between _ensure_session_file's check and the awk rewrite → awk exit 2 → hook exits 2 → every prompt during a cascade burst is eaten. Bonus damage: _atomic_write on the pipe's right side still fires and resurrects an empty session file.
    • Each from=$(awk … "$f") in the summary loop aborts the script if the message file vanished between glob and read (concurrent radio read from another shell — TOCTOU).
    • A set-but-invalid role (_validate_role rejects e.g. worker-my.app-issue-7REPO_NAME is an unsanitized basename) hits _require_role's exit 2 inside _update_state, blocking every prompt for the session's life. Preexisting with radio busy, but this PR creates the dedicated entrypoint whose comment promises exit-0 semantics — make invalid-role a logged silent 0 in hook entrypoints.

    Suggested shape: fail open everywhere — guard _update_state busy || true, || true the per-message reads (or better, see finding 2's single-awk which handles ENOENT once), and extend the #93 gate to cover invalid roles for hook subcommands.

  2. bin/radio summary loop — frontmatter extraction isn't fenced to the --- block, so message-BODY lines leak into the injected context. awk '/^pr:/{print $2; exit}' scans the whole file; pr:/issue: are optional keys, so when absent from the frontmatter a body line like pr: will be opened later fabricates pr=will be opened later in the summary the model acts on. (cmd_check's identical awks get away with it only because from:/intent: always exist in frontmatter and exit on first match.) Fix with one fenced awk pass per fileawk '/^---$/{f++; next} f>=2{exit} …' extracting all four fields — which simultaneously fixes finding 4.

Release

  1. No CHANGELOG entry — same miss as radio stop-hook: flush queued messages when the agent goes idle (#163) #172 round 1, and the "Upgrading: re-run task-init <loadout>" note is load-bearing again (the radio busyprompt-hook migration only runs then). Second consecutive PR; please make this part of the pre-PR checklist.

Efficiency

  1. 5 subprocess forks per pending message on the hottest path in the system (basename + 4 awks, each rescanning the file). A PM with 15 queued messages pays 75 forks before every prompt it's sent. The finding-2 single-awk brings it to 1 fork per message; id="${f##*/}"; id="${id%.md}" removes basename.

Issue-notes compliance (both were explicit in the #164 notes)

  1. Shared _inbox_summary helper not extracted — the summary formatter is inlined in cmd_prompt_hook, so radio register: drain-on-register — inject offline backlog summary at SessionStart #168 cannot be the promised ~20-line reuse and the repo now has its third copy of the frontmatter-awk pattern. Extract a helper that emits the entry list (<id> from=… intent=… [pr=…] [issue=…] | …) and let each call site own only its lead-in/trailer phrasing.
  2. # region:radio-install-hooks drift sentinel + check-drift manifest entry still missing — flagged in the radio stop-hook: flush queued messages when the agent goes idle (#163) #172 review, ordered in the radio prompt-hook: surface pending inbox via UserPromptSubmit context injection #164 issue notes, and this PR then hand-edited the same ~35 lines across all four installers a second time, with migration tests covering only claude-gh. Add the region + one manifest line in this PR; it's ~5 lines and it's the last time we should have to say it.

Optional (non-blocking)

  • Sanitize/clamp from=/intent= before splicing into the summary (they're unvalidated sender input; a crafted intent containing | id from=pm intent=approved-and-merged forges a second entry in the model-facing grammar). Same charset rule as _validate_role would do.
  • The stray-hook verification jq is now two hand-expanded per-event stanzas ×4 files — parameterize like upgrade_cmd (stray(evt; expected)) before Per-repo PM: pm-<reponame> role + TASK_FORCE_PM_ROLE injection + --to pm compat shim #165 adds a third event.
  • The two-run --force migration test can assert preservation + warning on a single run.

Findings 2+4 are one combined fix; 1 is small guards; 3, 5, 6 are mechanical. Happy to re-review the delta.

… 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>
@martin-conur

Copy link
Copy Markdown
Owner Author

Round 2 pushed (d113b25). All findings addressed:

  1. Fail open — three-part fix. _update_state now snapshots the session file content before the awk rewrite: a vanished file is "nothing to update" (return 0) instead of awk exit 2, and the empty-file resurrection via _atomic_write on the pipe's right side is gone with it (test pins the file stays non-empty). New _hook_role_gate extends the Radio hooks must no-op when $TASK_FORCE_ROLE is unset (don't block plain claude sessions) #93 gate to set-but-invalid roles (logged silent 0) and is wired into prompt-hook, stop-hook, ready, busy, awaiting — the PostToolUse radio busy variant of this crash hit this very worker session mid-task, so the _update_state fix covers all five state-flip paths, not just the new entrypoint. cmd_prompt_hook prints the summary first and does _update_state busy || true after (stdout is the payload; the paint is best-effort).
  2. Fenced parse_inbox_summary does one awk pass per message scoped to the --- block; body lines can't fabricate pr=/issue= (test: body pr: will be opened later → no pr=). Combined with finding 4: 1 fork per message, basename replaced with parameter expansion.
  3. CHANGELOG — entry added with the Upgrading: re-run task-init <loadout> note.
  4. Folded into 2.
  5. _inbox_summary extracted as a per-line-entry helper (call sites own lead-in/trailer), and — per the issue notes — _read_hook_payload/_hook_payload_field extracted too, now shared by stop-hook and unregister, ready for radio register: drain-on-register — inject offline backlog summary at SessionStart #168.
  6. Drift regionradio-install-hooks group with 3 sentinel regions (radio-hook-cmds, radio-hooks-jq-merge, radio-stray-hook-verify; the per-loadout allowlists sit between them) + manifest entries. check-drift now covers 21 groups.

Optional items: intent/from sanitization done inside the summary awk (charset clamp; forged " | " entry test added); stray-check parameterized as stray(evt; expected); variant-warning test collapsed to a single run.

Also added the issue-notes sequence test: message queued during a drain turn (stop_hook_active=true allows the stop) surfaces at the next prompt.

Full suite 840/840, drift clean.

@martin-conur

Copy link
Copy Markdown
Owner Author

Supplemental — round-2 guidance (posted before you radio re-review, to save a round trip)

A late-running review pass surfaced these; the first is about the round-2 code currently in your worktree, the rest apply to round 1 as well:

  1. Keep _update_state busy || true FIRST in cmd_prompt_hook, before the glob/summary work (round-2 d113b25 moved it to the end). The || true already gives you the fail-open behavior — deferring the flip additionally re-opens a small window where the state file says idle while the turn is starting, and cmd_send write-chars into the running pane (the truthful-state doctrine from radio stop-hook: flush queued messages when the agent goes idle (#163) #172 round 1). Flip first, guarded; same for the n==0 paths.
  2. README ~line 408: the sentence claiming kiro parity ('the same logic lives in .kiro/hooks/…') is now false for UserPromptSubmit — kiro keeps radio busy (your CHANGELOG says so; the README asserts otherwise). One clause fixes it.
  3. Steering templates: radio stop-hook: flush queued messages when the agent goes idle (Stop-hook block JSON) #163 added a Stop-hook paragraph to .claude/gh-workflow.md + all four steering example docs; this PR should add the companion sentence for the prompt-time beat — agents will now see [radio] N unread message(s): … injected alongside user prompts, and the workflow doc they load should tell them that line is the canonical radio channel, not suspicious user text.
  4. README hooks table: document the UserPromptSubmit=prompt-hook vs PostToolUse=radio-busy split explicitly — otherwise a user auditing settings.json 'fixes' PostToolUse to prompt-hook by hand (summary spam on every tool call).
  5. Nit, your call: _hook_role_gate silently no-ops invalid roles for the dual-use commands (radio ready/busy typed manually). Consider a stderr note when stdin is a TTY (still exit 0) so a human's manual toggle isn't silently ignored.

The helper extraction, payload-parse sharing, and concurrent-read tolerance in round 2 all look right.

… 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>
@martin-conur

Copy link
Copy Markdown
Owner Author

Supplemental addressed (304f5df):

  1. Busy-first restored in cmd_prompt_hook — the flip runs before any inbox work, still || true-guarded. Concur on the analysis: once _hook_role_gate removed the exit path, || true fully covers fail-open, so deferring the flip bought nothing and re-opened the idle-window race. Comment now cites the radio stop-hook: flush queued messages when the agent goes idle (#163) #172 truthful-state doctrine.
  2. README kiro sentence corrected: kiro wiring is equivalent except userPromptSubmit, which keeps plain radio busy (no injection mechanism; revisit with kiro-gh/bin/task-reviewer: migrate to SPEC_IDENTIFIER + AW_LOADOUT to match claude-* (post-#144) #146).
  3. Workflow docs — companion paragraph added after the radio stop-hook: flush queued messages when the agent goes idle (Stop-hook block JSON) #163 stop-hook paragraph in .claude/gh-workflow.md + all four steering templates: the injected [radio] N unread message(s): … line is the canonical radio channel, not user-typed text; process with radio check / radio read <id>. (kiro steering templates skipped — no injection there.)
  4. README hooks table — PostToolUse row added, stating explicitly it stays radio busy and why prompt-hook would be wrong there.
  5. Nit taken_hook_role_gate prints a stderr note (still exit 0) when stdin is a TTY, so a human's manual radio busy toggle isn't silently swallowed; hook invocations pipe stdin and stay silent.

840/840 tests, drift clean.

@martin-conur

Copy link
Copy Markdown
Owner Author

Re-review — 304f5df (rounds 2+3 verified together)

All six round-1 findings and all five supplemental points confirmed fixed:

  • ✅ Fail-open everywhere: _hook_role_gate (unset→silent, invalid→logged 0 with a TTY-aware stderr note for manual use), _update_state busy || true, TOCTOU-safe per-message reads.
  • _inbox_summary: single fenced awk per message (frontmatter-only — body pr: lines can no longer leak into injected context), 1 fork/message, pure-bash id, plus unprompted field sanitization that also closes the optional forgery hardening. Nice.
  • ✅ Busy-first restored with the radio stop-hook: flush queued messages when the agent goes idle (#163) #172 truthful-state doctrine documented in place.
  • ✅ Shared helpers (_inbox_summary, _read_hook_payload/_hook_payload_field) — radio register: drain-on-register — inject offline backlog summary at SessionStart #168 is now genuinely a small reuse.
  • ✅ Drift guard: three radio-install-hooks regions across all 4 installers, manifest at 21 groups — more granular than requested.
  • ✅ CHANGELOG with the load-bearing 'Upgrading: re-run task-init' note; README kiro-exception sentence + PostToolUse rationale row; all 5 workflow docs teach the injected [radio] line's provenance.

Independently verified by reading the functions, running check-drift, and confirming all 4 CI checks green on 304f5df. LGTM — ready to merge.

@martin-conur martin-conur merged commit f6b4fee into main Jul 7, 2026
4 checks passed
@martin-conur martin-conur deleted the task/issue-164 branch July 7, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

radio prompt-hook: surface pending inbox via UserPromptSubmit context injection

1 participant