mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b7d1b9b3 | ||
|
|
32b16c1ad9 | ||
|
|
9fb631f5d7 | ||
|
|
5c81e99fe5 | ||
|
|
de37577eaf | ||
|
|
3c570c7673 | ||
|
|
96abf29334 | ||
|
|
93f58c0d2f | ||
|
|
6811943f57 | ||
|
|
d627b8c5e9 | ||
|
|
403a668078 | ||
|
|
fb9aced315 | ||
|
|
0a501434c0 | ||
|
|
c073131488 | ||
|
|
fcf5aaf06d | ||
|
|
cc73e37b41 | ||
|
|
41f04ec31c | ||
|
|
8acd0bd27f | ||
|
|
4b82236c1f | ||
|
|
0111786af2 | ||
|
|
aa82193330 | ||
|
|
26f3e8a521 |
16
.github/workflows/publish.yml
vendored
16
.github/workflows/publish.yml
vendored
@@ -16,14 +16,14 @@ jobs:
|
||||
# Generating a GitHub token, so that PRs and tags created by
|
||||
# the release-please-action can trigger actions workflows.
|
||||
- name: Generate GitHub token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3
|
||||
id: generate-token
|
||||
with:
|
||||
# GitHub App ID secret name
|
||||
app-id: ${{ secrets.RELEASE_PLZ_APP_ID }}
|
||||
# GitHub App private key secret name
|
||||
private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }}
|
||||
- uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071
|
||||
id: release
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
@@ -38,7 +38,8 @@ jobs:
|
||||
name: Build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
# TODO: Re-enable once Bun SDK compatibility is fixed
|
||||
if: false
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 15
|
||||
@@ -99,7 +100,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: ${{ matrix.archive }}
|
||||
path: ${{ matrix.archive }}
|
||||
@@ -107,7 +108,7 @@ jobs:
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries, release-please]
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
@@ -129,8 +130,9 @@ jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries, publish-npm, release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
needs: [publish-npm, release-please]
|
||||
# TODO: Re-enable once Bun SDK compatibility is fixed
|
||||
if: false
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.25.2"
|
||||
".": "0.27.0"
|
||||
}
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## [0.27.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.26.0...v0.27.0) (2026-04-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow clients to opt into receiving raw SDK messages ([#527](https://github.com/agentclientprotocol/claude-agent-acp/issues/527)) ([403a668](https://github.com/agentclientprotocol/claude-agent-acp/commit/403a668078c067868062ed26cd4d3e36665b66b6))
|
||||
* Update to claude-agent-sdk 0.2.104 ([#537](https://github.com/agentclientprotocol/claude-agent-acp/issues/537)) ([6811943](https://github.com/agentclientprotocol/claude-agent-acp/commit/6811943f57ef08616be633a8197223d4072663cf))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Allow auto mode after plan mode and send description for auto mode ([#528](https://github.com/agentclientprotocol/claude-agent-acp/issues/528)) ([fb9aced](https://github.com/agentclientprotocol/claude-agent-acp/commit/fb9aced3151c40694f1f01fd95665c4f5d90eb67))
|
||||
* Better remote check for auth methods ([#538](https://github.com/agentclientprotocol/claude-agent-acp/issues/538)) ([93f58c0](https://github.com/agentclientprotocol/claude-agent-acp/commit/93f58c0d2fcf7365c7ca5a6e52f56663e2065ddb))
|
||||
* better shutdown logic ([#543](https://github.com/agentclientprotocol/claude-agent-acp/issues/543)) ([9fb631f](https://github.com/agentclientprotocol/claude-agent-acp/commit/9fb631f5d76a5c5f6f0a1b61bdef05fe368c754c))
|
||||
* exit process when ACP connection closes ([#530](https://github.com/agentclientprotocol/claude-agent-acp/issues/530)) ([5c81e99](https://github.com/agentclientprotocol/claude-agent-acp/commit/5c81e99fe5ccdf774819b7ff3a2bc78a6519d730))
|
||||
* guard tool info rendering when tool_use input is undefined ([#536](https://github.com/agentclientprotocol/claude-agent-acp/issues/536)) ([d627b8c](https://github.com/agentclientprotocol/claude-agent-acp/commit/d627b8c5e95be31ac03923ef67d91748ec8564c5))
|
||||
* Remove backup auth check from new session ([#544](https://github.com/agentclientprotocol/claude-agent-acp/issues/544)) ([32b16c1](https://github.com/agentclientprotocol/claude-agent-acp/commit/32b16c1ad99394238b0e5cd6c2f761c12debe142))
|
||||
|
||||
## [0.26.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.3...v0.26.0) (2026-04-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update claude-agent-sdk to 0.2.96 ([#526](https://github.com/agentclientprotocol/claude-agent-acp/issues/526)) ([c073131](https://github.com/agentclientprotocol/claude-agent-acp/commit/c07313148808a55f27f385c10babbaf7511a7f12))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Remove bun builds from release ([#525](https://github.com/agentclientprotocol/claude-agent-acp/issues/525)) ([fcf5aaf](https://github.com/agentclientprotocol/claude-agent-acp/commit/fcf5aaf06dfe9f7d1b285b976eeb2d1e20ea8dec))
|
||||
* Use TUI login for remote environments ([#523](https://github.com/agentclientprotocol/claude-agent-acp/issues/523)) ([cc73e37](https://github.com/agentclientprotocol/claude-agent-acp/commit/cc73e37b41678aa67813c4fbef66ba33ca538743))
|
||||
|
||||
## [0.25.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.2...v0.25.3) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Drop claude-agent-sdk back to 0.2.91 to fix broken import ([#513](https://github.com/agentclientprotocol/claude-agent-acp/issues/513)) ([26f3e8a](https://github.com/agentclientprotocol/claude-agent-acp/commit/26f3e8a5216295985fadb80fb3b977045c0c1b2c))
|
||||
* Recreate resumed sessions when params change ([#515](https://github.com/agentclientprotocol/claude-agent-acp/issues/515)) ([aa82193](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa82193330026bae132fed8391c47dde777dcf5a))
|
||||
|
||||
## [0.25.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.1...v0.25.2) (2026-04-06)
|
||||
|
||||
|
||||
|
||||
587
package-lock.json
generated
587
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.25.2",
|
||||
"version": "0.27.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -60,22 +60,22 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.18.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.92",
|
||||
"@agentclientprotocol/sdk": "0.18.2",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.104",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.82.0",
|
||||
"@anthropic-ai/sdk": "0.88.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.5.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.0",
|
||||
"@typescript-eslint/parser": "8.58.0",
|
||||
"@types/node": "25.6.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"@typescript-eslint/parser": "8.58.1",
|
||||
"eslint": "10.2.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.4.0",
|
||||
"prettier": "3.8.1",
|
||||
"globals": "17.5.0",
|
||||
"prettier": "3.8.2",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.2"
|
||||
"vitest": "4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
234
src/acp-agent.ts
234
src/acp-agent.ts
@@ -61,7 +61,6 @@ import {
|
||||
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
@@ -114,6 +113,9 @@ type Session = {
|
||||
input: Pushable<SDKUserMessage>;
|
||||
cancelled: boolean;
|
||||
cwd: string;
|
||||
/** Serialized snapshot of session-defining params (cwd, mcpServers) used to
|
||||
* detect when loadSession/resumeSession is called with changed values. */
|
||||
sessionFingerprint: string;
|
||||
settingsManager: SettingsManager;
|
||||
accumulatedUsage: AccumulatedUsage;
|
||||
modes: SessionModeState;
|
||||
@@ -123,8 +125,21 @@ type Session = {
|
||||
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
|
||||
nextPendingOrder: number;
|
||||
abortController: AbortController;
|
||||
emitRawSDKMessages: boolean | SDKMessageFilter[];
|
||||
};
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
* detect when a loadSession/resumeSession call requires tearing down and
|
||||
* recreating the underlying Query process. MCP servers are sorted by name
|
||||
* so that ordering differences don't trigger unnecessary recreations. */
|
||||
function computeSessionFingerprint(params: {
|
||||
cwd: string;
|
||||
mcpServers?: NewSessionRequest["mcpServers"];
|
||||
}): string {
|
||||
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
||||
}
|
||||
|
||||
type BackgroundTerminal =
|
||||
| {
|
||||
handle: TerminalHandle;
|
||||
@@ -136,6 +151,11 @@ type BackgroundTerminal =
|
||||
pendingOutput: TerminalOutputResponse;
|
||||
};
|
||||
|
||||
export type SDKMessageFilter = {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra metadata that can be given when creating a new session.
|
||||
*/
|
||||
@@ -157,6 +177,14 @@ export type NewSessionMeta = {
|
||||
* - tools (passed through; defaults to claude_code preset if not provided)
|
||||
*/
|
||||
options?: Options;
|
||||
/**
|
||||
* When set, raw SDK messages are emitted as extNotification("_claude/sdkMessage", message)
|
||||
* in addition to normal processing.
|
||||
* - true: emit all messages
|
||||
* - false/undefined: emit nothing (default)
|
||||
* - SDKMessageFilter[]: emit only messages matching at least one filter
|
||||
*/
|
||||
emitRawSDKMessages?: boolean | SDKMessageFilter[];
|
||||
};
|
||||
additionalRoots?: string[];
|
||||
};
|
||||
@@ -311,39 +339,82 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
|
||||
const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
|
||||
|
||||
const claudeLoginMethod: any = {
|
||||
description: "Use Claude subscription ",
|
||||
name: "Claude Subscription",
|
||||
id: "claude-ai-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
};
|
||||
// Detect remote environments where the OAuth browser redirect to localhost
|
||||
// won't work. This matches the SDK's internal isRemote check. In these cases,
|
||||
// the `auth login` subcommand would fall back to a device-code-like manual
|
||||
// flow, which doesn't work well over ACP, so we offer the TUI login instead.
|
||||
const isRemote = !!(
|
||||
process.env.NO_BROWSER ||
|
||||
process.env.SSH_CONNECTION ||
|
||||
process.env.SSH_CLIENT ||
|
||||
process.env.SSH_TTY ||
|
||||
process.env.CLAUDE_CODE_REMOTE
|
||||
);
|
||||
const terminalAuthMethods: AuthMethod[] = [];
|
||||
|
||||
const consoleLoginMethod: any = {
|
||||
description: "Use Anthropic Console (API usage billing)",
|
||||
name: "Anthropic Console",
|
||||
id: "console-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
};
|
||||
if (isRemote) {
|
||||
const remoteLoginMethod: AuthMethod = {
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude",
|
||||
id: "claude-login",
|
||||
type: "terminal",
|
||||
args: ["--cli"],
|
||||
};
|
||||
|
||||
// If client supports terminal-auth capability, use that instead.
|
||||
if (supportsMetaTerminalAuth) {
|
||||
const baseArgs = process.argv.slice(1);
|
||||
claudeLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...baseArgs, "--cli", "auth", "login", "--claudeai"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
if (supportsMetaTerminalAuth) {
|
||||
remoteLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...process.argv.slice(1), "--cli"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
|
||||
terminalAuthMethods.push(remoteLoginMethod);
|
||||
}
|
||||
} else {
|
||||
const claudeLoginMethod: AuthMethod = {
|
||||
description: "Use Claude subscription ",
|
||||
name: "Claude Subscription",
|
||||
id: "claude-ai-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
};
|
||||
consoleLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...baseArgs, "--cli", "auth", "login", "--console"],
|
||||
label: "Anthropic Console Login",
|
||||
},
|
||||
|
||||
const consoleLoginMethod: AuthMethod = {
|
||||
description: "Use Anthropic Console (API usage billing)",
|
||||
name: "Anthropic Console",
|
||||
id: "console-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
};
|
||||
|
||||
if (supportsMetaTerminalAuth) {
|
||||
const baseArgs = process.argv.slice(1);
|
||||
claudeLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...baseArgs, "--cli", "auth", "login", "--claudeai"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
};
|
||||
consoleLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...baseArgs, "--cli", "auth", "login", "--console"],
|
||||
label: "Anthropic Console Login",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
|
||||
terminalAuthMethods.push(claudeLoginMethod);
|
||||
}
|
||||
if (supportsTerminalAuth || supportsMetaTerminalAuth) {
|
||||
terminalAuthMethods.push(consoleLoginMethod);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -375,25 +446,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
title: "Claude Agent",
|
||||
version: packageJson.version,
|
||||
},
|
||||
authMethods: [
|
||||
...(!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)
|
||||
? [claudeLoginMethod]
|
||||
: []),
|
||||
...(supportsTerminalAuth || supportsMetaTerminalAuth ? [consoleLoginMethod] : []),
|
||||
...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
|
||||
],
|
||||
authMethods: [...terminalAuthMethods, ...(supportsGatewayAuth ? [gatewayAuthMethod] : [])],
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
if (
|
||||
!this.gatewayAuthMeta &&
|
||||
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
|
||||
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
|
||||
) {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
const response = await this.createSession(params, {
|
||||
// Revisit these meta values once we support resume
|
||||
resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.resume,
|
||||
@@ -531,6 +588,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
session.emitRawSDKMessages &&
|
||||
shouldEmitRawMessage(session.emitRawSDKMessages, message)
|
||||
) {
|
||||
await this.client.extNotification("_claude/sdkMessage", {
|
||||
sessionId: params.sessionId,
|
||||
message: message as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
switch (message.subtype) {
|
||||
@@ -601,6 +668,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "task_started":
|
||||
case "task_notification":
|
||||
case "task_progress":
|
||||
case "task_updated":
|
||||
case "elicitation_complete":
|
||||
case "api_retry":
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
@@ -891,7 +959,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
const session = this.sessions[params.sessionId];
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
return;
|
||||
}
|
||||
session.cancelled = true;
|
||||
for (const [, pending] of session.pendingMessages) {
|
||||
@@ -901,17 +969,30 @@ export class ClaudeAcpAgent implements Agent {
|
||||
await session.query.interrupt();
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
const session = this.sessions[params.sessionId];
|
||||
/** Cleanly tear down a session: cancel in-flight work, dispose resources,
|
||||
* and remove it from the session map. */
|
||||
private async teardownSession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
return;
|
||||
}
|
||||
await this.cancel({ sessionId: params.sessionId });
|
||||
|
||||
await this.cancel({ sessionId });
|
||||
session.settingsManager.dispose();
|
||||
session.abortController.abort();
|
||||
delete this.sessions[params.sessionId];
|
||||
session.query.close();
|
||||
delete this.sessions[sessionId];
|
||||
}
|
||||
|
||||
/** Tear down all active sessions. Called when the ACP connection closes. */
|
||||
async dispose(): Promise<void> {
|
||||
await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
await this.teardownSession(params.sessionId);
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -1079,6 +1160,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
if (toolName === "ExitPlanMode") {
|
||||
const options = [
|
||||
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Yes, and auto-accept edits",
|
||||
@@ -1116,6 +1198,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "default" ||
|
||||
response.outcome.optionId === "acceptEdits" ||
|
||||
response.outcome.optionId === "auto" ||
|
||||
response.outcome.optionId === "bypassPermissions")
|
||||
) {
|
||||
await this.client.sessionUpdate({
|
||||
@@ -1260,12 +1343,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}): Promise<NewSessionResponse> {
|
||||
const existingSession = this.sessions[params.sessionId];
|
||||
if (existingSession) {
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
modes: existingSession.modes,
|
||||
models: existingSession.models,
|
||||
configOptions: existingSession.configOptions,
|
||||
};
|
||||
const fingerprint = computeSessionFingerprint(params);
|
||||
if (fingerprint === existingSession.sessionFingerprint) {
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
modes: existingSession.modes,
|
||||
models: existingSession.models,
|
||||
configOptions: existingSession.configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
// Session-defining params changed (e.g. cwd pointed at a git worktree,
|
||||
// or MCP servers reconfigured). Tear down the existing session and
|
||||
// recreate it so the underlying Query process picks up the new values.
|
||||
await this.teardownSession(params.sessionId);
|
||||
}
|
||||
|
||||
const response = await this.createSession(
|
||||
@@ -1487,7 +1578,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
decription: "Use a model classifier to approve/deny permission prompts.",
|
||||
description: "Use a model classifier to approve/deny permission prompts.",
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
@@ -1531,6 +1622,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
input: input,
|
||||
cancelled: false,
|
||||
cwd: params.cwd,
|
||||
sessionFingerprint: computeSessionFingerprint(params),
|
||||
settingsManager,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -1545,6 +1637,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController,
|
||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1556,6 +1649,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldEmitRawMessage(
|
||||
config: boolean | SDKMessageFilter[],
|
||||
message: { type: string; subtype?: string },
|
||||
): boolean {
|
||||
if (config === true) return true;
|
||||
if (config === false) return false;
|
||||
return config.some(
|
||||
(f) => f.type === message.type && (f.subtype === undefined || f.subtype === message.subtype),
|
||||
);
|
||||
}
|
||||
|
||||
function sessionUsage(session: Session) {
|
||||
return {
|
||||
inputTokens: session.accumulatedUsage.inputTokens,
|
||||
@@ -1940,8 +2044,8 @@ export function toAcpNotifications(
|
||||
const alreadyCached = chunk.id in toolUseCache;
|
||||
toolUseCache[chunk.id] = chunk;
|
||||
if (chunk.name === "TodoWrite") {
|
||||
// @ts-expect-error - sometimes input is empty object
|
||||
if (Array.isArray(chunk.input.todos)) {
|
||||
// @ts-expect-error - sometimes input is empty object or undefined
|
||||
if (Array.isArray(chunk.input?.todos)) {
|
||||
update = {
|
||||
sessionUpdate: "plan",
|
||||
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
|
||||
@@ -2095,6 +2199,7 @@ export function toAcpNotifications(
|
||||
case "container_upload":
|
||||
case "compaction":
|
||||
case "compaction_delta":
|
||||
case "advisor_tool_result":
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -2177,7 +2282,12 @@ export function runAcp() {
|
||||
const output = nodeToWebReadable(process.stdin);
|
||||
|
||||
const stream = ndJsonStream(input, output);
|
||||
new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
|
||||
let agent!: ClaudeAcpAgent;
|
||||
const connection = new AgentSideConnection((client) => {
|
||||
agent = new ClaudeAcpAgent(client);
|
||||
return agent;
|
||||
}, stream);
|
||||
return { connection, agent };
|
||||
}
|
||||
|
||||
function commonPrefixLength(a: string, b: string) {
|
||||
|
||||
19
src/index.ts
19
src/index.ts
@@ -24,8 +24,23 @@ if (process.argv.includes("--cli")) {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
runAcp();
|
||||
const { connection, agent } = runAcp();
|
||||
|
||||
// Keep process alive
|
||||
async function shutdown() {
|
||||
await agent.dispose().catch((err) => {
|
||||
console.error("Error during cleanup:", err);
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport
|
||||
// error). Without this, `process.stdin.resume()` keeps the event loop
|
||||
// alive indefinitely, causing orphan process accumulation in oneshot mode.
|
||||
connection.closed.then(shutdown);
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
// Keep process alive while connection is open
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
type SDKMessageFilter,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
|
||||
@@ -1328,6 +1328,7 @@ describe("stop reason propagation", () => {
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
@@ -1348,6 +1349,7 @@ describe("stop reason propagation", () => {
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1466,6 +1468,7 @@ describe("stop reason propagation", () => {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cwd: "/tmp/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/tmp/test", mcpServers: [] }),
|
||||
cancelled: false,
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
@@ -1487,6 +1490,7 @@ describe("stop reason propagation", () => {
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
emitRawSDKMessages: false,
|
||||
};
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1534,12 +1538,13 @@ describe("session/close", () => {
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
|
||||
function* empty() {}
|
||||
const gen = Object.assign(empty(), { interrupt: vi.fn() });
|
||||
const gen = Object.assign(empty(), { interrupt: vi.fn(), close: vi.fn() });
|
||||
agent.sessions[sessionId] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
@@ -1560,6 +1565,7 @@ describe("session/close", () => {
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -1609,6 +1615,143 @@ describe("session/close", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrCreateSession param change detection", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
function injectSession(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
opts: { cwd?: string; mcpServers?: { name: string }[] } = {},
|
||||
) {
|
||||
const cwd = opts.cwd ?? "/test";
|
||||
const mcpServers = (opts.mcpServers ?? []) as any[];
|
||||
function* empty() {}
|
||||
const gen = Object.assign(empty(), {
|
||||
interrupt: vi.fn(),
|
||||
close: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
agent.sessions[sessionId] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd,
|
||||
sessionFingerprint: JSON.stringify({
|
||||
cwd,
|
||||
mcpServers: [...mcpServers].sort((a: any, b: any) => a.name.localeCompare(b.name)),
|
||||
}),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
|
||||
it("returns cached session when params are unchanged", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "s1", { cwd: "/project" });
|
||||
|
||||
await agent.unstable_resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
// Session object should be the exact same reference (not recreated)
|
||||
expect(agent.sessions["s1"]).toBe(session);
|
||||
expect(session.settingsManager.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tears down existing session when cwd changes", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "s1", { cwd: "/old" });
|
||||
|
||||
// Mock createSession to avoid spawning a real process.
|
||||
// It will throw, but we can catch that — we only need to verify
|
||||
// the old session was torn down before createSession was attempted.
|
||||
const createSessionSpy = vi
|
||||
.spyOn(agent as any, "createSession")
|
||||
.mockRejectedValue(new Error("mock"));
|
||||
|
||||
await expect(
|
||||
agent.unstable_resumeSession({ sessionId: "s1", cwd: "/new", mcpServers: [] }),
|
||||
).rejects.toThrow("mock");
|
||||
|
||||
// Old session should have been fully torn down
|
||||
expect(session.settingsManager.dispose).toHaveBeenCalled();
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
expect(session.query.interrupt).toHaveBeenCalled();
|
||||
expect(agent.sessions["s1"]).toBeUndefined();
|
||||
|
||||
// createSession should have been called with the new cwd
|
||||
expect(createSessionSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/new" }),
|
||||
expect.objectContaining({ resume: "s1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("tears down existing session when mcpServers change", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "s1", { cwd: "/project" });
|
||||
|
||||
const createSessionSpy = vi
|
||||
.spyOn(agent as any, "createSession")
|
||||
.mockRejectedValue(new Error("mock"));
|
||||
|
||||
await expect(
|
||||
agent.unstable_resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [{ name: "new-server", command: "node", args: ["server.js"], env: [] }],
|
||||
}),
|
||||
).rejects.toThrow("mock");
|
||||
|
||||
expect(session.settingsManager.dispose).toHaveBeenCalled();
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
expect(agent.sessions["s1"]).toBeUndefined();
|
||||
expect(createSessionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats mcpServers in different order as unchanged", async () => {
|
||||
const agent = createMockAgent();
|
||||
const servers = [
|
||||
{ name: "b-server", command: "node", args: ["b.js"], env: [] },
|
||||
{ name: "a-server", command: "node", args: ["a.js"], env: [] },
|
||||
] as const;
|
||||
const session = injectSession(agent, "s1", {
|
||||
cwd: "/project",
|
||||
mcpServers: servers as any,
|
||||
});
|
||||
|
||||
// Same servers but reversed order — should NOT trigger teardown
|
||||
await agent.unstable_resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [...servers].reverse() as any,
|
||||
});
|
||||
|
||||
expect(agent.sessions["s1"]).toBe(session);
|
||||
expect(session.settingsManager.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage_update computation", () => {
|
||||
function createAssistantMessage(overrides: {
|
||||
model: string;
|
||||
@@ -1710,6 +1853,7 @@ describe("usage_update computation", () => {
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
@@ -1730,6 +1874,7 @@ describe("usage_update computation", () => {
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2051,3 +2196,199 @@ describe("usage_update computation", () => {
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emitRawSDKMessages", () => {
|
||||
function createMockAgentWithExtNotification() {
|
||||
const updates: any[] = [];
|
||||
const extNotifications: { method: string; params: any }[] = [];
|
||||
const mockClient = {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
extNotification: async (method: string, params: any) => {
|
||||
extNotifications.push({ method, params });
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, updates, extNotifications };
|
||||
}
|
||||
|
||||
function injectSession(
|
||||
agent: ClaudeAcpAgent,
|
||||
messages: any[],
|
||||
emitRawSDKMessages: boolean | { type: string; subtype?: string }[],
|
||||
) {
|
||||
const input = new Pushable<any>();
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage, done } = await iter.next();
|
||||
if (!done && userMessage) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
yield* messages;
|
||||
}
|
||||
agent.sessions["test-session"] = {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages,
|
||||
};
|
||||
}
|
||||
|
||||
function createResultMessage() {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: "success" as const,
|
||||
is_error: false,
|
||||
result: "",
|
||||
errors: [],
|
||||
stop_reason: "end_turn" as const,
|
||||
cost_usd: 0,
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
|
||||
it("emits all raw messages when set to true", async () => {
|
||||
const { agent, extNotifications } = createMockAgentWithExtNotification();
|
||||
const systemMsg = {
|
||||
type: "system",
|
||||
subtype: "status",
|
||||
status: "compacting",
|
||||
session_id: "test-session",
|
||||
};
|
||||
injectSession(
|
||||
agent,
|
||||
[
|
||||
systemMsg,
|
||||
createResultMessage(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
// Should have emitted extNotifications for all messages (user replay + system + result + session_state_changed)
|
||||
expect(extNotifications.length).toBeGreaterThanOrEqual(3);
|
||||
expect(extNotifications.every((n) => n.method === "_claude/sdkMessage")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not emit when set to false", async () => {
|
||||
const { agent, extNotifications } = createMockAgentWithExtNotification();
|
||||
injectSession(
|
||||
agent,
|
||||
[
|
||||
{ type: "system", subtype: "status", status: "compacting", session_id: "test-session" },
|
||||
createResultMessage(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
expect(extNotifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits only messages matching a filter array", async () => {
|
||||
const { agent, extNotifications } = createMockAgentWithExtNotification();
|
||||
injectSession(
|
||||
agent,
|
||||
[
|
||||
{ type: "system", subtype: "compact_boundary", session_id: "test-session" },
|
||||
{ type: "system", subtype: "status", status: "compacting", session_id: "test-session" },
|
||||
createResultMessage(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
],
|
||||
[{ type: "system", subtype: "compact_boundary" }],
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
// Only the compact_boundary message should have been emitted
|
||||
const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage");
|
||||
expect(sdkMessages).toHaveLength(1);
|
||||
expect(sdkMessages[0].params.sessionId).toBe("test-session");
|
||||
expect(sdkMessages[0].params.message.type).toBe("system");
|
||||
expect(sdkMessages[0].params.message.subtype).toBe("compact_boundary");
|
||||
});
|
||||
|
||||
it("filter without subtype matches all messages of that type", async () => {
|
||||
const { agent, extNotifications } = createMockAgentWithExtNotification();
|
||||
injectSession(
|
||||
agent,
|
||||
[
|
||||
{ type: "system", subtype: "compact_boundary", session_id: "test-session" },
|
||||
{ type: "system", subtype: "status", status: "compacting", session_id: "test-session" },
|
||||
createResultMessage(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
],
|
||||
[{ type: "system" }],
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage");
|
||||
// All system messages should match (compact_boundary + status + session_state_changed)
|
||||
const systemMessages = sdkMessages.filter((n) => n.params.message.type === "system");
|
||||
expect(systemMessages).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("supports multiple filters", async () => {
|
||||
const { agent, extNotifications } = createMockAgentWithExtNotification();
|
||||
injectSession(
|
||||
agent,
|
||||
[
|
||||
{ type: "system", subtype: "compact_boundary", session_id: "test-session" },
|
||||
{ type: "system", subtype: "status", status: "compacting", session_id: "test-session" },
|
||||
createResultMessage(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
],
|
||||
[{ type: "system", subtype: "compact_boundary" }, { type: "result" }],
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage");
|
||||
expect(sdkMessages).toHaveLength(2);
|
||||
expect(sdkMessages[0].params.message.type).toBe("system");
|
||||
expect(sdkMessages[0].params.message.subtype).toBe("compact_boundary");
|
||||
expect(sdkMessages[1].params.message.type).toBe("result");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,6 +169,64 @@ describe("authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("SSH session falls back to single legacy login method", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, env: { ...process.env, SSH_TTY: "/dev/pts/0" } });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("CLAUDE_CODE_REMOTE falls back to single legacy login method", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, env: { ...process.env, CLAUDE_CODE_REMOTE: "1" } });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("remote environment respects hide-claude-auth", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", {
|
||||
...process,
|
||||
argv: ["--hide-claude-auth"],
|
||||
env: { ...process.env, SSH_CONNECTION: "192.168.1.1 12345 192.168.1.2 22" },
|
||||
});
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("show claude authentication", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
import { toolUpdateFromToolResult, createPostToolUseHook, toolInfoFromToolUse } from "../tools.js";
|
||||
import {
|
||||
toolUpdateFromToolResult,
|
||||
createPostToolUseHook,
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
} from "../tools.js";
|
||||
|
||||
describe("rawOutput in tool call updates", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
@@ -1271,3 +1276,112 @@ describe("toolInfoFromToolUse - ExitPlanMode", () => {
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInfoFromToolUse - undefined input regression", () => {
|
||||
it("Read with undefined input should not throw", () => {
|
||||
const toolUse = { name: "Read", id: "toolu_read_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("Read File");
|
||||
expect(info.locations).toEqual([]);
|
||||
});
|
||||
|
||||
it("Grep with undefined input should not throw", () => {
|
||||
const toolUse = { name: "Grep", id: "toolu_grep_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("grep");
|
||||
});
|
||||
|
||||
it("Glob with undefined input should not throw", () => {
|
||||
const toolUse = { name: "Glob", id: "toolu_glob_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("Find");
|
||||
expect(info.locations).toEqual([]);
|
||||
});
|
||||
|
||||
it("WebSearch with undefined input should not throw", () => {
|
||||
const toolUse = { name: "WebSearch", id: "toolu_ws_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("Web search");
|
||||
});
|
||||
|
||||
it("TodoWrite with undefined input should not throw", () => {
|
||||
const toolUse = { name: "TodoWrite", id: "toolu_todo_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("Update TODOs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planEntries - undefined input regression", () => {
|
||||
it("should return empty array when input is undefined", () => {
|
||||
expect(planEntries(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when input has no todos", () => {
|
||||
expect(planEntries({} as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should still map valid todos correctly", () => {
|
||||
const result = planEntries({
|
||||
todos: [
|
||||
{ content: "Task 1", status: "pending", activeForm: "" },
|
||||
{ content: "Task 2", status: "completed", activeForm: "" },
|
||||
],
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ content: "Task 1", status: "pending", priority: "medium" },
|
||||
{ content: "Task 2", status: "completed", priority: "medium" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpNotifications - TodoWrite with undefined input regression", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
it("should not throw when TodoWrite tool_use has undefined input", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_todo_undef",
|
||||
name: "TodoWrite",
|
||||
input: undefined as any,
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
// TodoWrite with undefined input should not crash, and should not emit plan update
|
||||
const planUpdates = notifications.filter((n) => (n.update as any).sessionUpdate === "plan");
|
||||
expect(planUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should still emit plan update when TodoWrite has valid input", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_todo_valid",
|
||||
name: "TodoWrite",
|
||||
input: { todos: [{ content: "Do X", status: "pending" }] },
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
const planUpdates = notifications.filter((n) => (n.update as any).sessionUpdate === "plan");
|
||||
expect(planUpdates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
72
src/tools.ts
72
src/tools.ts
@@ -128,7 +128,7 @@ export function toolInfoFromToolUse(
|
||||
switch (name) {
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
const input = toolUse.input as AgentInput | BashInput;
|
||||
const input = toolUse.input as AgentInput | BashInput | undefined;
|
||||
return {
|
||||
title: input?.description ? input.description : "Task",
|
||||
kind: "think",
|
||||
@@ -145,7 +145,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Bash": {
|
||||
const input = toolUse.input as BashInput;
|
||||
const input = toolUse.input as BashInput | undefined;
|
||||
return {
|
||||
title: input?.command ? input.command : "Terminal",
|
||||
kind: "execute",
|
||||
@@ -163,18 +163,18 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Read": {
|
||||
const input = toolUse.input as FileReadInput;
|
||||
const input = toolUse.input as FileReadInput | undefined;
|
||||
let limit = "";
|
||||
if (input.limit && input.limit > 0) {
|
||||
if (input?.limit && input.limit > 0) {
|
||||
limit = " (" + (input.offset ?? 1) + " - " + ((input.offset ?? 1) + input.limit - 1) + ")";
|
||||
} else if (input.offset) {
|
||||
} else if (input?.offset) {
|
||||
limit = " (from line " + input.offset + ")";
|
||||
}
|
||||
const displayPath = input.file_path ? toDisplayPath(input.file_path, cwd) : "File";
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : "File";
|
||||
return {
|
||||
title: "Read " + displayPath + limit,
|
||||
kind: "read",
|
||||
locations: input.file_path
|
||||
locations: input?.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
@@ -187,7 +187,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Write": {
|
||||
const input = toolUse.input as FileWriteInput;
|
||||
const input = toolUse.input as FileWriteInput | undefined;
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path) {
|
||||
content = [
|
||||
@@ -216,7 +216,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Edit": {
|
||||
const input = toolUse.input as FileEditInput;
|
||||
const input = toolUse.input as FileEditInput | undefined;
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path && (input.old_string || input.new_string)) {
|
||||
content = [
|
||||
@@ -238,44 +238,44 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Glob": {
|
||||
const input = toolUse.input as GlobInput;
|
||||
const input = toolUse.input as GlobInput | undefined;
|
||||
let label = "Find";
|
||||
if (input.path) {
|
||||
if (input?.path) {
|
||||
label += ` \`${input.path}\``;
|
||||
}
|
||||
if (input.pattern) {
|
||||
if (input?.pattern) {
|
||||
label += ` \`${input.pattern}\``;
|
||||
}
|
||||
return {
|
||||
title: label,
|
||||
kind: "search",
|
||||
content: [],
|
||||
locations: input.path ? [{ path: input.path }] : [],
|
||||
locations: input?.path ? [{ path: input.path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Grep": {
|
||||
const input = toolUse.input as GrepInput;
|
||||
const input = toolUse.input as GrepInput | undefined;
|
||||
let label = "grep";
|
||||
|
||||
if (input["-i"]) {
|
||||
if (input?.["-i"]) {
|
||||
label += " -i";
|
||||
}
|
||||
if (input["-n"]) {
|
||||
if (input?.["-n"]) {
|
||||
label += " -n";
|
||||
}
|
||||
|
||||
if (input["-A"] !== undefined) {
|
||||
if (input?.["-A"] !== undefined) {
|
||||
label += ` -A ${input["-A"]}`;
|
||||
}
|
||||
if (input["-B"] !== undefined) {
|
||||
if (input?.["-B"] !== undefined) {
|
||||
label += ` -B ${input["-B"]}`;
|
||||
}
|
||||
if (input["-C"] !== undefined) {
|
||||
if (input?.["-C"] !== undefined) {
|
||||
label += ` -C ${input["-C"]}`;
|
||||
}
|
||||
|
||||
if (input.output_mode) {
|
||||
if (input?.output_mode) {
|
||||
switch (input.output_mode) {
|
||||
case "files_with_matches":
|
||||
label += " -l";
|
||||
@@ -289,27 +289,27 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
}
|
||||
|
||||
if (input.head_limit !== undefined) {
|
||||
if (input?.head_limit !== undefined) {
|
||||
label += ` | head -${input.head_limit}`;
|
||||
}
|
||||
|
||||
if (input.glob) {
|
||||
if (input?.glob) {
|
||||
label += ` --include="${input.glob}"`;
|
||||
}
|
||||
|
||||
if (input.type) {
|
||||
if (input?.type) {
|
||||
label += ` --type=${input.type}`;
|
||||
}
|
||||
|
||||
if (input.multiline) {
|
||||
if (input?.multiline) {
|
||||
label += " -P";
|
||||
}
|
||||
|
||||
if (input.pattern) {
|
||||
if (input?.pattern) {
|
||||
label += ` "${input.pattern}"`;
|
||||
}
|
||||
|
||||
if (input.path) {
|
||||
if (input?.path) {
|
||||
label += ` ${input.path}`;
|
||||
}
|
||||
|
||||
@@ -338,14 +338,14 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "WebSearch": {
|
||||
const input = toolUse.input as WebSearchInput;
|
||||
let label = `"${input.query}"`;
|
||||
const input = toolUse.input as WebSearchInput | undefined;
|
||||
let label = input?.query ? `"${input.query}"` : "Web search";
|
||||
|
||||
if (input.allowed_domains && input.allowed_domains.length > 0) {
|
||||
if (input?.allowed_domains && input.allowed_domains.length > 0) {
|
||||
label += ` (allowed: ${input.allowed_domains.join(", ")})`;
|
||||
}
|
||||
|
||||
if (input.blocked_domains && input.blocked_domains.length > 0) {
|
||||
if (input?.blocked_domains && input.blocked_domains.length > 0) {
|
||||
label += ` (blocked: ${input.blocked_domains.join(", ")})`;
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "TodoWrite": {
|
||||
const input = toolUse.input as TodoWriteInput;
|
||||
const input = toolUse.input as TodoWriteInput | undefined;
|
||||
return {
|
||||
title: Array.isArray(input?.todos)
|
||||
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
||||
@@ -368,7 +368,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "ExitPlanMode": {
|
||||
const planInput = toolUse.input as { plan?: string };
|
||||
const planInput = toolUse.input as { plan?: string } | undefined;
|
||||
return {
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
@@ -661,10 +661,10 @@ export type ClaudePlanEntry = {
|
||||
activeForm: string;
|
||||
};
|
||||
|
||||
export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
|
||||
return input.todos.map((input) => ({
|
||||
content: input.content,
|
||||
status: input.status,
|
||||
export function planEntries(input: { todos: ClaudePlanEntry[] } | undefined): PlanEntry[] {
|
||||
return (input?.todos ?? []).map((todo) => ({
|
||||
content: todo.content,
|
||||
status: todo.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user