Claude Code's own signals (hooks)¶
BOSUN_HOOK_SIGNALS=1 (default) adds async hooks to every Claude Code window bosun launches (repo
sessions through repo_session_settings, the orchestrator through orchestrator_extra_settings).
Each runs bosun hook signal --window <window>, which appends one compact line to
$BOSUN_STATE_DIR/signals/<window>.jsonl (lib/signals.py). The hooks are async, so they
never delay the session; the recorder always exits 0 and reports problems to the hook log. Each
file keeps its newest half once it passes BOSUN_SIGNALS_MAX_KB (256).
What is recorded¶
| Event | kind | detail | extra |
|---|---|---|---|
| PermissionRequest | tool name | what it runs or touches (command, file_path, url, ...) |
|
| Notification | notification_type |
the message | |
| Stop | running: the background tasks still running (type, command or description) |
||
| StopFailure | the error type (rate_limit, billing_error, authentication_failed, ...) |
the error text | |
| SessionStart | source (startup, resume, compact, ...) |
||
| SessionEnd | reason |
Every line has at (UTC), event and session (Claude's session id); subagent events carry
agent. Details are redacted (lib/redact.py) and cut to 200 characters. Readers only use lines
of the window's current session (the sessionId of its ~/.claude/sessions/<pid>.json record),
so an earlier Claude's signals never count.
Who reads them, and what stays primary¶
Detection stays where it was reliable: the session record's status (waiting, shell, ...) and
the transcript's structured error fields. The signals add what those cannot say:
waiting(watchdog) and the events' waiting line: what the window was asked. The PermissionRequest (tool and command) is preferred over thepermission_promptnotification that follows it about 6 s later, whose text is only "Claude needs your permission".long-shell: which background work keeps the window inshell(the last Stop'srunning).spend-limit: a StopFailure whose error type israte_limitorbilling_errorand whose text names a spend or usage limit counts as a stop when the transcript shows none, until a later Stop (a turn finished). The transcript'sapiError: usage_limit_reachedstays the primary signal: a StopFailure only has the error type and the text, andrate_limitalso covers short throttling.
What was measured before choosing (2.1.312, a lab session on its own tmux socket)¶
- A permission prompt sets the record's
statustowaitingat the moment the PermissionRequest hook fires; the Notification (permission_prompt) follows about 6 s later. Answering it setsidleagain. - A turn that ends with a background subagent and a shell still running: the record says
shell,claude agents --jsonsaysbusy, and the Stop hook lists both tasks (background_tasks). Each wake from the subagent is one more Stop with a newprompt_id;idle_promptarrives about a minute after a turn ends, also while background work runs. - A subagent going idle on its own background work fires SubagentStop, not a Notification;
agent_needs_input/agent_completedare for background sessions (claude agents). - StopFailure carries
error(its type) andlast_assistant_message(the text); a forcedmax_output_tokensfailure produced it in a headless run. A spend limit arrives asrate_limit(HTTP 429), so the text is what tells it from throttling. claude agents --json(1.2 s a call) lists name, pid, sessionId, cwd and status, but nostatusUpdatedAt, and reports a window inshellasbusy. The session records it reads give more, so bosun keeps reading them (throughlib/claude_state.py).
What still reads the screen¶
bosun send: whether the input line is empty and whether the pasted prompt was rejected (lib/pane_input.py); a paste is a screen action, so its checks stay on the screen.- The check-in loop's login check falls back to the pane's last lines only when the window has no
transcript to read (
login_ok,claude_state.pane_auth_error). - A launch that fails shows its last lines in the error (
bosun send).