Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/gh-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ automatically: with an empty inbox it marks the role idle; if messages queued
up while the worker was busy, it blocks the stop (staying busy) so the agent
drains them immediately — you don't need to invoke it manually.

Likewise, every submitted prompt runs `radio prompt-hook` (the
`UserPromptSubmit` hook): if the inbox has unread messages, a line like
`[radio] 2 unread message(s): <id> from=pm intent=changes-requested pr=41 | …`
is injected into the agent's context alongside the prompt. That line is the
canonical radio channel, not user-typed text — trust it and process the
listed messages with `radio check` / `radio read <id>`.

Full command form:

```bash
Expand Down
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"hooks": [
{
"type": "command",
"command": "radio busy"
"command": "radio prompt-hook"
}
]
}
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Fixed

- **Prompting an idle agent now surfaces its queued radio messages — new `radio prompt-hook` replaces `radio busy` as the claude UserPromptSubmit hook.** An idle agent fires no hooks, so when the send-time zellij wake failed (no writable pane, stale `TAB_ID`, missing session file — live logs showed 20 `no writable pane` + 12 `no TAB` skips) the queued message stayed invisible until a later wake happened to succeed, landing precisely on recipients that were ready to work. UserPromptSubmit hook stdout IS injected into the model's context (unlike Stop's), so `radio prompt-hook` marks the role busy exactly as before and, when the inbox is non-empty, prints a one-line summary (`[radio] 2 unread message(s): <id> from=pm intent=changes-requested pr=41 | …`) the model sees alongside the user's prompt — including messages stranded by stop-hook's `stop_hook_active=true` guard. An empty inbox prints nothing (zero context noise, and nothing spawned beyond the state flip on the hottest hook path); fields are parsed only from message frontmatter and clamped to a safe charset, so message bodies can't fabricate `pr=`/`issue=` fields and a crafted intent can't forge extra summary entries. The hook fails open on every internal path — a nonzero UserPromptSubmit exit blocks *and erases* the user's typed prompt — which also hardens the shared plumbing: `_update_state` now snapshots the session file before rewriting it (the #140 unregister cascade could unlink it mid-flight, crashing any state-flip hook with `awk: can't open file` and resurrecting an *empty* session file), and a set-but-invalid `$TASK_FORCE_ROLE` is a logged no-op for hook entrypoints instead of `exit 2` on every prompt. The four claude `task-init` installers write the new hook and migrate a byte-exact legacy `radio busy` UserPromptSubmit entry in place (PostToolUse keeps plain `radio busy`); customized variants are left untouched with a loud warning. The now-thrice-edited installer hook block is drift-guarded (`radio-install-hooks` group, 3 sentinel regions in `tools/check-drift.sh`). kiro-* loadouts intentionally keep the legacy wiring (revisit with #146). **Upgrading**: re-run `task-init <loadout>` in each configured repo — the migration only runs then; without it the repo keeps plain `radio busy` and idle agents keep missing queued mail until a wake happens to succeed. (#164)
- **Messages queued while an agent is busy are now delivered at the end of its turn — new `radio stop-hook` replaces `radio ready && radio check` as the claude Stop hook.** `radio send` makes exactly one wake attempt, at send time; a message sent while the recipient was `busy`/`awaiting` (the common case for a working agent) queued with zero redelivery. The old Stop hook couldn't help: Stop-hook stdout goes to the hook subshell, never to the model, so its `radio check` was dead output — live logs showed 101 `busy … queued` entries with no redelivery and 23 messages dangling in the pm inbox. `radio stop-hook` counts the inbox first (empty → mark idle, normal stop — the common fast path spawns nothing); on unread messages it marks the role **busy** (the agent is about to continue) and emits Claude Code's `{"decision": "block", …}` JSON on stdout, which forces the agent to continue and drain the queue immediately. A `stop_hook_active: true` payload never re-blocks (no continue loop; any messages that arrived during the drain turn are logged as stranded — #164/#168 close that path), and a jq-less host degrades to the old queue-only behavior rather than risk blocking blind. The four claude `task-init` installers write the new hook and migrate a byte-exact legacy `radio ready && radio check` Stop entry in place; any customized variant is left untouched with a loud warning telling you what to replace. kiro-* loadouts intentionally keep the legacy wiring — kiro's `agentStop` has no block-JSON mechanism (revisit with #146). Also fixes the `radio ready` usage string, which falsely claimed "(and process pending)". **Upgrading**: re-run `task-init <loadout>` in each configured repo — the migration only runs then; without it the repo keeps the dead Stop hook and busy-recipient messages keep stranding. (#163)
- **`task-done --remove-worktree` now force-deletes reviewer branches so `task-reviewer` re-dispatch isn't blocked.** The cleanup's `git branch -d` is the safe-delete variant that requires the branch to be fully merged into HEAD — correct for worker branches (don't drop unmerged work), but wrong for reviewer worktrees, whose `task/review-pr<N>` branch is forked from the PR's head ref and is never going to land in main (the PR is). Result: every reviewer cleanup left `task/review-pr<N>` behind as an orphan, and the next `task-reviewer <N>` refused to dispatch via its per-PR guard with `Error: a review worktree or branch already exists`. The user had to manually `git branch -D task/review-pr<N>` between every re-review round. `task-done` now branches on the `PR_NUMBER=` marker that `task-reviewer` writes into the worktree's `.info` file: when present it does `git branch -D` (scaffold-only, safe to force); when absent worker behavior is unchanged. Lands across the `task-done-std` and `task-done-local` drift groups (7 binaries). (#148)
- **Radio session corruption on long-running workers — `LOADOUT` / `AGENT` / `TAB_ID` no longer lost mid-life.** Three-bug cluster surfaced by `--auto` workers running for hours: Claude Code's `SessionEnd` hook fires repeatedly with non-`clear|resume` reasons (the diagnostic added in #150 caught bursts of 144 invocations in ~25s, payload literally a single `y` character, `reason=<unset>` — phantom calls upstream of the documented hook contract), each one wiping `~/.task-force/radio/sessions/<role>.info`. The next `busy`/`ready` hook re-seeded the file with `LOADOUT=unknown`, empty `TAB_ID=`, and `AGENT=claude` (silently flipping kiro-* workers), which then broke `radio send`'s tab-id-based wake-up — PM pings landed in the mailbox silently and never woke the worker. Three concrete fixes: **(#140 Fix B)** `_zellij_tab_id_by_name` now matches the emoji-prefixed visible name (`⏸️ <slug>` / `▶️ <slug>` / `❓︎ <slug>`) so the re-seed's tab-id lookup survives `_rename_tab`'s repaints; **(#140 Fix C / #150 / #151)** `cmd_register` writes a `<role>.loadout` (and now `<role>.agent`) sidecar atomically BEFORE the `.info` write (so a kill mid-call leaves a recoverable state), `_ensure_session_file` reads from those sidecars on re-seed (TOCTOU-safe via `cat || true`), and `cmd_unregister`'s skip-list now also short-circuits on empty `reason` — treating the cascade pattern the same as `clear` / `resume`. Real-exit reasons (`logout` / `prompt_input_exit` / `other`) still flow through and clean up normally. Net result: a worker that previously degraded after 4–8 hours (or sooner under heavy subagent use) now keeps its full identity through the cascade, and PM `radio send` keeps waking it via the original `TAB_ID`. (#140, #150, #151)
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ Role names are addressable strings, not free-form: the PM is `pm`, and each work
| `radio register` / `radio unregister` | Add/remove this tab's session file (`~/.task-force/radio/sessions/<role>.info`) |
| `radio ready` / `radio busy` | Toggle this session's `STATE` field — drives the wake-up vs. queue decision on the sender side |
| `radio stop-hook` | Stop-hook entrypoint: empty inbox → mark idle; unread messages → mark busy and emit Stop-hook block JSON so the agent continues and drains them |
| `radio prompt-hook` | UserPromptSubmit-hook entrypoint: mark busy; if the inbox has unread messages, print a one-line summary that Claude Code injects into the model's context |
| `radio orphans` | List session files whose heartbeat is >1h stale |

### How wake-up works
Expand All @@ -401,14 +402,15 @@ Role names are addressable strings, not free-form: the PM is `pm`, and each work
| Hook | Command | Why |
|-------------------|-------------------------------|------------------------------------------------|
| `SessionStart` | `radio register` | Claims the role's session file for this tab |
| `UserPromptSubmit`| `radio busy` | Marks the session busy while a turn is running |
| `UserPromptSubmit`| `radio prompt-hook` | Marks the session busy — and surfaces any unread inbox into the model's context (its stdout is injected, unlike Stop's) |
| `Stop` | `radio stop-hook` | Marks idle — or blocks the stop so the agent drains queued messages first |
| `PostToolUse` | `radio busy` | State flip only — deliberately NOT `prompt-hook`; it fires after every tool call, and the inbox summary belongs at prompt time, not sprayed mid-turn |

For the kiro loadouts the same logic lives in `.kiro/hooks/` and runs off Kiro's equivalent triggers.
For the kiro loadouts the equivalent wiring lives in `.kiro/hooks/` and runs off Kiro's triggers — except `userPromptSubmit`, which keeps plain `radio busy` (no context-injection mechanism there; revisit with #146).

### Idle workers don't auto-act

A queued message arriving at an idle worker won't kick it into motion on its own — the worker only sees the message on its **next turn** (a human keystroke or its own next prompt). This is deliberate: radio is **notification + queue**, not auto-action. If you want fully autonomous handoffs, dispatch the worker with `task-work --auto` and bake all the instructions into the issue body.
A queued message arriving at an idle worker won't kick it into motion on its own — the worker only sees the message on its **next turn** (a human keystroke or its own next prompt). When that turn comes, the `UserPromptSubmit` hook (`radio prompt-hook`) injects a summary of the pending inbox into the model's context, so the backlog surfaces even if every send-time wake attempt failed. This is deliberate: radio is **notification + queue**, not auto-action. If you want fully autonomous handoffs, dispatch the worker with `task-work --auto` and bake all the instructions into the issue body.

### Cleanup

Expand Down
Loading
Loading