Repository navigation
fix(claude): a Stop ends Claude's process, and the next message resumes the conversation - #24235
Conversation
…es the conversation Claude's Stop now ends its child after the interrupt, whatever Claude answered, once the stopped turn ends or a 3 s grace runs out. The grace lets Claude's own result move the resume point past that turn, so the next send resumes after it. Background work ends with the child. Codex keeps its child.
…2025/claude-stop-ends-session
…t step on the chat's lane ends the child The Stop now answers as soon as the interrupt step finishes. Ending Claude's process runs as a second task on the session's serialized lane, queued in the same tick as the Stop, so a message sent meanwhile reaches only the resumed child. That step waits for the stopped turn to end (its result, the CLI's idle, the child's exit or its close), then ends the child; a failure there is reported and never fails the Stop, and the wind-down it leaves owed is retried. The interrupt's answer and the turn's end share one 3 s grace counted from when the interrupt goes out, so an interrupt Claude never answers ends the child in about 3 s instead of after the 30 s control deadline. The wait is derived per call from Claude's open turn: the armed latch and the close's user-stop branch are gone, and the close keeps its whole deadline for proving the exit. A Stop naming a turn that has since ended (the phone names the turn it last saw) now ends the child too when Claude took it, as it does when Claude interrupts a handed-over follow-up whose turn has not opened.
… on the shipping adapter - Nothing queued while the Stop's first step waits on the interrupt runs before the child ends. - A message sent with the pre-Stop fence during the Stop is accepted with no notice, and reaches only the resumed child, after the old child's close. - An interrupt Claude never answers ends the child within the grace. - A close that cannot prove the exit leaves the Stop answered with its row; the failure goes to the host's error hook. - A Stop naming the turn that just ended ends the child when Claude interrupts the handed-over follow-up. - A second Stop pressed while the first ends the child stays quiet.
…n its interrupt fails or goes unanswered The phone names the turn it last saw. When Claude interrupted a handed-over follow-up for that Stop and the answer failed or never came, the child stayed. Only a provider that answers it did not take the Stop keeps the child now. Also pins a queue-if-active send made while the Stop ends the child.
… on a question, never a bare interrupt A permission card's Cancel interrupted the turn and kept Claude running, the old Stop on a second control: a refused interrupt let the turn run on and background work survived. Now the host asks the provider how a card's Cancel is answered. For Claude, an approval's Cancel sends the same reply as its Deny option, so the turn goes on without that tool; a question's Cancel runs the chat's Stop, which ends the child, and the next send resumes the conversation. A provider that gives no answer keeps today's path, so Codex is unchanged. The host decides, so desktops and phones of any version keep sending the same cancel and get the new behaviour; nothing on the wire changes. With no Claude caller left, the prompt-cancel interrupt is gone: the bound claim, the cancellation observation, the admission of the prompt's cancellation, the prompt's turn binding, and withdrawing a refused stop (every Claude interrupt now precedes the child's end, so the recorded stop stands).
…Stop The approval card's "Stop" option answered Claude with a deny that interrupts the turn and kept Claude running, the last control on a Claude chat that interrupted without ending the child. The host now routes it, like the card's own Cancel, through the provider: for Claude that option is the chat's Stop. It runs the same Stop body as the Stop button, in the same order (withdraw, the Stop takes effect, interrupt), and the step that ends the child is queued in the same synchronous call. The body now lives in one place, structured-agent-session-chat-stop.ts, shared by the Stop button, a question card's Cancel and this option. The host decides, so an old card or client that sends option `cancel` gets the real Stop; nothing on the wire changes. The interrupting deny reply is gone.
…d's Cancel asks Claude to wait
- Approval and plan cards no longer offer "Stop". Neither reference offers a
Stop while an approval is up: the user denies, then stops. An older card's or
client's `cancel` answer is a plain deny, and the respond path is main's again.
- A plan card's X / Esc no longer answers "Keep planning", which sent Claude
straight back to revise and re-propose. It dismisses the plan: a deny that
tells Claude to end its turn and wait for the user. No interrupt; the child
stays. A tool approval's Cancel stays the Deny reply, and a question's Cancel
stays the chat's Stop.
- The provider's routing takes the pending card itself
(`routePromptCancel({ sessionId, prompt })`), so a plan is told from a tool
approval, instead of a kind plus an optional option id that meant Cancel when
absent. A routed answer may be one the card does not offer, like the
dismissal.
- The chat's Stop is reachable only through `mutateWithChatStop`, which owns the
mutation call and queues the step that ends the child in the same call, so
no caller can run the Stop and forget the child's end.
… passes The Stop's own body ends the child; the plan's default run serves only a card's interrupt and the background-task stop, which never end it.
…op's first step, and answers Claude when nothing stops A question card's Cancel runs the chat's Stop. The card stayed pending until the child ended (up to about 3 s), so it could still be pressed and the second press was refused once the chat rested. When the Stop found nothing in flight (Claude asking after its main turn ended), the card stayed pending for good and Claude's request went unanswered; the phone froze the card with every button disabled. Now the card is recorded as cancelled by the user in the Stop's own step, with the receipt "Cancelled on <device>". A Stop that ends the session leaves Claude's request to end with the child, so no reply races the interrupt; one that ends nothing declines the request itself. The provider forgets the card once the host records it, so neither Claude's own cancel nor the child's end writes over the user's cancel. The card's Stop stops whatever the chat has in flight, like the Stop button, instead of a turn the card names that may have ended. The same dismissal, a new provider member beside the cancel route, now answers a plan card's Cancel: recorded as cancelled by the user, and Claude told to end its turn and wait. That replaces resolving it as an option the card does not offer.
…held request, as it does once interrupted
…d a question card's Stop stays in its own turn
- A Stop pressed after Send but before Claude echoed it read as a normal finish
("Worked for 0s", a green done check): with no turn row to wait on, the child
was ended at once, and the echo that would have opened the stopped turn
landed on a retired send. The wait before the child ends is now keyed on what
Claude has in flight (an open turn or a send it has not answered), not on a
journal turn id, within the same 3 s from the interrupt and woken by the same
settles; a settle that leaves something in flight waits on. The echo opens
the turn, the aborted result ends it Interrupted. A Claude that says nothing
is ended when the grace runs out, the unanswered send doubt as before.
- A question card's Cancel takes the chat's Stop only when the card belongs to
the turn running now, judged after draining the provider's lifecycle. A card
a finished turn raised, such as a background agent's, is dismissed instead:
Claude's request is declined and nothing stops.
- Claude forgets a dismissed card before the host records it, so Claude's own
cancel landing during that write cannot replace the user's "Cancelled on
<device>".
…top-ends-session # Conflicts: # src/main/claude/claude-structured-session-adapter.ts # src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts
…ild records, and guard the optional dismissal Main's child records replaced the adapter's background-task callback: the background-work test now reads the host's child record (live before the Stop, settled after the child ends) and the session's agent status (done, Interrupted, nothing left for Monitoring).
…background work reads stopped - A tool approval card's X / Esc already sent Claude the plain deny, but the card read "Deny · Answered on <device>", the same as pressing Deny. It is now a dismissal like a plan card's: recorded as cancelled by the user, with the same deny reply, no interrupt and the child kept. Every card's Cancel now routes to a dismissal or the chat's Stop, so the option route is gone. - When a Stop ended Claude's child, a background agent or shell it was running settled as an unknown ending (a neutral dot), while the same task's own stop reads Interrupted. A close Orca asks for (a Stop, a rest, a quit) now ends what still runs as stopped; only a session that dies on its own leaves the ending unknown. The close reads no stop cause. A rest never meets live child work: the idle sweep keeps a chat with any. - If the host fails to record a dismissed card, Claude gets the card back, and a withdrawal Claude made meanwhile closes it, as before the host took it.
… breaking the chat-stop import cycle chat-stop.ts imported mutateStructuredAgentSession from host-mutations.ts, which imports mutateWithChatStop back. The mutation context and the one admit-then-serialize path now live in structured-agent-session-mutation-context.ts, which both import; host-mutations re-exports the context type for its other readers.
…work's ending unknown A Stop, rest or quit ended still-live background tasks as stopped even when the close found Claude's root gone but a descendant still running. Only a close that proved the whole tree gone stops them now; otherwise they settle unknown, as for an exit of the session's own. The stopped ending is marked as Orca's, so a frame of the child's own still replaces it.
What re-drives a failed child end, when the child-work decoder stops what is live, what a Stop's wait re-reads on a result, and why a card's Stop is unnamed.
Review status: ready for review (depends on #24267)The problem. In a native Claude chat, Stop only asked the running Claude process to interrupt, and kept that process alive. A Stop could fail to stop: Claude could refuse it ("Claude didn't stop."), the interrupt could go unanswered (about 30 s, then "Cancellation was not confirmed."), and background shells or subagents kept running with the chat stuck on "Monitoring". A card's X / Cancel had the same weakness. What changes for you. Stop now always stops: the turn reads Interrupted at once, Claude gets at most 3 s to finish the turn tidily, then Orca ends Claude's process and anything it started. Your next message restarts Claude on the same conversation (the first reply after a Stop takes about 1.8 s longer). An approval or plan card's X / Esc now means "no" to that card and the turn carries on; a question card's Cancel is a real Stop; approval and plan cards no longer offer a "Stop" option. Codex is unchanged. Fixed during review (each with a test that fails without it):
Depends on #24267, which must land first. After a Stop the chat rests with no Claude process, and on some models the composer then showed a stray "Medium" effort control; #24267 makes a resting chat's options match a running one. Deferred follow-ups (none caused by this PR unless noted):
Verified. Live on an M4 Air with real Claude (screenshots in the description): Stop mid-turn and resume with context; background shell and subagent ended; steered message withdrawn; a send right after Stop delivered once; an early Stop compared side by side with main; approval, plan and question cards; cold-start timing (median 1491 ms live vs 3256 ms after a Stop). Unit tests on the shipping Claude adapter cover every fix above, including a Stop racing a send. CI is green on the last pushed head; the final merge of main re-ran 369 test files (5016 passed) and typechecks clean. Not verified live. Windows, Linux, WSL, SSH hosts and the phone app; a question card left over from a finished turn (Claude gives background subagents no question tool); Codex (no login in the isolated test home). These rest on unit tests. |
|
| const { request, sessions, timeoutMs } = input | ||
| const { request, sessions } = input | ||
| // A Stop ends the child next, so its interrupt shares the grace with Claude's wind-down after it. | ||
| const timeoutMs = Math.min(input.timeoutMs ?? CLAUDE_STOP_GRACE_MS, CLAUDE_STOP_GRACE_MS) |
There was a problem hiding this comment.
Stop deadline multiplies per message
On a Claude CLI without interrupt_cancel_queued_v1, the interrupt receipt can list multiple queued messages. Although this line caps the interrupt timeout at three seconds, cancelClaudeTurn then cancels those messages one at a time, giving each request the full three seconds. If they do not answer, Stop can take three seconds per message before the process-close step even begins. Give the interrupt and queued-message withdrawals one shared deadline so Stop remains bounded.
There was a problem hiding this comment.
ℹ️ No critical issues — one boundedness gap on the legacy cancel path, plus minor nits inline.
Reviewed changes
- Stop ends Claude's child:
structured-agent-session-chat-stop.tsfolds the Stop into one body (mutateWithChatStop) that answers on the interrupt and queues, in the same synchronous tick, a second serialized step that waits for the wind-down and then ends the child. - Provider opt-in: new optional
StructuredAgentSessionAdapterStop(stopEndsSession/awaitStoppedRequestEnd/routePromptCancel/dismissPrompt). Claude declares all of it; Codex declares none, so its Stop and cards are unchanged. - Bounded wind-down: new
claude-request-end-wait.tsresolves when Claude has nothing in flight or after 3 s, woken on turn end, idle, exit and close; Claude's interrupt deadline drops 30 s → 3 s. - Cards: approval/plan
X/Escbecome a dismissal (deny); a question card's Cancel runs the chat's Stop; the Stop option is dropped from card presentations while an older card'scanceloption still answers as a dismissal. - Background work: a close that proved the whole process tree gone marks still-live tasks stopped (
stopLive); a close that saw a descendant survive, or a self-exit, leaves them unknown. - Refactor:
mutateStructuredAgentSessionand its context move tostructured-agent-session-mutation-context.ts(breaking the chat-stop import cycle);ClaudePromptRegistryloses turn-bound claims and cancellation observations.
ℹ️ Merge order with #24267
This branch rests the chat after every Stop, and the PR body states the stray "Medium" effort picker is only avoided once #24267 lands first. #24267 is still open and unmerged. Confirm the merge order holds, otherwise merging this reintroduces the picker after every Stop.
Technical details
# Dependency sequencing
## Affected sites
- PR #24267 (`fix(native-chat): a resting chat lists its model the way the running agent does`) — currently open, `merged: false`.
- src/main/claude/claude-structured-session-adapter.ts — `stopEndsSession = () => true` makes every Claude Stop rest the chat.
## Required outcome
- #24267 merges first, or this PR waits. Both touch host option/model resolution; a revert of either re-exposes the picker.ℹ️ Nitpicks
src/main/claude/claude-child-work-decoder.ts:129—stopLive()stampsbasis: 'stop-acknowledged'on every proven close (idle evict, quit, shutdown), but that enum is documented as the provider acknowledging a Stop. No consumer reads the basis today, so it is contract-purity rather than a visible mislabel; passing the close's stop cause through would let the basis match its meaning.src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts:40— theendStoppedStructuredAgentSessiondoc says the idle sweep retries an owed wind-down, but the sweep deliberately returns while the child still owes work, so recovery in that failure path actually rests on the next Stop.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| const { request, sessions, timeoutMs } = input | ||
| const { request, sessions } = input | ||
| // A Stop ends the child next, so its interrupt shares the grace with Claude's wind-down after it. | ||
| const timeoutMs = Math.min(input.timeoutMs ?? CLAUDE_STOP_GRACE_MS, CLAUDE_STOP_GRACE_MS) |
There was a problem hiding this comment.
The 3 s cap bounds each individual control request, but on a CLI without interrupt_cancel_queued_v1 the interrupt receipt's still_queued list is drained one cancelAsyncMessage at a time, each given the full timeout, so a Stop can take about 3 s per queued message before the child's end step even begins. The PR's bounded-3 s contract only holds on the capability path.
Technical details
# Stop's 3 s bound does not hold on the legacy cancel path
## Affected sites
- src/main/claude/claude-structured-prompt-ownership.ts:72 — caps the interrupt/`cancelAsyncMessage` timeout at 3 s.
- src/main/claude/claude-structured-control-actions.ts:50-58 — drains `receipt.still_queued` sequentially, each `cancelAsyncMessage(uuid, { timeoutMs })` receiving the same 3 s.
## Required outcome
- One deadline for the whole interrupt-and-withdraw sequence, so a Stop on a CLI without `interrupt_cancel_queued_v1` stays ~3 s regardless of how many messages were queued.
## Suggested approach
- Derive a single deadline from `stoppedAt` and pass each `cancelAsyncMessage` the remaining time, rather than the full budget.| }): Promise<void> { | ||
| const { request, sessions } = input | ||
| const session = sessions.get(request.sessionId) | ||
| const claim = session?.fence === request.fence ? session.prompts.claim(request.itemId) : null |
There was a problem hiding this comment.
dismissClaudeStructuredPrompt claims the prompt by fence, then awaits request.commit() (which yields) before answering, but unlike its sibling answerClaudeStructuredPrompt it never re-checks that the captured session is still the current entry in sessions — nor captures acquisitionGeneration. Impact is guarded downstream by answerClaudePrompt's ownsClaim, so this is symmetry/robustness rather than a wrong-answer path.
Technical details
# `dismissClaudeStructuredPrompt` is missing its sibling's recheck
## Affected sites
- src/main/claude/claude-structured-prompt-ownership.ts:195 — claims by fence only; no `sessions.get(request.sessionId) === session` / `acquisitionGeneration` guard around the `await request.commit()` at line 206.
- src/main/claude/claude-structured-prompt-ownership.ts:163-178 — `answerClaudeStructuredPrompt` captures `acquisitionGeneration` and re-checks identity after its commit.
## Required outcome
- The dismissal answers only the acquisition it was admitted for, matching `answerClaudeStructuredPrompt`.
## Open questions for the human
- Impact today is bounded by `answerClaudePrompt`'s `ownsClaim`, so this is consistency rather than a demonstrated wrong-answer path; confirm a commit cannot outlive the acquisition before deciding it is worth the extra guard.|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. 🧰 Additional context used📚 Code guidelines (1)No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (44)
💤 Files with no reviewable changes (4)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review. 📝 WalkthroughWalkthroughThe changes add serialized chat Stop handling and provider hooks for session wind-down. Claude Stop waits for in-flight requests within a grace period and stops live child work when session closure succeeds. Prompt-card cancellation now routes between stopping a turn and dismissing a card, with journal handling for dismissal commits. Tests cover these flows, session closure, and resumed Claude sessions. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to Stop now ends Claude's process after a bounded wait, and the next message resumes the conversation. Question-card Cancel routes to Stop; approval and plan cards are dismissed as denials. No outstanding defect was found, and the change appears ready to merge. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The reviewed cancellation paths retain session checks and permission denial. No new permission bypass was demonstrated. Remaining uncertainty is concentrated in exceptional shutdown failures and recovery before subsequent work proceeds. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…e-child-waiting Committed with --no-verify: pre-commit max-lines fails on structured-agent-session-adapter.ts, byte-identical to origin/main 4bd55c0, which #24235 put at 301/300; not touched by this PR. Full oxlint reports that file as its only error. Conflicts kept main's shape with this PR's additions on top: the Claude child decoder keeps main's stopLive beside observeWaiting, and the prompt registry and permission callback take main's removal of turnId while keeping the asking agent. Main's new dismissPrompt path now republishes child work as answerPrompt does, so a declined subagent request stops reading waiting at once.
#24235 makes a Claude Stop end its child after the interrupt. Ported onto B's model: the Claude turn's cause is gone (the turn end reads the Stop event), so recordTurnStop and the Stop's cause argument stay deleted, and the adapter close takes no cause in the merged-in tests. A refused Claude interrupt still ends the child, so its Stop writes no refusal: stop-refused is Codex-only, and a restart test now pins that for a provider whose Stop ends its child.
…on-ending Stop A Stop that ends the provider's session stopped its turn, so it writes its note even when the provider declined the interrupt; a repeated Cancel of a cancelled prompt is answered at the prompt Cancel's new entry point too.

What this fixes, and where
The problem. Pressing Stop in a native Claude chat only asked the running Claude process to interrupt the turn, and kept that process alive. So a Stop could fail to stop in three ways:
The cards had a matching problem: a card's X or Esc (on a permission, plan or question card), and the approval card's own "Stop" option, interrupted the turn the same way, with the same failure modes.
Why it happened. Stop was a request to a process that stayed alive. Whether anything ended depended on Claude taking that request, and background work outlives an interrupt by design.
Scope. Native (structured) Claude chats, on the desktop app and the phone, on local and SSH hosts alike: the change is on the host that runs Claude, and no message format changes. Codex chats are unchanged. Terminal agent tabs are unaffected.
Not affected: Codex's Stop and Codex cards, terminal agent tabs, an explicit Allow / Deny / answer on a card, Stopping one background task from its own control, and what happens to messages queued behind the turn (still withdrawn, never sent).
Depends on #24267 (must land first): #24267 makes a chat with no running Claude process list its model the way a running one does; this PR leaves the chat in that state after every Stop, so without it a stray "Medium" effort picker would appear after each Stop until the next reply.
ELI5
Before, Stop asked Claude to stop, and Claude could say no, not answer, or leave background work running.
Now Stop still asks first, gives Claude a moment (at most 3 s) to finish the turn tidily, then Orca ends that Claude process. Your next message starts Claude again on the same conversation, so nothing is lost; that first reply takes about 1.8 s longer.
A card's X now means "no" to that card, not "stop everything". A question's Cancel is a real Stop.
What Changed
Mechanism.
structured-agent-session-chat-stop.ts,mutateWithChatStop). Every request for a chat runs one at a time on that queue.stopEndsSession(structured-agent-session-adapter-stop.ts), is true for Claude. Codex does not declare it, so its Stop is unchanged.claude-request-end-wait.ts) is one budget of 3 s, counted from the interrupt.claude-structured-session-close.ts,ClaudeChildWorkDecoder.stopLive).routePromptCancel;structured-agent-session-prompt-cancel.ts):dismissPrompt). The card is recorded as cancelled by you, and Claude gets a deny: the same reply as Deny for a tool, or "dismissed, end your turn and wait" for a plan. Nothing is interrupted.Why
Ending the process removes all three failure modes at once. A refusal, a lost answer and surviving background work all end with the process. No new state is stored: the wait lives only for the length of the Stop.
Alternatives considered:
Differences from the common pattern
Linked Issue
N/A (maintainer change).
Visual Proof
Captured with live Claude 2.1.286 on an M4 Air, in a hidden window via the Chrome DevTools Protocol.
Stop mid-turn, then the next message resumes the same conversation:
(The "Medium" control in the after-Stop composer is the stray picker fixed by #24267.)
Background work ends with the Stop:
Cards:
Testing
Live QA (macOS, M4 Air, real Claude Code 2.1.286, structured chat; isolated home and profile). The final round ran at 3666db1. The three later commits are a module move, the proven-tree check for background tasks (covered by unit tests) and comments.
--resumewith the same idsleep 600stopEndsSession, the router,stopLive, proven vs unproven closes, and dismissal replies.Not verified:
Windows, Linux, WSL and SSH were not tested live. On WSL, a background shell could outlive the ended process while being recorded stopped; the proven-tree check covers what the host can see.
Cold start on a remote host.
The leftover question card, live.
Codex, live.
The phone app, live: the phone's Stop and card Cancel go through the same host path, which the unit tests cover.
I manually tested these changes locally
Automated tests added/updated, or explained why not below
Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
Remote and mixed versions.
Order with #24072 (records the Stop in the chat's history): the two PRs agree on the order inside Stop: withdraw queued messages, record the Stop, interrupt, end the process. Merging them touches only the Stop's body, now in
structured-agent-session-chat-stop.ts.Known follow-ups:
/compactStop, still end the process inside the Stop's own step, so a close failure there can still fail that Stop.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)