Summary
When Claude Code invokes Bash with run_in_background: true (e.g. starting a local dev server) and then immediately ends its turn, the notch stays stuck showing Processing... even though Claude is actually idle and waiting for user input.
The root cause is a race between the Stop hook and the late-arriving PostToolUse hook for the backgrounded Bash. PostToolUse is mapped to status="processing", and the SessionPhase state machine allows .waitingForInput → .processing, so the late PostToolUse flips an already-correct .waitingForInput back to .processing.
Environment
- macOS 15.x (Darwin 25.4.0)
- Vibe Notch v1.3 (built from
main)
- Claude Code 2.x (
claude --version ≥ 2.1)
- Hook script:
~/.claude/hooks/claude-island-state.py (auto-installed)
Reproduction
- Run any Claude Code session with vibe-notch attached.
- Ask Claude to do something that ends with starting a backgrounded shell, e.g.
sleep 60 with run_in_background: true, or python -m http.server in the background.
- Claude finishes its turn and the notch should turn green ("Ready for input"). Instead it stays cyan ("Processing...") indefinitely until the next
PreToolUse arrives — which never happens because Claude is idle.
Diagnostic evidence
Captured live via:
log stream --predicate 'subsystem == "com.claudeisland"' --info --debug --style compact
Real hook arrival order from a reproduction (session 8bbb736d, all events on the same session):
13:28:44.729 [Hooks] Received: PreToolUse for 8bbb736d
13:28:46.253 [Hooks] Received: Stop for 8bbb736d ← turn ended
13:28:47.365 [Hooks] Received: PostToolUse for 8bbb736d ← arrives 1.1s AFTER Stop
The Stop → PostToolUse ordering is reproducible: run_in_background:true Bash measured a consistent ~2s gap between PreToolUse and PostToolUse in my logs (13:27:58.902 PreToolUse → 13:28:01.019 PostToolUse). When Claude finishes its assistant turn during that gap, Stop fires first.
Root cause (file:line)
Three pieces interact:
ClaudeIsland/Resources/claude-island-state.py:113 — PostToolUse is mapped to state["status"] = "processing".
ClaudeIsland/Models/SessionEvent.swift:151-162 — determinePhase() maps "processing" to .processing.
ClaudeIsland/Models/SessionPhase.swift:117 — the state machine allows .waitingForInput → .processing (intended for "user replied, Claude resumes work" path, but it also fires on the late PostToolUse).
So Stop correctly transitions to .waitingForInput, then the delayed PostToolUse legally re-transitions to .processing and the notch never recovers until the next user prompt.
Proposed fix
Narrow the rule in ClaudeIsland/Services/State/SessionStore.swift:150-156: PostToolUse / PostToolUseFailure should only be allowed to flip the session phase when we're recovering from .waitingForApproval (terminal-side approve, which doesn't go through the socket). In all other cases they should only update tool status, not the session phase.
// Before
let newPhase = event.determinePhase()
if session.phase.canTransition(to: newPhase) {
session.phase = newPhase
}
// After
let newPhase = event.determinePhase()
let allowPhaseChange: Bool = {
if event.event == "PostToolUse" || event.event == "PostToolUseFailure" {
// PostToolUse can race after Stop for run_in_background bash.
// Only flip phase if recovering from terminal-approved permission.
return session.phase.isWaitingForApproval
}
return true
}()
if allowPhaseChange, session.phase.canTransition(to: newPhase) {
session.phase = newPhase
}
Scenario matrix
| Scenario |
Behavior after fix |
| Normal turn (Pre→Post→Stop) |
Pre→.processing, Post→no-op phase, Stop→.waitingForInput ✅ |
| Multiple sequential tools |
Pre→Post(no-op)→Pre→Post(no-op)→Stop→.waitingForInput ✅ |
| bg bash, PostToolUse arrives after Stop (this bug) |
Pre→.processing, Stop→.waitingForInput, Post no-op ✅ |
| Terminal-approved permission (no socket reply) |
PermissionRequest→.waitingForApproval, Post sees .waitingForApproval→flips to .processing ✅ (preserved) |
| User replies, Claude resumes |
UserPromptSubmit→.processing via status="processing" path ✅ (independent of PostToolUse) |
| Permission denied then Stop |
PermissionRequest→.waitingForApproval, PostToolUseFailure→.processing (recovery), Stop→.waitingForInput ✅ |
Notes
- This is purely a vibe-notch state machine fix — no upstream Claude Code change is needed.
- I have a working patch and have verified the fix locally against the reproduction. Happy to send a PR.
Summary
When Claude Code invokes Bash with
run_in_background: true(e.g. starting a local dev server) and then immediately ends its turn, the notch stays stuck showing Processing... even though Claude is actually idle and waiting for user input.The root cause is a race between the
Stophook and the late-arrivingPostToolUsehook for the backgrounded Bash.PostToolUseis mapped tostatus="processing", and the SessionPhase state machine allows.waitingForInput → .processing, so the latePostToolUseflips an already-correct.waitingForInputback to.processing.Environment
main)claude --version≥ 2.1)~/.claude/hooks/claude-island-state.py(auto-installed)Reproduction
sleep 60withrun_in_background: true, orpython -m http.serverin the background.PreToolUsearrives — which never happens because Claude is idle.Diagnostic evidence
Captured live via:
log stream --predicate 'subsystem == "com.claudeisland"' --info --debug --style compactReal hook arrival order from a reproduction (session
8bbb736d, all events on the same session):The
Stop → PostToolUseordering is reproducible:run_in_background:trueBash measured a consistent ~2s gap between PreToolUse and PostToolUse in my logs (13:27:58.902 PreToolUse → 13:28:01.019 PostToolUse). When Claude finishes its assistant turn during that gap,Stopfires first.Root cause (file:line)
Three pieces interact:
ClaudeIsland/Resources/claude-island-state.py:113—PostToolUseis mapped tostate["status"] = "processing".ClaudeIsland/Models/SessionEvent.swift:151-162—determinePhase()maps"processing"to.processing.ClaudeIsland/Models/SessionPhase.swift:117— the state machine allows.waitingForInput → .processing(intended for "user replied, Claude resumes work" path, but it also fires on the late PostToolUse).So
Stopcorrectly transitions to.waitingForInput, then the delayedPostToolUselegally re-transitions to.processingand the notch never recovers until the next user prompt.Proposed fix
Narrow the rule in
ClaudeIsland/Services/State/SessionStore.swift:150-156:PostToolUse/PostToolUseFailureshould only be allowed to flip the session phase when we're recovering from.waitingForApproval(terminal-side approve, which doesn't go through the socket). In all other cases they should only update tool status, not the session phase.Scenario matrix
.waitingForApproval→flips to .processing ✅ (preserved)status="processing"path ✅ (independent of PostToolUse)Notes