mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d608cb399 | ||
|
|
a84b81080a | ||
|
|
b965dd2191 | ||
|
|
0988438428 | ||
|
|
15b4eb46f3 | ||
|
|
fc95717b38 | ||
|
|
f9123f3e18 | ||
|
|
88da9d800f | ||
|
|
08e0e7f14e | ||
|
|
11a3193574 | ||
|
|
c98141201e | ||
|
|
76009f1ede | ||
|
|
fd60fc2b31 | ||
|
|
4302a4b0b6 |
17
.github/workflows/publish.yml
vendored
17
.github/workflows/publish.yml
vendored
@@ -3,9 +3,21 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Tag or commit to publish
|
||||
required: true
|
||||
type: string
|
||||
publish_npm:
|
||||
description: Publish the package before updating the registry
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
@@ -38,13 +50,15 @@ jobs:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }}
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
@@ -56,6 +70,7 @@ jobs:
|
||||
|
||||
trigger-registry-update:
|
||||
needs: publish-npm
|
||||
if: ${{ always() && (needs.publish-npm.result == 'success' || (github.event_name == 'workflow_dispatch' && !inputs.publish_npm)) }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions: {}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.64.1"
|
||||
".": "0.65.0"
|
||||
}
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [0.65.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.64.2...v0.65.0) (2026-08-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** Bump nanoid from 3.3.16 to 3.3.17 ([#951](https://github.com/agentclientprotocol/claude-agent-acp/issues/951)) ([b965dd2](https://github.com/agentclientprotocol/claude-agent-acp/commit/b965dd21917e822b56f5012c3572902f26c065c9))
|
||||
* **deps-dev:** Bump tinyexec from 1.2.4 to 1.3.0 in the minor group ([#959](https://github.com/agentclientprotocol/claude-agent-acp/issues/959)) ([15b4eb4](https://github.com/agentclientprotocol/claude-agent-acp/commit/15b4eb46f329566837eae58f2ee4b05e3e81bf64))
|
||||
* **deps:** Bump @hono/node-server from 1.19.17 to 2.1.0 ([#956](https://github.com/agentclientprotocol/claude-agent-acp/issues/956)) ([f9123f3](https://github.com/agentclientprotocol/claude-agent-acp/commit/f9123f3e18560b580398aabf49e2190f69746976))
|
||||
* **deps:** Bump fast-uri from 3.1.4 to 3.1.5 ([#952](https://github.com/agentclientprotocol/claude-agent-acp/issues/952)) ([0988438](https://github.com/agentclientprotocol/claude-agent-acp/commit/098843842895dcb450746bd064dbb1509e3049d1))
|
||||
* **steering:** settle a steered turn at idle, not at the interrupt ([#958](https://github.com/agentclientprotocol/claude-agent-acp/issues/958)) ([a84b810](https://github.com/agentclientprotocol/claude-agent-acp/commit/a84b81080a4127edf40bc448fc8bf2b15503304d))
|
||||
|
||||
## [0.64.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.64.1...v0.64.2) (2026-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* restore the single-tool representation for ExitPlanMode ([#942](https://github.com/agentclientprotocol/claude-agent-acp/issues/942)) ([4302a4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/4302a4b0b6df821b164cbe4857f26cf5b44b532c))
|
||||
|
||||
## [0.64.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.64.0...v0.64.1) (2026-08-02)
|
||||
|
||||
|
||||
|
||||
706
examples/simple-client.ts
Normal file
706
examples/simple-client.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
/**
|
||||
* Example: a dead-simple interactive ACP client for this agent.
|
||||
*
|
||||
* This is the "no editor required" way to exercise the agent: it launches
|
||||
* `dist/index.js` as a subprocess, speaks ACP over its stdio using the SDK, and
|
||||
* prints every JSON-RPC message that crosses the wire in both directions. Each
|
||||
* line you type becomes a `session/prompt`; the agent's answer streams back as
|
||||
* `session/update` notifications.
|
||||
*
|
||||
* Every message is preceded by a header line naming its direction:
|
||||
*
|
||||
* [agent] ◂ inbound — everything the agent sends us
|
||||
* [user] ▸ outbound — everything we send the agent, including our own
|
||||
* permission answers and `session/cancel`
|
||||
*
|
||||
* The label and the arrow carry the direction, so it survives NO_COLOR and
|
||||
* redirection to a file (`2> wire.log`). The JSON itself is printed in full,
|
||||
* flush left and undecorated — select it and you have valid JSON on the
|
||||
* clipboard. This exists to show you the protocol, not to summarize it.
|
||||
*
|
||||
* There are four ways for a client to get a message to this agent, and all four
|
||||
* are reachable from the prompt so you can watch what each one looks like on
|
||||
* the wire. Plain text is the first; the rest are `!`-prefixed commands, chosen
|
||||
* so they cannot be confused with the agent's own slash commands (`/compact`
|
||||
* and friends are ordinary prompt text here). `!!` escapes a literal `!`.
|
||||
*
|
||||
* <text> `session/prompt`. Typed mid-turn it is *buffered* by this
|
||||
* client and sent once the running turn responds.
|
||||
* !steer <text> `_session/steering` — injected into the turn that is
|
||||
* already running, so it can change course between tool
|
||||
* calls. Answers "injected", or "startedNewTurn" if the turn
|
||||
* beat us to the finish. An injected reply streams as
|
||||
* `session/update` notifications inside that turn, whose
|
||||
* `stopReason` waits for it. "startedNewTurn" output belongs
|
||||
* to a detached turn no `session/prompt` of ours tracks.
|
||||
* See ./steering.ts.
|
||||
* !queue <text> `session/prompt` sent mid-turn anyway. The agent advertises
|
||||
* `promptQueueing`, so it takes the backlog instead of us —
|
||||
* same ordering, different owner.
|
||||
* !cancel `session/cancel` for the running turn, as Ctrl-C does.
|
||||
*
|
||||
* Run (build the agent first so `dist/index.js` exists):
|
||||
*
|
||||
* npm run build
|
||||
* node examples/simple-client.ts
|
||||
*
|
||||
* or, equivalently, `npm run example:simple-client`.
|
||||
*
|
||||
* To debug the agent itself, pass a port. The agent is then rebuilt with source
|
||||
* maps and started under `--inspect-brk=<port>`, i.e. paused before its first
|
||||
* line, so you can attach (IntelliJ: Run → Attach to Node.js/Chrome; VS Code:
|
||||
* "Attach to Node Process"), set breakpoints in `src/*.ts`, and resume:
|
||||
*
|
||||
* node examples/simple-client.ts --debug-port=9229
|
||||
*
|
||||
* The client will sit waiting for `initialize` until you attach and resume —
|
||||
* that is expected, ACP has no request timeout.
|
||||
*
|
||||
* (Node < 22.18 needs `node --experimental-strip-types examples/simple-client.ts`.)
|
||||
*
|
||||
* Keys: Enter sends (or buffers), Ctrl-C cancels the running turn (again to
|
||||
* quit), Ctrl-D drains anything buffered and exits. `!help` lists the commands.
|
||||
*
|
||||
* Flags: --debug-port=<port>, --no-build, --help.
|
||||
* Env: AGENT_ENTRY (default dist/index.js), CWD (default process.cwd()).
|
||||
*
|
||||
* Requires the agent to be authenticated, since it talks to the real model.
|
||||
*
|
||||
* Note: this client does not advertise the `elicitation` capability, so the
|
||||
* agent disables its AskUserQuestion tool instead of asking us to render a
|
||||
* form. That is the one intentional behaviour difference from a full client.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
client as acpClient,
|
||||
methods,
|
||||
ndJsonStream,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
// Type-only: Node's type stripping does not elide value imports, and these two
|
||||
// exist only in the .d.ts — importing them normally is a runtime SyntaxError.
|
||||
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk";
|
||||
|
||||
// ---------------------------------------------------------------- steering
|
||||
|
||||
/**
|
||||
* The steering extension (../steering_protocol.md, and ./steering.ts for a
|
||||
* scripted walkthrough). It is not part of ACP proper, so it has no entry in
|
||||
* `methods` and is sent as a raw method name with hand-written types.
|
||||
*/
|
||||
const STEERING_METHOD = "_session/steering";
|
||||
|
||||
type SteeringRequest = {
|
||||
sessionId: string;
|
||||
prompt: Array<{ type: "text"; text: string }>;
|
||||
};
|
||||
|
||||
/** Both outcomes are successes; they say where the message landed, not whether
|
||||
* it worked. "startedNewTurn" means the turn finished before we got there. */
|
||||
type SteeringResponse = {
|
||||
outcome: "injected" | "startedNewTurn";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- arguments
|
||||
|
||||
const ARGV = process.argv.slice(2);
|
||||
|
||||
/** Reads `--name=value` or `--name value`; returns "" for a valueless flag. */
|
||||
function flag(name: string): string | undefined {
|
||||
const index = ARGV.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
|
||||
if (index === -1) return undefined;
|
||||
const [, ...rest] = ARGV[index].split("=");
|
||||
return rest.length > 0 ? rest.join("=") : (ARGV[index + 1] ?? "");
|
||||
}
|
||||
|
||||
if (ARGV.includes("--help") || ARGV.includes("-h")) {
|
||||
process.stdout.write(
|
||||
[
|
||||
"Usage: node examples/simple-client.ts [--debug-port=<port>] [--no-build]",
|
||||
"",
|
||||
" --debug-port=<port> rebuild with source maps and start the agent under",
|
||||
" --inspect-brk=<port>, paused until a debugger attaches",
|
||||
" --no-build skip the tsc rebuild and use dist/ as-is",
|
||||
"",
|
||||
"Env: AGENT_ENTRY (default dist/index.js), CWD (default process.cwd()).",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// The built agent entry. Rebuilt below unless --no-build is passed.
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const AGENT_ENTRY = process.env.AGENT_ENTRY ?? path.join(repoRoot, "dist", "index.js");
|
||||
const CWD = process.env.CWD ?? process.cwd();
|
||||
const BUILD = !ARGV.includes("--no-build");
|
||||
|
||||
const debugPortArg = flag("debug-port");
|
||||
const DEBUG_PORT = debugPortArg === undefined ? undefined : Number(debugPortArg);
|
||||
if (
|
||||
DEBUG_PORT !== undefined &&
|
||||
(!Number.isInteger(DEBUG_PORT) || DEBUG_PORT < 1 || DEBUG_PORT > 65535)
|
||||
) {
|
||||
process.stderr.write(`--debug-port expects a port between 1 and 65535, got "${debugPortArg}"\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Shown by `!help`, and once at startup. */
|
||||
const HELP = [
|
||||
"<text> send it — as a new turn, or buffered until this turn ends",
|
||||
"!steer <text> inject into the running turn (_session/steering)",
|
||||
"!queue <text> send session/prompt now and let the agent queue it",
|
||||
"!cancel session/cancel the running turn (same as Ctrl-C)",
|
||||
"!help this list; !! sends a line that really starts with '!'",
|
||||
];
|
||||
|
||||
// How long to wait for a cancelled turn to settle, and for the agent to exit
|
||||
// after we close its stdin, before escalating during shutdown.
|
||||
const CANCEL_TIMEOUT_MS = 5_000;
|
||||
const EXIT_TIMEOUT_MS = 5_000;
|
||||
|
||||
// ---------------------------------------------------------------- output
|
||||
|
||||
const COLOR = process.stderr.isTTY === true && !process.env.NO_COLOR;
|
||||
const paint = (code: string, text: string) => (COLOR ? `\x1b[${code}m${text}\x1b[0m` : text);
|
||||
const dim = (text: string) => paint("2", text);
|
||||
|
||||
const TTY = process.stdin.isTTY === true && process.stderr.isTTY === true;
|
||||
const startedAt = Date.now();
|
||||
|
||||
let rl: readline.Interface | null = null;
|
||||
let shuttingDown = false;
|
||||
let queue: string[] = [];
|
||||
// How many `session/prompt` requests are awaiting a response. Usually 0 or 1;
|
||||
// `!queue` deliberately drives it higher to exercise the agent's own queueing.
|
||||
let inFlight = 0;
|
||||
const turnRunning = () => inFlight > 0;
|
||||
let cancelRequested = false;
|
||||
let lastUpdateKind = "";
|
||||
// False until `initialize` returns — an agent still paused under --inspect-brk
|
||||
// will never act on stdin EOF, so shutdown skips straight to signalling it.
|
||||
let agentReady = false;
|
||||
|
||||
/**
|
||||
* The single writer for everything this process prints. It wipes the half-typed
|
||||
* input line, writes the block, then asks readline to redraw its prompt with the
|
||||
* buffer intact — without this, streamed output and typing shred each other.
|
||||
* (One caveat: a soft-wrapped input line leaves a stale fragment behind, since
|
||||
* clearLine only clears the row the cursor is on.)
|
||||
*/
|
||||
function writeBlock(text: string) {
|
||||
if (TTY) {
|
||||
readline.cursorTo(process.stderr, 0);
|
||||
readline.clearLine(process.stderr, 0);
|
||||
}
|
||||
process.stderr.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
if (TTY) rl?.prompt(true);
|
||||
}
|
||||
|
||||
function log(message: string) {
|
||||
writeBlock(`${dim("[client]")} ${message}`);
|
||||
}
|
||||
|
||||
function refreshPrompt() {
|
||||
if (!rl) return;
|
||||
const queued = queue.length > 0 ? `[${queue.length} queued] ` : "";
|
||||
const running = inFlight > 1 ? `[${inFlight} turns] ` : "";
|
||||
const busy = turnRunning() ? dim(`⋯ ${lastUpdateKind} `) : "";
|
||||
rl.setPrompt(`${busy}${running}${queued}› `);
|
||||
if (TTY) rl.prompt(true);
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ---------------------------------------------------------------- wire log
|
||||
|
||||
/** A permissive view of the JSON-RPC union, purely for the summary line. */
|
||||
type WireMessage = {
|
||||
id?: string | number | null;
|
||||
method?: string;
|
||||
params?: { update?: { sessionUpdate?: string } };
|
||||
result?: { stopReason?: string };
|
||||
error?: { code: number; message: string };
|
||||
};
|
||||
|
||||
/** One-line summary: method (plus session update kind), or which reply this is. */
|
||||
function describe(message: AnyMessage): string {
|
||||
const wire = message as WireMessage;
|
||||
const id = wire.id === undefined || wire.id === null ? "" : ` #${wire.id}`;
|
||||
if (wire.method !== undefined) {
|
||||
const kind = wire.params?.update?.sessionUpdate;
|
||||
return `${wire.method}${kind ? ` · ${kind}` : ""}${id}`;
|
||||
}
|
||||
if (wire.error !== undefined) {
|
||||
return `error${id} · ${wire.error.code} ${wire.error.message}`;
|
||||
}
|
||||
const stopReason = wire.result?.stopReason;
|
||||
return `result${id}${stopReason ? ` · ${stopReason}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints one JSON-RPC message. The SDK exposes no logging hook, so this is
|
||||
* called from a stream tap (see `tapStream`) that sits between the SDK and the
|
||||
* transport — what you see is exactly what is written to / read from the pipe.
|
||||
*/
|
||||
function logMessage(direction: "agent" | "user", message: AnyMessage) {
|
||||
const inbound = direction === "agent";
|
||||
const color = inbound ? "36" : "35";
|
||||
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(3);
|
||||
const header = [
|
||||
dim(`+${elapsed}s`),
|
||||
paint(`1;${color}`, inbound ? "[agent]" : "[user] "),
|
||||
paint(color, inbound ? "◂" : "▸"),
|
||||
dim(describe(message)),
|
||||
].join(" ");
|
||||
// The body is printed flush left and unadorned so that selecting it in the
|
||||
// terminal yields valid JSON you can paste straight into a tool.
|
||||
writeBlock(`${header}\n${JSON.stringify(message, null, 2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a transport stream so every message is logged post-parse/pre-stringify.
|
||||
* A TransformStream per direction is the only complete interposition point: the
|
||||
* SDK reads inbound with a single reader and takes a fresh writer per outbound
|
||||
* message, and both survive being handed a transform.
|
||||
*/
|
||||
function tapStream(inner: Stream): Stream {
|
||||
const inbound = new TransformStream<AnyMessage, AnyMessage>({
|
||||
transform(message, controller) {
|
||||
logMessage("agent", message);
|
||||
controller.enqueue(message);
|
||||
},
|
||||
});
|
||||
const outbound = new TransformStream<AnyMessage, AnyMessage>({
|
||||
transform(message, controller) {
|
||||
logMessage("user", message);
|
||||
controller.enqueue(message);
|
||||
},
|
||||
});
|
||||
// The SDK never closes its writable, so this pipe only settles on failure —
|
||||
// typically the agent died and its stdin is gone. Teardown handles the rest;
|
||||
// without the catch this would be an unhandled rejection.
|
||||
outbound.readable.pipeTo(inner.writable).catch((err) => {
|
||||
if (!shuttingDown) log(`transport write failed: ${err}`);
|
||||
});
|
||||
return { readable: inner.readable.pipeThrough(inbound), writable: outbound.writable };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- build
|
||||
|
||||
/** Runs the repo's tsc, sending its diagnostics to our stderr. */
|
||||
async function build(sourceMaps: boolean) {
|
||||
const localTsc = path.join(repoRoot, "node_modules", "typescript", "bin", "tsc");
|
||||
const useLocal = existsSync(localTsc);
|
||||
const args = sourceMaps ? ["--sourceMap"] : [];
|
||||
log(`building agent (tsc${sourceMaps ? " --sourceMap" : ""}) …`);
|
||||
|
||||
const child = useLocal
|
||||
? spawn(process.execPath, [localTsc, ...args], { cwd: repoRoot, stdio: ["ignore", 2, 2] })
|
||||
: spawn(process.platform === "win32" ? "npx.cmd" : "npx", ["tsc", ...args], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", 2, 2],
|
||||
});
|
||||
|
||||
const [code] = (await once(child, "exit")) as [number | null];
|
||||
if (code !== 0) {
|
||||
log(`build failed (tsc exit ${code}) — fix the errors above, or pass --no-build`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main
|
||||
|
||||
async function main() {
|
||||
if (BUILD) {
|
||||
await build(DEBUG_PORT !== undefined);
|
||||
} else if (!existsSync(AGENT_ENTRY)) {
|
||||
log(`${AGENT_ENTRY} not found — run \`npm run build\` first, or unset --no-build`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// An ACP client launches the agent as a subprocess and speaks JSON-RPC over
|
||||
// its stdin/stdout. Its stderr is piped rather than inherited so that agent
|
||||
// logs go through writeBlock() and don't corrupt the readline input line.
|
||||
const nodeArgs =
|
||||
DEBUG_PORT === undefined ? [AGENT_ENTRY] : [`--inspect-brk=${DEBUG_PORT}`, AGENT_ENTRY];
|
||||
const child = spawn(process.execPath, nodeArgs, {
|
||||
cwd: repoRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
log(`failed to spawn agent (${AGENT_ENTRY}): ${err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
// A write racing the agent's exit surfaces as an unhandled 'error' event that
|
||||
// would take this client down with it.
|
||||
child.stdin?.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code !== "EPIPE") log(`agent stdin error: ${err}`);
|
||||
});
|
||||
child.stdout?.on("error", () => {});
|
||||
process.stderr.on("error", () => {});
|
||||
|
||||
pumpAgentStderr(child);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!shuttingDown) log(`agent exited (code ${code}, signal ${signal})`);
|
||||
});
|
||||
|
||||
const stream = tapStream(
|
||||
ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
|
||||
),
|
||||
);
|
||||
|
||||
const connection = acpClient({ name: "simple-client" })
|
||||
// The tap already prints every update; this handler exists so the prompt line
|
||||
// can show what the agent is currently doing.
|
||||
.onNotification(methods.client.session.update, (ctx) => {
|
||||
lastUpdateKind = ctx.params.update.sessionUpdate;
|
||||
})
|
||||
// Auto-approve, chosen by `kind` and never by a hard-coded optionId: for
|
||||
// ExitPlanMode this agent uses permission-*mode* names as option ids
|
||||
// (auto / acceptEdits / default / plan / bypassPermissions).
|
||||
.onRequest(methods.client.session.requestPermission, (ctx) => {
|
||||
const options = ctx.params.options;
|
||||
const option = options.find((o) => o.kind === "allow_once") ?? options[0];
|
||||
if (!option) return { outcome: { outcome: "cancelled" } };
|
||||
log(`auto-approving permission: ${option.name} (${option.kind}/${option.optionId})`);
|
||||
return { outcome: { outcome: "selected", optionId: option.optionId } };
|
||||
})
|
||||
// Minimal file-system stubs. This agent performs its own file I/O inside the
|
||||
// Claude Agent SDK subprocess and never calls these today.
|
||||
.onRequest(methods.client.fs.readTextFile, () => ({ content: "" }))
|
||||
.onRequest(methods.client.fs.writeTextFile, () => ({}))
|
||||
.connect(stream);
|
||||
|
||||
const agent = connection.agent;
|
||||
|
||||
// Signals are wired up *before* the first request. Under --inspect-brk the
|
||||
// wait below is unbounded, and a client that dies without running shutdown()
|
||||
// orphans a paused agent still holding the debug port.
|
||||
const onInterrupt = () => {
|
||||
if (turnRunning() && !cancelRequested) {
|
||||
cancelRequested = true;
|
||||
const dropped = queue.length;
|
||||
queue = [];
|
||||
// A notification, not a request: every turn in flight settles through its
|
||||
// own session/prompt response, normally with stopReason "cancelled".
|
||||
void agent.notify(methods.agent.session.cancel, { sessionId });
|
||||
log(`sent session/cancel${dropped > 0 ? `, dropped ${dropped} queued line(s)` : ""}`);
|
||||
refreshPrompt();
|
||||
return;
|
||||
}
|
||||
void shutdown(turnRunning() ? "^C while cancelling" : "^C");
|
||||
};
|
||||
// In terminal mode readline puts stdin in raw mode and owns ^C, so this only
|
||||
// ever fires for an explicit `kill -INT`; with piped stdin it is the ^C path.
|
||||
process.on("SIGINT", onInterrupt);
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// 1. Initialize. Under --inspect-brk the agent is paused before it reads a
|
||||
// single byte, so this request stays unanswered until a debugger attaches
|
||||
// and resumes it. ACP has no request timeout, so waiting is safe; the
|
||||
// interval below just keeps the terminal from looking dead.
|
||||
let nudge: NodeJS.Timeout | undefined;
|
||||
if (DEBUG_PORT !== undefined) {
|
||||
log(`agent is PAUSED before its first line (--inspect-brk=${DEBUG_PORT})`);
|
||||
log(`attach a debugger to port ${DEBUG_PORT} and resume it — breakpoints go in src/*.ts`);
|
||||
nudge = setInterval(
|
||||
() => log(`still waiting for a debugger on port ${DEBUG_PORT} … (Ctrl-C to give up)`),
|
||||
10_000,
|
||||
);
|
||||
}
|
||||
let init;
|
||||
try {
|
||||
init = await agent.request(methods.agent.initialize, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
terminal: false,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (nudge) clearInterval(nudge);
|
||||
}
|
||||
agentReady = true;
|
||||
log(`connected to ${init.agentInfo?.name ?? "agent"} ${init.agentInfo?.version ?? ""}`.trim());
|
||||
|
||||
// Steering is advertised at the TOP-LEVEL `_meta` of the initialize result —
|
||||
// a sibling of `agentCapabilities`, not nested inside it. Prompt queueing is
|
||||
// a Claude-specific capability flag and does live inside.
|
||||
const initMeta = init._meta as { steering?: { supported?: boolean } } | null | undefined;
|
||||
const steeringSupported = initMeta?.steering?.supported === true;
|
||||
const queueMeta = init.agentCapabilities?._meta as
|
||||
{ claudeCode?: { promptQueueing?: boolean } } | null | undefined;
|
||||
log(
|
||||
`agent supports: steering=${steeringSupported} ` +
|
||||
`promptQueueing=${queueMeta?.claudeCode?.promptQueueing === true}`,
|
||||
);
|
||||
|
||||
// 2. Open a session.
|
||||
let sessionId: string;
|
||||
try {
|
||||
const session = await agent.request(methods.agent.session.new, { cwd: CWD, mcpServers: [] });
|
||||
sessionId = session.sessionId;
|
||||
} catch (err) {
|
||||
log(`session/new failed: ${err}`);
|
||||
log("if this is an auth error, log in first: node dist/index.js --cli auth login");
|
||||
await shutdown("session/new failed", 1);
|
||||
return;
|
||||
}
|
||||
log(`session ${sessionId} in ${CWD}`);
|
||||
|
||||
// 3. Turns. There are four ways to get a message to the agent, and the client
|
||||
// can drive all of them (see `handleCommand`); plain text uses the first.
|
||||
//
|
||||
// session/prompt a new turn, or — for text typed mid-turn — one
|
||||
// buffered here and sent when the turn responds
|
||||
// session/prompt sent mid-turn anyway, so the *agent* queues it
|
||||
// (!queue) (it advertises `_meta.claudeCode.promptQueueing`)
|
||||
// _session/steering injected into the turn that is already running
|
||||
// (!steer)
|
||||
// session/cancel abandon the running turn
|
||||
// (!cancel, ^C)
|
||||
let draining = false;
|
||||
|
||||
/** One `session/prompt` round-trip. Several may be in flight at once when
|
||||
* `!queue` is used; the agent runs them in order. */
|
||||
async function sendPrompt(text: string) {
|
||||
inFlight += 1;
|
||||
cancelRequested = false;
|
||||
refreshPrompt();
|
||||
try {
|
||||
const result = await agent.request(methods.agent.session.prompt, {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text }],
|
||||
});
|
||||
log(`turn ended: ${result.stopReason}`);
|
||||
} catch (err) {
|
||||
// The error response itself was already printed by the tap; this is
|
||||
// the human-readable "your prompt did not run".
|
||||
log(`prompt failed: ${err}`);
|
||||
if (connection.signal.aborted) queue = [];
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
refreshPrompt();
|
||||
exitWhenIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the buffered lines one turn at a time, oldest first. */
|
||||
async function drain() {
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
try {
|
||||
while (queue.length > 0 && !shuttingDown) {
|
||||
await sendPrompt(queue.shift()!);
|
||||
if (connection.signal.aborted) break;
|
||||
}
|
||||
} finally {
|
||||
draining = false;
|
||||
refreshPrompt();
|
||||
exitWhenIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** A piped stdin ends; exiting has to wait for the work already handed out. */
|
||||
function exitWhenIdle() {
|
||||
if (inputClosed && !draining && queue.length === 0 && inFlight === 0) {
|
||||
void shutdown("input closed, queue drained");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side commands, prefixed with `!` so they cannot collide with the
|
||||
* agent's own slash commands (`/compact` and friends, which are just prompt
|
||||
* text as far as this client is concerned). `!!` escapes a literal `!`.
|
||||
*/
|
||||
function handleCommand(line: string) {
|
||||
const word = line.split(/\s+/)[0];
|
||||
const text = line.slice(word.length).trim();
|
||||
switch (word) {
|
||||
case "!steer":
|
||||
if (text.length === 0) {
|
||||
log("usage: !steer <message> — injects into the turn that is running");
|
||||
break;
|
||||
}
|
||||
if (!steeringSupported) log("agent did not advertise steering; trying anyway");
|
||||
void steer(text);
|
||||
break;
|
||||
case "!queue":
|
||||
if (text.length === 0) {
|
||||
log("usage: !queue <message> — sends session/prompt now, agent queues it");
|
||||
break;
|
||||
}
|
||||
void sendPrompt(text);
|
||||
break;
|
||||
case "!cancel":
|
||||
if (!turnRunning()) {
|
||||
log("nothing to cancel");
|
||||
break;
|
||||
}
|
||||
onInterrupt();
|
||||
break;
|
||||
case "!help":
|
||||
for (const entry of HELP) log(entry);
|
||||
break;
|
||||
default:
|
||||
log(`unknown command ${word} — try !help`);
|
||||
break;
|
||||
}
|
||||
refreshPrompt();
|
||||
}
|
||||
|
||||
/** `_session/steering`: deliver a message to the turn already in progress. */
|
||||
async function steer(text: string) {
|
||||
const params: SteeringRequest = { sessionId, prompt: [{ type: "text", text }] };
|
||||
try {
|
||||
const result = await agent.request<SteeringResponse>(STEERING_METHOD, params);
|
||||
// Either way the steered message's own output arrives as `session/update`
|
||||
// notifications, not in this response. When injected, it lands inside the
|
||||
// turn we steered, whose `stopReason` waits for it.
|
||||
log(
|
||||
result.outcome === "injected"
|
||||
? "steer outcome: injected into the running turn"
|
||||
: "steer outcome: startedNewTurn — the turn had already finished, so the " +
|
||||
"agent began a new one that no session/prompt of ours is tracking",
|
||||
);
|
||||
} catch (err) {
|
||||
log(`steer rejected: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
let inputClosed = false;
|
||||
let steerHinted = false;
|
||||
|
||||
rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: TTY,
|
||||
crlfDelay: Infinity,
|
||||
historySize: 200,
|
||||
});
|
||||
|
||||
rl.on("line", (raw) => {
|
||||
const line = raw.trim();
|
||||
if (line.length === 0 || shuttingDown) {
|
||||
refreshPrompt();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith("!") && !line.startsWith("!!")) {
|
||||
handleCommand(line);
|
||||
return;
|
||||
}
|
||||
// "!!steer …" is how you send text that begins with the command prefix.
|
||||
queue.push(line.startsWith("!!") ? line.slice(1) : line);
|
||||
if (turnRunning()) {
|
||||
log(`buffered — will send when this turn ends (${queue.length} queued)`);
|
||||
if (!steerHinted) {
|
||||
steerHinted = true;
|
||||
log("…or use !steer <text> to inject into the running turn instead");
|
||||
}
|
||||
}
|
||||
refreshPrompt();
|
||||
void drain();
|
||||
});
|
||||
|
||||
rl.on("SIGINT", onInterrupt);
|
||||
|
||||
rl.on("close", () => {
|
||||
inputClosed = true;
|
||||
exitWhenIdle();
|
||||
});
|
||||
|
||||
void connection.closed.then(() => {
|
||||
if (!shuttingDown) void shutdown("connection closed");
|
||||
});
|
||||
|
||||
for (const entry of HELP) log(entry);
|
||||
log("Enter sends, Ctrl-C cancels a turn, Ctrl-D exits");
|
||||
refreshPrompt();
|
||||
|
||||
/**
|
||||
* Order matters here. The agent exits cleanly on stdin EOF (src/index.ts wires
|
||||
* `connection.closed` to `agent.dispose()`), whereas killing it skips disposal
|
||||
* and can orphan the Claude Agent SDK subprocess. Closing our connection first
|
||||
* would cancel the reader on the agent's stdout and make it die of EPIPE
|
||||
* mid-write, so that comes last.
|
||||
*/
|
||||
async function shutdown(reason: string, exitCode = 0) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log(`shutting down (${reason})`);
|
||||
queue = [];
|
||||
rl?.removeAllListeners("close");
|
||||
rl?.close();
|
||||
|
||||
if (turnRunning()) {
|
||||
if (!cancelRequested) {
|
||||
try {
|
||||
await agent.notify(methods.agent.session.cancel, { sessionId });
|
||||
} catch {
|
||||
// The connection may already be gone; teardown continues regardless.
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + CANCEL_TIMEOUT_MS;
|
||||
while (turnRunning() && Date.now() < deadline) await delay(50);
|
||||
}
|
||||
|
||||
child.stdin?.end();
|
||||
const exited = await Promise.race([
|
||||
once(child, "exit").then(() => true),
|
||||
delay(agentReady ? EXIT_TIMEOUT_MS : 250).then(() => false),
|
||||
]);
|
||||
if (!exited) {
|
||||
log("agent did not exit on stdin EOF; sending SIGTERM");
|
||||
child.kill("SIGTERM");
|
||||
const stopped = await Promise.race([
|
||||
once(child, "exit").then(() => true),
|
||||
delay(2_000).then(() => false),
|
||||
]);
|
||||
if (!stopped) child.kill("SIGKILL");
|
||||
}
|
||||
|
||||
connection.close();
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Streams the agent's stderr through writeBlock, flagging inspector messages. */
|
||||
function pumpAgentStderr(child: ReturnType<typeof spawn>) {
|
||||
let buffered = "";
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stderr?.on("data", (chunk: string) => {
|
||||
buffered += chunk;
|
||||
const lines = buffered.split("\n");
|
||||
buffered = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) continue;
|
||||
if (line.includes("Debugger listening on")) {
|
||||
writeBlock(paint("1;33", `[agent] ${line.trim()}`));
|
||||
} else if (line.includes("address already in use")) {
|
||||
writeBlock(paint("1;31", `[agent] ${line.trim()}`));
|
||||
log("that inspector port is taken — pass a different --debug-port");
|
||||
} else {
|
||||
writeBlock(dim(`[agent:stderr] ${line}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Tearing down rejects whatever request main() is awaiting; that is the
|
||||
// teardown finishing its job, not a failure, and shutdown() owns the exit.
|
||||
if (shuttingDown) return;
|
||||
log(`fatal: ${err?.stack ?? err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
338
package-lock.json
generated
338
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.64.1",
|
||||
"version": "0.65.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.64.1",
|
||||
"version": "0.65.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.3.0",
|
||||
@@ -227,43 +227,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "2.0.0-alpha.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
|
||||
"integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "2.0.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "2.0.0-alpha.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
|
||||
"integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
|
||||
"integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
|
||||
@@ -380,13 +343,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
|
||||
"integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz",
|
||||
"integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
@@ -527,32 +490,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz",
|
||||
"integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^2.0.0-alpha.3",
|
||||
"@emnapi/runtime": "^2.0.0-alpha.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
|
||||
"integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -560,9 +501,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
|
||||
"integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -577,9 +518,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz",
|
||||
"integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -594,9 +535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz",
|
||||
"integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -611,9 +552,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz",
|
||||
"integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -628,9 +569,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz",
|
||||
"integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -645,9 +586,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz",
|
||||
"integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -665,9 +606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz",
|
||||
"integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -685,9 +626,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz",
|
||||
"integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -705,9 +646,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz",
|
||||
"integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -725,9 +666,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz",
|
||||
"integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -745,9 +686,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz",
|
||||
"integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -765,9 +706,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz",
|
||||
"integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -781,63 +722,10 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz",
|
||||
"integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -852,9 +740,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz",
|
||||
"integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -923,17 +811,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -1481,9 +1358,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2155,9 +2032,9 @@
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2256,9 +2133,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz",
|
||||
"integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==",
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
|
||||
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -2412,9 +2289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.32",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz",
|
||||
"integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==",
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
|
||||
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2487,9 +2364,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
|
||||
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2543,9 +2420,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
|
||||
"integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==",
|
||||
"version": "6.2.8",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
|
||||
"integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
@@ -3010,9 +2887,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.17",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
|
||||
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3233,9 +3110,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.24",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz",
|
||||
"integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==",
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3369,13 +3246,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz",
|
||||
"integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@oxc-project/types": "=0.142.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3385,21 +3262,20 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
"@rolldown/binding-android-arm64": "1.2.2",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.2",
|
||||
"@rolldown/binding-darwin-x64": "1.2.2",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.2",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.2",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.2",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.2",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.2",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.2",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.2",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.2",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.2",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.2",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -3649,9 +3525,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
|
||||
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3758,14 +3634,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -3871,16 +3739,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
|
||||
"integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"postcss": "^8.5.23",
|
||||
"rolldown": "~1.2.0",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3897,7 +3765,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.3.0",
|
||||
"@vitejs/devtools": "^0.4.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.64.1",
|
||||
"version": "0.65.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -29,6 +29,7 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "npm run build && npm run start",
|
||||
"example:simple-client": "node --experimental-strip-types --disable-warning=ExperimentalWarning examples/simple-client.ts",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"lint:fix": "eslint src --ext .ts --fix",
|
||||
"format": "prettier --write .",
|
||||
|
||||
243
src/acp-agent.ts
243
src/acp-agent.ts
@@ -361,6 +361,26 @@ type Turn = {
|
||||
* pre-hold behavior (pending wakes are not countable: notifications can
|
||||
* batch into one followup). */
|
||||
deferredSettle?: PromptResponse;
|
||||
/** Uuids of `steer()`-injected messages the SDK has not replayed back yet.
|
||||
*
|
||||
* A steer is delivered at {@link STEER_PRIORITY} (`now`), so the CLI ABORTS
|
||||
* the running cycle: it emits its own human-origin `result` —
|
||||
* indistinguishable from a turn's terminal one — and the steered message runs
|
||||
* as a SECOND cycle. Settling at that result would answer `session/prompt`
|
||||
* mid-work, so a steered turn's results only RECORD their outcome
|
||||
* (`steeredSettle`) and it settles at the SDK's `idle`, the only signal
|
||||
* spanning both cycles (CLI 2.1.220).
|
||||
*
|
||||
* A non-empty set at an idle means the steered cycle hasn't started — the CLI
|
||||
* replays a message only when it picks it up, always after the interrupted
|
||||
* cycle's result — so that idle is swallowed. Drained by the replay handler.
|
||||
*
|
||||
* Residual: a message the CLI drops unreplayed parks the turn until
|
||||
* `session/cancel` or the next prompt (both settle it). */
|
||||
steeredEchoes?: Set<string>;
|
||||
/** What a steered turn settles with once its steered work has run: the outcome
|
||||
* of its latest result, so its usage covers every cycle the turn ran. */
|
||||
steeredSettle?: PromptResponse;
|
||||
resolve: (response: PromptResponse) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
@@ -525,11 +545,6 @@ type Session = {
|
||||
* tool_use block streams; this set makes the two paths converge regardless of
|
||||
* order. Pruned at `tool_result` time alongside `toolUseCache`. */
|
||||
emittedToolCalls: Set<string>;
|
||||
/** Last Markdown delivered through ACP's experimental `plan_update` lane,
|
||||
* keyed by plan id. Live ExitPlanMode updates are owned by the permission
|
||||
* path so the plan and referenced tool call are both delivered before the
|
||||
* permission request; replay uses the same cache without a concurrent ask. */
|
||||
emittedPlanContent?: Map<string, string>;
|
||||
/** Registry of live background tasks, keyed by task id: populated at
|
||||
* `task_started`, pruned when the task settles (a `task_notification` or
|
||||
* a terminal `task_updated` patch), and reconciled against
|
||||
@@ -694,6 +709,12 @@ function isHeldOpen(
|
||||
return turn != null && turn.deferredSettle !== undefined && !turn.settled;
|
||||
}
|
||||
|
||||
/** Whether a steer moved this turn's settlement off the next result and onto the
|
||||
* SDK's `idle` (see Turn.steeredEchoes). Shared by the consumer's settle lanes. */
|
||||
function isSteering(turn: Turn | null | undefined): turn is Turn & { steeredEchoes: Set<string> } {
|
||||
return turn != null && turn.steeredEchoes !== undefined && !turn.settled;
|
||||
}
|
||||
|
||||
/** Disarm the force-cancel backstop (see Session.forceCancelTimer). Every
|
||||
* path that settles the active turn must run this so a timer can never fire
|
||||
* on an already-settled turn — and must leave the field undefined, or the
|
||||
@@ -1889,6 +1910,10 @@ export class ClaudeAcpAgent {
|
||||
* calls). The steered message's own output streams via `session/update`, not
|
||||
* this response.
|
||||
*
|
||||
* Pre-empting means ABORTING: the interrupted cycle emits a `result` of its
|
||||
* own and the steered message runs as a second one, so the turn is marked
|
||||
* (`Turn.steeredEchoes`) to settle at the SDK's `idle` instead of that result.
|
||||
*
|
||||
* When the session is idle, the opt-in path returns `promptRequired` WITHOUT
|
||||
* calling `prompt()`, pushing SDK input, or mutating `turnQueue`: the content
|
||||
* stays Host-owned so the Host can submit it through a standard
|
||||
@@ -1909,7 +1934,7 @@ export class ClaudeAcpAgent {
|
||||
// which is exactly the window in which steering is meaningful. This check
|
||||
// and the active-path push below stay in one synchronous section so the
|
||||
// turn cannot settle in the gap between deciding to inject and enqueueing.
|
||||
const turnInFlight = (session.turnQueue ?? []).some((turn) => !turn.settled);
|
||||
const turnInFlight = (session.turnQueue ?? []).find((turn) => !turn.settled);
|
||||
if (!turnInFlight) {
|
||||
const promptRequest: PromptRequest = {
|
||||
sessionId,
|
||||
@@ -1934,10 +1959,23 @@ export class ClaudeAcpAgent {
|
||||
prompt: params.prompt,
|
||||
};
|
||||
const userMessage = promptToClaude(promptRequest);
|
||||
userMessage.uuid = randomUUID();
|
||||
const steeredUuid = randomUUID();
|
||||
userMessage.uuid = steeredUuid;
|
||||
// Deliver into the running turn rather than queuing behind it as a fresh
|
||||
// prompt would.
|
||||
userMessage.priority = STEER_PRIORITY;
|
||||
// Mark before the push and in the same synchronous section as the in-flight
|
||||
// check: the interrupt can have the CLI finalizing the aborted cycle by the
|
||||
// time the consumer next runs, and an unmarked result would settle the turn
|
||||
// (see Turn.steeredEchoes).
|
||||
(turnInFlight.steeredEchoes ??= new Set()).add(steeredUuid);
|
||||
// A turn already held for background subagents has a recorded outcome the
|
||||
// steer supersedes: move it into the steer lane so one lane owns settlement.
|
||||
// The idle handler re-applies the hold through the subagent gate.
|
||||
if (turnInFlight.deferredSettle !== undefined) {
|
||||
turnInFlight.steeredSettle = turnInFlight.deferredSettle;
|
||||
turnInFlight.deferredSettle = undefined;
|
||||
}
|
||||
session.input.push(userMessage);
|
||||
return { outcome: "injected" };
|
||||
}
|
||||
@@ -2268,6 +2306,14 @@ export class ClaudeAcpAgent {
|
||||
* calling settleActive directly bypasses the hold and re-opens the
|
||||
* out-of-turn permission deadlock (issue #866) through its lane. */
|
||||
const settleOrDefer = (outcome: PromptResponse) => {
|
||||
// No result ends a steered turn: the steer aborted the cycle this result
|
||||
// may belong to, and the steered one is still to come. Record the outcome
|
||||
// for the idle lane (see Turn.steeredEchoes); later cycles overwrite it,
|
||||
// so the last result and its usage win.
|
||||
if (isSteering(session.activeTurn)) {
|
||||
session.activeTurn.steeredSettle = outcome;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
session.activeTurn &&
|
||||
!session.activeTurn.settled &&
|
||||
@@ -2757,7 +2803,31 @@ export class ClaudeAcpAgent {
|
||||
// this lagged idle (no active turn to settle): the idle
|
||||
// still belongs to that settled turn, and skipping the
|
||||
// decrement would leak the debt permanently.
|
||||
// Deliberately BEFORE the steer lane below: an owed idle
|
||||
// belongs to an earlier turn, and reading it as the steered
|
||||
// sequence's turn-over signal would settle a turn whose
|
||||
// steered cycle is still running. Steered results owe none.
|
||||
session.owedTrailingIdles--;
|
||||
} else if (isSteering(session.activeTurn)) {
|
||||
// A steered turn settles here, not at a result: this idle is
|
||||
// the only signal spanning the interrupted and steered cycles
|
||||
// (see Turn.steeredEchoes). An idle before the steered echo,
|
||||
// or before any result was recorded, means the answer is
|
||||
// still ahead — swallow it, and never let it reach the #825
|
||||
// fail below, which would reject a prompt about to answer.
|
||||
//
|
||||
// Plain Turn so the fields can be cleared below; isSteering
|
||||
// narrows steeredEchoes to non-optional.
|
||||
const steered: Turn = session.activeTurn;
|
||||
if (steered.steeredEchoes?.size === 0 && steered.steeredSettle !== undefined) {
|
||||
// Via the subagent gate, not settleActive: a steered turn
|
||||
// can also have spawned background subagents, which own it
|
||||
// from here (settles now if none is live, holds otherwise).
|
||||
steered.deferredSettle = steered.steeredSettle;
|
||||
steered.steeredEchoes = undefined;
|
||||
steered.steeredSettle = undefined;
|
||||
settleDeferredIfDrained();
|
||||
}
|
||||
} else if (
|
||||
!session.cancelled &&
|
||||
session.activeTurn &&
|
||||
@@ -3206,7 +3276,16 @@ export class ClaudeAcpAgent {
|
||||
// turn's OWN result — a followup result arriving inside the
|
||||
// cancel window still gets its own trailer and must be counted,
|
||||
// or that idle would later false-fail the next prompt.
|
||||
if (isAutonomousResult || !session.cancelled || !session.activeTurn) {
|
||||
// A second exclusion: a steered turn's own results (see
|
||||
// Turn.steeredEchoes). One idle covers the whole interrupted +
|
||||
// steered sequence and that idle settles the turn, so counting
|
||||
// either result would leave a debt that swallows it. Autonomous
|
||||
// results inside a steered turn keep their own trailers.
|
||||
const owesTrailingIdle = isAutonomousResult || !isSteering(session.activeTurn);
|
||||
if (
|
||||
owesTrailingIdle &&
|
||||
(isAutonomousResult || !session.cancelled || !session.activeTurn)
|
||||
) {
|
||||
session.owedTrailingIdles++;
|
||||
}
|
||||
|
||||
@@ -3594,8 +3673,6 @@ export class ClaudeAcpAgent {
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
emittedPlanContent: session.emittedPlanContent,
|
||||
emitExitPlanUpdates: false,
|
||||
messageId: currentStreamMessageId,
|
||||
streamedToolInputs,
|
||||
},
|
||||
@@ -3661,6 +3738,23 @@ export class ClaudeAcpAgent {
|
||||
// either. Its trailing-idle debt stands and is absorbed
|
||||
// when the drain idle eventually arrives.
|
||||
settleActive(session.activeTurn.deferredSettle);
|
||||
} else if (
|
||||
isSteering(session.activeTurn) &&
|
||||
session.activeTurn.steeredSettle !== undefined
|
||||
) {
|
||||
// Same for a turn with an open steer (see
|
||||
// Turn.steeredEchoes): the user moving on outranks the
|
||||
// steered continuation, but its last result's stop reason
|
||||
// stands. With no result yet it falls through to the
|
||||
// end_turn hand-off below, owing nothing there.
|
||||
//
|
||||
// The steer lane normally pays for that result's trailing
|
||||
// idle by settling on it; this hand-off settles instead,
|
||||
// so count it here or it arrives un-owed against the fresh
|
||||
// turn and false-fails it (issue #825). Harmless if it
|
||||
// never comes: the debt absorbs one future idle.
|
||||
session.owedTrailingIdles++;
|
||||
settleActive(session.activeTurn.steeredSettle);
|
||||
} else {
|
||||
settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
|
||||
}
|
||||
@@ -3677,6 +3771,13 @@ export class ClaudeAcpAgent {
|
||||
break;
|
||||
}
|
||||
if ("isReplay" in message && message.isReplay) {
|
||||
// A steered message's echo matches no turn (steer() creates
|
||||
// none), but it marks the steered cycle as running — how the idle
|
||||
// lane tells "the answer is still ahead" from "the turn is over"
|
||||
// (see Turn.steeredEchoes).
|
||||
if (isSteering(session.activeTurn)) {
|
||||
session.activeTurn.steeredEchoes.delete(message.uuid);
|
||||
}
|
||||
// Unrelated replay (e.g. the echo of an already-settled turn).
|
||||
break;
|
||||
}
|
||||
@@ -3862,8 +3963,6 @@ export class ClaudeAcpAgent {
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
emittedPlanContent: session.emittedPlanContent,
|
||||
emitExitPlanUpdates: false,
|
||||
messageId: messageIdForGrouping(message),
|
||||
toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
|
||||
// On the wire since CLI 2.1.216 but not in SDKUserMessage's
|
||||
@@ -4156,6 +4255,16 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
// A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
|
||||
// consumer at the interrupt's trailing idle, like any live turn. But the
|
||||
// steer lane pays for its last result's trailer by settling on it, which
|
||||
// this cancel pre-empts, so count that trailer here or it arrives un-owed
|
||||
// against the next prompt and false-fails it (issue #825). Over-counting
|
||||
// when only one trailer comes absorbs a future idle.
|
||||
if (isSteering(session.activeTurn) && session.activeTurn.steeredSettle !== undefined) {
|
||||
session.owedTrailingIdles++;
|
||||
}
|
||||
|
||||
// Arm a backstop before interrupting: if a turn is actively consuming the
|
||||
// query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
|
||||
// block — issue #680), force the consumer to settle the active turn
|
||||
@@ -4463,7 +4572,6 @@ export class ClaudeAcpAgent {
|
||||
|
||||
private async replaySessionHistory(sessionId: string): Promise<void> {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const emittedPlanContent = new Map<string, string>();
|
||||
const messages = await getSessionMessages(sessionId);
|
||||
const forwardSubagentText =
|
||||
this.sessions[sessionId]?.forwardSubagentText ??
|
||||
@@ -4513,7 +4621,6 @@ export class ClaudeAcpAgent {
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
cwd: this.sessions[sessionId]?.cwd,
|
||||
taskState: this.sessions[sessionId]?.taskState,
|
||||
emittedPlanContent,
|
||||
messageId: replayMessageId,
|
||||
parentToolUseId,
|
||||
},
|
||||
@@ -4592,21 +4699,6 @@ export class ClaudeAcpAgent {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const planUpdate = exitPlanUpdate(
|
||||
sessionId,
|
||||
toolName,
|
||||
toolInput,
|
||||
this.clientCapabilities,
|
||||
(session.emittedPlanContent ??= new Map()),
|
||||
parentToolUseId,
|
||||
);
|
||||
if (planUpdate) {
|
||||
await this.client.sessionUpdate(planUpdate);
|
||||
session.emittedPlanContent.set(
|
||||
claudePlanId(parentToolUseId),
|
||||
(toolInput as { plan: string }).plan,
|
||||
);
|
||||
}
|
||||
if (session.emittedToolCalls.has(toolCallId)) {
|
||||
return;
|
||||
}
|
||||
@@ -4617,7 +4709,6 @@ export class ClaudeAcpAgent {
|
||||
toolInput,
|
||||
supportsTerminalOutput,
|
||||
session.cwd,
|
||||
supportsPlanUpdates(this.clientCapabilities),
|
||||
);
|
||||
if (parentToolUseId) {
|
||||
update._meta = {
|
||||
@@ -4721,7 +4812,6 @@ export class ClaudeAcpAgent {
|
||||
{ name: toolName, input: toolInput, id: toolUseID },
|
||||
supportsTerminalOutput,
|
||||
session?.cwd,
|
||||
supportsPlanUpdates(this.clientCapabilities),
|
||||
),
|
||||
// `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
|
||||
// so clients can rely on one shape everywhere.
|
||||
@@ -5895,7 +5985,6 @@ export class ClaudeAcpAgent {
|
||||
taskState,
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
emittedPlanContent: new Map(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
@@ -7074,7 +7163,6 @@ function toolCallNotification(
|
||||
rawInput: unknown,
|
||||
supportsTerminalOutput: boolean,
|
||||
cwd?: string,
|
||||
usePlanUpdate = false,
|
||||
refine = false,
|
||||
): SessionNotification["update"] {
|
||||
if (refine) {
|
||||
@@ -7083,7 +7171,7 @@ function toolCallNotification(
|
||||
toolCallId: toolUse.id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
rawInput,
|
||||
...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd, usePlanUpdate),
|
||||
...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -7097,56 +7185,7 @@ function toolCallNotification(
|
||||
sessionUpdate: "tool_call",
|
||||
rawInput,
|
||||
status: "pending",
|
||||
...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd, usePlanUpdate),
|
||||
};
|
||||
}
|
||||
|
||||
const CLAUDE_PLAN_ID = "claude-plan";
|
||||
|
||||
function claudePlanId(parentToolUseId?: string | null): string {
|
||||
return parentToolUseId ? `${CLAUDE_PLAN_ID}:${parentToolUseId}` : CLAUDE_PLAN_ID;
|
||||
}
|
||||
|
||||
function supportsPlanUpdates(clientCapabilities?: ClientCapabilities | null): boolean {
|
||||
return clientCapabilities?.plan != null;
|
||||
}
|
||||
|
||||
/** Build a Markdown plan update for ExitPlanMode when the client opts into the
|
||||
* experimental plan capability. A stable per-session plan id lets a revised
|
||||
* plan replace the previous draft instead of accumulating one plan per tool
|
||||
* call. `rawInput.plan` remains on the switch_mode call used by the permission
|
||||
* request; this only moves visual rendering out of that call. */
|
||||
function exitPlanUpdate(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
toolInput: unknown,
|
||||
clientCapabilities?: ClientCapabilities | null,
|
||||
emittedPlanContent?: Map<string, string>,
|
||||
parentToolUseId?: string | null,
|
||||
): SessionNotification | undefined {
|
||||
const planId = claudePlanId(parentToolUseId);
|
||||
if (
|
||||
toolName !== "ExitPlanMode" ||
|
||||
!supportsPlanUpdates(clientCapabilities) ||
|
||||
toolInput === null ||
|
||||
typeof toolInput !== "object" ||
|
||||
!("plan" in toolInput) ||
|
||||
typeof toolInput.plan !== "string" ||
|
||||
toolInput.plan.trim().length === 0 ||
|
||||
emittedPlanContent?.get(planId) === toolInput.plan
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "plan_update",
|
||||
plan: {
|
||||
type: "markdown",
|
||||
planId,
|
||||
content: toolInput.plan,
|
||||
},
|
||||
},
|
||||
...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7238,11 +7277,6 @@ export function toAcpNotifications(
|
||||
// tool_call/update decision falls back to `toolUseCache` presence (the
|
||||
// historical single-source behavior).
|
||||
emittedToolCalls?: Set<string>;
|
||||
emittedPlanContent?: Map<string, string>;
|
||||
// Live ExitPlanMode plan updates are sent from canUseTool immediately
|
||||
// before requestPermission, which gives strict clients deterministic
|
||||
// ordering. Replay has no concurrent permission request and emits here.
|
||||
emitExitPlanUpdates?: boolean;
|
||||
// Opaque id identifying the message these chunks belong to (ACP message ids
|
||||
// are opaque strings — no particular format is required). Attached to
|
||||
// user/agent message and thought chunks so clients can group streamed chunks
|
||||
@@ -7263,7 +7297,6 @@ export function toAcpNotifications(
|
||||
const taskState = options?.taskState ?? new Map();
|
||||
const registerHooks = options?.registerHooks !== false;
|
||||
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
|
||||
const usePlanUpdate = supportsPlanUpdates(options?.clientCapabilities);
|
||||
if (typeof content === "string") {
|
||||
if (content.length === 0) {
|
||||
return [];
|
||||
@@ -7367,23 +7400,6 @@ export function toAcpNotifications(
|
||||
// tool_result time once we have the task ID (for TaskCreate) and
|
||||
// confirmation that the change took effect.
|
||||
} else {
|
||||
if (options?.emitExitPlanUpdates !== false) {
|
||||
const planUpdate = exitPlanUpdate(
|
||||
sessionId,
|
||||
chunk.name,
|
||||
chunk.input,
|
||||
options?.clientCapabilities,
|
||||
options?.emittedPlanContent,
|
||||
options?.parentToolUseId,
|
||||
);
|
||||
if (planUpdate) {
|
||||
output.push(planUpdate);
|
||||
options?.emittedPlanContent?.set(
|
||||
claudePlanId(options?.parentToolUseId),
|
||||
(chunk.input as { plan: string }).plan,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Only register hooks on first encounter to avoid double-firing
|
||||
if (registerHooks && !alreadyCached) {
|
||||
// Capture the tool name in the closure rather than re-reading the
|
||||
@@ -7451,19 +7467,12 @@ export function toAcpNotifications(
|
||||
rawInput,
|
||||
supportsTerminalOutput,
|
||||
options?.cwd,
|
||||
usePlanUpdate,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
// First surface (streaming content_block_start or replay) — send as
|
||||
// tool_call (with terminal_info for Bash).
|
||||
update = toolCallNotification(
|
||||
chunk,
|
||||
rawInput,
|
||||
supportsTerminalOutput,
|
||||
options?.cwd,
|
||||
usePlanUpdate,
|
||||
);
|
||||
update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -7666,8 +7675,6 @@ export function streamEventToAcpNotifications(
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
emittedToolCalls?: Set<string>;
|
||||
emittedPlanContent?: Map<string, string>;
|
||||
emitExitPlanUpdates?: boolean;
|
||||
messageId?: string;
|
||||
streamedToolInputs?: StreamedToolInputCache;
|
||||
},
|
||||
@@ -7681,8 +7688,6 @@ export function streamEventToAcpNotifications(
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
emittedToolCalls: options?.emittedToolCalls,
|
||||
emittedPlanContent: options?.emittedPlanContent,
|
||||
emitExitPlanUpdates: options?.emitExitPlanUpdates,
|
||||
messageId: options?.messageId,
|
||||
};
|
||||
switch (event.type) {
|
||||
|
||||
@@ -1722,57 +1722,6 @@ describe("subagent transcript replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExitPlanMode replay", () => {
|
||||
it("replays Markdown through plan_update for capable clients without duplicating tool content", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (update: SessionNotification) => updates.push(update),
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
agent.clientCapabilities = { plan: {} };
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce([
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "plan-message",
|
||||
session_id: "s1",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
message: {
|
||||
id: "api-plan-message",
|
||||
model: "claude-sonnet-4-5",
|
||||
role: "assistant",
|
||||
type: "message",
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "exit-plan-tool",
|
||||
name: "ExitPlanMode",
|
||||
input: { plan: "# Replayed plan" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
] as Awaited<ReturnType<typeof getSessionMessages>>);
|
||||
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory("s1");
|
||||
|
||||
expect(updates.map((u) => u.update.sessionUpdate)).toEqual(["plan_update", "tool_call"]);
|
||||
expect(updates[0].update).toMatchObject({
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { type: "markdown", planId: "claude-plan", content: "# Replayed plan" },
|
||||
});
|
||||
expect(updates[1].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "exit-plan-tool",
|
||||
kind: "switch_mode",
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("escape markdown", () => {
|
||||
it("should escape markdown characters", () => {
|
||||
let text = "Hello *world*!";
|
||||
@@ -2410,118 +2359,6 @@ describe("tool_call emitted before permission request", () => {
|
||||
expect(events).toEqual(["permission"]);
|
||||
});
|
||||
|
||||
it("delivers plan_update and tool_call before an ExitPlanMode permission-first request", async () => {
|
||||
const { agent, events, updates, session } = setup();
|
||||
agent.clientCapabilities = { plan: {} };
|
||||
|
||||
await agent.canUseTool("session-1")("ExitPlanMode", { plan: "# Permission-first plan" }, {
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
toolUseID: "plan-tool-1",
|
||||
} as any);
|
||||
|
||||
expect(events).toEqual(["update:plan_update", "update:tool_call", "permission"]);
|
||||
expect(updates[0].update).toMatchObject({
|
||||
sessionUpdate: "plan_update",
|
||||
plan: {
|
||||
type: "markdown",
|
||||
planId: "claude-plan",
|
||||
content: "# Permission-first plan",
|
||||
},
|
||||
});
|
||||
expect(updates[1].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "plan-tool-1",
|
||||
kind: "switch_mode",
|
||||
content: [],
|
||||
});
|
||||
expect(session.emittedPlanContent?.get("claude-plan")).toBe("# Permission-first plan");
|
||||
|
||||
const streamed = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "plan-tool-1",
|
||||
name: "ExitPlanMode",
|
||||
input: { plan: "# Permission-first plan" },
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"session-1",
|
||||
session.toolUseCache,
|
||||
{} as AcpClient,
|
||||
console,
|
||||
{
|
||||
clientCapabilities: agent.clientCapabilities,
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
emittedPlanContent: session.emittedPlanContent,
|
||||
emitExitPlanUpdates: false,
|
||||
},
|
||||
);
|
||||
expect(streamed.map((n) => n.update.sessionUpdate)).toEqual(["tool_call_update"]);
|
||||
});
|
||||
|
||||
it("delivers a streamed tool_call, then plan_update, before the permission request", async () => {
|
||||
const { agent, events, session } = setup();
|
||||
agent.clientCapabilities = { plan: {} };
|
||||
|
||||
const streamed = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "plan-tool-2",
|
||||
name: "ExitPlanMode",
|
||||
input: { plan: "# Stream-first plan" },
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"session-1",
|
||||
session.toolUseCache,
|
||||
agent.client,
|
||||
console,
|
||||
{
|
||||
clientCapabilities: agent.clientCapabilities,
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
emittedPlanContent: session.emittedPlanContent,
|
||||
emitExitPlanUpdates: false,
|
||||
},
|
||||
);
|
||||
for (const notification of streamed) {
|
||||
await agent.client.sessionUpdate(notification);
|
||||
}
|
||||
|
||||
await agent.canUseTool("session-1")("ExitPlanMode", { plan: "# Stream-first plan" }, {
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
toolUseID: "plan-tool-2",
|
||||
} as any);
|
||||
|
||||
expect(events).toEqual(["update:tool_call", "update:plan_update", "permission"]);
|
||||
});
|
||||
|
||||
it("keeps the ExitPlanMode Markdown in the permission tool call for legacy clients", async () => {
|
||||
const { agent, events, updates } = setup();
|
||||
|
||||
await agent.canUseTool("session-1")("ExitPlanMode", { plan: "# Legacy plan" }, {
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
toolUseID: "plan-tool-legacy",
|
||||
} as any);
|
||||
|
||||
expect(events).toEqual(["update:tool_call", "permission"]);
|
||||
expect(updates[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "plan-tool-legacy",
|
||||
kind: "switch_mode",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "# Legacy plan" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("refines the eagerly-emitted tool_call with a tool_call_update when the chunk streams", () => {
|
||||
const { session } = setup();
|
||||
// Permission flow already emitted the tool_call for this id.
|
||||
@@ -2858,36 +2695,6 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes a subagent ExitPlanMode plan to the spawning tool call", async () => {
|
||||
const { agent, updates, session } = setup();
|
||||
agent.clientCapabilities = { plan: {} };
|
||||
session.liveBackgroundTasks.set("agent-42", {
|
||||
parentToolUseId: "toolu_parent",
|
||||
isSubagent: true,
|
||||
});
|
||||
|
||||
await agent.canUseTool("session-1")("ExitPlanMode", { plan: "# Subagent plan" }, {
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
toolUseID: "toolu_sub_plan",
|
||||
agentID: "agent-42",
|
||||
} as any);
|
||||
|
||||
expect(updates[0].update).toMatchObject({
|
||||
sessionUpdate: "plan_update",
|
||||
plan: {
|
||||
type: "markdown",
|
||||
planId: "claude-plan:toolu_parent",
|
||||
content: "# Subagent plan",
|
||||
},
|
||||
});
|
||||
expect(updates[1].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "toolu_sub_plan",
|
||||
_meta: { claudeCode: { parentToolUseId: "toolu_parent" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the attribution (and logs the miss) when the agent id has no recorded parent", async () => {
|
||||
const { agent, updates, requests, log } = setup();
|
||||
|
||||
@@ -10027,6 +9834,31 @@ describe("turn steering (_session/steering)", () => {
|
||||
};
|
||||
}
|
||||
|
||||
/** The `result` the CLI emits for the cycle a priority:'now' steer aborted, as
|
||||
* observed on CLI 2.1.220: `origin.kind` "human" (not autonomous, so it
|
||||
* reaches the user-turn lifecycle) and `stop_reason` "tool_use" — the abort
|
||||
* landed on a pending tool call. No test here depends on either: every
|
||||
* non-error combination maps to the `end_turn` clients observe. */
|
||||
function interruptedCycleResult() {
|
||||
return { ...createResultMessage(), stop_reason: "tool_use", origin: { kind: "human" } };
|
||||
}
|
||||
|
||||
/** The SDK's authoritative turn-over signal. */
|
||||
function idleMessage() {
|
||||
return { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
|
||||
/** Records every `agent_message_chunk` the client observes. */
|
||||
function timelineClient(timeline: string[]) {
|
||||
return {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
if (notification.update?.sessionUpdate === "agent_message_chunk") {
|
||||
timeline.push(notification.update.content?.text);
|
||||
}
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
const waitFor = async (cond: () => boolean) => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (cond()) return;
|
||||
@@ -10222,6 +10054,249 @@ describe("turn steering (_session/steering)", () => {
|
||||
expect(JSON.stringify(injected.message.content)).toContain("also handle X");
|
||||
});
|
||||
|
||||
// A steer does not join the running cycle — it ABORTS it: the CLI interrupts
|
||||
// the in-flight query as soon as a queued message carries priority 'now'. The
|
||||
// interrupted cycle ends with an ordinary human-origin `result` and the
|
||||
// steered message runs as a SECOND cycle. Treating that first result as
|
||||
// terminal answers session/prompt mid-work: the client is told `end_turn`,
|
||||
// then keeps receiving the turn's remaining tool calls and its actual answer
|
||||
// as updates belonging to no turn.
|
||||
it("keeps the turn open across the interrupt its own steer caused", async () => {
|
||||
const timeline: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(timelineClient(timeline), {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value); // turn becomes active
|
||||
yield createAssistantText("working on it");
|
||||
// The steered message is pushed at priority 'now'...
|
||||
const steered = await iter.next();
|
||||
// ...so the CLI aborts the query, ending the interrupted cycle with a
|
||||
// result of its own. No idle follows it: one comes at the very end, for
|
||||
// the whole interrupted + steered sequence.
|
||||
yield interruptedCycleResult();
|
||||
// Only now does the steered message run, as a second cycle. Its echo
|
||||
// matches no queued turn (dropped as an unrelated replay), its output is
|
||||
// the answer the user is waiting for, and its result has the last word
|
||||
// on the turn's stop reason.
|
||||
yield userEcho(steered.value);
|
||||
yield createAssistantText("STEERED-OK");
|
||||
yield createResultMessage();
|
||||
yield idleMessage();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const turn = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "start" }] })
|
||||
.then((response) => {
|
||||
timeline.push(`prompt:${response.stopReason}`);
|
||||
return response;
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
|
||||
await expect(
|
||||
agent.steer({ sessionId: "test-session", prompt: [{ type: "text", text: "also handle X" }] }),
|
||||
).resolves.toEqual({ outcome: "injected" });
|
||||
|
||||
const response = await turn;
|
||||
// Drain the rest of the stream so the timeline is complete either way — on
|
||||
// an early settle the steered output arrives after `response`.
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
// The steered continuation belongs to the turn the client is waiting on, so
|
||||
// all of it precedes that turn's response. A `prompt:` entry anywhere but
|
||||
// last is the bug: updates outlived the stopReason.
|
||||
expect(timeline).toEqual(["working on it", "STEERED-OK", "prompt:end_turn"]);
|
||||
// Both cycles ran for this one prompt, so its usage covers both (2 × the
|
||||
// mock result's 10 in / 5 out) rather than stopping at the interrupt.
|
||||
expect(response.usage).toEqual({
|
||||
inputTokens: 20,
|
||||
outputTokens: 10,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
totalTokens: 30,
|
||||
});
|
||||
// Still exactly one response for one prompt, and nothing left behind.
|
||||
expect(agent.sessions["test-session"].turnQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
// The other steer ordering: the cycle had already finished when the steer
|
||||
// landed, so nothing was aborted and its result is a natural one — followed by
|
||||
// its own idle, before the steered message is picked up. Settling on that idle
|
||||
// would answer the prompt with the steered work still ahead, so the turn waits
|
||||
// for the steered message's echo (Turn.steeredEchoes).
|
||||
it("does not settle a steered turn at an idle that precedes the steered echo", async () => {
|
||||
const timeline: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(timelineClient(timeline), {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value);
|
||||
yield createAssistantText("working on it");
|
||||
const steered = await iter.next();
|
||||
// The cycle ends and goes idle before the steered message is dequeued.
|
||||
yield interruptedCycleResult();
|
||||
yield idleMessage();
|
||||
// Then the steered message runs as its own cycle.
|
||||
yield userEcho(steered.value);
|
||||
yield createAssistantText("STEERED-OK");
|
||||
yield createResultMessage();
|
||||
yield idleMessage();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const turn = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "start" }] })
|
||||
.then((response) => {
|
||||
timeline.push(`prompt:${response.stopReason}`);
|
||||
return response;
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
await agent.steer({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "also handle X" }],
|
||||
});
|
||||
|
||||
await turn;
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
expect(timeline).toEqual(["working on it", "STEERED-OK", "prompt:end_turn"]);
|
||||
// The swallowed idle must not leave a phantom debt that would absorb a later
|
||||
// turn's trailing idle (and blind the issue-#825 detector).
|
||||
expect(agent.sessions["test-session"].owedTrailingIdles).toBe(0);
|
||||
});
|
||||
|
||||
// A steer must not hold a prompt hostage: the next prompt's echo hands the
|
||||
// steered turn off, reporting — like the subagent hold — the stop reason its
|
||||
// last result recorded rather than a guessed one.
|
||||
it("hands a steered turn off to the next prompt with its recorded outcome", async () => {
|
||||
const timeline: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(timelineClient(timeline), {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value);
|
||||
yield createAssistantText("working on it");
|
||||
await iter.next(); // the steered message, which this CLI never replays
|
||||
yield interruptedCycleResult();
|
||||
// The user moves on: the second prompt's echo arrives while the steered
|
||||
// turn is still open.
|
||||
const second = await iter.next();
|
||||
yield userEcho(second.value);
|
||||
yield createAssistantText("second turn");
|
||||
yield createResultMessage();
|
||||
yield idleMessage();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const first = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "start" }] })
|
||||
.then((response) => {
|
||||
timeline.push(`first:${response.stopReason}`);
|
||||
return response;
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
await agent.steer({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "also handle X" }],
|
||||
});
|
||||
|
||||
const second = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "next" }] })
|
||||
.then((response) => {
|
||||
timeline.push(`second:${response.stopReason}`);
|
||||
return response;
|
||||
});
|
||||
|
||||
const firstResponse = await first;
|
||||
await second;
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
// The steered turn ends at the hand-off — before the new turn's output — and
|
||||
// carries the interrupted cycle's own usage, not the next turn's.
|
||||
expect(timeline).toEqual(["working on it", "first:end_turn", "second turn", "second:end_turn"]);
|
||||
expect(firstResponse.usage).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
totalTokens: 15,
|
||||
});
|
||||
expect(agent.sessions["test-session"].turnQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Cancelling a steered turn answers "cancelled" through the normal lane, but
|
||||
// its last result's trailing idle went uncounted (the steer lane pays for its
|
||||
// idles by settling on them, which the cancel pre-empted). That idle can lag
|
||||
// past the next prompt's echo, where an un-owed idle reads as "the SDK went
|
||||
// idle without a result" and false-fails a healthy prompt (#825).
|
||||
it("cancels a steered turn without false-failing the next prompt", async () => {
|
||||
const agent = createMockAgent();
|
||||
let releaseAfterCancel = () => {};
|
||||
const afterCancel = new Promise<void>((resolve) => (releaseAfterCancel = resolve));
|
||||
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value);
|
||||
yield createAssistantText("working on it");
|
||||
await iter.next(); // the steered message
|
||||
yield interruptedCycleResult();
|
||||
await afterCancel;
|
||||
yield idleMessage(); // the interrupt's trailer: settles the turn "cancelled"
|
||||
const second = await iter.next();
|
||||
yield userEcho(second.value); // activates the next turn, clears `cancelled`
|
||||
yield idleMessage(); // the steered result's lagged trailer — must be absorbed
|
||||
yield createAssistantText("second turn");
|
||||
yield createResultMessage();
|
||||
yield idleMessage();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const first = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "start" }],
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
await agent.steer({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "also handle X" }],
|
||||
});
|
||||
// The interrupted cycle's result has been recorded on the turn (not settled).
|
||||
await waitFor(() => agent.sessions["test-session"]?.activeTurn?.steeredSettle !== undefined);
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
releaseAfterCancel();
|
||||
|
||||
await expect(first).resolves.toEqual(expect.objectContaining({ stopReason: "cancelled" }));
|
||||
// The next prompt runs to completion instead of being rejected by the #825
|
||||
// detector when the steered turn's lagged idle lands.
|
||||
await expect(
|
||||
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "next" }] }),
|
||||
).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
|
||||
expect(agent.sessions["test-session"].owedTrailingIdles).toBe(0);
|
||||
});
|
||||
|
||||
it("always injects at 'now' priority, ignoring any client-supplied priority", async () => {
|
||||
const agent = createMockAgent();
|
||||
const captured: any[] = [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
@@ -1357,103 +1357,5 @@ describe("session config options", () => {
|
||||
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
|
||||
expect(optionIds).toContain("auto");
|
||||
});
|
||||
|
||||
it("sends plan_update before permission when the tool call was already streamed", async () => {
|
||||
(agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities = {
|
||||
plan: {},
|
||||
};
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.emittedToolCalls.add("toolu_plan_update");
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
await expect(
|
||||
canUseTool(
|
||||
"ExitPlanMode",
|
||||
{ plan: "# Approved plan" },
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
suggestions: undefined,
|
||||
toolUseID: "toolu_plan_update",
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("Tool use aborted");
|
||||
|
||||
expect(sessionUpdates).toEqual([
|
||||
{
|
||||
sessionId: SESSION_ID,
|
||||
update: {
|
||||
sessionUpdate: "plan_update",
|
||||
plan: {
|
||||
type: "markdown",
|
||||
planId: "claude-plan",
|
||||
content: "# Approved plan",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(capturedPermissionRequest.toolCall).toMatchObject({
|
||||
toolCallId: "toolu_plan_update",
|
||||
kind: "switch_mode",
|
||||
rawInput: { plan: "# Approved plan" },
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("approves ExitPlanMode after publishing the capable-client plan", async () => {
|
||||
(agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities = {
|
||||
plan: {},
|
||||
};
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modes.currentModeId = "plan";
|
||||
permissionResponse = { outcome: { outcome: "selected", optionId: "default" } };
|
||||
|
||||
const result = await (agent as any).canUseTool(SESSION_ID)(
|
||||
"ExitPlanMode",
|
||||
{ plan: "# Implement it" },
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
suggestions: undefined,
|
||||
toolUseID: "toolu_plan_approve",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.behavior).toBe("allow");
|
||||
expect(session.modes.currentModeId).toBe("default");
|
||||
expect(sessionUpdates.map((n) => n.update.sessionUpdate)).toEqual([
|
||||
"plan_update",
|
||||
"tool_call",
|
||||
"current_mode_update",
|
||||
"config_option_update",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects ExitPlanMode without switching mode after publishing the plan", async () => {
|
||||
(agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities = {
|
||||
plan: {},
|
||||
};
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modes.currentModeId = "plan";
|
||||
permissionResponse = { outcome: { outcome: "selected", optionId: "plan" } };
|
||||
|
||||
const result = await (agent as any).canUseTool(SESSION_ID)(
|
||||
"ExitPlanMode",
|
||||
{ plan: "# Keep planning" },
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
suggestions: undefined,
|
||||
toolUseID: "toolu_plan_reject",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
behavior: "deny",
|
||||
message: "User rejected request to exit plan mode.",
|
||||
});
|
||||
expect(session.modes.currentModeId).toBe("plan");
|
||||
expect(sessionUpdates.map((n) => n.update.sessionUpdate)).toEqual([
|
||||
"plan_update",
|
||||
"tool_call",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1698,176 +1698,6 @@ describe("toolInfoFromToolUse - ExitPlanMode", () => {
|
||||
expect(info.kind).toBe("switch_mode");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes Markdown through plan_update for capable clients and keeps the tool call compact", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use" as const,
|
||||
name: "ExitPlanMode",
|
||||
id: "toolu_plan_capable",
|
||||
input: { plan: "# Implement\n\nRun the tests." },
|
||||
};
|
||||
const emittedPlanContent = new Map<string, string>();
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[toolUse],
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{
|
||||
clientCapabilities: { plan: {} },
|
||||
emittedPlanContent,
|
||||
},
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(2);
|
||||
expect(notifications[0].update).toEqual({
|
||||
sessionUpdate: "plan_update",
|
||||
plan: {
|
||||
type: "markdown",
|
||||
planId: "claude-plan",
|
||||
content: "# Implement\n\nRun the tests.",
|
||||
},
|
||||
});
|
||||
expect(notifications[1].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "toolu_plan_capable",
|
||||
kind: "switch_mode",
|
||||
rawInput: { plan: "# Implement\n\nRun the tests." },
|
||||
content: [],
|
||||
});
|
||||
|
||||
const repeated = toAcpNotifications(
|
||||
[toolUse],
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{
|
||||
clientCapabilities: { plan: {} },
|
||||
emittedPlanContent,
|
||||
},
|
||||
);
|
||||
expect(repeated.filter((n) => n.update.sessionUpdate === "plan_update")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("defers live plan_update to the permission path while keeping the tool call compact", () => {
|
||||
const emittedPlanContent = new Map<string, string>();
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
name: "ExitPlanMode",
|
||||
id: "toolu_plan_live",
|
||||
input: { plan: "# Live plan" },
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{
|
||||
clientCapabilities: { plan: {} },
|
||||
emittedPlanContent,
|
||||
emitExitPlanUpdates: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "toolu_plan_live",
|
||||
kind: "switch_mode",
|
||||
content: [],
|
||||
});
|
||||
expect(emittedPlanContent.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not create plan_update for an empty Markdown plan", () => {
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
name: "ExitPlanMode",
|
||||
id: "toolu_plan_empty",
|
||||
input: { plan: " \n" },
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{ clientCapabilities: { plan: {} } },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update.sessionUpdate).toBe("tool_call");
|
||||
});
|
||||
|
||||
it("scopes replayed subagent plans by their parent tool call", () => {
|
||||
const emittedPlanContent = new Map<string, string>();
|
||||
const render = (parentToolUseId: string, id: string, plan: string) =>
|
||||
toAcpNotifications(
|
||||
[{ type: "tool_use" as const, name: "ExitPlanMode", id, input: { plan } }],
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{
|
||||
clientCapabilities: { plan: {} },
|
||||
emittedPlanContent,
|
||||
parentToolUseId,
|
||||
},
|
||||
);
|
||||
|
||||
const first = render("agent-parent-1", "toolu_subplan_1", "# First subagent plan");
|
||||
const second = render("agent-parent-2", "toolu_subplan_2", "# Second subagent plan");
|
||||
|
||||
expect(first[0].update).toMatchObject({
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { planId: "claude-plan:agent-parent-1" },
|
||||
});
|
||||
expect(second[0].update).toMatchObject({
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { planId: "claude-plan:agent-parent-2" },
|
||||
});
|
||||
expect(emittedPlanContent.size).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps Markdown in the switch_mode tool call when plan_update is unsupported", () => {
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
name: "ExitPlanMode",
|
||||
id: "toolu_plan_fallback",
|
||||
input: { plan: "# Fallback plan" },
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
kind: "switch_mode",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "# Fallback plan" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInfoFromToolUse - undefined input regression", () => {
|
||||
|
||||
13
src/tools.ts
13
src/tools.ts
@@ -130,7 +130,6 @@ export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
cwd?: string,
|
||||
usePlanUpdate: boolean = false,
|
||||
): ToolInfo {
|
||||
const name = toolUse.name;
|
||||
|
||||
@@ -434,15 +433,9 @@ export function toolInfoFromToolUse(
|
||||
return {
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
content:
|
||||
!usePlanUpdate && planInput?.plan
|
||||
? [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: { type: "text" as const, text: planInput.plan },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
content: planInput?.plan
|
||||
? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user