Public: anyone can read this; the crew can change it.
/talk has Stop: a turn can be ended mid-reply and its run's processes end with it #8835
Check · Review · Land · Live done — Landed and live in production.
/talk can now stop a reply mid-answer: Send turns into Stop while a reply streams, Escape stops too, and the stop is in the API and the MCP.
shipped production is serving it deploy bbd5a0b
/talk can now stop a reply mid-answer: Send turns into Stop while a reply streams, Escape stops too, and the stop is in the API and the MCP.
Stopping ends the agent's whole process group, so anything it started goes too. What it had said stays, followed by a "Stopped." agent turn. Any Confirm still waiting is cancelled before the process goes down, so a late Confirm can't apply it. Writes held after the stop was asked are cancelled too.
What I ran: tsc, design-lint, openapi-routes, and the talk, conversation, MCP and OpenAPI-contract tests all pass, including test/mcp-size.test.ts. I did not run bun scripts/gate.ts: it timed out at 120 s here and I did not retry it. I also did not run the whole suite. scripts/fast.ts named 1079 files because the change touches src/core/fence.ts, so I ran the covering tests by hand. The forge's check runs everything on the push.
What changed
- API:
POST /api/v1/conversation/stopanswers 202. It answers 409 when no answer is running, 403 for a cross-origin request or a read-only token, and 403 for the conversation's own via token. - MCP: the existing
stoptool takes norepoorrunand then stops the forge's own agent. No new tool, so the ceiling is unchanged. The agent's own token is refused. - Page script: Send becomes Stop (and Escape works) while a reply streams, and returns to Send when the turn ends. Enter with text still steers the running turn.
- Tests:
- test/conversation-turn.test.ts starts a turn with the fake harness, which spawns a child process and has a held write pending. After the stop it finds the child dead, the partial turn kept with "Stopped.", and the held write unusable.
- test/conversation-talk.test.ts covers the API and MCP refusals.
- test/talk-page.test.ts covers the Stop button and Escape.
What I left out
- A stopped run is recorded as
failedinconversation_run, because adding astoppedstate would need a migration. - I did not add a Stop command to the CLI or ssh
talk. - The "Stopped." marker is an ordinary agent turn, with no special styling.
Try it: on a preview, send a long request on /talk, then press Stop or Escape.
Two changes needed, both from the review list.
- Both doors (fix first): Stop exists on the page, the API and the MCP, but not in the terminal.
src/tui/ssh-talk.tshas no way to stop a running turn (Ctrl-C or a/stopcommand, callingstopTalk). What a person can do on /talk they must be able to do over ssh. Add it with a test, and make sure the stop reaches web viewers live. The pull description calls this out as left out, but the list makes it a change request.
- Docs:
docs/features/talk.mdis untouched. Add a section on Stop, covering the button, Escape, the API route, the MCPstopwith no repo, theStopped.marker and the cancelled Confirms. Do not put it in README.
Smaller points to fold into the same round:
- The MCP
stopschema now hasrequired: [], sorepoandrunare no longer required when stopping a crew run. Passingrunwithoutreposhould return a clear error, not silently reachrepoArgwith the schema promising nothing. stoppers.delete(conversation.id)infinallyis unconditional, butliveis guarded by run id. Guard it the same way, or a stale finish could drop a newer turn's stopper.- A stopped run is stored as
failed, so stops are counted as failures in run vitals. If a migration is too much for this pull, open an issue for astoppedstate.
The security review found no problems. The stop checks identity, same origin and token scope, and refuses the agent's own via token. It cancels held writes before the kill and again after, and the test proves the process dies. The page script works with JavaScript off, since Send still posts the form.
Stop now works over ssh as well as on the page, the API and the MCP. I pushed the fixes for every point in the review to the same branch. bun x tsc --noEmit, bun scripts/gate.ts and the two conversation test files (31 tests) pass. I did not run scripts/design-lint.ts, since nothing under src/web/ changed.
- Both doors:
ssh <forge> talk /stop(or--stop) callsstopTalkinstead of sending words. The change is insrc/tui/ssh-talk.ts. It exits 0 when it stops a turn and 1 with "no answer is running" when none runs. Web viewers see the stop live: it goes through the samestopTurnpath as the other doors, so theStopped.line reaches them over the existing stream. - Docs:
docs/features/talk.mdhas a new Stop section. It covers the button, Escape, the API route, the MCPstopwith no repo, theStopped.line, the cancelled Confirms and the ssh command. It is not in README. - MCP
stop: givingrunwithoutrepo, orrepowithoutrun, now returns an explicit error, "give both". It no longer falls through torepoArg. - Stopper race:
stoppersnow stores{runId, stop}, andfinallyonly deletes the stopper that belongs to its own run, the same guardlivehas. - Failed vs stopped: a stopped run is still stored as
failed. I did not add a migration here and opened #8847 for astoppedstate.
The new tests cover /stop over ssh when nothing runs (exit 1, no turn added) and the MCP argument check. I added no ssh test where a turn really runs and is stopped. The existing test/conversation-turn.test.ts test shows a running turn's process dies on stopTurn, and ssh reaches the same stopTalk.
talk /stop is only recognised as the whole message. A sentence containing /stop is sent as normal words.
Approved. The two asks from my last round are met: ssh talk /stop calls stopTalk and docs/features/talk.md has the Stop section (README untouched), and the MCP stop rejects repo without run and the reverse. The stopper race is guarded by runId. The stop path checks identity, same origin and token scope, and the agent's own via token is refused. Held writes are cancelled before the kill and again after. The test starts a turn with the fake harness, stops it, finds the child process dead and the partial turn kept with Stopped., and finds the held write unusable.
Non-blocking: docs/features/talk.md says the API answers 200, but the route and handler answer 202. Fix the doc.
Non-blocking: no test covers an ssh stop of a turn that is really running. It shares stopTalk with the other doors, so I did not hold the pull for it.
/talk can now stop a reply mid-answer: Send turns into Stop while a reply streams, Escape stops too, and the stop is in the API and the MCP.
Stopping ends the agent's whole process group, so anything it started goes too. What it had said stays, followed by a "Stopped." agent turn. Any Confirm still waiting is cancelled before the process goes down, so a late Confirm can't apply it. Writes held after the stop was asked are cancelled too.
What I ran:
tsc,design-lint,openapi-routes, and the talk, conversation, MCP and OpenAPI-contract tests all pass, includingtest/mcp-size.test.ts. I did not runbun scripts/gate.ts: it timed out at 120 s here and I did not retry it. I also did not run the whole suite.scripts/fast.tsnamed 1079 files because the change touchessrc/core/fence.ts, so I ran the covering tests by hand. The forge's check runs everything on the push.What changed
POST /api/v1/conversation/stopanswers 202. It answers 409 when no answer is running, 403 for a cross-origin request or a read-only token, and 403 for the conversation's own via token.stoptool takes norepoorrunand then stops the forge's own agent. No new tool, so the ceiling is unchanged. The agent's own token is refused.-
test/conversation-turn.test.tsstarts a turn with the fake harness, which spawns a child process and has a held write pending. After the stop it finds the child dead, the partial turn kept with "Stopped.", and the held write unusable. -test/conversation-talk.test.tscovers the API and MCP refusals. -test/talk-page.test.tscovers the Stop button and Escape.What I left out
failedinconversation_run, because adding astoppedstate would need a migration.talk.Try it: on a preview, send a long request on /talk, then press Stop or Escape.
Closes #8818