Compare commits

...

2 Commits

Author SHA1 Message Date
Ben Brandt
cf18102af0 fix: bound the background output that a read holds and sends
`finish()` read the rest of a command's output file into one string and
sent it as one update before the terminal's exit. A command that writes
faster than the polls read (about 512 KiB/s) falls behind, so at its end
that string could be the whole backlog.

- Each read of at most 256 KiB is sent before the next, also when
  reading to the end.
- Past 1 MiB of unread output, the reads skip to the file's last 1 MiB
  and send it as a snapshot, so the terminal shows the latest output
  without a gap spliced into it.
- A file that shrank is read again from its start, as a snapshot.
2026-11-02 14:52:41 +01:00
Ben Brandt
91cbb43cb3 feat: stream the output of v2 background commands
Claude Code streams no output of a Bash command that went to the
background: it writes it to the task's output file. So the command's
terminal showed only the Bash result's placeholder ("Command running in
background with ID …") until the command exited. ACP v2 asks an Agent
to keep a listed terminal's output current whenever it can read it.

Read the output file that the Bash result names into the terminal while
the command runs:

- The file is polled every 500 ms, at most 256 KiB per read, with a
  timer that does not keep the process alive. A UTF-8 character that a
  read splits waits for its last byte.
- The file's first text replaces the placeholder as a `terminal_output`
  snapshot. Later text is appended as `terminal_output_chunk`s.
- When the task ends, the rest of the file is read before the
  terminal's exit, as v2 asks. When the query closes, reading stops.

Only for sessions that report their background work (v2), like the rest
of it.
2026-11-02 14:46:11 +01:00
5 changed files with 431 additions and 3 deletions

View File

