How the Claude adapter turns a raw lifecycle payload into the (chat_id, status, label) tuple the widget renders. All logic in this page lives in src-tauri/src/adapters/claude.rs; the Python hook (integrations/claude_hook.py) is a pure transport layer and does no classification.
A single event payload flows through four independent steps in this order: chat-id derivation, event-to-status classification, label formatting, and (for Stop / Notification) transcript question detection. The label policy that decides what’s actually shown on screen is downstream — see Sticky labels for the rules that pin the original prompt across approval cycles.
Chat-id derivation
Each Claude Code session collapses to one row in the widget. The row’s id (a.k.a. chat_id) is derived from payload.cwd and the configured projects_root in config.json:
| Input | Resulting chat_id |
|---|---|
cwd is under projects_root (case-insensitive) | relative path; /, -, _ replaced with spaces |
cwd is outside projects_root (or root unset) | basename of cwd |
cwd exactly equals projects_root | basename of projects_root |
cwd is missing or whitespace-only | claude-unknown (defensive; payloads always carry cwd) |
Backslashes are normalized to forward slashes before matching, so Windows paths work uniformly. Trailing slashes on cwd are tolerated. Examples (with projects_root = "d:/projects"):
cwd | chat_id |
|---|---|
D:/projects/bga/assistant | bga assistant |
d:/projects/foo-bar/sub_dir/leaf | foo bar sub dir leaf |
D:\projects\sub\deep | sub deep |
d:/projects | projects |
c:/Users/foo/bar | bar |
This derivation runs per event, but the result is only the first-seen anchor: http_server locks each session_id to the chat_id derived on its first event (chat_id_registry), so a mid-session cd into a subdirectory reuses the original id instead of spawning a second row. /clear mints a new session_id with the same cwd, so it re-derives — and re-locks — the same id.
Event → status
The adapter recognizes six event names. Anything else returns Ignore and the widget state is untouched.
| Event | Status produced | Notes |
|---|---|---|
SessionStart | idle (no fields) — otherwise treated like Notification | Used to seed an empty row before any user activity. |
UserPromptSubmit | working | Label is the cleaned prompt; blank prompt → label None. |
Notification | awaiting (default) — done if notification_type == "idle_prompt" with no question | See the notification-type table below. |
PreToolUse | awaiting for AskUserQuestion / ExitPlanMode only; other tools ignored | Label: "has a question" for AskUserQuestion, "plan approval" for ExitPlanMode. The matcher in ~/.claude/settings.json should restrict the hook to these two tools (see Installation → Wire the Claude Code hook) — Claude Code buffers the tool_use block until the user answers, so the JSONL transcript can’t carry the signal in flight. |
Stop | done — flips to awaiting if last assistant turn contains a question (see detection rules) | Question check ignores configured benign closers. Stop fires before the final assistant turn flushes to JSONL, so it classifies from the prior turn’s text and can be wrong either way — missing a trailing question (→ wrong done) or, when a statement turn follows a question turn, reading the stale question (→ wrong awaiting). The transcript watcher corrects both once the real text lands (see data flow). |
SessionEnd | emits Clear (removes the row) | Bypasses status classification entirely. |
SessionStart and Notification share a code path because Claude Code occasionally emits notifications under either name; the dispatcher merges them.
PostToolUse is intentionally ignored. Once the user answers an AskUserQuestion / ExitPlanMode, the next UserPromptSubmit or the transcript watcher carries the row out of awaiting.
Notification subtypes
Notification further splits on payload.notification_type:
notification_type | Status | Label |
|---|---|---|
permission_prompt | awaiting | "needs approval: <tool>" — <tool> is the text after "use " in the message; falls back to "tool" if the marker is absent. |
plan_approval | awaiting | "plan approval" (fixed) |
idle_prompt | awaiting if transcript ends with ? (non-benign), else done | "has a question" when flipped, else None |
| anything else | awaiting | cleaned payload.message, truncated to 60 chars |
| empty type, empty message | idle | None |
The 60-char truncation counts characters, not bytes, so multi-byte glyphs (emoji, CJK) are never split mid-codepoint.
Prompt and label cleaning
User-visible text comes from payload.prompt (UserPromptSubmit) or payload.message (Notification). Both go through clean_prompt, which:
- Replaces these whitespace characters with a single space:
\n,\r,\t, vertical tab, form feed. - Replaces all characters in U+2300–U+23FF (Miscellaneous Technical, e.g.
⎿) with a space. - Replaces all characters in U+2500–U+259F (Box Drawing + Block Elements, e.g.
│ ▌) with a space. - Collapses runs of spaces into one and trims.
This cleaning applies to the label (the one-line preview shown in the dashboard row). The dialog entry persisted for the multi-line history view takes payload.prompt raw on UserPromptSubmit instead, so the history preserves newlines and the user’s original formatting.
Other Unicode passes through untouched — accents, emoji, CJK, math symbols. The U+2300/U+2500 ranges are stripped because Claude Code’s terminal output frequently leaks box-drawing glyphs into prompt and notification text.
Transcript question detection
Stop and Notification (subtype idle_prompt) need to decide whether the agent is genuinely done or is actually waiting for an answer. The transcript watcher (log_watcher.rs) is a third caller: it reuses is_a_question to re-judge the verdict once the final assistant turn flushes to JSONL — the case Stop fires too early to read — and corrects the row both ways (done → awaiting for a missed question, awaiting → done for a stale-read one). The watcher’s demote is gated on a provenance flag (status_from_transcript_scan) so it only overturns awaiting rows that came from this scan, never a tool-gating awaiting (see data flow). The flow has two helpers:
last_assistant_text(path) — walks the JSONL transcript at payload.transcript_path:
- Read the file line-by-line.
- For each line, parse as JSON. Skip malformed lines.
- Skip entries whose
message.roleisn’t"assistant". - Extract assistant text from
message.content:- if it’s a JSON string, take the trimmed value;
- if it’s an array, walk each block and take the trimmed
textfrom blocks wheretype == "text".
- Track the last non-empty text seen (so trailing whitespace-only assistant turns don’t reset the state) and return it.
is_a_question(text, benign_closers) — pure check on a string, four detection paths. Before any path runs, inline Markdown formatting characters (*, _, `, #, ~) are stripped so a final **Push?** reduces to Push? and is still recognized — only those marker characters are removed; newlines and every other character (crucially the terminal ?) are preserved.
Path 1 — trailing ?:
- If
text(after trim) ends with), peel off one trailing(...)group only when the substring before the matching(ends with?. This handles option lists like"Save these? (all / numbers / none)"→"Save these?". Other trailing parens (e.g."Look at this code (foo.py)") are left alone — there’s no?before them, so the text falls through unchanged. - After that strip, if the text ends with
?, check againstConfig::benign_closers— case-insensitive suffix match. A hit skips this path. Defaults:"What's next?","Anything else?". They exist because Claude often signs off with a polite question that isn’t a real ask — flipping toawaitingon everyWhat's next?would be noise.
Path 2 — hand-back phrase in last paragraph:
If path 1 doesn’t match, extract the last paragraph of text (split by \n\n) and check whether it contains any of these phrases (case-insensitive). A phrase that already ends in ? matches literally; the rest only count when a ? follows them later in the same paragraph:
"want me to""shall i""should i""do you want""save this?""save these?""can you""could you""did you""want to""confirm ""ready to "
This catches questions embedded mid-paragraph like "Want me to add that? The plan: write sessions.json to disk." where the response continues past the ?. The phrase list is empirically derived from real assistant messages — only patterns that actually appeared are included; new ones are added as observed. "save this?" / "save these?" were added for the /reflect and /commit save prompts, whose "Save this? (all / 1 / none)" menu can be trailed by a clause like "— then I'll run /commit." that defeats path 1 (the text no longer ends with ?); the baked-in ? keeps a declarative "save this config" from matching. "can you" / "could you" / "did you" / "want to" catch directed second-person questions whose paragraph continues past the ? ("Did you try the admin launch? That's the most likely fix."). "confirm " / "ready to " carry a trailing space so confirmed / confirmation don’t match — they catch approval prompts whose ? isn’t last ("Confirm v0.5.0 and these notes? On approval I'll …", "Ready to tag v0.5.1 and push it? Reply with y …"). Only the last paragraph is scanned: a question in an earlier paragraph followed by a concluding statement (e.g. "Want me to fix it?\n\nI went ahead and fixed it.") correctly returns false.
Only round brackets () are recognized for the option-list strip; [] and {} aren’t peeled.
Path 3 — hand-back request in last paragraph:
If neither path above matches, check whether any sentence in the last paragraph (split on .!? and newlines) starts with one of the hand-back openers "paste ", "please provide ", or "confirm " (case-insensitive). This catches the imperative hand-back where the agent waits for the user to supply something but never ends on a ? — "Paste the tableinfos output and I'll finish arena.", "Please provide the model group and the model name.", "Confirm to tag v1.2.0, or request edits.". Only a sentence-initial opener counts, so a mid-sentence mention like "you can paste this" or "I'll paste the result" doesn’t trigger. The list is kept narrow and phrase-matched — a blanket "please " would misfire on informational openers like “Please note …” / “Please see …”, and the trailing space in "confirm " keeps Confirmed: … statements out.
Path 4 — leading question in last paragraph:
If nothing above matches, check whether the first sentence of the last paragraph is itself a question — it ends with ? before a concluding clause follows. This catches a hand-back whose question leads and is then trailed by context, like "Apply this edit? (yes / no) Everything else is aligned." — the trailing sentence defeats path 1 (the whole text no longer ends with ?), the option menu sits mid-text rather than at the end, and "apply" is no path-2 phrase, so without this nothing sees it. Three guards keep it tight:
- The terminating
?must immediately follow an alphanumeric character, so a bare mention of the glyph —"a?` `` immediately followed by …”, which markdown-stripping leaves as“a ? …”` — isn’t read as a sentence terminator. - The first sentence must not open with a self-directed phrase (
"let me ","let's ","lets ","i'll ","i will ","i'm going to ","i am going to "). These mark the agent reasoning aloud and about to act, not asking —"Let me investigate — does X have a cleaner fix? This affects what we do next."staysdone. - Configured benign closers are honored, so a leading polite
"What's next? …"still doesn’t flag.
This path was validated against the recorded dialog history (prompt_history.json): it fires on 12 of 60 real assistant turns with zero false positives. Like path 2 it scans only the last paragraph, and like it the question must be the paragraph’s first sentence — a statement-first paragraph ("The migration is ready. Looks good to you?") is left to path 1’s trailing-? check.
Failure modes are silent: a missing transcript file returns None from last_assistant_text (treated as “no question”), and malformed JSONL lines are individually skipped. The adapter never crashes a status update because of a transcript read error.
What this layer does not decide
- Whether the user-visible label changes. The adapter emits a candidate
(status, label)pair; the sticky-label state machine decides whether the row’soriginal_promptupdates, gets re-captured at a task boundary, or stays pinned across an approval cycle. - Token counts and model. Those come from the transcript watcher (
log_watcher.rs) reading assistant turn metadata, not from hook events. - Timer accumulation.
state.rs::apply_setowns theworking_accumulated_msarithmetic on status transitions.