Compare commits

...

3 Commits

Author SHA1 Message Date
acp-release-bot[bot]
a1d83c6031 chore(main): release 0.103.0 (#1559)
🤖 I have created a release *beep* *boop*
---


##
[0.103.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.102.0...v0.103.0)
(2026-11-10)


### ⚠ BREAKING CHANGES

* deep imports of `dist/` files fail with
`ERR_PACKAGE_PATH_NOT_EXPORTED`, and `lib.ts` exports only `serveAcp`,
`AUTH_STATUS_UPDATE_METHOD` and their types.

### Features

* serve ACP v2 with a direct v2 agent and remove the v1 bridge
([#1560](https://github.com/agentclientprotocol/claude-agent-acp/issues/1560))
([cb285cd](cb285cd285))


### Bug Fixes

* give subagent checklists their own plan ID
([#1558](https://github.com/agentclientprotocol/claude-agent-acp/issues/1558))
([2f7e386](2f7e386f36))


### Code Refactoring

* export only lib.ts, with serveAcp and the authStatus wire names
([#1566](https://github.com/agentclientprotocol/claude-agent-acp/issues/1566))
([566e265](566e2652a9))

---
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-10 09:35:53 +01:00
Ben Brandt
966aee0f04 refactor: move the test-only tool render and metadata wrappers into src/tests/ (#1569)
`toolInfoFromToolUse` and `toolUpdateFromToolResult` in `src/tools.ts`
and `isLocalCommandMetadata` in `src/message-filters.ts` were public
`lib.ts` exports. #1566 removed them from `lib.ts`, so now only tests
call them. They're thin wrappers: the first two build an
`AcpToolCallRenderer` for one tool use or result, and the third checks
that `stripLocalCommandMetadata` drops everything.

They move unchanged to `src/tests/tool-render.ts`. `tools.test.ts`,
`diff-patch.test.ts` and `v1/acp-agent.test.ts` import them from there.
Production code renders through `AcpToolCallRenderer` directly, as
before. Code changes only: no test changes and no behavior change.

A scan of `src/` for exports finds no export that nothing uses. The only
remaining test-only exports are the deliberate test hooks
`clearDiffMemo` (`diff.ts`) and `sleep` (`utils.ts`).

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped
2026-11-10 08:31:08 +00:00
Ben Brandt
66adc1a4c9 refactor: share the subagent replay's transcript scan between v1 and v2 (#1568)
`src/subagent-replay.ts` (v1, `RfdSubagentReplay`) and
`src/v2/subagent-replay.ts` (`SubagentReplay`) each kept a copy of the
transcript scan. The copies differed only in formatting and variable
names. The scan does four things:
- It registers each Agent or Task launch that has metadata as a child,
and records a launch without metadata as omitted.
- It records each SendMessage and SubagentHandback call.
- It marks a failed hand-back.
- It sets a foreground launch's end from its tool result.

That scan, the `ReplayChild` and `SendMessageCall` types, the child's
end state, and the `contentBlocks`, `inputOf`, `textOf` and
`isDirectory` helpers move to `ReplayTranscript` in
`subagent-replay.ts`. Each replay holds one and reads `children`,
`sendMessages`, `handbacks` and `omitted` from it.

What each replay sends stays where it was. v1 publishes asynchronously
through `Publish`, and v2 sends synchronously through `SessionOutput`,
with the v2-only rules: each child's end also goes on the child's own
stream, and live children get their current state after the response.
Sharing those parts would tie the two protocols together, so I left them
separate. The scan only parses transcripts, so sharing it adds no
dependency between v1 and v2.

v2's `ReplayChild` is the shared type plus `output?: SessionOutput`. Its
`launchEnd` was a narrower `"completed" | "failed" | "cancelled"`, which
`endState` passed through `nativeSubagentState`. That call returns those
three values unchanged, so `replayEndState` gives the same result in
both versions.

No behavior change: 128 lines added and 242 removed, with no test
changes.

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped. The v1 and v2 replay
scenario goldens are unchanged.
- `RUN_INTEGRATION_TESTS=1 npx vitest run`: passes. One run hit the
known load flake "absorbs the interrupt's lagged trailer after
cancelling a held turn", which passes on its own.
2026-11-10 08:19:11 +00:00
12 changed files with 227 additions and 316 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.102.0"
".": "0.103.0"
}

View File

@@ -1,5 +1,26 @@
# Changelog
## [0.103.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.102.0...v0.103.0) (2026-11-10)
### ⚠ BREAKING CHANGES
* deep imports of `dist/` files fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`, and `lib.ts` exports only `serveAcp`, `AUTH_STATUS_UPDATE_METHOD` and their types.
### Features
* serve ACP v2 with a direct v2 agent and remove the v1 bridge ([#1560](https://github.com/agentclientprotocol/claude-agent-acp/issues/1560)) ([cb285cd](https://github.com/agentclientprotocol/claude-agent-acp/commit/cb285cd2857924c07038c129cc836da007e2737f))
### Bug Fixes
* give subagent checklists their own plan ID ([#1558](https://github.com/agentclientprotocol/claude-agent-acp/issues/1558)) ([2f7e386](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f7e386f3600658d905906f3b37301120b07605f))
### Code Refactoring
* export only lib.ts, with serveAcp and the authStatus wire names ([#1566](https://github.com/agentclientprotocol/claude-agent-acp/issues/1566)) ([566e265](https://github.com/agentclientprotocol/claude-agent-acp/commit/566e2652a9da5a31dab57a23ea1b06e9fff2ead8))
## [0.102.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.1...v0.102.0) (2026-11-09)

4
package-lock.json generated
View File

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

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.102.0",
"version": "0.103.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

@@ -139,10 +139,6 @@ export function stripLocalCommandMetadata(content: unknown): unknown | null {
return kept;
}
export function isLocalCommandMetadata(content: unknown): boolean {
return stripLocalCommandMetadata(content) === null;
}
/**
* True for the synthetic assistant message the CLI injects into the transcript
* when a turn fails authentication (e.g. "Not logged in · Please run /login",

View File

@@ -84,7 +84,7 @@ export type ReplayChild = {
responseModel?: string;
};
type SendMessageCall = {
export type SendMessageCall = {
toolUseId: string;
to: string;
message: string;
@@ -97,34 +97,27 @@ type SendMessageCall = {
outgoingSent: boolean;
};
export class RfdSubagentReplay {
/** The children by the id of the tool use that launched each. */
private readonly children = new Map<string, ReplayChild>();
private readonly byAgentId = new Map<string, ReplayChild>();
private readonly sendMessages: SendMessageCall[] = [];
/** Launches without metadata, which the replay leaves out. */
private readonly omitted = new Set<string>();
/** Children whose transcript could not be read: only their association is replayed. */
private withoutHistory = 0;
/** The SubagentHandback calls, with their report and whether their result failed. */
private readonly handbacks = new Map<
string,
{ message: string; failed: boolean; sent: boolean }
>();
/** A SubagentHandback call: its report, whether its result failed, and whether the replay sent it. */
type ReplayHandback = { message: string; failed: boolean; sent: boolean };
constructor(
private readonly history: SubagentHistory,
private readonly publish: Publish,
private readonly models?: SubagentModels,
/**
* Whether the replay maps a model to the session's model list as it goes.
* A load lacks the list until the CLI started, so it maps only in
* {@link currentStates}, after the load response.
*/
private readonly mapsDuringReplay = true,
/** The working directory of the root session, which a child inherits unless it reports its own. */
private readonly rootCwd?: string,
) {}
/**
* What both replays read from the transcripts before they send anything: the
* children that launches with metadata started, the launches without metadata
* (left out), the SendMessage calls, and the SubagentHandback calls. It sends
* nothing; `RfdSubagentReplay` (v1) and `src/v2/subagent-replay.ts` send the
* replay from it.
*/
export class ReplayTranscript<Child extends ReplayChild = ReplayChild> {
/** The children by the id of the tool use that launched each. */
readonly children = new Map<string, Child>();
readonly byAgentId = new Map<string, Child>();
readonly sendMessages: SendMessageCall[] = [];
/** Launches without metadata, which the replay leaves out. */
readonly omitted = new Set<string>();
/** The SubagentHandback calls, by tool use id. */
readonly handbacks = new Map<string, ReplayHandback>();
constructor(private readonly history: SubagentHistory) {}
/**
* Registers the launches, the launch results, and the SendMessage calls in
@@ -165,9 +158,64 @@ export class RfdSubagentReplay {
}
}
private registerLaunch(
toolUseId: string,
input: Record<string, unknown>,
parentSessionId: string,
): void {
if (this.children.has(toolUseId)) return;
const record = this.history.agents.get(toolUseId);
if (!record) {
this.omitted.add(toolUseId);
return;
}
// A child type adds only optional fields, which a new child leaves unset.
const child = {
launchId: toolUseId,
sessionId: record.agentId,
record,
parentSessionId,
input,
announced: false,
promptSent: false,
} as Child;
this.children.set(toolUseId, child);
this.byAgentId.set(record.agentId, child);
}
}
export class RfdSubagentReplay {
private readonly transcript: ReplayTranscript;
/** Children whose transcript could not be read: only their association is replayed. */
private withoutHistory = 0;
constructor(
private readonly history: SubagentHistory,
private readonly publish: Publish,
private readonly models?: SubagentModels,
/**
* Whether the replay maps a model to the session's model list as it goes.
* A load lacks the list until the CLI started, so it maps only in
* {@link currentStates}, after the load response.
*/
private readonly mapsDuringReplay = true,
/** The working directory of the root session, which a child inherits unless it reports its own. */
private readonly rootCwd?: string,
) {
this.transcript = new ReplayTranscript(history);
}
/**
* Registers the launches, the launch results, and the SendMessage calls in
* `messages`, the transcript of the session `ownerSessionId`.
*/
scan(messages: readonly SessionMessage[], ownerSessionId: string): void {
this.transcript.scan(messages, ownerSessionId);
}
/** The child that the tool use `toolUseId` launched, when the replay shows it. */
childOfLaunch(toolUseId: string): ReplayChild | undefined {
return this.children.get(toolUseId);
return this.transcript.children.get(toolUseId);
}
/**
@@ -211,11 +259,11 @@ export class RfdSubagentReplay {
*/
private effectiveCwd(sessionId: string): string | undefined {
const seen = new Set<string>();
let child = this.byAgentId.get(sessionId);
let child = this.transcript.byAgentId.get(sessionId);
while (child && !seen.has(child.sessionId)) {
if (child.cwd) return child.cwd;
seen.add(child.sessionId);
child = this.byAgentId.get(child.parentSessionId);
child = this.transcript.byAgentId.get(child.parentSessionId);
}
return this.rootCwd;
}
@@ -299,7 +347,9 @@ export class RfdSubagentReplay {
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return false;
}
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
const sendMessage = this.transcript.sendMessages.find(
(call) => call.toolUseId === update.toolCallId,
);
if (sendMessage) {
if (sendMessage.recipientSessionId === undefined) {
sendMessage.shownAsToolCall = true;
@@ -317,7 +367,7 @@ export class RfdSubagentReplay {
}
return true;
}
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
const handback = owner ? this.transcript.handbacks.get(update.toolCallId) : undefined;
if (owner && handback && !handback.failed) {
if (!handback.sent) {
handback.sent = true;
@@ -333,7 +383,7 @@ export class RfdSubagentReplay {
}
return true;
}
const child = this.children.get(update.toolCallId);
const child = this.transcript.children.get(update.toolCallId);
if (!child) return false;
if (
update.status === "completed" &&
@@ -352,29 +402,29 @@ export class RfdSubagentReplay {
* its task id is the agent id.
*/
taskNotification(taskId: string, status: string): boolean {
const child = this.byAgentId.get(taskId);
const child = this.transcript.byAgentId.get(taskId);
if (!child) return false;
child.notified = nativeSubagentState(status) ?? child.notified;
return true;
}
/**
* Sends the end of every announced child (see `endState`), and a notice when
* Sends the end of every announced child (see `replayEndState`), and a notice when
* subagent history is missing.
*/
async finish(supportsNotices: boolean, rootSessionId: string): Promise<void> {
for (const child of [...this.children.values()].reverse()) {
for (const child of [...this.transcript.children.values()].reverse()) {
if (!child.announced) continue;
await this.publish({
sessionId: child.parentSessionId,
update: {
sessionUpdate: "subagent_update",
sessionId: child.sessionId,
state: endState(child),
state: replayEndState(child),
},
});
}
const missing = this.omitted.size + this.withoutHistory;
const missing = this.transcript.omitted.size + this.withoutHistory;
if (missing > 0 && supportsNotices) {
await this.publish({ sessionId: rootSessionId, update: missingHistoryNotice(missing) });
}
@@ -399,12 +449,14 @@ export class RfdSubagentReplay {
live: (sessionId: string) => WorkState["state"] | undefined,
liveConfiguration?: (sessionId: string) => SessionConfigOption[] | undefined,
): AcpSessionNotification[] {
return [...this.children.values()]
return [...this.transcript.children.values()]
.filter((child) => child.announced)
.flatMap((child): AcpSessionNotification[] => {
const liveState = live(child.sessionId);
const state: WorkState =
liveState === undefined || liveState === "idle" ? endState(child) : { state: liveState };
liveState === undefined || liveState === "idle"
? replayEndState(child)
: { state: liveState };
// The session's model list may be known only now, after the load response.
const entry = responseModelEntry(child.responseModel, this.models);
const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined;
@@ -435,30 +487,6 @@ export class RfdSubagentReplay {
});
}
private registerLaunch(
toolUseId: string,
input: Record<string, unknown>,
parentSessionId: string,
): void {
if (this.children.has(toolUseId)) return;
const record = this.history.agents.get(toolUseId);
if (!record) {
this.omitted.add(toolUseId);
return;
}
const child: ReplayChild = {
launchId: toolUseId,
sessionId: record.agentId,
record,
parentSessionId,
input,
announced: false,
promptSent: false,
};
this.children.set(toolUseId, child);
this.byAgentId.set(record.agentId, child);
}
/** The prompt of the launch, with the launch's id, to the parent and the child, as live. */
private async sendPrompt(child: ReplayChild, text: string): Promise<void> {
child.promptSent = true;
@@ -489,7 +517,7 @@ export class RfdSubagentReplay {
text: string,
): Promise<void> {
const call = deliveredCall(
this.sendMessages,
this.transcript.sendMessages,
[child.sessionId, child.record.name, child.input.name],
text,
);
@@ -553,7 +581,7 @@ export function replaySubagentTerminalState(
* `cancelled` for a child the user stopped, else the result of its foreground
* launch, else `idle` with no stop reason.
*/
function endState(child: ReplayChild): WorkState {
export function replayEndState(child: ReplayChild): WorkState {
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
return end ? rfdEndState(end, undefined) : { state: "idle" };
}
@@ -586,7 +614,7 @@ function inputOf(block: Record<string, unknown>): Record<string, unknown> {
}
/** The text of user message content: a string, or its text blocks. */
function textOf(content: unknown): string {
export function textOf(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
@@ -598,7 +626,7 @@ function textOf(content: unknown): string {
}
/** Whether `dir` is an existing directory. */
function isDirectory(dir: string): boolean {
export function isDirectory(dir: string): boolean {
try {
return statSync(dir).isDirectory();
} catch {

View File

@@ -15,8 +15,8 @@ import {
} from "../diff.js";
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
import { buildClaudePermissionPresentation } from "../permissions/presentation.js";
import { toolInfoFromToolUse } from "../tools.js";
import { WriteReporter } from "../tool-calls/reporters/file-edit.js";
import { toolInfoFromToolUse } from "./tool-render.js";
const tempDirectories: string[] = [];

63
src/tests/tool-render.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Test shorthands for the shared tool call renderer: one tool use or tool
* result, rendered for a client with the given capabilities. Production code
* renders through {@link AcpToolCallRenderer} directly.
*/
import type {
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk/unstable";
import { stripLocalCommandMetadata } from "../message-filters.js";
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
import type { ToolResultBlock } from "../tool-calls/content.js";
import { AcpToolCallRenderer, type RenderedResult } from "../tool-calls/renderer.js";
/**
* The title, kind, content, and locations of a tool use, for a client with the
* given terminal and patch capabilities. The {@link AcpToolCallRenderer} builds
* them from the facts of the tool reporter.
*/
export function toolInfoFromToolUse(
toolUse: any,
supportsTerminalOutput: boolean = false,
cwd?: string,
supportsDiffPatch: boolean = false,
): {
title: string;
kind: ToolKind;
content: ToolCallContent[];
locations?: ToolCallLocation[];
} {
const renderer = new AcpToolCallRenderer(
new ClientCapabilities(supportsTerminalOutput, false, supportsDiffPatch),
);
return renderer.toolInfo({ id: toolUse?.id, name: toolUse?.name, input: toolUse?.input }, cwd);
}
/**
* The result fields of a tool result, for a client with the given terminal
* capabilities. The {@link AcpToolCallRenderer} builds them from the facts of
* the tool reporter.
*/
export function toolUpdateFromToolResult(
toolResult: ToolResultBlock,
toolUse: any | undefined,
supportsTerminalOutput: boolean = false,
toolUseResult?: unknown,
preferTerminalOutputDelta: boolean = false,
): RenderedResult {
const renderer = new AcpToolCallRenderer(
new ClientCapabilities(supportsTerminalOutput, preferTerminalOutputDelta),
);
return renderer.resultFields(
{ id: toolUse?.id, name: toolUse?.name ?? "", input: toolUse?.input },
toolResult as Parameters<AcpToolCallRenderer["resultFields"]>[1],
toolUseResult,
);
}
/** Whether `content` is only Claude Code's local-command metadata, which the agent drops. */
export function isLocalCommandMetadata(content: unknown): boolean {
return stripLocalCommandMetadata(content) === null;
}

View File

@@ -26,12 +26,10 @@ import { AcpClient } from "../acp-client.js";
import { toAcpNotifications, ToolUseCache } from "../sdk-to-acp.js";
import { Logger } from "../logger.js";
import {
toolUpdateFromToolResult,
createPostToolUseHook,
createTaskHook,
clearHookCallbacks,
registerHookCallback,
toolInfoFromToolUse,
planEntries,
applyTaskCreate,
applyTaskList,
@@ -43,6 +41,7 @@ import {
} from "../tools.js";
import { ClientCapabilities as ToolCallCapabilities } from "../tool-calls/client-capabilities.js";
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
import { toolInfoFromToolUse, toolUpdateFromToolResult } from "./tool-render.js";
/** An AIR client: it gets the ACP tool call contract (`docs/air-extensions.md`). */
const AIR_CLIENT: ClientCapabilities = {

View File

@@ -25,13 +25,7 @@ import {
import type { AcpSessionNotification } from "../../acp-subagents.js";
import { NativeSubagentRuntime } from "../../native-subagents.js";
import { nodeToWebWritable, nodeToWebReadable } from "../../utils.js";
import {
createPostToolUseHook,
markdownEscape,
toolInfoFromToolUse,
toDisplayPath,
toolUpdateFromToolResult,
} from "../../tools.js";
import { createPostToolUseHook, markdownEscape, toDisplayPath } from "../../tools.js";
import { toolUpdateFromDiffToolResponse } from "../../diff.js";
import { ToolCallFieldTracker } from "../../tool-calls/field-tracker.js";
import {
@@ -50,7 +44,6 @@ import {
} from "../../sdk-to-acp.js";
import { promptToClaude } from "../../prompt-to-claude.js";
import {
isLocalCommandMetadata,
isReplayHiddenMetaMessage,
isSyntheticLoginMessage,
stripLocalCommandMetadata,
@@ -124,6 +117,11 @@ import type {
BetaCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { validateRecorded } from "../acp-scenarios/schema.js";
import {
isLocalCommandMetadata,
toolInfoFromToolUse,
toolUpdateFromToolResult,
} from "../tool-render.js";
/** The capabilities of an AIR client. Only AIR gets the AIR extensions of
* `docs/air-extensions.md`. */

View File

@@ -1,9 +1,4 @@
import type {
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk/unstable";
import type { PlanEntry } from "@agentclientprotocol/sdk/unstable";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import type {
TaskCreateInput,
@@ -12,56 +7,9 @@ import type {
TaskUpdateInput,
TaskUpdateOutput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import { ClientCapabilities } from "./tool-calls/client-capabilities.js";
import type { ToolResultBlock } from "./tool-calls/content.js";
import { AcpToolCallRenderer, type RenderedResult } from "./tool-calls/renderer.js";
export { markdownEscape, toDisplayPath } from "./tool-calls/content.js";
/**
* The title, kind, content, and locations of a tool use, for a client with the
* given terminal and patch capabilities. The {@link AcpToolCallRenderer} builds
* them from the facts of the tool reporter.
*/
export function toolInfoFromToolUse(
toolUse: any,
supportsTerminalOutput: boolean = false,
cwd?: string,
supportsDiffPatch: boolean = false,
): {
title: string;
kind: ToolKind;
content: ToolCallContent[];
locations?: ToolCallLocation[];
} {
const renderer = new AcpToolCallRenderer(
new ClientCapabilities(supportsTerminalOutput, false, supportsDiffPatch),
);
return renderer.toolInfo({ id: toolUse?.id, name: toolUse?.name, input: toolUse?.input }, cwd);
}
/**
* The result fields of a tool result, for a client with the given terminal
* capabilities. The {@link AcpToolCallRenderer} builds them from the facts of
* the tool reporter.
*/
export function toolUpdateFromToolResult(
toolResult: ToolResultBlock,
toolUse: any | undefined,
supportsTerminalOutput: boolean = false,
toolUseResult?: unknown,
preferTerminalOutputDelta: boolean = false,
): RenderedResult {
const renderer = new AcpToolCallRenderer(
new ClientCapabilities(supportsTerminalOutput, preferTerminalOutputDelta),
);
return renderer.resultFields(
{ id: toolUse?.id, name: toolUse?.name ?? "", input: toolUse?.input },
toolResult as Parameters<AcpToolCallRenderer["resultFields"]>[1],
toolUseResult,
);
}
export type ClaudePlanEntry = {
content: string;
status: "pending" | "in_progress" | "completed";

View File

@@ -35,40 +35,36 @@
* parent, and a notice at the end says that subagent history is missing, as
* it does for a child whose transcript cannot be read.
*/
import * as fs from "node:fs";
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "../logger.js";
import type { AcpSessionNotification, SubagentState } from "../acp-subagents.js";
import type { AcpSessionNotification } from "../acp-subagents.js";
import {
childModelOption,
HANDBACK_TOOL,
handbackMessage,
isNativeSubagentControlTool,
launchReply,
linkedSessionMessages,
nativeSubagentState,
responseModelEntry,
rfdEndState,
rfdLabels,
type ModelEntry,
type SubagentModels,
type WorkState,
} from "../native-subagents.js";
import {
readSubagentMessages,
subagentHistory,
subagentLaunchIds,
type SubagentHistory,
type SubagentRecord,
} from "../subagent-history.js";
import {
deliveredCall,
firstResponseModel,
isDelivery,
isDirectory,
missingHistoryNotice,
replaySubagentTerminalState,
ReplayTranscript,
replayEndState,
textOf,
type ReplayChild as SharedReplayChild,
} from "../subagent-replay.js";
import { taskNotificationsOf } from "../task-notifications.js";
import type { V2UpdateTranslator } from "./update-translator.js";
@@ -96,53 +92,11 @@ export interface LiveChildren {
const NO_LIVE_CHILDREN: LiveChildren = { tracks: () => false, configuration: () => undefined };
type ReplayChild = {
launchId: string;
sessionId: string;
record: SubagentRecord;
parentSessionId: string;
input: Record<string, unknown>;
/** How the launch's tool result ended a foreground child's run. */
launchEnd?: "completed" | "failed" | "cancelled";
/** The state of the child's last persisted task notification. */
notified?: SubagentState;
announced: boolean;
promptSent: boolean;
/** Whether it handed a report to its parent with a SubagentHandback call. */
handedBack?: boolean;
/** The `model` option entry that the replay sent. */
model?: ModelEntry;
/** The model id of its latest replayed response. */
responseModel?: string;
/** The `cwd` it was announced with, when not its parent's. */
cwd?: string;
output?: SessionOutput;
};
type SendMessageCall = {
toolUseId: string;
to: string;
message: string;
senderSessionId: string;
/** The child the replay delivered the message to. */
recipientSessionId?: string;
/** The replay reached the call before the delivery, so it stays a tool call. */
shownAsToolCall: boolean;
/** The sender's view of the message went out, at the call. */
outgoingSent: boolean;
};
/** A replayed child, with the output of its session once announced. */
type ReplayChild = SharedReplayChild & { output?: SessionOutput };
export class SubagentReplay {
/** The children, by the id of the call that launched each. */
private readonly children = new Map<string, ReplayChild>();
private readonly sendMessages: SendMessageCall[] = [];
/** The SubagentHandback calls, with their report and whether their result failed. */
private readonly handbacks = new Map<
string,
{ message: string; failed: boolean; sent: boolean }
>();
/** Launches without metadata, which the replay leaves out. */
private readonly omitted = new Set<string>();
private readonly transcript: ReplayTranscript<ReplayChild>;
/** Children whose transcript could not be read: only their association is replayed. */
private withoutHistory = 0;
private rootOutput: SessionOutput | undefined;
@@ -153,7 +107,9 @@ export class SubagentReplay {
/** The transcript of each child, by agent id. */
private readonly transcripts: Map<string, SessionMessage[]>,
private readonly live: LiveChildren,
) {}
) {
this.transcript = new ReplayTranscript(history);
}
/**
* Reads the subagent history that `messages`, the session's transcript,
@@ -183,7 +139,7 @@ export class SubagentReplay {
/** The hooks of the replay of the root session's history (`messages`). */
hooks(output: SessionOutput, messages: SessionMessage[]): ReplayHooks {
this.rootOutput = output;
this.scan(messages, output.sessionId);
this.transcript.scan(messages, output.sessionId);
return this.ownerHooks(output, undefined);
}
@@ -192,24 +148,24 @@ export class SubagentReplay {
* stream, and the notice when subagent history is missing.
*/
finish(): void {
const announced = [...this.children.values()].filter((child) => child.announced);
const announced = [...this.transcript.children.values()].filter((child) => child.announced);
for (const child of [...announced].reverse()) {
this.host.send({
sessionId: child.parentSessionId,
update: {
sessionUpdate: "subagent_update",
sessionId: child.sessionId,
state: endState(child) as v2.StateUpdate,
state: replayEndState(child) as v2.StateUpdate,
},
});
}
for (const child of announced) {
this.host.send({
sessionId: child.sessionId,
update: { sessionUpdate: "state_update", ...endState(child) } as v2.SessionUpdate,
update: { sessionUpdate: "state_update", ...replayEndState(child) } as v2.SessionUpdate,
});
}
const missing = this.omitted.size + this.withoutHistory;
const missing = this.transcript.omitted.size + this.withoutHistory;
if (missing > 0) {
this.rootOutput?.emitV1([
{ sessionId: this.host.sessionId, update: missingHistoryNotice(missing) },
@@ -227,7 +183,7 @@ export class SubagentReplay {
* the session's model list.
*/
afterResponse(): void {
for (const child of this.children.values()) {
for (const child of this.transcript.children.values()) {
if (!child.announced) continue;
if (!this.live.tracks(child.sessionId)) this.sendEndState(child);
this.sendConfiguration(child);
@@ -249,7 +205,7 @@ export class SubagentReplay {
}
private sendEndState(child: ReplayChild): void {
const state = endState(child);
const state = replayEndState(child);
this.host.send({
sessionId: child.parentSessionId,
update: {
@@ -280,63 +236,6 @@ export class SubagentReplay {
* Registers the launches, the launch results, the SendMessage calls and the
* SubagentHandback calls in `messages`, of the session `ownerSessionId`.
*/
private scan(messages: readonly SessionMessage[], ownerSessionId: string): void {
for (const message of messages) {
for (const block of contentBlocks(message)) {
if (block.type === "tool_use" && typeof block.id === "string") {
if (isNativeSubagentControlTool(block.name)) {
this.registerLaunch(block.id, inputOf(block), ownerSessionId);
} else if (block.name === HANDBACK_TOOL) {
const report = handbackMessage(block.input);
if (report)
this.handbacks.set(block.id, { message: report, failed: false, sent: false });
} else if (block.name === "SendMessage") {
const input = inputOf(block);
if (typeof input.to === "string" && typeof input.message === "string") {
this.sendMessages.push({
toolUseId: block.id,
to: input.to,
message: input.message,
senderSessionId: ownerSessionId,
shownAsToolCall: false,
outgoingSent: false,
});
}
}
} else if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
const handback = this.handbacks.get(block.tool_use_id);
if (handback && block.is_error === true) handback.failed = true;
const child = this.children.get(block.tool_use_id);
// A background launch answers at once; its result is not the end.
if (child && !child.record.background)
child.launchEnd = replaySubagentTerminalState(block);
}
}
}
}
private registerLaunch(
launchId: string,
input: Record<string, unknown>,
parentSessionId: string,
): void {
if (this.children.has(launchId)) return;
const record = this.history.agents.get(launchId);
if (!record) {
this.omitted.add(launchId);
return;
}
this.children.set(launchId, {
launchId,
sessionId: record.agentId,
record,
parentSessionId,
input,
announced: false,
promptSent: false,
});
}
/**
* A replayed update of a tool call of `output`, the session of `owner`
* when it is a child's. The client is not shown the launch of a replayed
@@ -354,7 +253,9 @@ export class SubagentReplay {
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return [notification];
}
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
const sendMessage = this.transcript.sendMessages.find(
(call) => call.toolUseId === update.toolCallId,
);
if (sendMessage) {
if (sendMessage.recipientSessionId === undefined) {
sendMessage.shownAsToolCall = true;
@@ -370,7 +271,7 @@ export class SubagentReplay {
);
return [outgoing!];
}
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
const handback = owner ? this.transcript.handbacks.get(update.toolCallId) : undefined;
if (owner && handback && !handback.failed) {
if (handback.sent) return [];
handback.sent = true;
@@ -386,7 +287,7 @@ export class SubagentReplay {
this.outputOf(owner.parentSessionId)?.emitV1([incoming!]);
return [];
}
const child = this.children.get(update.toolCallId);
const child = this.transcript.children.get(update.toolCallId);
if (!child) return [notification];
if (
update.status === "completed" &&
@@ -412,7 +313,9 @@ export class SubagentReplay {
return;
}
for (const notification of taskNotificationsOf(inner?.content)) {
const child = [...this.children.values()].find((c) => c.sessionId === notification.task_id);
const child = [...this.transcript.children.values()].find(
(c) => c.sessionId === notification.task_id,
);
if (child) child.notified = nativeSubagentState(notification.status) ?? child.notified;
}
}
@@ -421,13 +324,13 @@ export class SubagentReplay {
private replayed(message: SessionMessage, parent: SessionOutput): void {
if (message.type !== "assistant") return;
for (const launchId of launchIdsOf(message)) {
const child = this.children.get(launchId);
const child = this.transcript.children.get(launchId);
if (!child || child.announced) continue;
const messages = this.transcripts.get(child.sessionId) ?? [];
if (messages.length === 0) this.withoutHistory++;
this.announce(child);
this.sendModel(child, firstResponseModel(messages));
this.scan(messages, child.sessionId);
this.transcript.scan(messages, child.sessionId);
child.output!.replay(messages, this.ownerHooks(child.output!, child));
if (!child.promptSent && typeof child.input.prompt === "string") {
this.sendPrompt(child, parent, child.input.prompt);
@@ -517,7 +420,7 @@ export class SubagentReplay {
*/
private sendDelivery(child: ReplayChild, message: SessionMessage, text: string): void {
const call = deliveredCall(
this.sendMessages,
this.transcript.sendMessages,
[child.sessionId, child.record.name, child.input.name],
text,
);
@@ -553,63 +456,18 @@ export class SubagentReplay {
private outputOf(sessionId: string): SessionOutput | undefined {
if (sessionId === this.host.sessionId) return this.rootOutput;
return [...this.children.values()].find((child) => child.sessionId === sessionId)?.output;
return [...this.transcript.children.values()].find((child) => child.sessionId === sessionId)
?.output;
}
private cwdOf(sessionId: string): string {
const child = [...this.children.values()].find((c) => c.sessionId === sessionId);
const child = [...this.transcript.children.values()].find((c) => c.sessionId === sessionId);
if (!child) return this.host.cwd;
return child.cwd ?? this.cwdOf(child.parentSessionId);
}
}
/**
* How a replayed child's work ended: the state of its last task
* notification, else a stop by the user, else its foreground launch's result.
*/
function endState(child: ReplayChild): WorkState {
const end =
child.notified ??
nativeSubagentState(child.record.stoppedByUser ? "cancelled" : child.launchEnd);
return end ? rfdEndState(end, undefined) : { state: "idle" };
}
function launchIdsOf(message: SessionMessage): string[] {
if (message.type !== "assistant") return [];
return subagentLaunchIds((message.message as { content?: unknown } | undefined)?.content);
}
function contentBlocks(message: SessionMessage): Record<string, unknown>[] {
const content = (message as { message?: { content?: unknown } }).message?.content;
return Array.isArray(content)
? content.filter(
(block): block is Record<string, unknown> => typeof block === "object" && block !== null,
)
: [];
}
function inputOf(block: Record<string, unknown>): Record<string, unknown> {
return typeof block.input === "object" && block.input !== null
? (block.input as Record<string, unknown>)
: {};
}
/** The text of user message content: a string, or its text blocks. */
function textOf(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((block) =>
typeof block === "object" && block !== null && block.type === "text" ? [block.text] : [],
)
.filter((text): text is string => typeof text === "string")
.join("\n");
}
function isDirectory(dir: string): boolean {
try {
return fs.statSync(dir).isDirectory();
} catch {
return false;
}
}