Compare commits

...

2 Commits

Author SHA1 Message Date
acp-release-bot[bot]
981bf9a28a chore(main): release 0.100.0 (#1485)
🤖 I have created a release *beep* *boop*
---


##
[0.100.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.1...v0.100.0)
(2026-11-02)


### Features

* list v2 background tool calls
([#1511](https://github.com/agentclientprotocol/claude-agent-acp/issues/1511))
([4d79b9f](4d79b9ffbc))
* report subagent working directories
([#1496](https://github.com/agentclientprotocol/claude-agent-acp/issues/1496))
([eb78afa](eb78afa855))
* report v2 background work
([#1510](https://github.com/agentclientprotocol/claude-agent-acp/issues/1510))
([5565259](5565259771))
* report why Claude Code could not start a session
([#1508](https://github.com/agentclientprotocol/claude-agent-acp/issues/1508))
([38cf656](38cf656fc9))


### Bug Fixes

* align session/cancel, session/close, and v2 setup with the ACP v2 spec
([#1494](https://github.com/agentclientprotocol/claude-agent-acp/issues/1494))
([f50598a](f50598a39c))
* forward v2 elicitation values and read dismissed v2 permission prompts
as a refusal
([#1493](https://github.com/agentclientprotocol/claude-agent-acp/issues/1493))
([3bc6711](3bc671193c))
* keep native Windows paths in v2 git patches
([#1492](https://github.com/agentclientprotocol/claude-agent-acp/issues/1492))
([c3428de](c3428de1bf))
* preserve live session settings across provider refreshes
([#1474](https://github.com/agentclientprotocol/claude-agent-acp/issues/1474))
([e01a288](e01a288384))
* reject unreadable skill manifests
([#1482](https://github.com/agentclientprotocol/claude-agent-acp/issues/1482))
([99a6e76](99a6e76158))
* report the fresh context's usage when ExitPlanMode clears context
([#1484](https://github.com/agentclientprotocol/claude-agent-acp/issues/1484))
([2fd46c8](2fd46c866d))
* update to @agentclientprotocol/sdk 2.2.0
([#1491](https://github.com/agentclientprotocol/claude-agent-acp/issues/1491))
([1d2b92f](1d2b92feae))
* update to @agentclientprotocol/sdk 2.4.0
([#1509](https://github.com/agentclientprotocol/claude-agent-acp/issues/1509))
([720bbb3](720bbb3e4c))
* update to Claude Agent SDK 0.3.316 (Claude Code 2.1.316)
([#1507](https://github.com/agentclientprotocol/claude-agent-acp/issues/1507))
([1d03064](1d0306468b))
* validate session, prompt, config, and auth requests per the latest ACP
spec
([#1495](https://github.com/agentclientprotocol/claude-agent-acp/issues/1495))
([d2ef490](d2ef490a31))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-11-02 15:33:56 +01:00
Ben Brandt
4d79b9ffbc feat: list v2 background tool calls (#1511)
Follow-up to #1510, phase 2a of the v2 [Background Work
RFD](https://agentclientprotocol.com/rfds/v2/background-work).

A Monitor, Workflow or backgrounded MCP call returns as soon as its task
starts, so its tool call completed at its result while the task ran on.
#1510 listed such tasks as `_claude/task` items, which clients don't
recognise. This PR lists them as the tool calls that started them, as
the RFD's adapter table describes.

## What a v2 client gets

| When | The tool call |
|---|---|
| Tool result, while the task is in Claude Code's live background task
set | Reports `in_progress` instead of `completed`, and is listed as a
`tool_call` item |
| While listed | Advertises `capabilities.cancel`. `tool_call/cancel`
stops the task with `stopTask`, or returns `-32602` if the call isn't
listed (also when its task ended mid-cancel) |
| `task_updated` with `paused` / `running` | `paused` / `in_progress` |
| The task ends | `completed`, `failed`, or `cancelled` for a stopped
(`killed`) task, with `capabilities: null`, before the list drops it.
The same happens when the live set drops the task without an end event
(`completed`, since nothing was reported), or the query closes
(`cancelled`) |
| `session/resume` | The call's current state, rebuilt from its tool
use, then its capability, before the snapshot |

The task is matched to its call through the structured `taskId` of a
Monitor or Workflow result, or through the live task whose
`task_started.tool_use_id` names the call (backgrounded MCP calls).

`session/cancel` no longer stops background workflows for sessions that
report their background work, because listed work must keep running.
Clients stop them with `tool_call/cancel` instead. v1 and AIR are
unchanged.

A task without such a call is still a `_claude/task` item. It is now
listed only once its `task_started` has arrived and the call that
started it, if any, has returned. Before that, the foreground work is
still waiting on that call, and the spec says foreground work is not
listed.

## Design

- `background-work.ts` holds the logic: registering background tool
calls, pause and end updates, the shared `cancelBackgroundWork` for
`terminal/cancel` and `tool_call/cancel`, and the resume states.
- `acp-agent.ts` only gets wiring.
- `paused` travels as a cast v1 status, alongside the existing
`V2_CANCELLED`.
- `V2Background` advertises and clears the cancel capability.

## Tests

- Six new v2 tests, using the real CLI's event order (tool use, task
joins the live set, `task_started`, tool result) and the `killed` +
`stopped` pair for a stopped task. They cover:
  - listing, pause and resume, and the final status
  - `tool_call/cancel`
  - a query that closes
  - a listed Workflow left running on `session/cancel`
  - resume
  - the `_claude/task` fallback
- I checked by mutation that each new guard fails a test when removed.

`npm run check`, `npm run build` and `npm run test:run` pass (3545
passed, 30 skipped).

## Still to come

- Tailing background Bash `output_file` into terminal output (phase 2b).
- `ScheduleWakeup` / `CronCreate`, which need a signal from the SDK.
- The SHOULD gap about follow-up work is being relaxed upstream in
agentclientprotocol/agent-client-protocol#2745.
2026-11-02 14:40:01 +01:00
11 changed files with 710 additions and 105 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.99.1"
".": "0.100.0"
}

View File

@@ -1,5 +1,29 @@
# Changelog
## [0.100.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.1...v0.100.0) (2026-11-02)
### Features
* list v2 background tool calls ([#1511](https://github.com/agentclientprotocol/claude-agent-acp/issues/1511)) ([4d79b9f](https://github.com/agentclientprotocol/claude-agent-acp/commit/4d79b9ffbce95c23928a5dd771d6c87476d37160))
* report subagent working directories ([#1496](https://github.com/agentclientprotocol/claude-agent-acp/issues/1496)) ([eb78afa](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb78afa8556b8fc4082495e48e3fd1762fb6a50f))
* report v2 background work ([#1510](https://github.com/agentclientprotocol/claude-agent-acp/issues/1510)) ([5565259](https://github.com/agentclientprotocol/claude-agent-acp/commit/5565259771cb74e3a9bdbacfcbce43e061d81422))
* report why Claude Code could not start a session ([#1508](https://github.com/agentclientprotocol/claude-agent-acp/issues/1508)) ([38cf656](https://github.com/agentclientprotocol/claude-agent-acp/commit/38cf656fc9d80fec2d1a16c472779803e2c802d2))
### Bug Fixes
* align session/cancel, session/close, and v2 setup with the ACP v2 spec ([#1494](https://github.com/agentclientprotocol/claude-agent-acp/issues/1494)) ([f50598a](https://github.com/agentclientprotocol/claude-agent-acp/commit/f50598a39c5dc3908d4b98a7213e01d63ea867d5))
* forward v2 elicitation values and read dismissed v2 permission prompts as a refusal ([#1493](https://github.com/agentclientprotocol/claude-agent-acp/issues/1493)) ([3bc6711](https://github.com/agentclientprotocol/claude-agent-acp/commit/3bc671193c6456516fe05a0eaa53c17bdfb6a190))
* keep native Windows paths in v2 git patches ([#1492](https://github.com/agentclientprotocol/claude-agent-acp/issues/1492)) ([c3428de](https://github.com/agentclientprotocol/claude-agent-acp/commit/c3428de1bf411c513b0703ddd56bb6c5fdff583c))
* preserve live session settings across provider refreshes ([#1474](https://github.com/agentclientprotocol/claude-agent-acp/issues/1474)) ([e01a288](https://github.com/agentclientprotocol/claude-agent-acp/commit/e01a28838499dd6dd7c31e35708090b938e852c6))
* reject unreadable skill manifests ([#1482](https://github.com/agentclientprotocol/claude-agent-acp/issues/1482)) ([99a6e76](https://github.com/agentclientprotocol/claude-agent-acp/commit/99a6e76158b9164e51bd62e3b89113beadea5ce2))
* report the fresh context's usage when ExitPlanMode clears context ([#1484](https://github.com/agentclientprotocol/claude-agent-acp/issues/1484)) ([2fd46c8](https://github.com/agentclientprotocol/claude-agent-acp/commit/2fd46c866d4ff2939fe1b1245b15a536d6de7c0b))
* update to @agentclientprotocol/sdk 2.2.0 ([#1491](https://github.com/agentclientprotocol/claude-agent-acp/issues/1491)) ([1d2b92f](https://github.com/agentclientprotocol/claude-agent-acp/commit/1d2b92feae5eeccde3ac738edca1f1d71250762e))
* update to @agentclientprotocol/sdk 2.4.0 ([#1509](https://github.com/agentclientprotocol/claude-agent-acp/issues/1509)) ([720bbb3](https://github.com/agentclientprotocol/claude-agent-acp/commit/720bbb3e4c65874775a0bb9418799baf4b6e3680))
* update to Claude Agent SDK 0.3.316 (Claude Code 2.1.316) ([#1507](https://github.com/agentclientprotocol/claude-agent-acp/issues/1507)) ([1d03064](https://github.com/agentclientprotocol/claude-agent-acp/commit/1d0306468b632c6a56b4fb076ad7a19f23d9e7b5))
* validate session, prompt, config, and auth requests per the latest ACP spec ([#1495](https://github.com/agentclientprotocol/claude-agent-acp/issues/1495)) ([d2ef490](https://github.com/agentclientprotocol/claude-agent-acp/commit/d2ef490a318033049af00cb9164a39ac2dd1eb95))
## [0.99.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.0...v0.99.1) (2026-10-29)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.99.1",
"version": "0.100.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.99.1",
"version": "0.100.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.4.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.99.1",
"version": "0.100.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",

View File

@@ -168,11 +168,17 @@ import {
backgroundLevel,
type BackgroundLevel,
type BackgroundSink,
backgroundToolCallPause,
backgroundToolCallStates,
type BackgroundTaskEnd,
type BackgroundToolCall,
BackgroundWorkReporter,
cancelBackgroundTerminal,
droppedTerminalTasks,
cancelBackgroundWork,
droppedTasks,
endBackgroundWork,
registerBackgroundToolCalls,
takeBackgroundTerminalExit,
takeBackgroundToolCallEnd,
} from "./background-work.js";
import {
AUTH_STATUS_PROBE_TIMEOUT_MS,
@@ -312,7 +318,11 @@ import {
unregisterHookCallback,
} from "./tools.js";
import { previewPatchContent } from "./diff.js";
import { backgroundedBashToolCall, withRunningTerminal } from "./tool-calls/background.js";
import {
backgroundedBashToolCall,
withRunningTerminal,
withRunningToolCall,
} from "./tool-calls/background.js";
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
@@ -1465,7 +1475,9 @@ export type Session = {
* `perTaskStopAffordance`: then it spares both, and the client is expected
* to stop each one itself. A client that can cancel one subagent gets the
* declaration, but it has no control for a workflow unless it is AIR with
* `asyncTasks` (`_session/async_task/stop`). So for such a client the
* `asyncTasks` (`_session/async_task/stop`), or gets the session's
* background work (`reportsBackground`, with `tool_call/cancel`), which
* `session/cancel` must leave running. So for any other such client the
* adapter stops the workflows after the interrupt, as the interrupt did
* before. */
stopsWorkflowsOnCancel?: boolean;
@@ -1476,6 +1488,11 @@ export type Session = {
* `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 }>;
/** 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
* (see `withRunningToolCall`). Only while `reportsBackground`. */
backgroundToolCalls?: Map<string, BackgroundToolCall>;
/** Whether the client gets the session's background work (see
* `background-work.ts`). Only when Claude Code's interrupt spares
* background tasks, as `perTaskStopAffordance` makes it. */
@@ -5400,11 +5417,15 @@ export class ClaudeAcpAgent {
this.syncAwaitingUser(params.sessionId, session);
};
/** Ends the display terminal of a command that went to the background, once
* its task ends (see `Session.backgroundTerminals`). */
const endBackgroundTerminal = async (taskId: string) => {
/** Ends the display terminal of a command that went to the background, and
* gives a tool call whose task went on in the background its final
* status, once the task ends (see `Session.backgroundTerminals` and
* `Session.backgroundToolCalls`). */
const endBackgroundTask = async (taskId: string, end: BackgroundTaskEnd) => {
const exit = takeBackgroundTerminalExit(session, params.sessionId, taskId);
if (exit) await sendUpdate(exit);
const toolCallEnd = takeBackgroundToolCallEnd(session, params.sessionId, taskId, end);
if (toolCallEnd) await sendUpdate(toolCallEnd);
};
/** Settles a task. Its request may still be open, and an agent without a
@@ -6856,7 +6877,7 @@ export class ClaudeAcpAgent {
streamedBlocksByParent.delete(message.tool_use_id);
droppedStreams.delete(message.tool_use_id);
}
await endBackgroundTerminal(message.task_id);
await endBackgroundTask(message.task_id, message.status);
if (message.status === "stopped") {
await closeStoppedSubagentToolCalls(message.tool_use_id);
}
@@ -6901,15 +6922,33 @@ export class ClaudeAcpAgent {
streamedBlocksByParent.delete(parentToolUseId);
droppedStreams.delete(parentToolUseId);
}
await endBackgroundTerminal(message.task_id);
await endBackgroundTask(
message.task_id,
message.patch.status === "killed" ? "stopped" : message.patch.status,
);
if (message.patch.status === "killed") {
await closeStoppedSubagentToolCalls(parentToolUseId);
}
settleLiveTask(message.task_id);
} else if (message.patch.status === "paused") {
const pause = backgroundToolCallPause(
session,
params.sessionId,
message.task_id,
true,
);
if (pause) await sendUpdate(pause);
} else if (
message.patch.status === "running" ||
message.patch.status === "pending"
) {
const resume = backgroundToolCallPause(
session,
params.sessionId,
message.task_id,
false,
);
if (resume) await sendUpdate(resume);
// The SDK can resume a finished subagent under the same
// agent id without a new task_started.
resumeLiveTask(message.task_id);
@@ -7081,8 +7120,8 @@ export class ClaudeAcpAgent {
await asyncTasks.backgroundTasksChanged(message.tasks);
if (session.reportsBackground) {
const level = backgroundLevel(message.tasks);
for (const taskId of droppedTerminalTasks(session, level)) {
await endBackgroundTerminal(taskId);
for (const taskId of droppedTasks(session, level)) {
await endBackgroundTask(taskId, undefined);
}
session.backgroundLevel = level;
}
@@ -8488,6 +8527,7 @@ export class ClaudeAcpAgent {
const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
let backgroundedToolCalls: ReadonlySet<string> = new Set();
let runningTerminal: string | undefined;
let runningToolCalls: ReadonlySet<string> = new Set();
if (message.type === "user") {
rememberResolvedToolNames(session, content);
recordSubagentFileEdits(
@@ -8520,6 +8560,13 @@ export class ClaudeAcpAgent {
: {}),
});
}
runningToolCalls = registerBackgroundToolCalls(
session,
content,
toolUseResult,
session.toolUseCache,
message.parent_tool_use_id,
);
await asyncTasks.toolResults(content);
backgroundedToolCalls = backgroundedBashToolCallIds(
content,
@@ -8582,13 +8629,16 @@ export class ClaudeAcpAgent {
// (e.g. a subagent image) carry the stamped parentToolUseId
// meta and are excluded there.
await sendUpdate(
withRunningTerminal(
backgroundedBashToolCall(
acceptedPlanToolResult(notification, acceptedPlanToolUseId),
backgroundedToolCalls,
asyncTasks.enabled,
withRunningToolCall(
withRunningTerminal(
backgroundedBashToolCall(
acceptedPlanToolResult(notification, acceptedPlanToolUseId),
backgroundedToolCalls,
asyncTasks.enabled,
),
runningTerminal,
),
runningTerminal,
runningToolCalls,
),
);
}
@@ -9417,9 +9467,37 @@ export class ClaudeAcpAgent {
}
/** Stops a command running in the background (v2 `terminal/cancel`, see
* {@link cancelBackgroundTerminal}). */
* {@link cancelBackgroundWork}). */
cancelBackgroundTerminal(params: { sessionId: string; terminalId: string }): Promise<void> {
return cancelBackgroundTerminal(this.sessions, params.sessionId, params.terminalId);
return cancelBackgroundWork(this.sessions, params.sessionId, {
terminalId: params.terminalId,
});
}
/** Stops the task of a tool call running in the background (v2
* `tool_call/cancel`, see {@link cancelBackgroundWork}). */
cancelBackgroundToolCall(params: { sessionId: string; toolCallId: string }): Promise<void> {
return cancelBackgroundWork(this.sessions, params.sessionId, {
toolCallId: params.toolCallId,
});
}
/** Sends the current state of each tool call in `toolCallIds` that the root
* session `sessionId` runs in the background: the call as its tool use
* shows it, with its status now. v2 requires it for each tool call that a
* `session/resume` snapshot lists. A failure is logged, as for
* {@link publishLiveSubagents}. */
async publishBackgroundToolCalls(sessionId: string, toolCallIds: string[]): Promise<void> {
const session = this.sessions[sessionId];
if (!session) return;
const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
try {
for (const update of backgroundToolCallStates(session, sessionId, toolCallIds, renderer)) {
await this.client.sessionUpdate(update);
}
} catch (error) {
this.logger.error(`Session ${sessionId}: failed to send background tool calls:`, error);
}
}
/** Sends the current `subagent_update` of each live subagent session in
@@ -12651,6 +12729,7 @@ export class ClaudeAcpAgent {
reportsBackground: this.backgroundWork.enabled && options.perTaskStopAffordance === true,
stopsWorkflowsOnCancel:
options.perTaskStopAffordance === true &&
!this.backgroundWork.enabled &&
!clientSupportsAsyncTasks(this.clientCapabilities),
nativeSubagentsByTaskId: new Map(),
nativeSubagentTaskIdByToolUseId: new Map(),

View File

@@ -14,9 +14,13 @@
* Bash tool result until its task ends (`Session.backgroundTerminals`).
* - A detached subagent: its child session, in its parent's list, while it
* works, and while it is idle with background work of its own.
* - Any other task, such as a `Monitor` or a `Workflow`: a `_claude/task`
* item with the task's id and description. Its tool call ended at its tool
* result, so it cannot be listed as a tool call.
* - A task that a tool call started, such as a `Monitor`, a `Workflow` or a
* backgrounded MCP call: that tool call, kept `in_progress` (or `paused`)
* from its tool result until its task ends (`Session.backgroundToolCalls`).
* - Any other task, once its `task_started` came and the tool call that
* started it, if any, returned: a `_claude/task` item with the task's id
* and description. For example, a task whose tool call ended before the
* task joined the level.
*
* Only a session whose interrupt spares background tasks reports its lists
* (`Session.reportsBackground`): `session/cancel` must leave listed work
@@ -25,13 +29,28 @@
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";
/** An item of a `background` list. */
export type BackgroundItem =
| { type: "terminal"; terminalId: string }
| { type: "tool_call"; toolCallId: string }
| { type: "subagent"; sessionId: string }
| { type: "_claude/task"; taskId: string; description: string };
/** A tool call whose task runs on in the background (see `Session.backgroundToolCalls`). */
export interface BackgroundToolCall {
toolCallId: string;
/** The tool use of the call, which Claude Code no longer caches after its result. */
toolUse: { name: string; input: unknown };
/** The subagent tool call that made the call, if any. */
parentToolUseId?: string;
paused?: boolean;
}
/** How a background task ended, as Claude Code reports it. Undefined when it did not say. */
export type BackgroundTaskEnd = "completed" | "failed" | "stopped" | undefined;
/** The live background task set of `background_tasks_changed`, by task id. */
export type BackgroundLevel = Map<
string,
@@ -64,10 +83,77 @@ export interface BackgroundWorkSession {
reportsBackground?: boolean;
backgroundLevel?: BackgroundLevel;
backgroundTerminals?: Map<string, { toolCallId: string; parentToolUseId?: string }>;
backgroundToolCalls?: Map<string, BackgroundToolCall>;
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
liveBackgroundTasks?: Map<string, { parentToolUseId?: string; isSubagent: boolean }>;
/** The tool uses whose result has not come yet, by id. */
toolUseCache?: Record<string, unknown>;
query: { stopTask(taskId: string): Promise<void> };
}
/** The tools whose structured result names the task that the call started. */
const TASK_TOOLS = new Set(["Monitor", "Workflow"]);
/**
* Registers each tool call of a tool result message whose task goes on in
* the background: a `Monitor` or `Workflow` call, whose structured result
* names its task, or a call that a live task names as its tool use, such as
* a backgrounded MCP call. The task must be in the level: one that ended
* already, or that Claude Code marks `ambient`, leaves its call as it is.
* Returns the ids of the calls, whose result then reports `in_progress` (see
* `withRunningToolCall`).
*/
export function registerBackgroundToolCalls(
session: BackgroundWorkSession,
content: unknown,
toolUseResult: unknown,
toolUses: Record<string, { name: string; input: unknown }>,
parentToolUseId: string | null,
): Set<string> {
const registered = new Set<string>();
if (!session.reportsBackground || !Array.isArray(content)) return registered;
const results = content.filter(
(block): block is { type: "tool_result"; tool_use_id: string; is_error?: boolean } =>
typeof block === "object" &&
block !== null &&
block.type === "tool_result" &&
typeof block.tool_use_id === "string" &&
block.is_error !== true,
);
const structuredTaskId =
results.length === 1 &&
typeof toolUseResult === "object" &&
toolUseResult !== null &&
typeof (toolUseResult as { taskId?: unknown }).taskId === "string"
? (toolUseResult as { taskId: string }).taskId
: undefined;
for (const { tool_use_id: toolCallId } of results) {
const toolUse = toolUses[toolCallId];
if (!toolUse) continue;
const taskId =
(TASK_TOOLS.has(toolUse.name) ? structuredTaskId : undefined) ??
[...(session.liveBackgroundTasks ?? [])].find(
([, task]) => task.parentToolUseId === toolCallId && !task.isSubagent,
)?.[0];
const task = taskId === undefined ? undefined : session.backgroundLevel?.get(taskId);
if (
!task ||
task.ambient ||
task.taskType === "local_bash" ||
task.taskType === "local_agent"
) {
continue;
}
(session.backgroundToolCalls ??= new Map()).set(taskId!, {
toolCallId,
toolUse,
...(parentToolUseId ? { parentToolUseId } : {}),
});
registered.add(toolCallId);
}
return registered;
}
/** The level of a `background_tasks_changed` payload. */
export function backgroundLevel(
tasks: {
@@ -118,18 +204,32 @@ export function backgroundLists(
const level: BackgroundLevel = session.backgroundLevel ?? new Map();
for (const [taskId, task] of level) {
if (task.ambient) continue;
// A subagent's command or tool call runs on the child's stream.
const ownerOf = (parentToolUseId: string | undefined) =>
parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : rootSessionId;
const terminal = session.backgroundTerminals?.get(taskId);
if (terminal) {
// A subagent's command runs on the child's stream.
const owner = terminal.parentToolUseId
? childByToolUse.get(terminal.parentToolUseId)?.sessionId
: rootSessionId;
const owner = ownerOf(terminal.parentToolUseId);
if (owner) lists.get(owner)!.push({ type: "terminal", terminalId: terminal.toolCallId });
continue;
}
const toolCall = session.backgroundToolCalls?.get(taskId);
if (toolCall) {
const owner = ownerOf(toolCall.parentToolUseId);
if (owner) lists.get(owner)!.push({ type: "tool_call", toolCallId: toolCall.toolCallId });
continue;
}
// A command without a terminal yet is one that foreground work waits on.
// A subagent is listed below, as its session.
if (task.taskType === "local_bash" || task.taskType === "local_agent") continue;
// A task joins the level before its `task_started`, and before the tool
// call that starts it returns. Until both, the task is unknown or part of
// that tool call, which the foreground work waits on. Claude Code drops a
// tool use from its cache at the tool's result.
const live = session.liveBackgroundTasks?.get(taskId);
if (!live || (live.parentToolUseId && session.toolUseCache?.[live.parentToolUseId])) {
continue;
}
const parent = task.parentTaskId ? childByTask.get(task.parentTaskId) : undefined;
lists
.get(parent?.sessionId ?? rootSessionId)!
@@ -166,18 +266,18 @@ export function backgroundLists(
}
/**
* The tasks of the terminals that `level` does not hold: Claude Code no
* longer runs them, so their terminals exit before a list drops them, as v2
* requires. A terminal is registered at its Bash result, after its task
* joined the level, so a later level without the task means that it ended.
* Claude Code usually sends a task's end before that level, which ends the
* terminal first, but the SDK does not promise that order.
* The tasks of the terminals and tool calls that `level` does not hold:
* Claude Code no longer runs them, so they end before a list drops them, as
* v2 requires. A terminal or tool call is registered at its tool result,
* after its task joined the level, so a later level without the task means
* that it ended. Claude Code usually sends a task's end before that level,
* which ends them first, but the SDK does not promise that order.
*/
export function droppedTerminalTasks(
session: BackgroundWorkSession,
level: BackgroundLevel,
): string[] {
return [...(session.backgroundTerminals?.keys() ?? [])].filter((taskId) => !level.has(taskId));
export function droppedTasks(session: BackgroundWorkSession, level: BackgroundLevel): string[] {
return [
...(session.backgroundTerminals?.keys() ?? []),
...(session.backgroundToolCalls?.keys() ?? []),
].filter((taskId) => !level.has(taskId));
}
/**
@@ -210,12 +310,96 @@ export function takeBackgroundTerminalExit(
};
}
/**
* Removes the background tool call of the task `taskId` of the session
* `sessionId`, and returns the update that gives it its final status, or
* undefined when the task has no such call. A task that Claude Code stopped
* is `cancelled`. One that ended without saying how, such as one that the
* level dropped, is `completed`: it no longer runs, and reported no failure.
*/
export function takeBackgroundToolCallEnd(
session: BackgroundWorkSession,
sessionId: string,
taskId: string,
end: BackgroundTaskEnd,
): AcpSessionNotification | undefined {
const toolCall = session.backgroundToolCalls?.get(taskId);
if (!toolCall) return undefined;
session.backgroundToolCalls!.delete(taskId);
return toolCallStatusUpdate(
sessionId,
toolCall,
end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
);
}
/**
* The update that reports the background tool call of the task `taskId` as
* `paused`, or as `in_progress` again, when `paused` changes it. Claude Code
* pauses a workflow this way.
*/
export function backgroundToolCallPause(
session: BackgroundWorkSession,
sessionId: string,
taskId: string,
paused: boolean,
): AcpSessionNotification | undefined {
const toolCall = session.backgroundToolCalls?.get(taskId);
if (!toolCall || (toolCall.paused ?? false) === paused) return undefined;
toolCall.paused = paused;
return toolCallStatusUpdate(sessionId, toolCall, paused ? V2_PAUSED : "in_progress");
}
/**
* The current state of each background tool call in `toolCallIds` of the
* root session `sessionId`: the call as `renderer` shows its tool use, with
* its status now (see `ClaudeAcpAgent.publishBackgroundToolCalls`).
*/
export function backgroundToolCallStates(
session: BackgroundWorkSession & { cwd: string },
sessionId: string,
toolCallIds: string[],
renderer: AcpToolCallRenderer,
): AcpSessionNotification[] {
return [...(session.backgroundToolCalls?.values() ?? [])]
.filter((toolCall) => toolCallIds.includes(toolCall.toolCallId))
.map((toolCall) => {
const update = renderer.toolCall(
{ id: toolCall.toolCallId, ...toolCall.toolUse },
{ cwd: session.cwd },
);
const status = toolCall.paused ? V2_PAUSED : "in_progress";
return { sessionId, update: { ...update, status } as AcpSessionNotification["update"] };
});
}
function toolCallStatusUpdate(
sessionId: string,
toolCall: BackgroundToolCall,
status: NonNullable<
Extract<AcpSessionNotification["update"], { sessionUpdate: "tool_call_update" }>["status"]
>,
): AcpSessionNotification {
return {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: toolCall.toolCallId,
status,
...(toolCall.parentToolUseId
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
: {}),
},
};
}
/**
* Ends the background work of a session whose query closed: Claude Code's
* background tasks end with its process. Returns the updates that exit the
* root session's terminals, and empties the level, so the next lists are
* empty. A subagent's command is left as it was: its terminal is on the
* child's stream, which the closed query's routing owned.
* root session's terminals and cancel its tool calls, and empties the level,
* so the next lists are empty. A subagent's command or tool call is left as
* it was: it is on the child's stream, which the closed query's routing
* owned.
*/
export function endBackgroundWork(
session: BackgroundWorkSession,
@@ -228,42 +412,56 @@ export function endBackgroundWork(
: takeBackgroundTerminalExit(session, sessionId, taskId);
return exit ? [exit] : [];
});
const ends = [...(session.backgroundToolCalls ?? [])].flatMap(([taskId, toolCall]) => {
const end = toolCall.parentToolUseId
? undefined
: takeBackgroundToolCallEnd(session, sessionId, taskId, "stopped");
return end ? [end] : [];
});
session.backgroundLevel = undefined;
return exits;
return [...exits, ...ends];
}
/**
* Stops the background command whose terminal `terminalId` the session
* `sessionId` lists (v2 `terminal/cancel`). `sessions` are the root sessions,
* by id: a subagent session's list is built with its root's. The terminal
* exits when Claude Code reports the task stopped.
* Stops the task of a terminal or tool call that the session `sessionId`
* lists (v2 `terminal/cancel` and `tool_call/cancel`). `sessions` are the
* root sessions, by id: a subagent session's list is built with its root's.
* The terminal exits, or the tool call gets its final status, when Claude
* Code reports the task stopped.
*/
export async function cancelBackgroundTerminal(
export async function cancelBackgroundWork(
sessions: Record<string, BackgroundWorkSession>,
sessionId: string,
terminalId: string,
target: { terminalId: string } | { toolCallId: string },
): Promise<void> {
const notCancellable = () =>
RequestError.invalidParams(
{ sessionId, terminalId },
`Terminal ${terminalId} is not a command running in the background, so it cannot be cancelled.`,
);
"terminalId" in target
? RequestError.invalidParams(
{ sessionId, terminalId: target.terminalId },
`Terminal ${target.terminalId} is not a command running in the background, so it cannot be cancelled.`,
)
: RequestError.invalidParams(
{ sessionId, toolCallId: target.toolCallId },
`Tool call ${target.toolCallId} is not running in the background, so it cannot be cancelled.`,
);
const isTarget = (item: BackgroundItem) =>
"terminalId" in target
? item.type === "terminal" && item.terminalId === target.terminalId
: item.type === "tool_call" && item.toolCallId === target.toolCallId;
const listed = (root: string, session: BackgroundWorkSession) =>
session.reportsBackground === true &&
(backgroundLists(session, root)
.get(sessionId)
?.some((item) => item.type === "terminal" && item.terminalId === terminalId) ??
false);
(backgroundLists(session, root).get(sessionId)?.some(isTarget) ?? false);
const owner = Object.entries(sessions).find(([root, session]) => listed(root, session));
if (!owner) throw notCancellable();
const [root, session] = owner;
const taskId = [...(session.backgroundTerminals ?? [])].find(
([, terminal]) => terminal.toolCallId === terminalId,
)![0];
const id = "terminalId" in target ? target.terminalId : target.toolCallId;
const taskId = [
...(("terminalId" in target ? session.backgroundTerminals : session.backgroundToolCalls) ?? []),
].find(([, entry]) => entry.toolCallId === id)![0];
try {
await session.query.stopTask(taskId);
} catch (error) {
// The command ended meanwhile: it has exited, or is about to.
// The task ended meanwhile: it has ended on the client too, or is about to.
if (!listed(root, session)) throw notCancellable();
throw error;
}

View File

@@ -5519,7 +5519,11 @@ describe("ACP v2 terminal of a command in the background", () => {
"stopReason" in update && update.stopReason ? ` ${update.stopReason}` : "";
const { background } = update as { background?: v2.BackgroundWork[] | null };
const items = (background ?? []).map((item) =>
"terminalId" in item ? String(item.terminalId) : String(item.type),
"terminalId" in item
? String(item.terminalId)
: "toolCallId" in item
? String(item.toolCallId)
: String(item.type),
);
return [`${update.state}${stopReason} [${items.join(",")}]`];
});
@@ -5696,23 +5700,234 @@ describe("ACP v2 terminal of a command in the background", () => {
expect(rejected).toMatchObject({ code: -32602 });
});
it("lists another task, such as a Monitor, as a _claude/task item", async () => {
scriptTurns(async function* (options) {
yield level(options, [
{ task_id: "mon1", task_type: "local_monitor", description: "Watch the logs" },
]);
yield result(options);
yield idleState(options);
});
const client = await promptUntil("idle end_turn [_claude/task]");
describe("a task that a tool call started", () => {
const monitor = {
task_id: "mon1",
task_type: "local_monitor",
description: "Watch the logs",
};
const workflow = { task_id: "wf1", task_type: "local_workflow", description: "Review" };
/** A Monitor call whose task `mon1` runs on after the call returns. The
* tool starts the task, which joins the level before its
* `task_started`, and returns. */
async function* monitorStarts(options: Options) {
yield toolUseMessage(options, "toolu_mon", "Monitor", { command: "tail -f app.log" });
yield level(options, [monitor]);
yield taskEvent(options, "task_started", { ...monitor, tool_use_id: "toolu_mon" });
yield {
...toolResultMessage(options, "toolu_mon", { content: "Monitor started (task mon1)." }),
tool_use_result: { taskId: "mon1", timeoutMs: 0, persistent: true },
};
}
const monitorEvent = (options: Options, subtype: string, fields: Record<string, unknown>) =>
taskEvent(options, subtype, { ...fields, task_id: "mon1" });
expect(backgroundTrace(client)).toEqual([
"running []",
"running [_claude/task]",
"idle end_turn [_claude/task]",
]);
expect(client.updates("state_update").at(-1)).toMatchObject({
background: [{ type: "_claude/task", taskId: "mon1", description: "Watch the logs" }],
/** The states with their lists, and the Monitor call's statuses and capability. */
const toolCallTrace = (client: ReturnType<typeof v2Client>) =>
client.sessionUpdates.flatMap(({ update }) => {
if (update.sessionUpdate === "tool_call_update" && update.toolCallId === "toolu_mon") {
const capabilities = update.capabilities as { cancel?: unknown } | null | undefined;
return [
...(update.status ? [String(update.status)] : []),
...(capabilities === null ? ["no cancel"] : capabilities?.cancel ? ["cancel"] : []),
];
}
if (update.sessionUpdate !== "state_update") return [];
const stopReason =
"stopReason" in update && update.stopReason ? ` ${update.stopReason}` : "";
const { background } = update as { background?: v2.BackgroundWork[] | null };
const items = (background ?? []).map((item) =>
"toolCallId" in item ? String(item.toolCallId) : String(item.type),
);
return [`${update.state}${stopReason} [${items.join(",")}]`];
});
it("keeps the call in_progress and listed until its task ends, and paused while the task is", async () => {
scriptTurns(async function* (options) {
yield* monitorStarts(options);
yield result(options);
yield idleState(options);
yield monitorEvent(options, "task_updated", { patch: { status: "paused" } });
yield monitorEvent(options, "task_updated", { patch: { status: "running" } });
yield monitorEvent(options, "task_notification", {
tool_use_id: "toolu_mon",
status: "completed",
output_file: "/tmp/tasks/mon1.output",
summary: "Done",
});
yield level(options, []);
});
const client = await promptUntil("idle end_turn [toolu_mon]");
await vi.waitFor(() => expect(toolCallTrace(client)).toContain("idle []"));
// The task is part of the call until the call returns, so nothing is
// listed before. The call can be cancelled before a list names it, and
// no longer once it ends, before the list drops it.
expect(toolCallTrace(client)).toEqual([
"running []",
"pending",
"in_progress",
"cancel",
"running [toolu_mon]",
"idle end_turn [toolu_mon]",
"paused",
"in_progress",
"completed",
"no cancel",
"idle []",
]);
});
it("stops the task on tool_call/cancel, and reports the call cancelled", async () => {
const stopped = Promise.withResolvers<string>();
sdk.stopTask = async (taskId) => stopped.resolve(taskId);
scriptTurns(async function* (options) {
yield* monitorStarts(options);
yield result(options);
yield idleState(options);
await stopped.promise;
// Claude Code reports a stopped task as killed first.
yield monitorEvent(options, "task_updated", { patch: { status: "killed" } });
yield monitorEvent(options, "task_notification", {
tool_use_id: "toolu_mon",
status: "stopped",
output_file: "/tmp/tasks/mon1.output",
summary: "Stopped",
});
yield level(options, []);
});
let rejected: unknown;
const client = await promptUntil(
"idle end_turn [toolu_mon]",
async (agent, sessionId, client) => {
await expect(
agent.request(v2.methods.agent.toolCall.cancel, {
sessionId,
toolCallId: "toolu_mon",
}),
).resolves.toEqual({});
expect(await stopped.promise).toBe("mon1");
await vi.waitFor(() => expect(toolCallTrace(client)).toContain("idle []"));
rejected = await agent
.request(v2.methods.agent.toolCall.cancel, { sessionId, toolCallId: "toolu_mon" })
.catch((error: unknown) => error);
},
);
expect(toolCallTrace(client).slice(-3)).toEqual(["cancelled", "no cancel", "idle []"]);
expect(rejected).toMatchObject({ code: -32602 });
});
it("cancels the call when the query ends, as its task ends with it", async () => {
scriptTurns(async function* (options) {
yield* monitorStarts(options);
yield result(options);
yield idleState(options);
throw new Error("The Claude Code process exited");
});
const client = await promptUntil("idle []");
expect(toolCallTrace(client).slice(-4)).toEqual([
"idle end_turn [toolu_mon]",
"cancelled",
"no cancel",
"idle []",
]);
});
it("leaves a listed Workflow running on session/cancel", async () => {
const interrupted = Promise.withResolvers<void>();
sdk.interrupt = async () => interrupted.resolve();
const stopTask = vi.fn(async () => {});
sdk.stopTask = stopTask;
scriptTurns(async function* (options) {
yield toolUseMessage(options, "toolu_wf", "Workflow", { script: "review" });
yield level(options, [workflow]);
yield taskEvent(options, "task_started", { ...workflow, tool_use_id: "toolu_wf" });
yield {
...toolResultMessage(options, "toolu_wf", { content: "Workflow launched." }),
tool_use_result: {
status: "async_launched",
taskId: "wf1",
taskType: "local_workflow",
},
};
await interrupted.promise;
yield result(options, ABORTED_TOOLS);
yield idleState(options);
});
const client = await promptUntil("running [toolu_wf]", async (agent, sessionId, client) => {
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
await vi.waitFor(() =>
expect(backgroundTrace(client)).toContain("idle cancelled [toolu_wf]"),
);
});
expect(stopTask).not.toHaveBeenCalled();
expect(
client
.updates("tool_call_update")
.filter((update) => update.toolCallId === "toolu_wf" && update.status)
.map((update) => update.status),
).toEqual(["pending", "in_progress"]);
});
it("sends each listed call's current state before the state snapshot of session/resume", async () => {
scriptTurns(async function* (options) {
yield* monitorStarts(options);
yield result(options);
yield idleState(options);
yield monitorEvent(options, "task_updated", { patch: { status: "paused" } });
});
let resumed: v2.SessionUpdate[] = [];
await promptUntil("idle end_turn [toolu_mon]", async (agent, sessionId, client) => {
await vi.waitFor(() => expect(toolCallTrace(client)).toContain("paused"));
const before = client.sessionUpdates.length;
await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
resumed = client.sessionUpdates.slice(before).map(({ update }) => update);
});
// The call as its tool use shows it, with its status now, then its
// capability.
expect(resumed).toEqual([
expect.objectContaining({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_mon",
name: "Monitor",
status: "paused",
rawInput: { command: "tail -f app.log" },
}),
{
sessionUpdate: "tool_call_update",
toolCallId: "toolu_mon",
capabilities: { cancel: {} },
},
{
sessionUpdate: "state_update",
state: "idle",
background: [{ type: "tool_call", toolCallId: "toolu_mon" }],
},
]);
});
it("lists a task without such a call as a _claude/task item", async () => {
scriptTurns(async function* (options) {
yield level(options, [monitor]);
// No tool call started it, such as a task that a hook started.
yield taskEvent(options, "task_started", monitor);
yield result(options);
yield idleState(options);
});
const client = await promptUntil("idle end_turn [_claude/task]");
expect(backgroundTrace(client)).toEqual([
"running []",
"running [_claude/task]",
"idle end_turn [_claude/task]",
]);
expect(client.updates("state_update").at(-1)).toMatchObject({
background: [{ type: "_claude/task", taskId: "mon1", description: "Watch the logs" }],
});
});
});

View File

@@ -72,3 +72,27 @@ export function withRunningTerminal(
if (Object.keys(meta).length === 0) delete next._meta;
return { ...notification, update: next };
}
/**
* Keeps the tool call of a task that went on in the background running: the
* tool result update of a tool call in `toolCallIds` reports `in_progress`
* instead of `completed`.
*
* A Monitor or Workflow call returns as soon as its task starts. The consumer
* reports the task's end, or its pause, on the tool call (see
* `Session.backgroundToolCalls`).
*/
export function withRunningToolCall(
notification: SessionNotification,
toolCallIds: ReadonlySet<string>,
): SessionNotification {
const update = notification.update;
if (
update.sessionUpdate !== "tool_call_update" ||
!toolCallIds.has(update.toolCallId) ||
update.status !== "completed"
) {
return notification;
}
return { ...notification, update: { ...update, status: "in_progress" } };
}

View File

@@ -29,12 +29,13 @@ export type ToolCallUpdate = SessionNotification["update"];
const PLAN_FILE_CLEAR_SUFFIX = ":plan-file-clear";
/**
* ACP v2's `cancelled` tool call status. v1's statuses have no such value, and
* the agent builds its reports in v1 types, so it travels as a v1 status. Only
* a v2 client gets it, and the v2 surface sends it as it is
* (`src/v2/tool-call.ts`).
* ACP v2's `cancelled` and `paused` tool call statuses. v1's statuses have no
* such values, and the agent builds its reports in v1 types, so they travel as
* v1 statuses. Only a v2 client gets them, and the v2 surface sends them as
* they are (`src/v2/tool-call.ts`).
*/
const V2_CANCELLED = "cancelled" as unknown as ToolCallStatus;
export const V2_CANCELLED = "cancelled" as unknown as ToolCallStatus;
export const V2_PAUSED = "paused" as unknown as ToolCallStatus;
/** Whether `status` ends a tool call: `completed`, `failed`, or, on v2, `cancelled`. */
export function isFinalToolCallStatus(status: ToolCallStatus | null | undefined): boolean {

View File

@@ -106,13 +106,17 @@ export function v2AgentApp(
// v2 requires a snapshot of the foreground state after the replay and
// before the response, also without a replay: a resumed live session can
// be running a turn, or waiting on a permission request. The current
// state of each subagent and terminal it lists comes first, as v2
// requires. The subagents' go again after the response (see below).
// state of each subagent, terminal and tool call it lists comes first,
// as v2 requires. The subagents' go again after the response (see below).
await agent.publishLiveSubagents(
params.sessionId,
client.background.listedSubagents(params.sessionId),
);
for (const notification of client.background.currentTerminals(params.sessionId)) {
await agent.publishBackgroundToolCalls(
params.sessionId,
client.background.listedToolCalls(params.sessionId),
);
for (const notification of client.background.currentItems(params.sessionId)) {
void client.send(notification);
}
void client.send({
@@ -177,6 +181,10 @@ export function v2AgentApp(
await agent.cancelBackgroundTerminal(params);
return {};
})
.onRequest(v2.methods.agent.toolCall.cancel, async ({ params }) => {
await agent.cancelBackgroundToolCall(params);
return {};
})
.onNotification(v2.methods.agent.session.cancel, ({ params }) => agent.cancel(params));
}

View File

@@ -30,6 +30,9 @@ function withBackground(state: v2.StateUpdate, background: BackgroundItem[]): v2
return { ...state, background } as v2.StateUpdate;
}
/** The final tool call statuses. */
const FINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
export class V2Background {
/**
* The latest list of each session that reports one, whether a state of the
@@ -40,6 +43,13 @@ export class V2Background {
{ items: BackgroundItem[]; reported: boolean; parent?: string }
>();
/**
* The tool calls that advertise `cancel`, by session and tool call. Like a
* terminal's (see `V2Terminals.advertiseCancel`), the capability goes with
* the final status.
*/
private readonly cancellableToolCalls = new Set<string>();
constructor(private readonly terminals: V2Terminals) {}
/**
@@ -50,6 +60,14 @@ export class V2Background {
*/
stamp(notification: v2.UpdateSessionNotification): v2.UpdateSessionNotification {
const { sessionId, update } = notification;
if (
update.sessionUpdate === "tool_call_update" &&
FINAL_STATUSES.has(String(update.status)) &&
this.cancellableToolCalls.delete(`${sessionId} ${update.toolCallId}`)
) {
// A tool call that reached its final status can no longer be cancelled.
return { ...notification, update: { ...update, capabilities: null } };
}
if (update.sessionUpdate === "state_update") {
const session = this.sessions.get(sessionId);
if (!session) return notification;
@@ -76,9 +94,10 @@ export class V2Background {
/**
* Records the new list of a session, and returns the updates that report it:
*
* - For each terminal that the list adds, a `terminal_update` that lets the
* client cancel it. It also comes before the first list that names the
* terminal, as v2 requires: the terminal's own updates came earlier.
* - For each terminal or tool call that the list adds, a `terminal_update`
* or `tool_call_update` that lets the client cancel it. It comes before
* the first list that names it, as v2 requires of an announcement: its
* own updates came earlier.
* - Once a state of the session carried a list, the state again with the
* new list, without a stop reason: a repeated `idle` is no new
* completion. A subagent's goes to its parent's mirror first, as its
@@ -93,14 +112,10 @@ export class V2Background {
reported: session?.reported ?? false,
parent: parentSessionId ?? session?.parent,
});
const known = new Set(
(session?.items ?? []).flatMap((item) => (item.type === "terminal" ? [item.terminalId] : [])),
const known = new Set((session?.items ?? []).map((item) => JSON.stringify(item)));
const updates: v2.UpdateSessionNotification[] = items.flatMap((item) =>
known.has(JSON.stringify(item)) ? [] : this.advertiseCancel(sessionId, item, false),
);
const updates: v2.UpdateSessionNotification[] = items.flatMap((item) => {
if (item.type !== "terminal" || known.has(item.terminalId)) return [];
const update = this.terminals.advertiseCancel(sessionId, item.terminalId);
return update ? [{ sessionId, update }] : [];
});
if (session?.reported && state && !unchanged) {
if (parentSessionId) {
updates.push({
@@ -115,15 +130,53 @@ export class V2Background {
/**
* The current `terminal_update` of each terminal that the session
* `sessionId` lists. v2 requires it before the state snapshot of
* `session/resume`, as a replay may not have delivered it.
* `sessionId` lists, and the `cancel` capability of each tool call that it
* lists, whose other fields the agent sends (see
* `ClaudeAcpAgent.publishBackgroundToolCalls`). v2 requires them before the
* state snapshot of `session/resume`, as a replay may not have delivered
* them.
*/
currentTerminals(sessionId: string): v2.UpdateSessionNotification[] {
return (this.sessions.get(sessionId)?.items ?? []).flatMap((item) => {
if (item.type !== "terminal") return [];
const update = this.terminals.advertiseCancel(sessionId, item.terminalId, true);
currentItems(sessionId: string): v2.UpdateSessionNotification[] {
return (this.sessions.get(sessionId)?.items ?? []).flatMap((item) =>
this.advertiseCancel(sessionId, item, true),
);
}
/** The tool calls that the session `sessionId` lists. */
listedToolCalls(sessionId: string): string[] {
return (this.sessions.get(sessionId)?.items ?? []).flatMap((item) =>
item.type === "tool_call" ? [item.toolCallId] : [],
);
}
/**
* The update that lets the client cancel the listed terminal or tool call
* `item`, unless it advertises that already. `again` sends it anyway, as
* the item's current state.
*/
private advertiseCancel(
sessionId: string,
item: BackgroundItem,
again: boolean,
): v2.UpdateSessionNotification[] {
if (item.type === "terminal") {
const update = this.terminals.advertiseCancel(sessionId, item.terminalId, again);
return update ? [{ sessionId, update }] : [];
});
}
if (item.type !== "tool_call") return [];
const key = `${sessionId} ${item.toolCallId}`;
if (this.cancellableToolCalls.has(key) && !again) return [];
this.cancellableToolCalls.add(key);
return [
{
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: item.toolCallId,
capabilities: { cancel: {} },
},
},
];
}
/** The subagent sessions that the session `sessionId` lists. */
@@ -136,6 +189,9 @@ export class V2Background {
/** Forgets the list of a session, and those of its subagent sessions. */
forget(sessionId: string): void {
this.sessions.delete(sessionId);
for (const key of this.cancellableToolCalls) {
if (key.startsWith(`${sessionId} `)) this.cancellableToolCalls.delete(key);
}
for (const [child, { parent }] of this.sessions) {
if (parent === sessionId) this.forget(child);
}