@@ -168,6 +168,7 @@ import {
backgroundLevel,
type BackgroundLevel,
type BackgroundSink,
type BackgroundTerminal,
backgroundToolCallPause,
backgroundToolCallStates,
type BackgroundTaskEnd,
@@ -176,6 +177,8 @@ import {
cancelBackgroundWork,
droppedTasks,
endBackgroundWork,
finishBackgroundTerminalOutput,
followBackgroundTerminalOutput,
registerBackgroundToolCalls,
takeBackgroundTerminalExit,
takeBackgroundToolCallEnd,
@@ -1487,7 +1490,7 @@ export type Session = {
* terminal exits when the task ends, not at the tool result (see
* `withRunningTerminal`). Only for a client that renders terminals and is
* not AIR, which shows the command through its async tasks. */
backgroundTerminals?: Map<string, { toolCallId: string; parentToolUseId?: string }>;
backgroundTerminals?: Map<string, BackgroundTerminal>;
/** The tool calls whose tasks went on in the background and still run, by
* task id: a Monitor, a Workflow, or a backgrounded MCP call. The tool call
* stays `in_progress` (or `paused`) after its result, until its task ends
@@ -5422,6 +5425,7 @@ export class ClaudeAcpAgent {
* status, once the task ends (see `Session.backgroundTerminals` and
* `Session.backgroundToolCalls`). */
const endBackgroundTask = async (taskId: string, end: BackgroundTaskEnd) => {
await finishBackgroundTerminalOutput(session, taskId);
const exit = takeBackgroundTerminalExit(session, params.sessionId, taskId);
if (exit) await sendUpdate(exit);
const toolCallEnd = takeBackgroundToolCallEnd(session, params.sessionId, taskId, end);
@@ -8527,6 +8531,7 @@ export class ClaudeAcpAgent {
const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
let backgroundedToolCalls: ReadonlySet<string> = new Set();
let runningTerminal: string | undefined;
let runningTerminalTask: string | undefined;
let runningToolCalls: ReadonlySet<string> = new Set();
if (message.type === "user") {
rememberResolvedToolNames(session, content);
@@ -8558,7 +8563,11 @@ export class ClaudeAcpAgent {
...(message.parent_tool_use_id
? { parentToolUseId: message.parent_tool_use_id }
: {}),
...(backgroundBashTask.outputFilePath
? { outputFilePath: backgroundBashTask.outputFilePath }
: {}),
});
runningTerminalTask = backgroundBashTask.taskId;
}
runningToolCalls = registerBackgroundToolCalls(
session,
@@ -8642,6 +8651,22 @@ export class ClaudeAcpAgent {
),
);
}
// After the result's own updates: the command's output replaces
// the placeholder that they gave its terminal.
if (runningTerminalTask) {
followBackgroundTerminalOutput(
session,
params.sessionId,
runningTerminalTask,
(notification) =>
sendUpdate(notification).catch((error) => {
this.logger.error(
`Session ${params.sessionId}: failed to send background command output:`,
error,
);
}),
);
}
break;
}
case "tool_progress": {

131
src/background-output.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* The output of a command that went to the background, read from the file
* that Claude Code writes it to. Claude Code streams no output of such a
* command, so its display terminal would show only the placeholder of the
* Bash result ("Command running in background with ID …") until the command
* exits. ACP v2 asks an Agent to keep a listed terminal's output current
* whenever it can read it (the v2 prompt lifecycle, "Background work").
*
* The file is polled: watching a file is not reliable on every platform, and
* a missing file only means that nothing was written yet.
*/
import { open } from "node:fs/promises";
import { StringDecoder } from "node:string_decoder";
/** How often a running command's output file is read. */
export const BACKGROUND_OUTPUT_POLL_MS = 500;
/** The most bytes one read takes, so a burst of output does not block the loop. */
export const BACKGROUND_OUTPUT_READ_BYTES = 256 * 1024;
/**
* The most unread bytes that are still sent. A command that writes faster
* than the polls read falls behind. Past this, the reads skip to the file's
* last bytes, as a snapshot: a terminal shows the latest output, and a
* client keeps a bounded amount of it anyway.
*/
export const BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES = 1024 * 1024;
/**
* Text that the file adds. A snapshot replaces the terminal's output so far:
* the first one replaces the placeholder of the Bash result, and a later one
* follows a skip or a file that shrank. Other text appends to it.
*/
export type BackgroundOutput = { text: string; replaces: boolean };
/**
* Follows the output file `path` of one background command and hands each
* piece of new text to `publish`, in order. Reads never overlap. A read that
* fails, for example because the file does not exist yet, is tried again at
* the next poll. `publish` must not reject: the next read waits for it.
*/
export class BackgroundOutputTail {
private offset = 0;
private decoder = new StringDecoder("utf8");
/** Whether the next text replaces the terminal's output. */
private replaces = true;
private stopped = false;
private reading: Promise<void> = Promise.resolve();
private readonly timer: ReturnType<typeof setInterval>;
constructor(
private readonly path: string,
private readonly publish: (output: BackgroundOutput) => Promise<void>,
) {
this.timer = setInterval(() => void this.read(false), BACKGROUND_OUTPUT_POLL_MS);
// A command that runs on must not keep the agent process alive.
this.timer.unref();
}
/**
* Reads the rest of the file and stops. The command ended, so what the file
* holds is its whole output, and it goes out before the terminal's exit.
*/
async finish(): Promise<void> {
clearInterval(this.timer);
await this.read(true);
this.stopped = true;
}
/** Stops without reading more: the session ended. */
stop(): void {
clearInterval(this.timer);
this.stopped = true;
}
/**
* Reads what the file adds, one read of at most
* {@link BACKGROUND_OUTPUT_READ_BYTES} at a time, and publishes each read's
* text before the next. `toEnd` reads until the end of the file, and ends a
* character that the command cut off with a replacement character.
*/
private read(toEnd: boolean): Promise<void> {
this.reading = this.reading.then(async () => {
if (this.stopped) return;
let file;
try {
file = await open(this.path, "r");
} catch {
// Nothing written yet, or the file is gone: try again at the next
// poll. At the end, the terminal keeps the output it has.
return;
}
try {
const { size } = await file.stat();
// A file that shrank is a new one: its output starts over. Past the
// backlog, the reads skip to the file's last bytes.
const start = size < this.offset ? 0 : this.offset;
const skipTo = Math.max(start, size - BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES);
if (skipTo !== this.offset) {
this.offset = skipTo;
this.decoder = new StringDecoder("utf8");
this.replaces = true;
}
const buffer = Buffer.alloc(BACKGROUND_OUTPUT_READ_BYTES);
let bytesRead: number;
do {
({ bytesRead } = await file.read(buffer, 0, buffer.length, this.offset));
this.offset += bytesRead;
const text = this.decoder.write(buffer.subarray(0, bytesRead));
if (text !== "") await this.send(text);
} while (toEnd && bytesRead > 0);
if (toEnd) {
const rest = this.decoder.end();
// A command that wrote nothing still replaces the placeholder.
if (rest !== "" || this.replaces) await this.send(rest);
}
} catch {
// The read failed: try again at the next poll.
} finally {
await file.close();
}
});
return this.reading;
}
private async send(text: string): Promise<void> {
const replaces = this.replaces;
this.replaces = false;
await this.publish({ text, replaces });
}
}

View File

@@ -30,6 +30,7 @@ import { RequestError } from "@agentclientprotocol/sdk/unstable";
import type { AcpSessionNotification } from "./acp-subagents.js";
import type { NativeSubagent } from "./native-subagents.js";
import { type AcpToolCallRenderer, V2_CANCELLED, V2_PAUSED } from "./tool-calls/renderer.js";
import { type BackgroundOutput, BackgroundOutputTail } from "./background-output.js";
/** An item of a `background` list. */
export type BackgroundItem =
@@ -38,6 +39,18 @@ export type BackgroundItem =
| { type: "subagent"; sessionId: string }
| { type: "_claude/task"; taskId: string; description: string };
/** The display terminal of a command that went to the background (see `Session.backgroundTerminals`). */
export interface BackgroundTerminal {
/** The Bash tool call, whose id names the terminal. */
toolCallId: string;
/** The subagent tool call that ran the command, if any. */
parentToolUseId?: string;
/** The file that Claude Code writes the command's output to, from the Bash result. */
outputFilePath?: string;
/** Reads that file into the terminal while the command runs. */
output?: BackgroundOutputTail;
}
/** A tool call whose task runs on in the background (see `Session.backgroundToolCalls`). */
export interface BackgroundToolCall {
toolCallId: string;
@@ -82,7 +95,7 @@ export interface BackgroundSink {
export interface BackgroundWorkSession {
reportsBackground?: boolean;
backgroundLevel?: BackgroundLevel;
backgroundTerminals?: Map<string, { toolCallId: string; parentToolUseId?: string }>;
backgroundTerminals?: Map<string, BackgroundTerminal>;
backgroundToolCalls?: Map<string, BackgroundToolCall>;
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
liveBackgroundTasks?: Map<string, { parentToolUseId?: string; isSubagent: boolean }>;
@@ -280,6 +293,61 @@ export function droppedTasks(session: BackgroundWorkSession, level: BackgroundLe
].filter((taskId) => !level.has(taskId));
}
/**
* Starts reading the output of the background command `taskId` into its
* terminal, when the session reports its background work and the Bash result
* named the command's output file. `send` sends an update of the terminal's
* tool call, routed as the call's other updates. Called once the Bash
* result's own updates are sent: the first output replaces the placeholder
* that they gave the terminal.
*/
export function followBackgroundTerminalOutput(
session: BackgroundWorkSession,
sessionId: string,
taskId: string,
send: (notification: AcpSessionNotification) => Promise<void>,
): void {
const terminal = session.backgroundTerminals?.get(taskId);
if (!session.reportsBackground || !terminal?.outputFilePath || terminal.output) return;
terminal.output = new BackgroundOutputTail(terminal.outputFilePath, (output) =>
send(terminalOutputUpdate(sessionId, terminal, output)),
);
}
/** The update that gives the terminal of `terminal` the text `output`. */
function terminalOutputUpdate(
sessionId: string,
terminal: BackgroundTerminal,
output: BackgroundOutput,
): AcpSessionNotification {
const data = { terminal_id: terminal.toolCallId, data: output.text };
return {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: terminal.toolCallId,
_meta: {
...(terminal.parentToolUseId
? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
: {}),
...(output.replaces ? { terminal_output: data } : { terminal_output_delta: data }),
},
},
};
}
/**
* Reads the rest of the output of the background command `taskId` into its
* terminal: the command ended, and its output goes before its exit, as v2
* asks.
*/
export async function finishBackgroundTerminalOutput(
session: BackgroundWorkSession,
taskId: string,
): Promise<void> {
await session.backgroundTerminals?.get(taskId)?.output?.finish();
}
/**
* Removes the terminal of the background command `taskId` of the session
* `sessionId`, and returns the update that exits it, or undefined when the
@@ -294,6 +362,7 @@ export function takeBackgroundTerminalExit(
): AcpSessionNotification | undefined {
const terminal = session.backgroundTerminals?.get(taskId);
if (!terminal) return undefined;
terminal.output?.stop();
session.backgroundTerminals!.delete(taskId);
return {
sessionId,
@@ -406,6 +475,8 @@ export function endBackgroundWork(
sessionId: string,
): AcpSessionNotification[] {
if (!session.reportsBackground) return [];
// No command writes output any more.
for (const terminal of session.backgroundTerminals?.values() ?? []) terminal.output?.stop();
const exits = [...(session.backgroundTerminals ?? [])].flatMap(([taskId, terminal]) => {
const exit = terminal.parentToolUseId
? undefined

View File

@@ -5,7 +5,7 @@
* schema, so a malformed v2 message fails the test.
*/
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -5598,6 +5598,78 @@ describe("ACP v2 terminal of a command in the background", () => {
]);
});
it("reads the command's output file into its terminal, and the rest before its exit", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "background-output-"));
const outputFile = path.join(dir, "tasks", "bg1.output");
await mkdir(path.dirname(outputFile));
await writeFile(outputFile, "compiling\n");
const ended = Promise.withResolvers<void>();
scriptTurns(async function* (options) {
yield level(options, [{}]);
yield taskStarted(options);
yield bashUse(options);
// The Bash result names the file, as Claude Code writes it.
const bashResult = backgrounded(options);
yield {
...bashResult,
message: {
...bashResult.message,
content: [
{
...bashResult.message.content[0],
content: `Command running in background with ID: bg1. Output is being written to: ${outputFile}`,
},
],
},
};
yield result(options);
yield idleState(options);
await ended.promise;
yield* taskEnded(options);
yield level(options, []);
});
const output = (client: ReturnType<typeof v2Client>) =>
client.sessionUpdates.flatMap(({ update }) => {
if (update.sessionUpdate === "terminal_output_chunk") {
return [`chunk ${Buffer.from(String(update.data), "base64").toString()}`];
}
if (update.sessionUpdate !== "terminal_update") return [];
return [
...(update.output
? [
`output ${Buffer.from((update.output as { data: string }).data, "base64").toString()}`,
]
: []),
...(update.exitStatus ? ["exit"] : []),
];
});
try {
const client = await promptUntil("idle end_turn [toolu_bg]", async (_, __, client) => {
await vi.waitFor(() => expect(output(client)).toContain("output compiling\n"), {
timeout: 2000,
});
await appendFile(outputFile, "tests passed\n");
await vi.waitFor(() => expect(output(client)).toContain("chunk tests passed\n"), {
timeout: 2000,
});
// The command writes its last line as it ends.
await appendFile(outputFile, "done\n");
ended.resolve();
await vi.waitFor(() => expect(backgroundTrace(client)).toContain("idle []"));
});
// The file's first text replaces the Bash result's placeholder.
expect(output(client).slice(-4)).toEqual([
"output compiling\n",
"chunk tests passed\n",
"chunk done\n",
"exit",
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("ends the listed terminals when the query ends, as its background tasks end with it", async () => {
scriptTurns(async function* (options) {
yield level(options, [{}]);

View File

@@ -0,0 +1,129 @@
import { appendFile, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES,
BACKGROUND_OUTPUT_READ_BYTES,
type BackgroundOutput,
BackgroundOutputTail,
} from "../background-output.js";
describe("BackgroundOutputTail", () => {
let dir: string;
let file: string;
let published: BackgroundOutput[];
let tail: BackgroundOutputTail | undefined;
const follow = () =>
(tail = new BackgroundOutputTail(file, async (output) => void published.push(output)));
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "background-output-"));
file = path.join(dir, "bg1.output");
published = [];
});
afterEach(async () => {
tail?.stop();
await rm(dir, { recursive: true, force: true });
});
it("replaces the terminal's output with the file's first text, then appends what the file adds", async () => {
await writeFile(file, "building\n");
follow();
await vi.waitFor(() => expect(published).toHaveLength(1), { timeout: 2000 });
await appendFile(file, "done\n");
await vi.waitFor(() => expect(published).toHaveLength(2), { timeout: 2000 });
expect(published).toEqual([
{ text: "building\n", replaces: true },
{ text: "done\n", replaces: false },
]);
});
it("waits for a file that does not exist yet", async () => {
follow();
await new Promise((resolve) => setTimeout(resolve, 700));
expect(published).toEqual([]);
await writeFile(file, "started\n");
await vi.waitFor(() => expect(published).toHaveLength(1), { timeout: 2000 });
expect(published[0]).toEqual({ text: "started\n", replaces: true });
});
it("holds a character that a read splits until its last byte is written", async () => {
const check = Buffer.from("✓", "utf8");
await writeFile(file, check.subarray(0, 1));
follow();
await new Promise((resolve) => setTimeout(resolve, 700));
expect(published).toEqual([]);
await appendFile(file, check.subarray(1));
await tail!.finish();
expect(published).toEqual([{ text: "✓", replaces: true }]);
});
it("reads the rest of the file on finish, and nothing after", async () => {
await writeFile(file, "a");
follow();
await appendFile(file, "b");
await tail!.finish();
await appendFile(file, "c");
await new Promise((resolve) => setTimeout(resolve, 700));
expect(published.map((output) => output.text).join("")).toBe("ab");
});
it("sends at most the backlog, read by read, from the file's last bytes as a snapshot", async () => {
// More than the backlog, written faster than the polls read it.
const skipped = "x".repeat(BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES);
const kept = "y".repeat(BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES - 4) + "end\n";
await writeFile(file, skipped + kept);
follow();
await tail!.finish();
expect(published.map((output) => output.text).join("")).toBe(kept);
expect(published[0].replaces).toBe(true);
expect(published.slice(1).every((output) => !output.replaces)).toBe(true);
expect(
published.every((output) => Buffer.byteLength(output.text) <= BACKGROUND_OUTPUT_READ_BYTES),
).toBe(true);
});
it("starts over with a snapshot when the file shrinks", async () => {
await writeFile(file, "old output\n");
follow();
await vi.waitFor(() => expect(published).toHaveLength(1), { timeout: 2000 });
await writeFile(file, "new\n");
await tail!.finish();
expect(published).toEqual([
{ text: "old output\n", replaces: true },
{ text: "new\n", replaces: true },
]);
});
it("replaces the placeholder with an empty output when the command wrote nothing", async () => {
await writeFile(file, "");
follow();
await tail!.finish();
expect(published).toEqual([{ text: "", replaces: true }]);
});
it("publishes nothing on finish when the file never appeared", async () => {
follow();
await tail!.finish();
expect(published).toEqual([]);
});
it("reads nothing after stop", async () => {
follow();
tail!.stop();
await writeFile(file, "late\n");
await new Promise((resolve) => setTimeout(resolve, 700));
expect(published).toEqual([]);
});
});