Repository navigation
Live shell output while a command runs; honest background tasks (#26, #35) - #97
Merged
Merged
Conversation
`BashOutput` is only the Bash tool's output type in SDK 0.3.278, the CLI removed the polling tool (and `TaskOutput`), and `KillShell` / `KillBash` are aliases of `TaskStop`. The shell list now says `Bash` and `TaskStop`; the old names stay recognised as built-ins so a stored agent config that uses them is not turned into an MCP name, and a scope drops the removed ones instead of handing them to the CLI. The guard holds only `Bash` for approval on a host with no sandbox, and only `Bash` is distilled into a terminal card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#26) A non-zero exit makes the CLI throw, so a failed `Bash` has no `BashOutput`: `tool_use_result` is the string `Error: Exit code N` followed by the merged output. That fell back to the generic card, which is exactly the case where the output matters most. Only the CLI's first line is parsed; anything else (a denial, a pre-spawn error) still goes to the generic card. `ShellDetails` gains optional `exitCode`, `returnCodeInterpretation` (grep's "No matches found") and a background `status`, all optional so persisted blocks still type-check. `appendStreamTail` grows an already-capped stream to the same 16k tail `clipTail` keeps, for the live output that follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#35) The SDK sends no output for a running command (`tool_progress` is elapsed seconds only) and the `BashOutput` polling tool is gone, but the CLI writes a backgrounded command's output to `<tmp>/<session>/tasks/<task>.output` and names the path in the call's result. The engine now tails that file on the host, once a second, and sends each new piece as a live-only `shell_output` frame keyed on the call, growing the persisted block to the same tail. The path is trusted only when it is absolute, has no `..`, ends `<session id>/tasks/<task id>.output` with both ids the CLI's own, is the only such path in the text, and on every read is still a regular file with one link whose real path (through the containment helper) keeps that shape. Anything else means no live output, never an error in the run loop. `task_notification` now keeps `task_id`, `tool_use_id`, `output_file` and the summary's exit code, and settles the card with a persisted `shell_task_done` that carries the final output. A command still running when the turn ends gets a final read, is marked `ended_with_turn`, and the turn gets one notice listing what was stopped: the engine closes the CLI after every turn, and its background commands go with it. A subagent's commands are left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#26, #35) - Escapes: `$lib/chat/terminal-text` strips OSC links and titles, charset escapes (`tput sgr0`'s `ESC(B`), truecolour and private-mode CSI, and resolves bare-CR progress-bar redraws, where the old regex let all of those through as text. - Long output opens on its last 20 lines with Show all / Show last 20, instead of the whole card starting closed and hiding a failure's output. Copy still copies everything. - One badge: `live`, `exit N`, finished / failed / stopped, `ended with turn`, background, timed out or interrupted. The CLI's reading of a special exit code is shown. - A live background card follows the newest output unless the reader has scrolled up. The page applies `shell_output` / `shell_task_done` through pure helpers in `streaming-blocks`, notices a gap after a reconnect, and saves a card still running at a partial save as ended with the turn. The header chips clear on `done`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CLI tells the model a backgrounded command "will be notified when it completes" and to Read its output file. Neither holds here: the engine closes the CLI after every turn, and the output file is outside the workspace, where the containment guard refuses the Read. Both tool policies now say so, and point at `tee` into the workspace for output the model needs to read itself. Deleting a conversation only deleted the row, leaving its live turn (and the commands it had backgrounded) running with nothing that could reach it. The delete now interrupts the live run first, through the Stop button's path; with no `perTaskStopAffordance` declared, the CLI's interrupt kills the session's background tasks too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chat spec gains plain-English sections on the shell card and on background commands, including what was verified in the SDK 0.3.278 typings and what is only observed in the bundled CLI. The tools doc, README and parity table follow; the re-audit corrects its "poll `BashOutput`" plan and marks both issues shipped, #35 as turn-scoped. Closes #26 Closes #35 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…once (#35) The output file was opened O_RDONLY | O_NOFOLLOW and only checked with isFile() after the open. A FIFO swapped in for <taskId>.output (the same sandboxed-writer threat the symlink and hard-link checks exist for) made open() wait for a writer that may never come: the read never resolved, so the final read at the end of the turn never did either, and the libuv threadpool thread it held stalled every fs call queued behind it. Open the way the bundled CLI opens its own task output files, adding O_NONBLOCK where the platform has it. A FIFO then opens at once, fails the isFile() check on the descriptor, and the tail gives up. Regular files ignore the flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… stall a turn (#35) Three review findings in how a backgrounded Bash command is followed. A reloaded or reconnected page never saw live output again. `shell_output` is live-only, and the resume replay reads only persisted run events, so a card on a page that reattached mid-turn stayed frozen under its "live" badge until the command ended. What arrived since the last save now also goes out as a persisted `shell_output_checkpoint` (same shape, `from`/`to` positions), at most every 5 s and only when there is something new; the first output is saved at once. The tail's new `onPoll` hook runs it in the same queue as the chunks, so a checkpoint never lands out of order with the live frames, and output that goes quiet is still saved. The end of a turn could hang on a read. endTurn() and settle() awaited the tail's final read with no limit, so a read that never returned (the FIFO case, fixed at the open in the previous commit) kept the engine from closing the CLI and sending `done`. Final reads and stopAll() are now bounded at 2 s, and a read that comes back after its card was closed changes nothing. A command that exits at once could be reported as stopped with the turn. The CLI emits task_notification the moment a task ends, which can be before the call's tool_result; settle() ignored a task it was not tracking yet. Such settlements are now held (by task id or call id) and track() creates the card settled, with its output and exit code, before the tool_result frame goes out; track() is awaited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#26) The page applies `shell_output_checkpoint` the way it applies `shell_output`, so a card on a page that reloaded or reconnected mid-turn keeps moving, a few seconds behind, instead of freezing under its "live" badge. applyShellOutput now reads the frame's `from`/`to` positions: a chunk the card already has is skipped, one that overlaps adds only its new part, and one that starts past the card's end still marks the output as a tail. A stream clipped to its 16k tail is cut at an arbitrary character, which can land inside a colour code and leave `[31m` at the top of the card. The card now drops the first, partial line of a clipped stream when a line follows it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#35) The chat spec said a page reloaded mid-turn "picks the card up again from the next piece of output", which was not true; it now describes the saved checkpoints a reconnecting page catches up from, what they cost, the 2 s bound on final reads, a command whose finish notice beats its result, and the clipped first line. TaskStop is exempt from the no-sandbox "ask for every command" rule only; the permission mode still applies (plan mode refuses it), which the chat spec, the tools doc and the re-audit now say. The workspace-guard spec says its TaskStop case pins what the docs promise rather than a behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #4 (the SDK's native AskUserQuestion replaces ask_user) and the chain under it: #24 rewind/edit/regenerate, #22 composer mentions, #9 gateway, #14 right rail. Conflict resolutions: - src/lib/engine/stream.server.ts: keep both imports - #35's createBackgroundShells and #4's AskUserQuestion answering. The rest merged cleanly and was checked as combined control flow: the per-turn SDKUserMessage input (#24) still ends at the first result, so background shells still end with the turn; AskUserQuestion is answered in canUseTool (#4) while Bash results with a backgroundTaskId are tracked for live output (#35); tool_use_result distillation keeps both the shell details and the AskUserQuestion answers fallback; endBackgroundShells runs before the summary that now carries sdkTailUuid. - src/lib/chat/streaming-blocks.ts: keep both import sets (#4's ask-user-question names, #35's appendStreamTail / BackgroundShellStatus). - src/lib/chat/stream-slots.server.ts: orchestrator policy takes #4's AskUserQuestion wording and keeps #35's BACKGROUND_COMMAND_POLICY_LINES; the agent policy already carried both. - src/lib/engine/builtin-tools.ts: BUILTIN_TOOL_SET holds AskUserQuestion (#4) plus #35's old shell spellings and REMOVED_BUILTIN_TOOLS. - README.md: keep #35's command-output paragraph and #4/#24/#9/#22's paragraphs. - docs/structure/parity-reaudit-2026-09-22.md: #35 row is #35's "shipped, turn-scoped"; #24 row is #24's "shipped" with the control session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The #14 note in the parity re-audit said the chips would stay in the topbar "until #35 defines what a task row shows". With #35 in the same chain that is settled: the chips stay in the header with a stop control each, and a command's live output is its own card in the thread, not a rail panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Picked up the interrupted attempt (one commit plus uncommitted work), reviewed it against the SDK 0.3.278 typings and the bundled CLI 2.1.278, then ran check, build and specs, wrote the docs and committed in logical groups. #35 is built as option A, turn-scoped and made honest. The shell tools are now named the way the CLI names them:
BashandTaskStop. The old namesKillShellandKillBashstill work as aliases, andBashOutputandTaskOutputare dropped from scopes. The engine now reads a backgrounded command's output file on the host once a second, through the containment helper and only after strict path checks, and sends liveshell_outputframes that are not persisted.task_notificationsettles the card through a persistedshell_task_doneframe that carries the exit code and the final output. A command still running when the turn ends is markedended_with_turn, and the turn gets one persisted notice listing what was stopped. The header chips clear ondone, both tool policies tell the model that background commands end with its reply, and deleting a conversation interrupts its live run first. For #26, the card now strips all escape codes and progress-bar redraws, and opens on the last 20 lines with Show all and a copy button. It shows one status badge (live, exit N, finished, stopped, ended with turn, and so on). A failed foreground command is now distilled into the terminal card with its exit code, and the CLI'sreturnCodeInterpretationis shown. No migration.After review: This pass resumed the six earlier commits on the branch and addressed every review item in four new commits. First, the task output file is now opened with O_NONBLOCK (O_RDONLY | O_NOFOLLOW | O_NONBLOCK, where the platform has the flags). A named pipe put in place of the file now opens at once, fails isFile() and is given up on, so it can no longer hang a read (src/lib/engine/task-output.server.ts). Second, the final reads in endTurn()/settle() and the reads stopped by stopAll() are now capped at 2 s, and a read that returns after its card was closed changes nothing, so the engine always closes the CLI and always sends
done(src/lib/engine/background-shells.server.ts). Third, reloaded or reconnected pages now keep getting live output. At most every 5 s, and only when there is new output, the engine sends a persistedshell_output_checkpointframe holding the output since the last save. The page applies it through applyShellOutput, which uses from/to positions to skip output it already has, add only the new part of an overlap, or mark a gap. A reattached card therefore catches up and keeps updating a few seconds behind, and the chat spec now says exactly that. Fourth, a task_notification that arrives before its call's tool_result is held, and track() (now awaited in stream.server.ts) creates the card already settled with its output and exit code, instead of later marking it 'stopped when the turn ended'. Also fixed from the minor notes: a stream clipped at 16k now drops its partial first line, so escape fragments like[31mno longer show. The docs now say the TaskStop exemption covers only the no-sandbox 'ask for every command' rule, while permission modes still apply and plan mode refuses it. The workspace-guard spec comment now says that test pins documented behaviour rather than a change. No migration.Closes #26
Closes #35
What was addressed
Audit findings (from the 2026-09-22 subsystem audit) were re-checked against the code before any change; "not a bug" means the claim did not hold up.
shell_output_checkpointholding the output since the last save: the first output at once, then at most every 5 s (SHELL_CHECKPOINT_MS), only when something changed. It is emitted from the tail's new onPoll hook, in the same queue as the chunks, so it cannot arrive out of order with live frames. +page.svelte applies it with applyShellOutput (one-line change). The from/to positions let a page skip output it already has, append only the new part of an overlap, and mark a real gap. The spec.md paragraph, the applyShellOutputVerification
bun run check: 0 errorsbun run build: passesDocs updated
Follow-ups / residual risk
🤖 Generated with Claude Code