Skip to content

Live shell output while a command runs; honest background tasks (#26, #35) - #97

Merged
derekhearst merged 13 commits into
mainfrom
feat/35-live-shell
Sep 24, 2026
Merged

derekhearst merged 13 commits into
mainfrom
feat/35-live-shell

Conversation

@derekhearst

Copy link
Copy Markdown
Owner

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: Bash and TaskStop. The old names KillShell and KillBash still work as aliases, and BashOutput and TaskOutput are 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 live shell_output frames that are not persisted. task_notification settles the card through a persisted shell_task_done frame that carries the exit code and the final output. A command still running when the turn ends is marked ended_with_turn, and the turn gets one persisted notice listing what was stopped. The header chips clear on done, 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's returnCodeInterpretation is 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 persisted shell_output_checkpoint frame 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 [31m no 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.

# Item Verdict Note
must_fix-1 task-output.server.ts:136 FIFO open blocks / unbounded finish() must_fix-1 task-output.server.ts:136 FIFO open blocks / unbounded finish() fixed OPEN_FLAGS now adds O_NONBLOCK (?? 0 on Windows), the same way the bundled CLI opens these files, so a FIFO opens at once and fails isFile(). In background-shells, the final read in endTurn/settle/track goes through finishTail(), which races tail.finish() against 2 s (TAIL_FINISH_TIMEOUT_MS). stopAll() is capped the same way. applyChunk ignores chunks once a card is settled or closed. Specs: a FIFO case in tests/engine.task-output.spec.ts (the tail gives up and finish() returns in under 1 s) and in tests/engine.background-shells.spec.ts (endTurn() resolves in under 1 s). Both skip on win32, si
must_fix-2 docs/chat/spec.md:219 live output never reaches a reloaded/reconnected page must_fix-2 docs/chat/spec.md:219 live output never reaches a reloaded/reconnected page fixed Fixed by making the claim true. background-shells now sends a persisted shell_output_checkpoint holding 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 applyShellOutput
should_fix background-shells.server.ts:173 notification before tool_result should_fix background-shells.server.ts:173 notification before tool_result fixed settle() now holds a notification for a task not yet tracked, in a bounded Map (64 entries) keyed by task id, and can also match it by toolUseId. track() is now async and awaited in stream.server.ts before the tool_result frame is sent. When a settlement is already held, it does one bounded final read (from the result's path, or from the notification's outputFile after isTaskOutputPath) and sets details.background to the real status plus exitCode. It sends no frames, because the tool_result carries everything, and endTurn later has nothing to report. Specs cover the notification-first order th
claimed_fixes_not_actually_fixed: reloaded page 'picks the card up again' claimed_fixes_not_actually_fixed: reloaded page 'picks the card up again' fixed Same fix as must_fix-2. The claim is now true, a few seconds behind, and the docs say so.
notes-minor: clipped 16k tail can start mid-escape notes-minor: clipped 16k tail can start mid-escape fixed cleanTerminalText(text, { clipped }) drops the partial first line when another line follows it. ShellOutputCard passes clipped only for a stream that is truncated and at MAX_STREAM_CHARS. Spec added to tests/chat.shell-output.spec.ts.
notes-minor: docs say TaskStop is 'never held for approval' notes-minor: docs say TaskStop is 'never held for approval' fixed docs/chat/spec.md, docs/tools/tools.md and the re-audit now say only the no-sandbox 'ask for every command' rule is skipped. The permission mode and per-tool approval settings still apply, and plan mode refuses TaskStop because it is classified as mutate, which I verified in permission-mode.ts.
notes-minor: workspace-guard TaskStop spec also passes on main notes-minor: workspace-guard TaskStop spec also passes on main partially-fixed The test is kept as a regression pin, and its comment now says this behaviour already held before #35 and is pinned because the docs now promise it. No behaviour change was needed.

Verification

  • bun run check: 0 errors
  • bun run build: passes
  • Specs: Run with the e2e-port patch applied and then reverted (tests/server-env.ts is clean), on both projects with --workers=3. (1) tests/engine.task-output.spec.ts, tests/engine.background-shells.spec.ts (new), tests/engine.stream-routing.spec.ts, tests/chat.shell-output.spec.ts, tests/engine.workspace-guard.spec.ts, tests/engine.sdk-notices.spec.ts, tests/engine.tool-result-details.spec.ts: 208 passed, 4 skipped. The skips are the two mkfifo specs on each project; they skip on win32 and have not run anywhere yet, so Linux CI will be their first run. (2) tests/chat.shell-output-render.spec.ts (including the new checkpoint-only card spec), tests/chat.background-commands.spec.ts, tests/chat.stop-task.spec.ts: 26 passed. bun run check: 0 errors, 0 warnings. bun run build: exit 0.
  • Independent review: changes-needed (2 must-fix, 1 should-fix), addressed in a follow-up round.

Docs updated

  • docs/chat/spec.md
  • docs/tools/tools.md
  • docs/structure/parity-reaudit-2026-09-22.md
  • docs/structure/claude-desktop-parity.md
  • README.md

Follow-ups / residual risk

  1. The two mkfifo specs (tests/engine.task-output.spec.ts, tests/engine.background-shells.spec.ts) skip on Windows, and Docker/WSL were not available here, so they have never run. Linux CI will be their first run. 2. Checkpoints are stored in run_events for good (nothing prunes that table). A quiet dev server costs a few small rows; a very chatty command costs at most one row of up to 16k characters every 5 s for as long as the turn runs. Raise SHELL_CHECKPOINT_MS or add pruning if that becomes a problem. 3. The notification-before-tool_result order was read from the CLI's code, not reproduced live; both orders are now handled and the docs say so. 4. Still carried over from before: a live check on the Linux image that a backgrounded process really dies with session.close() (the CLI spawns detached shells). Option B, a command that outlives its turn, is not built. 5. Issues Surface shell output as a terminal, not a tool card #26 and Background work inside a chat turn #35 close through the 'Closes' trailers in 75fe76c once the PR merges; nothing was pushed or merged.

🤖 Generated with Claude Code

derekhearst and others added 13 commits September 23, 2026 16:39
`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>
@derekhearst
derekhearst merged commit 9e118cf into main Sep 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Background work inside a chat turn Surface shell output as a terminal, not a tool card

1 participant