mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-04 00:12:14 +00:00
Compare commits
2 Commits
v0.100.1-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bbdea6cc6 | ||
|
|
1e986b526d |
115
src/acp-agent.ts
115
src/acp-agent.ts
@@ -165,23 +165,11 @@ import {
|
||||
clientSupportsAsyncTasks,
|
||||
} from "./async-tasks.js";
|
||||
import {
|
||||
backgroundLevel,
|
||||
type BackgroundLevel,
|
||||
type BackgroundSink,
|
||||
type BackgroundTerminal,
|
||||
backgroundToolCallPause,
|
||||
backgroundToolCallStates,
|
||||
type BackgroundTaskEnd,
|
||||
type BackgroundToolCall,
|
||||
BackgroundWorkReporter,
|
||||
cancelBackgroundWork,
|
||||
droppedTasks,
|
||||
endBackgroundWork,
|
||||
finishBackgroundTerminalOutput,
|
||||
followBackgroundTerminalOutput,
|
||||
registerBackgroundToolCalls,
|
||||
takeBackgroundTerminalExit,
|
||||
takeBackgroundToolCallEnd,
|
||||
SessionBackgroundWork,
|
||||
} from "./background-work.js";
|
||||
import {
|
||||
AUTH_STATUS_PROBE_TIMEOUT_MS,
|
||||
@@ -1479,30 +1467,17 @@ export type Session = {
|
||||
* 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`), or gets the session's
|
||||
* background work (`reportsBackground`, with `tool_call/cancel`), which
|
||||
* background work (`background.reported`, 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;
|
||||
/** The display terminals of the commands that went to the background and
|
||||
* still run, by the id of the command's task: the Bash tool call whose
|
||||
* terminal it is, and the subagent tool call that ran it, if any. The
|
||||
* 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, 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
|
||||
* (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. */
|
||||
reportsBackground?: boolean;
|
||||
/** Claude Code's live background task set, from its last
|
||||
* `background_tasks_changed`. Per CLI process, as is a session. */
|
||||
backgroundLevel?: BackgroundLevel;
|
||||
/** The tasks that Claude Code runs in the background for the session: the
|
||||
* terminals of commands, kept for a client that renders terminals and is
|
||||
* not AIR (AIR shows a command through its async tasks), and, when the
|
||||
* client gets the session's background work, the tool calls of tasks and
|
||||
* the `background` lists. Per CLI process, as is a session. */
|
||||
background: SessionBackgroundWork;
|
||||
/** The parent tool call of each subagent that a settle bookend removed from
|
||||
* `liveBackgroundTasks`. The SDK can resume such a subagent under the same
|
||||
* agent id without a new `task_started` (a running `task_updated` patch or
|
||||
@@ -5420,17 +5395,10 @@ export class ClaudeAcpAgent {
|
||||
this.syncAwaitingUser(params.sessionId, session);
|
||||
};
|
||||
|
||||
/** 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) => {
|
||||
await finishBackgroundTerminalOutput(session, taskId);
|
||||
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);
|
||||
};
|
||||
/** Ends the terminal and the tool call of a task that ran in the
|
||||
* background, once the task ends (see `Session.background`). */
|
||||
const endBackgroundTask = (taskId: string, end: BackgroundTaskEnd) =>
|
||||
session.background.taskEnded(taskId, end, sendUpdate);
|
||||
|
||||
/** Settles a task. Its request may still be open, and an agent without a
|
||||
* record no longer blocks the foreground work, so that is checked again. */
|
||||
@@ -6935,23 +6903,13 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
settleLiveTask(message.task_id);
|
||||
} else if (message.patch.status === "paused") {
|
||||
const pause = backgroundToolCallPause(
|
||||
session,
|
||||
params.sessionId,
|
||||
message.task_id,
|
||||
true,
|
||||
);
|
||||
const pause = session.background.taskPaused(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,
|
||||
);
|
||||
const resume = session.background.taskPaused(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.
|
||||
@@ -7122,13 +7080,7 @@ export class ClaudeAcpAgent {
|
||||
break;
|
||||
case "background_tasks_changed": {
|
||||
await asyncTasks.backgroundTasksChanged(message.tasks);
|
||||
if (session.reportsBackground) {
|
||||
const level = backgroundLevel(message.tasks);
|
||||
for (const taskId of droppedTasks(session, level)) {
|
||||
await endBackgroundTask(taskId, undefined);
|
||||
}
|
||||
session.backgroundLevel = level;
|
||||
}
|
||||
await session.background.levelChanged(message.tasks, sendUpdate);
|
||||
// A level signal: the full live background-task set on every
|
||||
// membership change, with REPLACE semantics. Used only to
|
||||
// reconcile `liveBackgroundTasks` — dropping (or, for
|
||||
@@ -8558,18 +8510,18 @@ export class ClaudeAcpAgent {
|
||||
session.liveBackgroundTasks.has(backgroundBashTask.taskId)
|
||||
) {
|
||||
runningTerminal = terminalId;
|
||||
(session.backgroundTerminals ??= new Map()).set(backgroundBashTask.taskId, {
|
||||
session.background.terminalStarted(backgroundBashTask.taskId, {
|
||||
toolCallId: terminalId,
|
||||
...(message.parent_tool_use_id
|
||||
? { parentToolUseId: message.parent_tool_use_id }
|
||||
: {}),
|
||||
...(backgroundBashTask.outputFilePath
|
||||
...(typeof backgroundBashTask.outputFilePath === "string"
|
||||
? { outputFilePath: backgroundBashTask.outputFilePath }
|
||||
: {}),
|
||||
});
|
||||
runningTerminalTask = backgroundBashTask.taskId;
|
||||
}
|
||||
runningToolCalls = registerBackgroundToolCalls(
|
||||
runningToolCalls = session.background.toolResults(
|
||||
session,
|
||||
content,
|
||||
toolUseResult,
|
||||
@@ -8654,17 +8606,13 @@ 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,
|
||||
);
|
||||
}),
|
||||
session.background.followOutput(runningTerminalTask, (notification) =>
|
||||
sendUpdate(notification).catch((error) => {
|
||||
this.logger.error(
|
||||
`Session ${params.sessionId}: failed to send background command output:`,
|
||||
error,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -9383,7 +9331,7 @@ export class ClaudeAcpAgent {
|
||||
// Its list row now shows what the registry and the transcript tell.
|
||||
this.sessionIndex.onOwnSessionChanged(sessionId);
|
||||
// Its background tasks end with the query's process.
|
||||
for (const exit of endBackgroundWork(session, sessionId)) {
|
||||
for (const exit of session.background.queryClosed()) {
|
||||
this.client.sessionUpdate(exit).catch((error) => {
|
||||
this.logger.error(`Session ${sessionId}: failed to end a background terminal:`, error);
|
||||
});
|
||||
@@ -9494,7 +9442,7 @@ export class ClaudeAcpAgent {
|
||||
/** Stops a command running in the background (v2 `terminal/cancel`, see
|
||||
* {@link cancelBackgroundWork}). */
|
||||
cancelBackgroundTerminal(params: { sessionId: string; terminalId: string }): Promise<void> {
|
||||
return cancelBackgroundWork(this.sessions, params.sessionId, {
|
||||
return cancelBackgroundWork(Object.values(this.sessions), params.sessionId, {
|
||||
terminalId: params.terminalId,
|
||||
});
|
||||
}
|
||||
@@ -9502,7 +9450,7 @@ export class ClaudeAcpAgent {
|
||||
/** 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, {
|
||||
return cancelBackgroundWork(Object.values(this.sessions), params.sessionId, {
|
||||
toolCallId: params.toolCallId,
|
||||
});
|
||||
}
|
||||
@@ -9517,7 +9465,7 @@ export class ClaudeAcpAgent {
|
||||
if (!session) return;
|
||||
const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
|
||||
try {
|
||||
for (const update of backgroundToolCallStates(session, sessionId, toolCallIds, renderer)) {
|
||||
for (const update of session.background.toolCallStates(toolCallIds, renderer, session.cwd)) {
|
||||
await this.client.sessionUpdate(update);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -12751,7 +12699,10 @@ export class ClaudeAcpAgent {
|
||||
emittedToolCalls: new Set(),
|
||||
eagerToolCallSessions: new Map(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
reportsBackground: this.backgroundWork.enabled && options.perTaskStopAffordance === true,
|
||||
background: new SessionBackgroundWork(
|
||||
sessionId,
|
||||
this.backgroundWork.enabled && options.perTaskStopAffordance === true,
|
||||
),
|
||||
stopsWorkflowsOnCancel:
|
||||
options.perTaskStopAffordance === true &&
|
||||
!this.backgroundWork.enabled &&
|
||||
|
||||
@@ -91,227 +91,425 @@ export interface BackgroundSink {
|
||||
forget(sessionId: string): void;
|
||||
}
|
||||
|
||||
/** The state of a session that its lists are built from. */
|
||||
export interface BackgroundWorkSession {
|
||||
reportsBackground?: boolean;
|
||||
backgroundLevel?: BackgroundLevel;
|
||||
backgroundTerminals?: Map<string, BackgroundTerminal>;
|
||||
backgroundToolCalls?: Map<string, BackgroundToolCall>;
|
||||
/**
|
||||
* The state of a session that other modules own, and that the background
|
||||
* work reads: Claude Code's tasks as the consumer tracks them, the subagent
|
||||
* sessions, and the query that stops a task.
|
||||
*/
|
||||
export interface BackgroundOwner {
|
||||
liveBackgroundTasks: Map<string, { parentToolUseId?: string; isSubagent: boolean }>;
|
||||
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> };
|
||||
}
|
||||
|
||||
/** A task of a `background_tasks_changed` payload. */
|
||||
export type LevelTask = {
|
||||
task_id: string;
|
||||
task_type: string;
|
||||
description: string;
|
||||
parent_task_id?: string;
|
||||
ambient?: boolean;
|
||||
};
|
||||
|
||||
/** The most returned tool calls that {@link SessionBackgroundWork} remembers. */
|
||||
const MAX_RETURNED_CALLS = 1024;
|
||||
|
||||
/** 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`).
|
||||
* The background work of one root session, and of its subagent sessions: the
|
||||
* terminals and tool calls of the tasks that run in the background, and what
|
||||
* the client is told of them.
|
||||
*
|
||||
* Terminals are kept for every client that renders them, as before the
|
||||
* background lists. The rest is kept only when `reported`.
|
||||
*/
|
||||
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);
|
||||
export class SessionBackgroundWork {
|
||||
/** Claude Code's live background task set, from its last `background_tasks_changed`. */
|
||||
private level: BackgroundLevel = new Map();
|
||||
/** The terminals of the commands that went to the background, by task id. */
|
||||
private readonly terminals = new Map<string, BackgroundTerminal>();
|
||||
/** The tool calls whose tasks went on in the background, by task id. */
|
||||
private readonly toolCalls = new Map<string, BackgroundToolCall>();
|
||||
/**
|
||||
* The tool calls that returned, oldest first. A task that a call started
|
||||
* is part of that call until it returns, and the foreground work waits on
|
||||
* it. The task's `task_started` can come after the call returned. Bounded
|
||||
* by {@link MAX_RETURNED_CALLS}: a task joins the level around the time its
|
||||
* call returns.
|
||||
*/
|
||||
private readonly returned = new Set<string>();
|
||||
|
||||
/**
|
||||
* `reported`: whether the client gets the lists. Only when Claude Code's
|
||||
* interrupt spares background tasks, as `perTaskStopAffordance` makes it.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessionId: string,
|
||||
readonly reported: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Keeps the display terminal of the command `taskId` running after its Bash
|
||||
* result, until its task ends (see `withRunningTerminal`).
|
||||
*/
|
||||
terminalStarted(taskId: string, terminal: BackgroundTerminal): void {
|
||||
this.terminals.set(taskId, terminal);
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
|
||||
/** The level of a `background_tasks_changed` payload. */
|
||||
export function backgroundLevel(
|
||||
tasks: {
|
||||
task_id: string;
|
||||
task_type: string;
|
||||
description: string;
|
||||
parent_task_id?: string;
|
||||
ambient?: boolean;
|
||||
}[],
|
||||
): BackgroundLevel {
|
||||
return new Map(
|
||||
tasks.map((task) => [
|
||||
task.task_id,
|
||||
{
|
||||
ambient: task.ambient === true,
|
||||
taskType: task.task_type,
|
||||
description: task.description,
|
||||
...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Reads what a tool result message tells of the background tasks:
|
||||
*
|
||||
* - The returned calls no longer hold the tasks that they started.
|
||||
* - Each call whose task goes on in the background is registered: 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 registered calls, whose result then reports
|
||||
* `in_progress` (see `withRunningToolCall`).
|
||||
*/
|
||||
toolResults(
|
||||
owner: BackgroundOwner,
|
||||
content: unknown,
|
||||
toolUseResult: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
parentToolUseId: string | null,
|
||||
): Set<string> {
|
||||
const registered = new Set<string>();
|
||||
if (!this.reported || !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",
|
||||
);
|
||||
for (const { tool_use_id: toolCallId } of results) {
|
||||
this.returned.add(toolCallId);
|
||||
if (this.returned.size > MAX_RETURNED_CALLS) {
|
||||
this.returned.delete(this.returned.values().next().value!);
|
||||
}
|
||||
}
|
||||
const succeeded = results.filter((block) => block.is_error !== true);
|
||||
const structuredTaskId =
|
||||
succeeded.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 succeeded) {
|
||||
const toolUse = toolUses[toolCallId];
|
||||
if (!toolUse) continue;
|
||||
const taskId =
|
||||
(TASK_TOOLS.has(toolUse.name) ? structuredTaskId : undefined) ??
|
||||
[...owner.liveBackgroundTasks].find(
|
||||
([, task]) => task.parentToolUseId === toolCallId && !task.isSubagent,
|
||||
)?.[0];
|
||||
const task = taskId === undefined ? undefined : this.level.get(taskId);
|
||||
if (
|
||||
!task ||
|
||||
task.ambient ||
|
||||
task.taskType === "local_bash" ||
|
||||
task.taskType === "local_agent"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.toolCalls.set(taskId!, {
|
||||
toolCallId,
|
||||
toolUse,
|
||||
...(parentToolUseId ? { parentToolUseId } : {}),
|
||||
});
|
||||
registered.add(toolCallId);
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `background` list of the root session `rootSessionId` and of each of
|
||||
* its announced subagent sessions, by session id, deeper sessions first and
|
||||
* the root last. Each list is empty while
|
||||
* the level has not been reported: Claude Code sends none before the first
|
||||
* background task starts.
|
||||
*/
|
||||
export function backgroundLists(
|
||||
session: BackgroundWorkSession,
|
||||
rootSessionId: string,
|
||||
): Map<string, BackgroundItem[]> {
|
||||
const children = [...(session.nativeSubagentsByTaskId ?? [])].filter(
|
||||
([, child]) => child.announced,
|
||||
);
|
||||
const childByTask = new Map(children);
|
||||
const childByToolUse = new Map(
|
||||
children.flatMap(([, child]) =>
|
||||
child.parentToolUseId ? [[child.parentToolUseId, child] as const] : [],
|
||||
),
|
||||
);
|
||||
const lists = new Map<string, BackgroundItem[]>([
|
||||
[rootSessionId, []],
|
||||
...children.map(([, child]): [string, BackgroundItem[]] => [child.sessionId, []]),
|
||||
]);
|
||||
const level: BackgroundLevel = session.backgroundLevel ?? new Map();
|
||||
for (const [taskId, task] of level) {
|
||||
if (task.ambient) continue;
|
||||
/**
|
||||
* Starts reading the output of the background command `taskId` into its
|
||||
* terminal, when the client gets the lists 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.
|
||||
*/
|
||||
followOutput(taskId: string, send: Send): void {
|
||||
const terminal = this.terminals.get(taskId);
|
||||
if (!this.reported || !terminal?.outputFilePath || terminal.output) return;
|
||||
terminal.output = new BackgroundOutputTail(terminal.outputFilePath, (output) =>
|
||||
send(terminalOutputUpdate(this.sessionId, terminal, output)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The task `taskId` ended. Reads the rest of its command's output, then
|
||||
* exits its terminal, and gives a tool call whose task it was its final
|
||||
* status: `cancelled` for a task that Claude Code stopped, and `completed`
|
||||
* for one that ended without saying how, such as one that the level
|
||||
* dropped: it no longer runs, and reported no failure. Claude Code reports
|
||||
* no exit code for a command, and `completed` does not mean 0: Claude Code
|
||||
* accepts some non-zero codes as a success, such as grep's 1.
|
||||
*/
|
||||
async taskEnded(taskId: string, end: BackgroundTaskEnd, send: Send): Promise<void> {
|
||||
const terminal = this.terminals.get(taskId);
|
||||
if (terminal) {
|
||||
await terminal.output?.finish();
|
||||
this.terminals.delete(taskId);
|
||||
await send(terminalExit(this.sessionId, terminal));
|
||||
}
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
if (toolCall) {
|
||||
this.toolCalls.delete(taskId);
|
||||
await send(
|
||||
toolCallStatusUpdate(
|
||||
this.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.
|
||||
*/
|
||||
taskPaused(taskId: string, paused: boolean): AcpSessionNotification | undefined {
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
if (!toolCall || (toolCall.paused ?? false) === paused) return undefined;
|
||||
toolCall.paused = paused;
|
||||
return toolCallStatusUpdate(this.sessionId, toolCall, paused ? V2_PAUSED : "in_progress");
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes Claude Code's new live background task set. The terminals and tool
|
||||
* calls whose tasks it no longer holds end first, 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.
|
||||
*/
|
||||
async levelChanged(tasks: LevelTask[], send: Send): Promise<void> {
|
||||
if (!this.reported) return;
|
||||
const level: BackgroundLevel = new Map(
|
||||
tasks.map((task) => [
|
||||
task.task_id,
|
||||
{
|
||||
ambient: task.ambient === true,
|
||||
taskType: task.task_type,
|
||||
description: task.description,
|
||||
...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
for (const taskId of [...this.terminals.keys(), ...this.toolCalls.keys()]) {
|
||||
if (!level.has(taskId)) await this.taskEnded(taskId, undefined, send);
|
||||
}
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query closed: Claude Code's background tasks end with its process.
|
||||
* Stops reading output, and returns the updates that exit the root
|
||||
* session's terminals and cancel its tool calls. The level empties, 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.
|
||||
*/
|
||||
queryClosed(): AcpSessionNotification[] {
|
||||
for (const terminal of this.terminals.values()) terminal.output?.stop();
|
||||
if (!this.reported) return [];
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
for (const [taskId, terminal] of this.terminals) {
|
||||
if (terminal.parentToolUseId) continue;
|
||||
this.terminals.delete(taskId);
|
||||
updates.push(terminalExit(this.sessionId, terminal));
|
||||
}
|
||||
for (const [taskId, toolCall] of this.toolCalls) {
|
||||
if (toolCall.parentToolUseId) continue;
|
||||
this.toolCalls.delete(taskId);
|
||||
updates.push(toolCallStatusUpdate(this.sessionId, toolCall, V2_CANCELLED));
|
||||
}
|
||||
this.level = new Map();
|
||||
this.returned.clear();
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `background` list of the root session and of each of its announced
|
||||
* subagent sessions, with what their repeated states need, deeper sessions
|
||||
* first and the root last: a child's own list goes out before its parent's
|
||||
* list drops it. Each list is empty while the level has not been reported:
|
||||
* Claude Code sends none before the first background task starts.
|
||||
*/
|
||||
reports(owner: BackgroundOwner, rootState: WorkStateName): BackgroundReport[] {
|
||||
const children = [...(owner.nativeSubagentsByTaskId ?? [])].filter(
|
||||
([, child]) => child.announced,
|
||||
);
|
||||
const childByTask = new Map(children);
|
||||
const childByToolUse = new Map(
|
||||
children.flatMap(([, child]) =>
|
||||
child.parentToolUseId ? [[child.parentToolUseId, child] as const] : [],
|
||||
),
|
||||
);
|
||||
const lists = new Map<string, BackgroundItem[]>([
|
||||
[this.sessionId, []],
|
||||
...children.map(([, child]): [string, BackgroundItem[]] => [child.sessionId, []]),
|
||||
]);
|
||||
// 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) {
|
||||
const owner = ownerOf(terminal.parentToolUseId);
|
||||
if (owner) lists.get(owner)!.push({ type: "terminal", terminalId: terminal.toolCallId });
|
||||
continue;
|
||||
parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : this.sessionId;
|
||||
for (const [taskId, task] of this.level) {
|
||||
if (task.ambient) continue;
|
||||
const terminal = this.terminals.get(taskId);
|
||||
if (terminal) {
|
||||
const session = ownerOf(terminal.parentToolUseId);
|
||||
if (session)
|
||||
lists.get(session)!.push({ type: "terminal", terminalId: terminal.toolCallId });
|
||||
continue;
|
||||
}
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
if (toolCall) {
|
||||
const session = ownerOf(toolCall.parentToolUseId);
|
||||
if (session)
|
||||
lists.get(session)!.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.
|
||||
const live = owner.liveBackgroundTasks.get(taskId);
|
||||
if (!live || (live.parentToolUseId && !this.returned.has(live.parentToolUseId))) continue;
|
||||
const parent = task.parentTaskId ? childByTask.get(task.parentTaskId) : undefined;
|
||||
lists
|
||||
.get(parent?.sessionId ?? this.sessionId)!
|
||||
.push({ type: "_claude/task", taskId, description: task.description });
|
||||
}
|
||||
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 child is listed in its parent's list while it works in the background,
|
||||
// and while it is idle with background work of its own. Deeper children
|
||||
// first, so a child's list is whole before its parent's reads it.
|
||||
const sessions = new Map(children.map(([, child]) => [child.sessionId, child]));
|
||||
const depth = (child: NativeSubagent): number => {
|
||||
const parent = sessions.get(child.parentSessionId);
|
||||
return parent ? depth(parent) + 1 : 0;
|
||||
};
|
||||
const ordered = children.sort(([, a], [, b]) => depth(b) - depth(a));
|
||||
for (const [taskId, child] of ordered) {
|
||||
const task = this.level.get(taskId);
|
||||
const working =
|
||||
child.workState !== undefined &&
|
||||
child.workState !== "idle" &&
|
||||
task !== undefined &&
|
||||
!task.ambient;
|
||||
if (working || lists.get(child.sessionId)!.length > 0) {
|
||||
lists.get(child.parentSessionId)?.push({ type: "subagent", sessionId: child.sessionId });
|
||||
}
|
||||
}
|
||||
// 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)!
|
||||
.push({ type: "_claude/task", taskId, description: task.description });
|
||||
return [
|
||||
...ordered.map(([, child]) => ({
|
||||
sessionId: child.sessionId,
|
||||
items: lists.get(child.sessionId)!,
|
||||
state: workStateName(child.workState),
|
||||
parentSessionId: child.parentSessionId,
|
||||
})),
|
||||
{ sessionId: this.sessionId, items: lists.get(this.sessionId)!, state: rootState },
|
||||
];
|
||||
}
|
||||
// A child is listed in its parent's list while it works in the background,
|
||||
// and while it is idle with background work of its own. Deeper children
|
||||
// first, so a child's list is whole before its parent's reads it.
|
||||
const sessions = new Map(children.map(([, child]) => [child.sessionId, child]));
|
||||
const depth = (child: NativeSubagent): number => {
|
||||
const parent = sessions.get(child.parentSessionId);
|
||||
return parent ? depth(parent) + 1 : 0;
|
||||
};
|
||||
for (const [taskId, child] of children.sort(([, a], [, b]) => depth(b) - depth(a))) {
|
||||
const task = level.get(taskId);
|
||||
const working =
|
||||
child.workState !== undefined &&
|
||||
child.workState !== "idle" &&
|
||||
task !== undefined &&
|
||||
!task.ambient;
|
||||
if (working || lists.get(child.sessionId)!.length > 0) {
|
||||
lists.get(child.parentSessionId)?.push({ type: "subagent", sessionId: child.sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of each background tool call in `toolCallIds`: the
|
||||
* call as `renderer` shows its tool use, with its status now (see
|
||||
* `ClaudeAcpAgent.publishBackgroundToolCalls`).
|
||||
*/
|
||||
toolCallStates(
|
||||
toolCallIds: string[],
|
||||
renderer: AcpToolCallRenderer,
|
||||
cwd: string,
|
||||
): AcpSessionNotification[] {
|
||||
return [...this.toolCalls.values()]
|
||||
.filter((toolCall) => toolCallIds.includes(toolCall.toolCallId))
|
||||
.map((toolCall) => {
|
||||
const update = renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd });
|
||||
const status = toolCall.paused ? V2_PAUSED : "in_progress";
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
update: { ...update, status } as AcpSessionNotification["update"],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The task of the terminal or tool call `target` that the session
|
||||
* `sessionId` lists, or undefined when it lists no such item.
|
||||
*/
|
||||
listedTask(
|
||||
owner: BackgroundOwner,
|
||||
sessionId: string,
|
||||
target: BackgroundTarget,
|
||||
): string | undefined {
|
||||
if (!this.reported) return undefined;
|
||||
const listed = this.reports(owner, "idle")
|
||||
.find((report) => report.sessionId === sessionId)
|
||||
?.items.some((item) =>
|
||||
"terminalId" in target
|
||||
? item.type === "terminal" && item.terminalId === target.terminalId
|
||||
: item.type === "tool_call" && item.toolCallId === target.toolCallId,
|
||||
);
|
||||
if (!listed) return undefined;
|
||||
const id = "terminalId" in target ? target.terminalId : target.toolCallId;
|
||||
const entries = "terminalId" in target ? this.terminals : this.toolCalls;
|
||||
return [...entries].find(([, entry]) => entry.toolCallId === id)?.[0];
|
||||
}
|
||||
// In the same order, the root last: a child's own list goes out before its
|
||||
// parent's list drops it.
|
||||
return new Map([
|
||||
...children.map(([, child]): [string, BackgroundItem[]] => [
|
||||
child.sessionId,
|
||||
lists.get(child.sessionId)!,
|
||||
]),
|
||||
[rootSessionId, lists.get(rootSessionId)!],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 droppedTasks(session: BackgroundWorkSession, level: BackgroundLevel): string[] {
|
||||
return [
|
||||
...(session.backgroundTerminals?.keys() ?? []),
|
||||
...(session.backgroundToolCalls?.keys() ?? []),
|
||||
].filter((taskId) => !level.has(taskId));
|
||||
}
|
||||
type Send = (notification: AcpSessionNotification) => Promise<void>;
|
||||
|
||||
/** A terminal or tool call that `terminal/cancel` or `tool_call/cancel` names. */
|
||||
export type BackgroundTarget = { terminalId: string } | { toolCallId: string };
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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: 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 function followBackgroundTerminalOutput(
|
||||
session: BackgroundWorkSession,
|
||||
export async function cancelBackgroundWork(
|
||||
sessions: Array<BackgroundOwner & { background?: SessionBackgroundWork }>,
|
||||
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)),
|
||||
);
|
||||
target: BackgroundTarget,
|
||||
): Promise<void> {
|
||||
const notCancellable = () =>
|
||||
"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.`,
|
||||
);
|
||||
for (const session of sessions) {
|
||||
const taskId = session.background?.listedTask(session, sessionId, target);
|
||||
if (taskId === undefined) continue;
|
||||
try {
|
||||
await session.query.stopTask(taskId);
|
||||
} catch (error) {
|
||||
// The task ended meanwhile: it has ended on the client too, or is about to.
|
||||
if (session.background?.listedTask(session, sessionId, target) === undefined) {
|
||||
throw notCancellable();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw notCancellable();
|
||||
}
|
||||
|
||||
/** The update that gives the terminal of `terminal` the text `output`. */
|
||||
@@ -336,34 +534,8 @@ function terminalOutputUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* command has no terminal. Claude Code reports no exit code for it, and
|
||||
* `completed` does not mean 0: Claude Code accepts some non-zero codes as a
|
||||
* success, such as grep's 1.
|
||||
*/
|
||||
export function takeBackgroundTerminalExit(
|
||||
session: BackgroundWorkSession,
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
): AcpSessionNotification | undefined {
|
||||
const terminal = session.backgroundTerminals?.get(taskId);
|
||||
if (!terminal) return undefined;
|
||||
terminal.output?.stop();
|
||||
session.backgroundTerminals!.delete(taskId);
|
||||
/** The update that exits the terminal of `terminal`, with no exit code. */
|
||||
function terminalExit(sessionId: string, terminal: BackgroundTerminal): AcpSessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
@@ -379,69 +551,6 @@ 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,
|
||||
@@ -462,82 +571,6 @@ function toolCallStatusUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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,
|
||||
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
|
||||
: 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, ...ends];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 cancelBackgroundWork(
|
||||
sessions: Record<string, BackgroundWorkSession>,
|
||||
sessionId: string,
|
||||
target: { terminalId: string } | { toolCallId: string },
|
||||
): Promise<void> {
|
||||
const notCancellable = () =>
|
||||
"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(isTarget) ?? false);
|
||||
const owner = Object.entries(sessions).find(([root, session]) => listed(root, session));
|
||||
if (!owner) throw notCancellable();
|
||||
const [root, session] = owner;
|
||||
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 task ended meanwhile: it has ended on the client too, or is about to.
|
||||
if (!listed(root, session)) throw notCancellable();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the lists of each session that reports them, each time one
|
||||
* changes. The sink sends them to the client synchronously, so a list follows
|
||||
@@ -559,25 +592,17 @@ export class BackgroundWorkReporter {
|
||||
* first call for a session publishes its list, also when it is empty.
|
||||
* `rootState` is the root session's state now.
|
||||
*/
|
||||
sync(rootSessionId: string, session: BackgroundWorkSession, rootState: WorkStateName): void {
|
||||
if (!this.sink || !session.reportsBackground) return;
|
||||
const children = new Map(
|
||||
[...(session.nativeSubagentsByTaskId?.values() ?? [])].map((child) => [
|
||||
child.sessionId,
|
||||
child,
|
||||
]),
|
||||
);
|
||||
for (const [sessionId, items] of backgroundLists(session, rootSessionId)) {
|
||||
const json = JSON.stringify(items);
|
||||
if (this.published.get(sessionId)?.json === json) continue;
|
||||
this.published.set(sessionId, { json, root: rootSessionId });
|
||||
const child = children.get(sessionId);
|
||||
this.sink.changed({
|
||||
sessionId,
|
||||
items,
|
||||
state: child ? workStateName(child.workState) : rootState,
|
||||
...(child ? { parentSessionId: child.parentSessionId } : {}),
|
||||
});
|
||||
sync(
|
||||
rootSessionId: string,
|
||||
session: BackgroundOwner & { background?: SessionBackgroundWork },
|
||||
rootState: WorkStateName,
|
||||
): void {
|
||||
if (!this.sink || !session.background?.reported) return;
|
||||
for (const report of session.background.reports(session, rootState)) {
|
||||
const json = JSON.stringify(report.items);
|
||||
if (this.published.get(report.sessionId)?.json === json) continue;
|
||||
this.published.set(report.sessionId, { json, root: rootSessionId });
|
||||
this.sink.changed(report);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,9 @@ type InitializeSessionModeParams = {
|
||||
* The permission mode a new Claude query starts in.
|
||||
*
|
||||
* An explicit mode wins. A resume or a load continues in the mode of the last
|
||||
* user record of the transcript. If that mode is unknown or not available now,
|
||||
* for example bypass, the session starts in `defaultMode` of the settings.
|
||||
* user record of the transcript. If that mode is unknown or absent from the
|
||||
* session's mode catalog, use an available `defaultMode` from settings, or
|
||||
* `default` when that setting is unavailable too.
|
||||
*/
|
||||
export function resolveInitialPermissionMode(
|
||||
sources: { explicit?: PermissionMode; resumed?: string; defaultMode?: unknown },
|
||||
@@ -97,13 +98,23 @@ export function resolveInitialPermissionMode(
|
||||
{ error: () => (rejected = true) },
|
||||
allowBypass,
|
||||
);
|
||||
if (!rejected) return mode;
|
||||
if (!rejected && buildAvailableModes(allowBypass).some((available) => available.id === mode)) {
|
||||
return mode;
|
||||
}
|
||||
logger.error(
|
||||
`Ignoring permission mode "${sources.resumed}" of the resumed transcript: ` +
|
||||
`not available in this session.`,
|
||||
);
|
||||
}
|
||||
return resolvePermissionMode(sources.defaultMode, logger, allowBypass);
|
||||
const configuredMode = resolvePermissionMode(sources.defaultMode, logger, allowBypass);
|
||||
// A rejected transcript mode falls back only to a mode this session can offer.
|
||||
if (
|
||||
sources.resumed !== undefined &&
|
||||
!buildAvailableModes(allowBypass).some((available) => available.id === configuredMode)
|
||||
) {
|
||||
return "default";
|
||||
}
|
||||
return configuredMode;
|
||||
}
|
||||
|
||||
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
|
||||
@@ -120,7 +131,7 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
modes: SessionModeState;
|
||||
autoModeFallbackWarningPending: boolean;
|
||||
}> {
|
||||
const availableModes = this.buildAvailableModes(allowBypass);
|
||||
const availableModes = buildAvailableModes(allowBypass, this.options.airClient?.() ?? false);
|
||||
let effectiveMode = requestedMode;
|
||||
let autoModeFallbackWarningPending = false;
|
||||
|
||||
@@ -350,50 +361,6 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
}
|
||||
|
||||
private buildAvailableModes(allowBypass: boolean): SessionModeState["availableModes"] {
|
||||
// Only AIR gets the mode kind.
|
||||
const air = this.options.airClient?.() ?? false;
|
||||
const kind = (value: string) => {
|
||||
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
|
||||
return meta ? { _meta: meta } : {};
|
||||
};
|
||||
const modes: SessionModeState["availableModes"] = [
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
...kind("plan"),
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
...kind("auto_review"),
|
||||
},
|
||||
];
|
||||
if (allowBypass) {
|
||||
modes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
...kind("full_access"),
|
||||
});
|
||||
}
|
||||
return modes;
|
||||
}
|
||||
|
||||
private async trySyncMode(
|
||||
query: Pick<Query, "setPermissionMode">,
|
||||
mode: PermissionMode,
|
||||
@@ -406,3 +373,49 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAvailableModes(
|
||||
allowBypass: boolean,
|
||||
air = false,
|
||||
): SessionModeState["availableModes"] {
|
||||
// Only AIR gets the mode kind.
|
||||
const kind = (value: string) => {
|
||||
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
|
||||
return meta ? { _meta: meta } : {};
|
||||
};
|
||||
const modes: SessionModeState["availableModes"] = [
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
...kind("plan"),
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
...kind("auto_review"),
|
||||
},
|
||||
];
|
||||
if (allowBypass) {
|
||||
modes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
...kind("full_access"),
|
||||
});
|
||||
}
|
||||
return modes;
|
||||
}
|
||||
|
||||
@@ -464,6 +464,16 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("uses the available configured mode when the restored mode is not in the catalog", async () => {
|
||||
const { permissionMode, response } = await resume([user("dontAsk")]);
|
||||
|
||||
expect(permissionMode).toBe("plan");
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
expect(response.configOptions.find((option: any) => option.id === "mode").currentValue).toBe(
|
||||
"plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode when bypass is not available now", async () => {
|
||||
const { permissionMode, response } = await resume([user("bypassPermissions")], {
|
||||
claudeCode: { options: { allowDangerouslySkipPermissions: false } },
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
type StreamedToolInputCache,
|
||||
} from "../acp-agent.js";
|
||||
import { SessionTitles } from "../session-titles.js";
|
||||
import { SessionBackgroundWork } from "../background-work.js";
|
||||
import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "../usage-markdown.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { heldRequest, initializeClient, makeMockQuery } from "./helpers.js";
|
||||
@@ -5201,6 +5202,7 @@ describe("permission request cancellation", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -14998,6 +15000,7 @@ describe("session/close", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -15120,6 +15123,7 @@ describe("session/delete", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -15283,6 +15287,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -19764,6 +19769,7 @@ describe("post-error recovery", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork("test-session", false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -28199,6 +28205,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork("test-session", false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -30107,6 +30114,7 @@ describe("agent selection config option", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
|
||||
@@ -615,6 +615,40 @@ describe("ACP v2 sessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes an unavailable mode using the configured mode in the v2 response", async () => {
|
||||
const project = path.join(tempDir, "claude", "projects", "-project");
|
||||
await mkdir(project, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(tempDir, "claude", "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "plan" } }),
|
||||
);
|
||||
const sessionId = randomUUID();
|
||||
await writeFile(
|
||||
path.join(project, `${sessionId}.jsonl`),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: randomUUID(),
|
||||
parentUuid: null,
|
||||
sessionId,
|
||||
cwd,
|
||||
isSidechain: false,
|
||||
origin: { kind: "human" },
|
||||
message: { role: "user", content: "hello" },
|
||||
permissionMode: "dontAsk",
|
||||
}) + "\n",
|
||||
);
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const response = await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
|
||||
expect(sdk.queryOptions.at(-1)?.permissionMode).toBe("plan");
|
||||
expect(
|
||||
response.configOptions?.find((option) => option.configId === "mode")?.currentValue,
|
||||
).toBe("plan");
|
||||
await authUpdate(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("sets a config option with a v2 value and reports modes only as config options", async () => {
|
||||
const client = v2Client();
|
||||
const { response, updatesBeforeResponse } = await client.app.connectWith(
|
||||
@@ -5982,6 +6016,25 @@ describe("ACP v2 terminal of a command in the background", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists a task whose call returned before the task started as a _claude/task item", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield toolUseMessage(options, "toolu_mcp", "mcp__ci__watch", { job: "build" });
|
||||
yield toolResultMessage(options, "toolu_mcp", { content: "Watching the build." });
|
||||
// The call's task joins the level and starts after the call returned,
|
||||
// so the call ended and cannot be listed.
|
||||
const task = { task_id: "mcp1", task_type: "mcp_task", description: "Watch the build" };
|
||||
yield level(options, [task]);
|
||||
yield taskEvent(options, "task_started", { ...task, tool_use_id: "toolu_mcp" });
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptUntil("idle end_turn [_claude/task]");
|
||||
|
||||
expect(client.updates("state_update").at(-1)).toMatchObject({
|
||||
background: [{ type: "_claude/task", taskId: "mcp1", description: "Watch the build" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("lists a task without such a call as a _claude/task item", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield level(options, [monitor]);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { vi } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { SessionBackgroundWork } from "../background-work.js";
|
||||
import { SessionTitles } from "../session-titles.js";
|
||||
|
||||
/** Stand-in agent for a `SessionTitles` whose test doesn't care about titles:
|
||||
@@ -91,6 +92,7 @@ export function mockSessionState(
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
|
||||
@@ -253,15 +253,14 @@ describe("resolveInitialPermissionMode", () => {
|
||||
expect(resolveInitialPermissionMode({ defaultMode: "plan" }, logger(), true)).toBe("plan");
|
||||
});
|
||||
|
||||
it("continues in the mode of the resumed transcript instead of defaultMode", () => {
|
||||
expect(
|
||||
resolveInitialPermissionMode(
|
||||
{ resumed: "bypassPermissions", defaultMode: "plan" },
|
||||
logger(),
|
||||
true,
|
||||
),
|
||||
).toBe("bypassPermissions");
|
||||
});
|
||||
it.each(["default", "acceptEdits", "plan", "auto", "bypassPermissions"])(
|
||||
"continues in the supported restored mode %s instead of defaultMode",
|
||||
(resumed) => {
|
||||
expect(
|
||||
resolveInitialPermissionMode({ resumed, defaultMode: "dontAsk" }, logger(), true),
|
||||
).toBe(resumed);
|
||||
},
|
||||
);
|
||||
|
||||
it("lets an explicit mode win over the resumed transcript", () => {
|
||||
expect(
|
||||
@@ -285,9 +284,25 @@ describe("resolveInitialPermissionMode", () => {
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('"bypassPermissions"'));
|
||||
});
|
||||
|
||||
it("falls back to defaultMode for an unknown resumed mode", () => {
|
||||
it.each(["warp", "dontAsk"])("falls back to defaultMode for resumed mode %s", (resumed) => {
|
||||
expect(
|
||||
resolveInitialPermissionMode({ resumed: "warp", defaultMode: "acceptEdits" }, logger(), true),
|
||||
resolveInitialPermissionMode({ resumed, defaultMode: "acceptEdits" }, logger(), true),
|
||||
).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ defaultMode: "plan", allowBypass: true, expected: "plan" },
|
||||
{ defaultMode: "bypassPermissions", allowBypass: true, expected: "bypassPermissions" },
|
||||
{ defaultMode: "bypassPermissions", allowBypass: false, expected: "default" },
|
||||
{ defaultMode: "dontAsk", allowBypass: true, expected: "default" },
|
||||
{ defaultMode: "warp", allowBypass: true, expected: "default" },
|
||||
{ defaultMode: undefined, allowBypass: true, expected: "default" },
|
||||
])(
|
||||
"uses available settings fallback $defaultMode for an unavailable restored mode",
|
||||
({ defaultMode, allowBypass, expected }) => {
|
||||
expect(
|
||||
resolveInitialPermissionMode({ resumed: "dontAsk", defaultMode }, logger(), allowBypass),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user