From cdd19f20efb0558a895c8320f614ed36d489d2d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 02:35:15 +0000 Subject: [PATCH 01/24] feat(use-local-agent): bump @agentclientprotocol/sdk to ^0.20.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Picks up reliability fixes from 0.17โ†’0.20: - PR #103/#111: clean transport-failure handling, propagate ndjson errors - PR #119: parse final NDJSON message without trailing newline - PR #122: no spurious unhandledRejection on transport failures - PR #127: TS private keyword for cross-copy compatibility - PR #130: response/notification ordering fix - Renames stabilized methods (PR #132): - unstable_resumeSession โ†’ resumeSession - unstable_closeSession โ†’ closeSession - Updates LocalAgent, mock-agent harness, echo-agent fixture - Adds resume-close.test.ts regression tests Co-authored-by: Aiden Bai --- apps/playground/src/echo-agent.mjs | 4 +- packages/use-local-agent/package.json | 2 +- packages/use-local-agent/src/local-agent.ts | 4 +- .../use-local-agent/src/testing/mock-agent.ts | 16 ++--- .../tests/resume-close.test.ts | 72 +++++++++++++++++++ pnpm-lock.yaml | 13 +++- 6 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 packages/use-local-agent/tests/resume-close.test.ts diff --git a/apps/playground/src/echo-agent.mjs b/apps/playground/src/echo-agent.mjs index 4d21a7b..04f0cd8 100644 --- a/apps/playground/src/echo-agent.mjs +++ b/apps/playground/src/echo-agent.mjs @@ -61,8 +61,8 @@ const agent = { }, loadSession: async () => ({}), - unstable_resumeSession: async () => ({}), - unstable_closeSession: async () => ({}), + resumeSession: async () => ({}), + closeSession: async () => ({}), listSessions: async () => ({ sessions: [] }), setSessionMode: async () => ({}), setSessionConfigOption: async (params) => ({ diff --git a/packages/use-local-agent/package.json b/packages/use-local-agent/package.json index 719f85c..3e73aad 100644 --- a/packages/use-local-agent/package.json +++ b/packages/use-local-agent/package.json @@ -33,7 +33,7 @@ "check": "vp check" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1" + "@agentclientprotocol/sdk": "^0.20.0" }, "devDependencies": { "@types/node": "^22.19.17", diff --git a/packages/use-local-agent/src/local-agent.ts b/packages/use-local-agent/src/local-agent.ts index 9897074..b8d9724 100644 --- a/packages/use-local-agent/src/local-agent.ts +++ b/packages/use-local-agent/src/local-agent.ts @@ -451,7 +451,7 @@ export class LocalAgent { const cwd = input.cwd ?? this.#defaultCwd; const mcpServers = this.#validateMcpServers(input.mcpServers ?? this.#defaultMcpServers); try { - await this.#connection.connection.unstable_resumeSession({ + await this.#connection.connection.resumeSession({ sessionId: input.sessionId, cwd, mcpServers: [...mcpServers], @@ -481,7 +481,7 @@ export class LocalAgent { const state = this.#sessions.get(sessionId); if (state) this.#cleanupSession(state, undefined); try { - await this.#connection.connection.unstable_closeSession({ sessionId }); + await this.#connection.connection.closeSession({ sessionId }); } finally { this.#sessions.delete(sessionId); this.#pendingUpdates.delete(sessionId); diff --git a/packages/use-local-agent/src/testing/mock-agent.ts b/packages/use-local-agent/src/testing/mock-agent.ts index 60bc630..c6d19ba 100644 --- a/packages/use-local-agent/src/testing/mock-agent.ts +++ b/packages/use-local-agent/src/testing/mock-agent.ts @@ -47,10 +47,10 @@ export interface MockAgentHandlers { request: LoadSessionRequest, conn: AgentSideConnection, ) => LoadSessionResponse | Promise; - unstable_resumeSession?: ( + resumeSession?: ( request: ResumeSessionRequest, ) => ResumeSessionResponse | Promise; - unstable_closeSession?: ( + closeSession?: ( request: CloseSessionRequest, ) => CloseSessionResponse | Promise; unstable_forkSession?: ( @@ -128,16 +128,16 @@ class MockAcpAgent implements Agent { return {}; } - async unstable_resumeSession(request: ResumeSessionRequest): Promise { - if (this.handlers.unstable_resumeSession) { - return this.handlers.unstable_resumeSession(request); + async resumeSession(request: ResumeSessionRequest): Promise { + if (this.handlers.resumeSession) { + return this.handlers.resumeSession(request); } return {}; } - async unstable_closeSession(request: CloseSessionRequest): Promise { - if (this.handlers.unstable_closeSession) { - return this.handlers.unstable_closeSession(request); + async closeSession(request: CloseSessionRequest): Promise { + if (this.handlers.closeSession) { + return this.handlers.closeSession(request); } return {}; } diff --git a/packages/use-local-agent/tests/resume-close.test.ts b/packages/use-local-agent/tests/resume-close.test.ts new file mode 100644 index 0000000..dc28530 --- /dev/null +++ b/packages/use-local-agent/tests/resume-close.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; +import { CapabilityNotSupportedError } from "../src/errors"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { SessionId } from "../src/types"; + +describe("resumeSession / closeSession (SDK 0.20 stabilized)", () => { + it("resumeSession succeeds when sessionCapabilities.resume is advertised", async () => { + let resumed: string | undefined; + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { resume: {} } }, + }), + resumeSession: (request) => { + resumed = request.sessionId; + return {}; + }, + }); + + const result = await session.agent.resumeSession({ + sessionId: "abc" as SessionId, + cwd: "/tmp", + }); + expect(result).toBe("abc"); + expect(resumed).toBe("abc"); + await session.close(); + }); + + it("resumeSession throws CapabilityNotSupportedError when not advertised", async () => { + const session = await connectMockAgent({ + initialize: () => ({ protocolVersion: 1, agentCapabilities: {} }), + }); + + await expect( + session.agent.resumeSession({ sessionId: "abc" as SessionId, cwd: "/tmp" }), + ).rejects.toBeInstanceOf(CapabilityNotSupportedError); + await session.close(); + }); + + it("closeSession succeeds when sessionCapabilities.close is advertised", async () => { + let closed: string | undefined; + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { close: {} } }, + }), + newSession: () => ({ sessionId: "s1" }), + closeSession: (request) => { + closed = request.sessionId; + return {}; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await session.agent.closeSession(sessionId); + expect(closed).toBe(sessionId); + await session.close(); + }); + + it("closeSession throws CapabilityNotSupportedError when not advertised", async () => { + const session = await connectMockAgent({ + initialize: () => ({ protocolVersion: 1, agentCapabilities: {} }), + newSession: () => ({ sessionId: "s1" }), + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await expect(session.agent.closeSession(sessionId)).rejects.toBeInstanceOf( + CapabilityNotSupportedError, + ); + await session.close(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c32cf6..bcf7cb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,8 +68,8 @@ importers: specifier: ^0.24.0 version: 0.24.2 '@agentclientprotocol/sdk': - specifier: ^0.16.1 - version: 0.16.1(zod@4.3.6) + specifier: ^0.20.0 + version: 0.20.0(zod@4.3.6) '@zed-industries/codex-acp': specifier: ^0.10.0 version: 0.10.0 @@ -97,6 +97,11 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@agentclientprotocol/sdk@0.20.0': + resolution: {integrity: sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@anthropic-ai/claude-agent-sdk@0.2.84': resolution: {integrity: sha512-rvp3kZJM4IgDBE1zwj30H3N0bI3pYRF28tDJoyAVuWTLiWls7diNVCyFz7GeXZEAYYD87lCBE3vnQplLLluNHg==} engines: {node: '>=18.0.0'} @@ -1471,6 +1476,10 @@ snapshots: dependencies: zod: 4.3.6 + '@agentclientprotocol/sdk@0.20.0(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@anthropic-ai/claude-agent-sdk@0.2.84(zod@4.3.6)': dependencies: zod: 4.3.6 From 3b59ec18b2d2311efff7206c6637e542c0828700 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 02:39:59 +0000 Subject: [PATCH 02/24] feat(use-local-agent): transport reliability hardening (Phase 2) - connect.ts: - Wait for spawn or error event before constructing connection (avoids races with later spawn errors leaking unhandled rejections). - Add disposeGraceMs option (default unchanged at 2000ms). - Add onTrace hook for inspecting in/out JSON-RPC messages and stderr. - Add envFilter hook for env scrubbing. - local-agent.ts: - Race connection.initialize() against subprocess close so a process that exits before responding fails fast as AgentConnectionClosedError (instead of waiting initializeTimeoutMs). - Gate stderr-pattern fatal detection (auth/usage) until after init succeeds, eliminating false positives during boot/login banners. - Allow callers to override stderrFatalPatterns. - utils/run-command.ts: Escalate to SIGKILL after a grace when SIGTERM is ignored on timeout. - New tests: - tests/spawn-failures.test.ts: ENOENT, fast subprocess exit - tests/stderr-fatal-detection.test.ts: pre/post-init stderr gating Co-authored-by: Aiden Bai --- packages/use-local-agent/src/connect.ts | 82 +++++++++++++++- packages/use-local-agent/src/local-agent.ts | 54 +++++++++-- .../use-local-agent/src/utils/run-command.ts | 5 + .../tests/spawn-failures.test.ts | 71 ++++++++++++++ .../tests/stderr-fatal-detection.test.ts | 96 +++++++++++++++++++ 5 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 packages/use-local-agent/tests/spawn-failures.test.ts create mode 100644 packages/use-local-agent/tests/stderr-fatal-detection.test.ts diff --git a/packages/use-local-agent/src/connect.ts b/packages/use-local-agent/src/connect.ts index f60f531..76c2e0d 100644 --- a/packages/use-local-agent/src/connect.ts +++ b/packages/use-local-agent/src/connect.ts @@ -1,17 +1,22 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { Readable } from "node:stream"; -import type { Client } from "@agentclientprotocol/sdk"; +import type { AnyMessage, Client } from "@agentclientprotocol/sdk"; import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; import type { AgentAdapter } from "./adapter"; import { DEFAULT_DISPOSE_GRACE_MS, DEFAULT_STDERR_TAIL_LIMIT_BYTES } from "./constants"; import { AgentSpawnError } from "./errors"; +export type TraceDirection = "out" | "in" | "stderr"; + export interface ConnectOptions { readonly client: Client; readonly onStderr?: (line: string) => void; readonly onExit?: (exitCode: number | null, signal: NodeJS.Signals | null) => void; readonly extraEnv?: NodeJS.ProcessEnv; readonly stderrTailLimit?: number; + readonly disposeGraceMs?: number; + readonly onTrace?: (direction: TraceDirection, payload: unknown) => void; + readonly envFilter?: (env: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; } export interface ConnectResult { @@ -29,11 +34,12 @@ export const connect = async ( options: ConnectOptions, ): Promise => { const resolved = await adapter.resolve(); - const env: NodeJS.ProcessEnv = { + const mergedEnv: NodeJS.ProcessEnv = { ...process.env, ...resolved.env, ...options.extraEnv, }; + const env = options.envFilter ? options.envFilter(mergedEnv) : mergedEnv; let child: ChildProcessWithoutNullStreams; try { @@ -45,6 +51,8 @@ export const connect = async ( throw new AgentSpawnError(adapter.id, cause); } + await waitForSpawnOrError(child, adapter.id); + const stderrLimit = options.stderrTailLimit ?? DEFAULT_STDERR_TAIL_LIMIT_BYTES; let stderrTail = ""; const appendStderrTail = (chunk: string): void => { @@ -59,6 +67,7 @@ export const connect = async ( child.stderr.on("data", (chunk: Buffer) => { const text = chunk.toString("utf8"); appendStderrTail(text); + options.onTrace?.("stderr", text); if (!options.onStderr) return; stderrLineBuffer += text; let newlineIndex = stderrLineBuffer.indexOf("\n"); @@ -102,15 +111,17 @@ export const connect = async ( }), }); - const stream = ndJsonStream(childStdinWritable, childStdoutWebStream); + const baseStream = ndJsonStream(childStdinWritable, childStdoutWebStream); + const stream = options.onTrace ? wrapStreamWithTrace(baseStream, options.onTrace) : baseStream; const connection = new ClientSideConnection(() => options.client, stream); + const disposeGraceMs = options.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS; const dispose = async (): Promise => { if (!child.killed) { child.kill("SIGTERM"); const killTimer = setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); - }, DEFAULT_DISPOSE_GRACE_MS); + }, disposeGraceMs); try { await closed; } finally { @@ -129,3 +140,66 @@ export const connect = async ( dispose, }; }; + +const waitForSpawnOrError = ( + child: ChildProcessWithoutNullStreams, + adapterId: string, +): Promise => + new Promise((resolveSpawn, rejectSpawn) => { + const onSpawn = (): void => { + child.removeListener("error", onError); + resolveSpawn(); + }; + const onError = (error: Error): void => { + child.removeListener("spawn", onSpawn); + rejectSpawn(new AgentSpawnError(adapterId, error)); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); + +const wrapStreamWithTrace = ( + base: { writable: WritableStream; readable: ReadableStream }, + onTrace: (direction: TraceDirection, payload: unknown) => void, +): { writable: WritableStream; readable: ReadableStream } => { + const tracedReadable = base.readable.pipeThrough( + new TransformStream({ + transform(message, controller) { + try { + onTrace("in", message); + } catch {} + controller.enqueue(message); + }, + }), + ); + const tracedWritable = new WritableStream({ + async write(message) { + try { + onTrace("out", message); + } catch {} + const writer = base.writable.getWriter(); + try { + await writer.write(message); + } finally { + writer.releaseLock(); + } + }, + async close() { + const writer = base.writable.getWriter(); + try { + await writer.close(); + } finally { + writer.releaseLock(); + } + }, + async abort(reason) { + const writer = base.writable.getWriter(); + try { + await writer.abort(reason); + } finally { + writer.releaseLock(); + } + }, + }); + return { writable: tracedWritable, readable: tracedReadable }; +}; diff --git a/packages/use-local-agent/src/local-agent.ts b/packages/use-local-agent/src/local-agent.ts index b8d9724..8b9c41c 100644 --- a/packages/use-local-agent/src/local-agent.ts +++ b/packages/use-local-agent/src/local-agent.ts @@ -93,6 +93,13 @@ export interface LocalAgentConnectOptions { readonly clock?: Clock; readonly onStderr?: (line: string) => void; readonly stderrTailLimit?: number; + readonly disposeGraceMs?: number; + readonly onTrace?: (direction: "out" | "in" | "stderr", payload: unknown) => void; + readonly envFilter?: (env: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; + readonly stderrFatalPatterns?: { + readonly auth?: ReadonlyArray; + readonly usage?: ReadonlyArray; + }; } interface SessionState { @@ -208,19 +215,29 @@ export class LocalAgent { const { dispatcher, clientCapabilities, clientInfo, onFatalError, fatalErrorListeners } = LocalAgent.#buildDispatcher(options); + const stderrFatalEnabled = { value: false }; + + const authPatterns: ReadonlyArray = + options.stderrFatalPatterns?.auth ?? AUTH_FAILURE_PATTERNS; + const usagePatterns: ReadonlyArray = + options.stderrFatalPatterns?.usage ?? USAGE_LIMIT_PATTERNS; const connectionResult = await connect(adapter, { stderrTailLimit: options.stderrTailLimit, extraEnv: options.env, + ...(options.disposeGraceMs !== undefined ? { disposeGraceMs: options.disposeGraceMs } : {}), + ...(options.onTrace ? { onTrace: options.onTrace } : {}), + ...(options.envFilter ? { envFilter: options.envFilter } : {}), onStderr: (line) => { options.onStderr?.(line); - if (matchesAny(line, AUTH_FAILURE_PATTERNS)) { + if (!stderrFatalEnabled.value) return; + if (matchesAny(line, authPatterns)) { onFatalError( new AgentUnauthenticatedError( adapter.id, `${adapter.displayName} reported authentication failure: ${line}`, ), ); - } else if (matchesAny(line, USAGE_LIMIT_PATTERNS)) { + } else if (matchesAny(line, usagePatterns)) { onFatalError(new AgentUsageLimitError(adapter.id, line)); } }, @@ -232,13 +249,15 @@ export class LocalAgent { }, }); - return LocalAgent.fromConnectResult(connectionResult, { + const localAgent = await LocalAgent.fromConnectResult(connectionResult, { options, dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, }); + stderrFatalEnabled.value = true; + return localAgent; } static buildDispatcher(options: LocalAgentConnectOptions): { @@ -267,19 +286,38 @@ export class LocalAgent { const initializeTimeoutMs = init.options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS; let initResponse: InitializeResponse; + let processExited: { exitCode: number | null; signal: NodeJS.Signals | null } | undefined; + void connectionResult.closed.then((exit) => { + processExited = exit; + }); try { + const initPromise = connectionResult.connection.initialize({ + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: init.clientCapabilities, + clientInfo: init.clientInfo, + }); initResponse = await raceTimeout( - connectionResult.connection.initialize({ - protocolVersion: ACP_PROTOCOL_VERSION, - clientCapabilities: init.clientCapabilities, - clientInfo: init.clientInfo, - }), + initPromise, initializeTimeoutMs, () => new AgentInitTimeoutError(adapterId, initializeTimeoutMs), ); } catch (cause) { + if (processExited === undefined) { + await Promise.race([ + connectionResult.closed, + new Promise((resolveSleep) => setTimeout(resolveSleep, 50)), + ]); + } await connectionResult.dispose(); if (cause instanceof AgentInitTimeoutError) throw cause; + if (processExited !== undefined) { + throw new AgentConnectionClosedError( + adapterId, + processExited.exitCode, + processExited.signal, + connectionResult.stderrTail(), + ); + } throw new AgentInitError(adapterId, cause); } diff --git a/packages/use-local-agent/src/utils/run-command.ts b/packages/use-local-agent/src/utils/run-command.ts index 818f8fa..fb81e1c 100644 --- a/packages/use-local-agent/src/utils/run-command.ts +++ b/packages/use-local-agent/src/utils/run-command.ts @@ -73,6 +73,11 @@ export const runCommand = ( if (options.timeoutMs && options.timeoutMs > 0) { timer = setTimeout(() => { child.kill("SIGTERM"); + const escalateAfterMs = Math.min(2000, Math.max(50, Math.floor(options.timeoutMs! / 2))); + const killTimer = setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, escalateAfterMs); + killTimer.unref?.(); reject( new CommandError(`Command ${bin} timed out after ${options.timeoutMs}ms`, { stdout: stdoutBuffer, diff --git a/packages/use-local-agent/tests/spawn-failures.test.ts b/packages/use-local-agent/tests/spawn-failures.test.ts new file mode 100644 index 0000000..79895ca --- /dev/null +++ b/packages/use-local-agent/tests/spawn-failures.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { AgentAdapter } from "../src/adapter"; +import { AgentConnectionClosedError, AgentSpawnError, LocalAgentError } from "../src/errors"; +import { LocalAgent } from "../src/local-agent"; + +const missingBinAdapter = (): AgentAdapter => ({ + id: "missing", + displayName: "Missing Adapter", + resolve: async () => ({ + bin: "/path/that/does/not/exist/definitely-not-real-binary-xyz", + args: [], + env: {}, + }), +}); + +const exitImmediatelyAdapter = (): AgentAdapter => ({ + id: "exit-fast", + displayName: "Exit Fast Adapter", + resolve: async () => ({ + bin: process.execPath, + args: ["-e", "process.exit(7)"], + env: {}, + }), +}); + +describe("spawn / init fast-fail", () => { + it("spawn ENOENT surfaces AgentSpawnError without unhandled rejection", async () => { + const captured: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + captured.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + try { + await expect( + LocalAgent.connect(missingBinAdapter(), { + inactivityTimeoutMs: 0, + initializeTimeoutMs: 30_000, + }), + ).rejects.toBeInstanceOf(AgentSpawnError); + await new Promise((resolveSleep) => setTimeout(resolveSleep, 30)); + expect(captured).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + it("subprocess exit before initialize raises AgentConnectionClosedError quickly", async () => { + const start = Date.now(); + await expect( + LocalAgent.connect(exitImmediatelyAdapter(), { + inactivityTimeoutMs: 0, + initializeTimeoutMs: 30_000, + }), + ).rejects.toBeInstanceOf(LocalAgentError); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(2_000); + }); + + it("subprocess exit before initialize: error tagged ConnectionClosed", async () => { + let thrown: unknown; + try { + await LocalAgent.connect(exitImmediatelyAdapter(), { + inactivityTimeoutMs: 0, + initializeTimeoutMs: 30_000, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(AgentConnectionClosedError); + }); +}); diff --git a/packages/use-local-agent/tests/stderr-fatal-detection.test.ts b/packages/use-local-agent/tests/stderr-fatal-detection.test.ts new file mode 100644 index 0000000..121b62f --- /dev/null +++ b/packages/use-local-agent/tests/stderr-fatal-detection.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { AgentAdapter } from "../src/adapter"; +import { AgentUnauthenticatedError } from "../src/errors"; +import { LocalAgent } from "../src/local-agent"; + +const echoStderrAdapter = (script: string): AgentAdapter => ({ + id: "stderr-fixture", + displayName: "Stderr Fixture", + resolve: async () => ({ + bin: process.execPath, + args: ["-e", script], + env: {}, + }), +}); + +const initRespondingAgent = (preInitStderr: string, postInitStderr: string): string => ` +const { Readable } = require("node:stream"); +let inputBuffer = ""; +process.stdin.on("data", (chunk) => { + inputBuffer += chunk.toString("utf8"); + let newlineIndex = inputBuffer.indexOf("\\n"); + while (newlineIndex !== -1) { + const line = inputBuffer.slice(0, newlineIndex); + inputBuffer = inputBuffer.slice(newlineIndex + 1); + if (line.trim().length === 0) { + newlineIndex = inputBuffer.indexOf("\\n"); + continue; + } + let msg; + try { + msg = JSON.parse(line); + } catch (error) { + newlineIndex = inputBuffer.indexOf("\\n"); + continue; + } + if (msg.method === "initialize") { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { + protocolVersion: 1, + agentCapabilities: {}, + }, + }) + "\\n"); + setTimeout(() => process.stderr.write(${JSON.stringify(postInitStderr)} + "\\n"), 30); + } + newlineIndex = inputBuffer.indexOf("\\n"); + } +}); +process.stderr.write(${JSON.stringify(preInitStderr)} + "\\n"); +`; + +describe("stderr fatal detection gating", () => { + it("auth-failure stderr after init triggers AgentUnauthenticatedError on next session", async () => { + const lines: string[] = []; + const fatals: unknown[] = []; + + const agent = await LocalAgent.connect( + echoStderrAdapter(initRespondingAgent("", "Authentication failed")), + { + inactivityTimeoutMs: 0, + initializeTimeoutMs: 5_000, + onStderr: (line) => { + lines.push(line); + }, + }, + ); + + await new Promise((resolveSleep) => setTimeout(resolveSleep, 100)); + try { + await agent.createSession({ cwd: "/tmp" }); + } catch (error) { + fatals.push(error); + } + expect(fatals[0]).toBeInstanceOf(AgentUnauthenticatedError); + expect(lines.some((line) => /authentication failed/i.test(line))).toBe(true); + await agent.close(); + }); + + it("pre-init stderr matches do not crash and surface only via onStderr", async () => { + const lines: string[] = []; + const agent = await LocalAgent.connect( + echoStderrAdapter(initRespondingAgent("Authentication failed", "")), + { + inactivityTimeoutMs: 0, + initializeTimeoutMs: 5_000, + onStderr: (line) => { + lines.push(line); + }, + }, + ); + expect(lines.some((line) => /authentication failed/i.test(line))).toBe(true); + expect(agent.protocolVersion).toBe(1); + await agent.close(); + }); +}); From cd721a41de16145c92883149a0df3d3e5bece27a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 02:46:21 +0000 Subject: [PATCH 03/24] feat(use-local-agent): turn-lifecycle correctness (Phase 3) - Inactivity watchdog: pause while a permission request is pending in stream mode (prevents killing sessions waiting on the user). Permission events also bump lastActivityAt. - Per-session buffered update cap (MAX_BUFFERED_UPDATES_PER_SESSION = 1024) drops oldest first to prevent unbounded memory growth. - Add loadSessionStreaming() returning { sessionId, replay, completion } so callers can iterate replay updates emitted during session/load BEFORE the first prompt. Existing loadSession() preserved for back-compat. - Slash commands: - Track latest available_commands_update per session. - Add LocalAgent.commandsFor(sessionId) / modeStateFor() / configOptionsFor() getters. - PromptInput now accepts { command: { name, input? } } and validates against advertised commands when known. - Error message preservation: stringifyCause now includes Error.cause message in wrapped errors when distinct. - Late tool_call_update notifications received after session/cancel are forwarded through the iterator (already worked in practice; explicit test coverage added). - Mode tracking: current_mode_update mutates the cached modeState. - New tests: - tests/late-updates.test.ts - tests/watchdog-permission.test.ts - tests/buffer-cap.test.ts - tests/load-session-replay.test.ts - tests/slash-commands.test.ts Co-authored-by: Aiden Bai --- packages/use-local-agent/src/constants.ts | 2 + packages/use-local-agent/src/errors.ts | 11 +- packages/use-local-agent/src/index.ts | 1 + packages/use-local-agent/src/local-agent.ts | 155 ++++++++++++++++-- packages/use-local-agent/src/types.ts | 8 +- .../use-local-agent/tests/buffer-cap.test.ts | 38 +++++ .../tests/late-updates.test.ts | 68 ++++++++ .../tests/load-session-replay.test.ts | 45 +++++ .../tests/slash-commands.test.ts | 73 +++++++++ .../tests/watchdog-permission.test.ts | 69 ++++++++ 10 files changed, 455 insertions(+), 15 deletions(-) create mode 100644 packages/use-local-agent/tests/buffer-cap.test.ts create mode 100644 packages/use-local-agent/tests/late-updates.test.ts create mode 100644 packages/use-local-agent/tests/load-session-replay.test.ts create mode 100644 packages/use-local-agent/tests/slash-commands.test.ts create mode 100644 packages/use-local-agent/tests/watchdog-permission.test.ts diff --git a/packages/use-local-agent/src/constants.ts b/packages/use-local-agent/src/constants.ts index 17703eb..9e8ec89 100644 --- a/packages/use-local-agent/src/constants.ts +++ b/packages/use-local-agent/src/constants.ts @@ -10,6 +10,8 @@ export const DEFAULT_DISPOSE_GRACE_MS = 2_000; export const DEFAULT_STDERR_TAIL_LIMIT_BYTES = 8 * 1024; export const STDERR_TAIL_PREVIEW_BYTES = 500; +export const MAX_BUFFERED_UPDATES_PER_SESSION = 1024; + export const PACKAGE_NAME = "use-local-agent"; export const PACKAGE_VERSION = "0.0.0"; export const PACKAGE_TITLE = "Use Local Agent"; diff --git a/packages/use-local-agent/src/errors.ts b/packages/use-local-agent/src/errors.ts index 4f38bed..96a42bd 100644 --- a/packages/use-local-agent/src/errors.ts +++ b/packages/use-local-agent/src/errors.ts @@ -223,7 +223,16 @@ export const isMethodNotFoundJsonRpcError = (cause: unknown): boolean => getJsonRpcErrorCode(cause) === ACP_METHOD_NOT_FOUND_ERROR_CODE; const stringifyCause = (cause: unknown): string => { - if (cause instanceof Error) return cause.message; + if (cause instanceof Error) { + if ( + cause.cause instanceof Error && + cause.cause.message && + cause.cause.message !== cause.message + ) { + return `${cause.message} (caused by: ${cause.cause.message})`; + } + return cause.message; + } if (typeof cause === "string") return cause; try { return JSON.stringify(cause); diff --git a/packages/use-local-agent/src/index.ts b/packages/use-local-agent/src/index.ts index 9f50e91..7851cc5 100644 --- a/packages/use-local-agent/src/index.ts +++ b/packages/use-local-agent/src/index.ts @@ -52,6 +52,7 @@ export type { SessionModeState, SessionNotification, SessionUpdate, + SlashCommandInput, StopReason, ToolCallContent, ToolCallLocation, diff --git a/packages/use-local-agent/src/local-agent.ts b/packages/use-local-agent/src/local-agent.ts index 8b9c41c..e681263 100644 --- a/packages/use-local-agent/src/local-agent.ts +++ b/packages/use-local-agent/src/local-agent.ts @@ -19,6 +19,7 @@ import { AUTH_FAILURE_PATTERNS, DEFAULT_INACTIVITY_TIMEOUT_MS, DEFAULT_INITIALIZE_TIMEOUT_MS, + MAX_BUFFERED_UPDATES_PER_SESSION, PACKAGE_NAME, PACKAGE_TITLE, PACKAGE_VERSION, @@ -53,6 +54,7 @@ import type { PendingPermission, PromptInput, SessionId, + SlashCommandInput, TurnResult, UsageReport, } from "./types"; @@ -110,6 +112,7 @@ interface SessionState { readonly systemPrompt?: string; pendingConfigOptions?: readonly SessionConfigOption[]; modeState?: SessionModeState; + availableCommands?: readonly schema.AvailableCommand[]; activeStream: ActiveStreamState | undefined; pendingPermissions: Set; inFlightToolCalls: Map; @@ -144,10 +147,16 @@ const matchesAny = (line: string, patterns: ReadonlyArray): boolean => { }; const toContentBlocks = (prompt: PromptInput["prompt"]): ContentBlock[] => { + if (prompt === undefined) return []; if (typeof prompt === "string") return [{ type: "text", text: prompt }]; return [...prompt]; }; +const formatSlashCommand = (command: SlashCommandInput): string => + command.input && command.input.length > 0 + ? `/${command.name} ${command.input}` + : `/${command.name}`; + const PERMISSION_MODE_PATTERN = /invalid permissions\.defaultmode/i; export class LocalAgent { @@ -473,6 +482,72 @@ export class LocalAgent { } } + loadSessionStreaming(input: LoadSessionInput): { + readonly sessionId: SessionId; + readonly replay: AsyncIterable; + readonly completion: Promise; + } { + this.#assertOpen(); + if (!this.agentCapabilities.loadSession) { + throw new CapabilityNotSupportedError(this.id, "loadSession"); + } + const cwd = input.cwd ?? this.#defaultCwd; + const mcpServers = this.#validateMcpServers(input.mcpServers ?? this.#defaultMcpServers); + const systemPrompt = input.systemPrompt ?? this.#defaultSystemPrompt; + + const events = new AsyncQueue(); + const activeStream: ActiveStreamState = { + events, + textBuffer: "", + thinkingBuffer: "", + lastActivityAt: this.#clock.now(), + cancelled: false, + }; + const state: SessionState = { + id: input.sessionId, + cwd, + mcpServers, + meta: input.meta, + systemPrompt, + activeStream, + pendingPermissions: new Set(), + inFlightToolCalls: new Map(), + }; + this.#sessions.set(input.sessionId, state); + + const buffered = this.#pendingUpdates.get(input.sessionId); + if (buffered) { + this.#pendingUpdates.delete(input.sessionId); + for (const notification of buffered) { + this.#emitFromUpdate(state, activeStream, notification.update); + } + } + + const completion = (async (): Promise => { + try { + await this.#connection.connection.loadSession({ + sessionId: input.sessionId, + cwd, + mcpServers: [...mcpServers], + }); + events.end(); + } catch (cause) { + const mapped = this.#mapSessionError(cause, "load"); + events.fail(mapped); + this.#sessions.delete(input.sessionId); + throw mapped; + } finally { + state.activeStream = undefined; + } + })(); + + return { + sessionId: input.sessionId, + replay: { [Symbol.asyncIterator]: () => events[Symbol.asyncIterator]() }, + completion, + }; + } + async listSessions(): Promise { this.#assertOpen(); if (!this.agentCapabilities.sessionCapabilities?.list) { @@ -526,6 +601,18 @@ export class LocalAgent { } } + commandsFor(sessionId: SessionId): readonly schema.AvailableCommand[] { + return this.#sessions.get(sessionId)?.availableCommands ?? []; + } + + modeStateFor(sessionId: SessionId): SessionModeState | undefined { + return this.#sessions.get(sessionId)?.modeState; + } + + configOptionsFor(sessionId: SessionId): readonly SessionConfigOption[] { + return this.#sessions.get(sessionId)?.pendingConfigOptions ?? []; + } + async setMode(sessionId: SessionId, modeId: string): Promise { this.#assertOpen(); const state = this.#sessions.get(sessionId); @@ -598,7 +685,8 @@ export class LocalAgent { const systemPrompt = input.systemPrompt ?? state.systemPrompt; let promptBlocks: ContentBlock[]; try { - promptBlocks = this.#buildPromptBlocks(input.prompt, systemPrompt); + const baseBlocks = this.#applySlashCommand(input, state); + promptBlocks = this.#buildPromptBlocks(baseBlocks, systemPrompt); this.#validatePromptCapabilities(promptBlocks); } catch (validationError) { events.fail(validationError); @@ -806,6 +894,9 @@ export class LocalAgent { respond, cancel, }; + if (state.activeStream) { + state.activeStream.lastActivityAt = this.#clock.now(); + } state.activeStream?.events.push({ type: "permission-request", request: pending }); }); } @@ -814,20 +905,25 @@ export class LocalAgent { const sessionId = notification.sessionId as SessionId; const state = this.#sessions.get(sessionId); if (!state) { - const buffered = this.#pendingUpdates.get(notification.sessionId) ?? []; - buffered.push(notification); - this.#pendingUpdates.set(notification.sessionId, buffered); + this.#bufferUpdate(notification); return; } if (!state.activeStream) { - const buffered = this.#pendingUpdates.get(notification.sessionId) ?? []; - buffered.push(notification); - this.#pendingUpdates.set(notification.sessionId, buffered); + this.#bufferUpdate(notification); return; } this.#emitFromUpdate(state, state.activeStream, notification.update); } + #bufferUpdate(notification: SessionNotification): void { + const buffered = this.#pendingUpdates.get(notification.sessionId) ?? []; + buffered.push(notification); + while (buffered.length > MAX_BUFFERED_UPDATES_PER_SESSION) { + buffered.shift(); + } + this.#pendingUpdates.set(notification.sessionId, buffered); + } + #emitFromUpdate(state: SessionState, active: ActiveStreamState, update: SessionUpdate): void { if (isMeaningfulActivity(update)) { active.lastActivityAt = this.#clock.now(); @@ -896,10 +992,17 @@ export class LocalAgent { return; } case "available_commands_update": { + state.availableCommands = update.availableCommands; active.events.push({ type: "available-commands", commands: update.availableCommands }); return; } case "current_mode_update": { + if (state.modeState) { + state.modeState = { + ...state.modeState, + currentModeId: update.currentModeId, + }; + } active.events.push({ type: "mode-changed", modeId: update.currentModeId }); return; } @@ -931,6 +1034,11 @@ export class LocalAgent { if (this.#inactivityTimeoutMs <= 0) return; const tick = (): void => { if (active !== state.activeStream) return; + if (state.pendingPermissions.size > 0) { + active.lastActivityAt = this.#clock.now(); + active.inactivityTimer = this.#clock.setTimeout(tick, this.#inactivityTimeoutMs); + return; + } const elapsed = this.#clock.now() - active.lastActivityAt; if (elapsed >= this.#inactivityTimeoutMs) { this.#cleanupSession(state, new AgentInactivityError(this.id, state.id, elapsed)); @@ -988,16 +1096,37 @@ export class LocalAgent { } #buildPromptBlocks( - prompt: PromptInput["prompt"], + blocks: readonly ContentBlock[], systemPrompt: string | undefined, ): ContentBlock[] { - const blocks = toContentBlocks(prompt); - if (!systemPrompt || this.id === "claude") return blocks; - if (blocks.length > 0 && blocks[0].type === "text") { + const copy = [...blocks]; + if (!systemPrompt || this.id === "claude") return copy; + if (copy.length > 0 && copy[0].type === "text") { + const first = copy[0]; + return [{ type: "text", text: `${systemPrompt}\n\n${first.text}` }, ...copy.slice(1)]; + } + return [{ type: "text", text: systemPrompt }, ...copy]; + } + + #applySlashCommand(input: PromptInput, state: SessionState): readonly ContentBlock[] { + const blocks = toContentBlocks(input.prompt); + if (!input.command) return blocks; + if (state.availableCommands && state.availableCommands.length > 0) { + const known = state.availableCommands.some((command) => command.name === input.command!.name); + if (!known) { + throw new AgentStreamError( + this.id, + `Slash command "${input.command.name}" is not advertised by ${this.displayName}`, + ); + } + } + const commandText = formatSlashCommand(input.command); + if (blocks.length === 0) return [{ type: "text", text: commandText }]; + if (blocks[0].type === "text") { const first = blocks[0]; - return [{ type: "text", text: `${systemPrompt}\n\n${first.text}` }, ...blocks.slice(1)]; + return [{ type: "text", text: `${commandText} ${first.text}` }, ...blocks.slice(1)]; } - return [{ type: "text", text: systemPrompt }, ...blocks]; + return [{ type: "text", text: commandText }, ...blocks]; } #validatePromptCapabilities(blocks: ReadonlyArray): void { diff --git a/packages/use-local-agent/src/types.ts b/packages/use-local-agent/src/types.ts index d66ddff..b264127 100644 --- a/packages/use-local-agent/src/types.ts +++ b/packages/use-local-agent/src/types.ts @@ -97,8 +97,14 @@ export interface ModelPreference { readonly value: string; } +export interface SlashCommandInput { + readonly name: string; + readonly input?: string; +} + export interface PromptInput { - readonly prompt: string | readonly ContentBlock[]; + readonly prompt?: string | readonly ContentBlock[]; + readonly command?: SlashCommandInput; readonly meta?: Record; readonly modelPreference?: ModelPreference; readonly systemPrompt?: string; diff --git a/packages/use-local-agent/tests/buffer-cap.test.ts b/packages/use-local-agent/tests/buffer-cap.test.ts new file mode 100644 index 0000000..c6a1fad --- /dev/null +++ b/packages/use-local-agent/tests/buffer-cap.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; +import { MAX_BUFFERED_UPDATES_PER_SESSION } from "../src/constants"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId } from "../src/types"; + +describe("per-session buffered update cap", () => { + it("drops oldest buffered updates beyond MAX_BUFFERED_UPDATES_PER_SESSION", async () => { + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: () => ({ stopReason: "end_turn" }), + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + + const overflow = MAX_BUFFERED_UPDATES_PER_SESSION + 50; + for (let index = 0; index < overflow; index += 1) { + await session.mockConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `chunk-${index}` }, + }, + }); + } + await new Promise((resolveSleep) => setTimeout(resolveSleep, 30)); + + const stream = session.agent.prompt(sessionId, { prompt: "go" }); + const events: AgentEvent[] = []; + for await (const event of stream) events.push(event); + + const textDeltas = events.filter((event) => event.type === "text-delta"); + expect(textDeltas.length).toBe(MAX_BUFFERED_UPDATES_PER_SESSION); + if (textDeltas[0]?.type === "text-delta") { + expect(textDeltas[0].text).toBe(`chunk-${overflow - MAX_BUFFERED_UPDATES_PER_SESSION}`); + } + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/late-updates.test.ts b/packages/use-local-agent/tests/late-updates.test.ts new file mode 100644 index 0000000..e263a90 --- /dev/null +++ b/packages/use-local-agent/tests/late-updates.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId } from "../src/types"; + +describe("late updates after cancel", () => { + it("forwards tool_call_update notifications received after session/cancel", async () => { + let cancelTriggered = false; + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "t1", + title: "running", + kind: "execute", + status: "in_progress", + }, + }); + // Wait until cancel arrives + for (let attempts = 0; attempts < 50; attempts += 1) { + if (cancelTriggered) break; + await new Promise((resolveSleep) => setTimeout(resolveSleep, 10)); + } + // Send a final update AFTER cancel + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "t1", + status: "failed", + }, + }); + return { stopReason: "cancelled" }; + }, + cancel: () => { + cancelTriggered = true; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { prompt: "go" }); + + const events: AgentEvent[] = []; + const iter = stream[Symbol.asyncIterator](); + const first = await iter.next(); + if (first.value) events.push(first.value); + await stream.cancel(); + while (true) { + try { + const next = await iter.next(); + if (next.done) break; + events.push(next.value); + } catch { + break; + } + } + await stream.completion.catch(() => {}); + + expect(events.find((event) => event.type === "tool-call-cancelled")).toBeDefined(); + const lateUpdate = events.find( + (event) => event.type === "tool-call-update" && event.status === "failed", + ); + expect(lateUpdate).toBeDefined(); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/load-session-replay.test.ts b/packages/use-local-agent/tests/load-session-replay.test.ts new file mode 100644 index 0000000..ad1fdb1 --- /dev/null +++ b/packages/use-local-agent/tests/load-session-replay.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId } from "../src/types"; + +describe("loadSessionStreaming", () => { + it("streams replay updates emitted during loadSession back to the caller", async () => { + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + }), + loadSession: async (request, conn) => { + for (let i = 0; i < 5; i += 1) { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: `replay-${i}` }, + }, + }); + } + return {}; + }, + }); + + const handle = session.agent.loadSessionStreaming({ + sessionId: "sess-old" as SessionId, + cwd: "/tmp", + }); + expect(handle.sessionId).toBe("sess-old"); + + const events: AgentEvent[] = []; + const collector = (async () => { + for await (const event of handle.replay) events.push(event); + })(); + await handle.completion; + await collector; + + const rawUserMessages = events.filter( + (event) => event.type === "raw" && event.update.sessionUpdate === "user_message_chunk", + ); + expect(rawUserMessages.length).toBe(5); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/slash-commands.test.ts b/packages/use-local-agent/tests/slash-commands.test.ts new file mode 100644 index 0000000..61345c5 --- /dev/null +++ b/packages/use-local-agent/tests/slash-commands.test.ts @@ -0,0 +1,73 @@ +import type { PromptRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { AgentStreamError } from "../src/errors"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { SessionId } from "../src/types"; + +describe("slash command helper", () => { + it("formats /command name with input as a text block", async () => { + let captured: PromptRequest | undefined; + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "web", description: "Search" }], + }, + }); + captured = request; + return { stopReason: "end_turn" }; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await session.agent.prompt(sessionId, { prompt: "warmup" }).completion; + + let captured2: PromptRequest | undefined; + const session2Prompt = async (): Promise => { + const handlers = (session.agent as unknown as { id: string }).id; + void handlers; + }; + await session2Prompt(); + + const commands = session.agent.commandsFor(sessionId); + expect(commands.length).toBe(1); + + const stream = session.agent.prompt(sessionId, { + command: { name: "web", input: "agent client protocol" }, + }); + await stream.completion; + + captured2 = captured; + expect(captured2?.prompt[0]?.type).toBe("text"); + if (captured2?.prompt[0]?.type === "text") { + expect(captured2.prompt[0].text).toBe("/web agent client protocol"); + } + await session.close(); + }); + + it("rejects unknown slash commands when commands are advertised", async () => { + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "web", description: "Search" }], + }, + }); + return { stopReason: "end_turn" }; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await session.agent.prompt(sessionId, { prompt: "warmup" }).completion; + + const stream = session.agent.prompt(sessionId, { command: { name: "nope" } }); + await expect(stream.completion).rejects.toBeInstanceOf(AgentStreamError); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/watchdog-permission.test.ts b/packages/use-local-agent/tests/watchdog-permission.test.ts new file mode 100644 index 0000000..80b428f --- /dev/null +++ b/packages/use-local-agent/tests/watchdog-permission.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { Clock } from "../src/utils/clock"; +import type { AgentEvent, SessionId } from "../src/types"; + +const makeFakeClock = (): { + clock: Clock; + advance: (ms: number) => void; +} => { + let current = 0; + const handles = new Set<{ deadline: number; callback: () => void }>(); + const clock: Clock = { + now: () => current, + setTimeout: (callback, ms) => { + const handle = { deadline: current + ms, callback }; + handles.add(handle); + return { + clear: () => handles.delete(handle), + }; + }, + }; + const advance = (ms: number): void => { + current += ms; + for (const handle of [...handles]) { + if (current >= handle.deadline) { + handles.delete(handle); + handle.callback(); + } + } + }; + return { clock, advance }; +}; + +describe("inactivity watchdog respects pending permissions", () => { + it("does not fire while a permission request is open in stream mode", async () => { + const { clock, advance } = makeFakeClock(); + const session = await connectMockAgent( + { + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + const response = await conn.requestPermission({ + sessionId: request.sessionId, + toolCall: { toolCallId: "t1", title: "edit" }, + options: [{ optionId: "ok", name: "Allow", kind: "allow_once" }], + }); + if (response.outcome.outcome !== "selected") { + return { stopReason: "cancelled" }; + } + return { stopReason: "end_turn" }; + }, + }, + { permission: "stream", inactivityTimeoutMs: 1_000, clock }, + ); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { prompt: "hi" }); + const iter = stream[Symbol.asyncIterator](); + const first = await iter.next(); + expect((first.value as AgentEvent | undefined)?.type).toBe("permission-request"); + + advance(5_000); + if (first.value && first.value.type === "permission-request") { + first.value.request.respond("ok"); + } + const result = await stream.completion; + expect(result.stopReason).toBe("end_turn"); + await session.close(); + }); +}); From 83f58117e11a62d7585eab731e4e5ce8ecebd81c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 02:52:38 +0000 Subject: [PATCH 04/24] feat(use-local-agent): spec coverage expansion (Phase 4) - Optional terminal/* support: when caller provides options.terminal handlers, advertise terminal capability and forward all five client terminal methods (create/output/release/wait_for_exit/kill). - additionalDirectories on createSession / loadSession / loadSessionStreaming / resumeSession with capability gating (sessionCapabilities.additionalDirectories) and absolute-path validation. - listSessions(input?: { cwd?, cursor? }) with pagination plumbing. Adds streamAllSessions helper that auto-paginates. - _meta trace context propagation via opt-in traceContext: () => Record. Only the W3C-reserved keys traceparent / tracestate / baggage are forwarded; other keys filtered out. - onAuthRequired hook: when newSession returns auth_required (-32000) and a hook is configured, prompt for an auth method, call authenticate, then retry once. Returning undefined from the hook preserves the original AgentUnauthenticatedError. - Convenience getters: commandsFor / modeStateFor / configOptionsFor. - Mode tracking: current_mode_update mutates cached modeState. - Tests: - tests/terminal.test.ts - tests/additional-directories.test.ts - tests/list-sessions-pagination.test.ts - tests/trace-context.test.ts - tests/auth-retry.test.ts Co-authored-by: Aiden Bai --- packages/use-local-agent/src/index.ts | 1 + packages/use-local-agent/src/local-agent.ts | 158 ++++++++++++++++-- .../use-local-agent/src/testing/mock-agent.ts | 7 + packages/use-local-agent/src/types.ts | 1 + .../tests/additional-directories.test.ts | 62 +++++++ .../use-local-agent/tests/auth-retry.test.ts | 62 +++++++ .../tests/list-sessions-pagination.test.ts | 55 ++++++ .../use-local-agent/tests/terminal.test.ts | 64 +++++++ .../tests/trace-context.test.ts | 47 ++++++ 9 files changed, 444 insertions(+), 13 deletions(-) create mode 100644 packages/use-local-agent/tests/additional-directories.test.ts create mode 100644 packages/use-local-agent/tests/auth-retry.test.ts create mode 100644 packages/use-local-agent/tests/list-sessions-pagination.test.ts create mode 100644 packages/use-local-agent/tests/terminal.test.ts create mode 100644 packages/use-local-agent/tests/trace-context.test.ts diff --git a/packages/use-local-agent/src/index.ts b/packages/use-local-agent/src/index.ts index 7851cc5..b8203aa 100644 --- a/packages/use-local-agent/src/index.ts +++ b/packages/use-local-agent/src/index.ts @@ -3,6 +3,7 @@ export { type LocalAgentClientInfo, type LocalAgentConnectOptions, type FileSystemHandlers, + type TerminalHandlers, } from "./local-agent"; export { streamAgent, diff --git a/packages/use-local-agent/src/local-agent.ts b/packages/use-local-agent/src/local-agent.ts index e681263..bcaa385 100644 --- a/packages/use-local-agent/src/local-agent.ts +++ b/packages/use-local-agent/src/local-agent.ts @@ -69,6 +69,21 @@ export interface ClientDispatcher { onPermissionRequest: (request: RequestPermissionRequest) => Promise; onReadTextFile?: (params: schema.ReadTextFileRequest) => Promise; onWriteTextFile?: (params: schema.WriteTextFileRequest) => Promise; + onCreateTerminal?: ( + params: schema.CreateTerminalRequest, + ) => Promise; + onTerminalOutput?: ( + params: schema.TerminalOutputRequest, + ) => Promise; + onReleaseTerminal?: ( + params: schema.ReleaseTerminalRequest, + ) => Promise; + onWaitForTerminalExit?: ( + params: schema.WaitForTerminalExitRequest, + ) => Promise; + onKillTerminal?: ( + params: schema.KillTerminalRequest, + ) => Promise; } export interface LocalAgentClientInfo { @@ -82,6 +97,18 @@ export interface FileSystemHandlers { writeTextFile?(params: schema.WriteTextFileRequest): Promise; } +export interface TerminalHandlers { + createTerminal(params: schema.CreateTerminalRequest): Promise; + terminalOutput(params: schema.TerminalOutputRequest): Promise; + releaseTerminal( + params: schema.ReleaseTerminalRequest, + ): Promise; + waitForTerminalExit( + params: schema.WaitForTerminalExitRequest, + ): Promise; + killTerminal(params: schema.KillTerminalRequest): Promise; +} + export interface LocalAgentConnectOptions { readonly cwd?: string; readonly env?: Readonly>; @@ -92,6 +119,9 @@ export interface LocalAgentConnectOptions { readonly systemPrompt?: string; readonly clientInfo?: LocalAgentClientInfo; readonly fileSystem?: FileSystemHandlers; + readonly terminal?: TerminalHandlers; + readonly traceContext?: () => Record; + readonly onAuthRequired?: (methods: readonly schema.AuthMethod[]) => Promise; readonly clock?: Clock; readonly onStderr?: (line: string) => void; readonly stderrTailLimit?: number; @@ -176,6 +206,8 @@ export class LocalAgent { #defaultMcpServers: readonly McpServer[]; #defaultSystemPrompt?: string; #inactivityTimeoutMs: number; + #traceContext?: () => Record; + #onAuthRequired?: (methods: readonly schema.AuthMethod[]) => Promise; #clock: Clock; #sessions = new Map(); #pendingUpdates = new Map(); @@ -204,6 +236,8 @@ export class LocalAgent { this.#defaultMcpServers = options.mcpServers ?? []; this.#defaultSystemPrompt = options.systemPrompt; this.#inactivityTimeoutMs = options.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS; + if (options.traceContext) this.#traceContext = options.traceContext; + if (options.onAuthRequired) this.#onAuthRequired = options.onAuthRequired; this.#clock = options.clock ?? realClock; const policy: PermissionPolicy = options.permission ?? "auto-allow"; this.#permissionStream = policy === "stream"; @@ -255,6 +289,13 @@ export class LocalAgent { requestPermission: async (request) => dispatcher.onPermissionRequest(request), ...(dispatcher.onReadTextFile ? { readTextFile: dispatcher.onReadTextFile } : {}), ...(dispatcher.onWriteTextFile ? { writeTextFile: dispatcher.onWriteTextFile } : {}), + ...(dispatcher.onCreateTerminal ? { createTerminal: dispatcher.onCreateTerminal } : {}), + ...(dispatcher.onTerminalOutput ? { terminalOutput: dispatcher.onTerminalOutput } : {}), + ...(dispatcher.onReleaseTerminal ? { releaseTerminal: dispatcher.onReleaseTerminal } : {}), + ...(dispatcher.onWaitForTerminalExit + ? { waitForTerminalExit: dispatcher.onWaitForTerminalExit } + : {}), + ...(dispatcher.onKillTerminal ? { killTerminal: dispatcher.onKillTerminal } : {}), }, }); @@ -368,6 +409,7 @@ export class LocalAgent { fatalErrorListeners: Set<(error: AgentUnauthenticatedError | AgentUsageLimitError) => void>; } { const fs = options.fileSystem; + const term = options.terminal; const clientCapabilities: schema.ClientCapabilities = { ...(fs?.readTextFile || fs?.writeTextFile ? { @@ -377,6 +419,7 @@ export class LocalAgent { }, } : {}), + ...(term ? { terminal: true } : {}), }; const clientInfo: LocalAgentClientInfo = options.clientInfo ?? { name: PACKAGE_NAME, @@ -389,6 +432,11 @@ export class LocalAgent { onPermissionRequest: async () => ({ outcome: { outcome: "cancelled" } }), onReadTextFile: fs?.readTextFile?.bind(fs), onWriteTextFile: fs?.writeTextFile?.bind(fs), + onCreateTerminal: term?.createTerminal?.bind(term), + onTerminalOutput: term?.terminalOutput?.bind(term), + onReleaseTerminal: term?.releaseTerminal?.bind(term), + onWaitForTerminalExit: term?.waitForTerminalExit?.bind(term), + onKillTerminal: term?.killTerminal?.bind(term), }; const fatalErrorListeners = new Set< @@ -422,12 +470,30 @@ export class LocalAgent { const mcpServers = this.#validateMcpServers(input.mcpServers ?? this.#defaultMcpServers); const systemPrompt = input.systemPrompt ?? this.#defaultSystemPrompt; const meta = this.#buildSessionMeta(input.meta, systemPrompt); + const additionalDirectories = this.#validateAdditionalDirectories(input.additionalDirectories); + const newSessionRequest = { + cwd, + mcpServers: [...mcpServers], + ...(additionalDirectories ? { additionalDirectories: [...additionalDirectories] } : {}), + ...(meta ? { _meta: meta } : {}), + }; try { - const response = await this.#connection.connection.newSession({ - cwd, - mcpServers: [...mcpServers], - ...(meta ? { _meta: meta } : {}), - }); + let response; + try { + response = await this.#connection.connection.newSession(newSessionRequest); + } catch (initialCause) { + if (this.#onAuthRequired && isAuthRequiredJsonRpcError(initialCause)) { + const methodId = await this.#onAuthRequired(this.authMethods); + if (methodId !== undefined) { + await this.authenticate(methodId); + response = await this.#connection.connection.newSession(newSessionRequest); + } else { + throw initialCause; + } + } else { + throw initialCause; + } + } const sessionId = response.sessionId as SessionId; const state: SessionState = { id: sessionId, @@ -459,11 +525,13 @@ export class LocalAgent { const cwd = input.cwd ?? this.#defaultCwd; const mcpServers = this.#validateMcpServers(input.mcpServers ?? this.#defaultMcpServers); const systemPrompt = input.systemPrompt ?? this.#defaultSystemPrompt; + const additionalDirectories = this.#validateAdditionalDirectories(input.additionalDirectories); try { await this.#connection.connection.loadSession({ sessionId: input.sessionId, cwd, mcpServers: [...mcpServers], + ...(additionalDirectories ? { additionalDirectories: [...additionalDirectories] } : {}), }); const state: SessionState = { id: input.sessionId, @@ -494,6 +562,7 @@ export class LocalAgent { const cwd = input.cwd ?? this.#defaultCwd; const mcpServers = this.#validateMcpServers(input.mcpServers ?? this.#defaultMcpServers); const systemPrompt = input.systemPrompt ?? this.#defaultSystemPrompt; + const additionalDirectories = this.#validateAdditionalDirectories(input.additionalDirectories); const events = new AsyncQueue(); const activeStream: ActiveStreamState = { @@ -529,6 +598,7 @@ export class LocalAgent { sessionId: input.sessionId, cwd, mcpServers: [...mcpServers], + ...(additionalDirectories ? { additionalDirectories: [...additionalDirectories] } : {}), }); events.end(); } catch (cause) { @@ -548,12 +618,31 @@ export class LocalAgent { }; } - async listSessions(): Promise { + async listSessions( + input: { readonly cwd?: string; readonly cursor?: string } = {}, + ): Promise { this.#assertOpen(); if (!this.agentCapabilities.sessionCapabilities?.list) { throw new CapabilityNotSupportedError(this.id, "sessionCapabilities.list"); } - return this.#connection.connection.listSessions({}); + return this.#connection.connection.listSessions({ + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.cursor ? { cursor: input.cursor } : {}), + }); + } + + async *streamAllSessions( + input: { readonly cwd?: string } = {}, + ): AsyncIterable { + let cursor: string | undefined; + do { + const response = await this.listSessions({ + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(cursor ? { cursor } : {}), + }); + for (const session of response.sessions) yield session; + cursor = response.nextCursor ?? undefined; + } while (cursor); } async resumeSession(input: LoadSessionInput): Promise { @@ -563,11 +652,13 @@ export class LocalAgent { } const cwd = input.cwd ?? this.#defaultCwd; const mcpServers = this.#validateMcpServers(input.mcpServers ?? this.#defaultMcpServers); + const additionalDirectories = this.#validateAdditionalDirectories(input.additionalDirectories); try { await this.#connection.connection.resumeSession({ sessionId: input.sessionId, cwd, mcpServers: [...mcpServers], + ...(additionalDirectories ? { additionalDirectories: [...additionalDirectories] } : {}), }); const state: SessionState = { id: input.sessionId, @@ -709,10 +800,11 @@ export class LocalAgent { ), ); } + const promptMeta = this.#buildRequestMeta(input.meta); const promptPromise = this.#connection.connection.prompt({ sessionId, prompt: promptBlocks, - ...(input.meta ? { _meta: input.meta } : {}), + ...(promptMeta ? { _meta: promptMeta } : {}), }); const forcePromise = new Promise((_, rejectForce) => { activeStream.forceFail = rejectForce; @@ -1148,6 +1240,21 @@ export class LocalAgent { } } + #validateAdditionalDirectories( + dirs: readonly string[] | undefined, + ): readonly string[] | undefined { + if (!dirs || dirs.length === 0) return undefined; + if (!this.agentCapabilities.sessionCapabilities?.additionalDirectories) { + throw new CapabilityNotSupportedError(this.id, "sessionCapabilities.additionalDirectories"); + } + for (const dir of dirs) { + if (!dir.startsWith("/") && !/^[a-zA-Z]:[\\/]/.test(dir)) { + throw new AgentStreamError(this.id, `additionalDirectories must be absolute paths: ${dir}`); + } + } + return dirs; + } + #validateMcpServers(servers: readonly McpServer[]): readonly McpServer[] { const mcpCaps = this.agentCapabilities.mcpCapabilities ?? {}; for (const server of servers) { @@ -1166,12 +1273,37 @@ export class LocalAgent { meta: Record | undefined, systemPrompt: string | undefined, ): Record | undefined { - if (!systemPrompt && !meta) return undefined; - if (!systemPrompt) return meta; - if (this.id === "claude") { - return { ...(meta ?? {}), systemPrompt }; + let result: Record | undefined = meta ? { ...meta } : undefined; + if (systemPrompt && this.id === "claude") { + result = { ...(result ?? {}), systemPrompt }; + } + const trace = this.#collectTraceContext(); + if (trace) { + result = { ...(result ?? {}), ...trace }; + } + return result; + } + + #buildRequestMeta( + meta: Record | undefined, + ): Record | undefined { + const trace = this.#collectTraceContext(); + if (!meta && !trace) return undefined; + return { ...(meta ?? {}), ...(trace ?? {}) }; + } + + #collectTraceContext(): Record | undefined { + if (!this.#traceContext) return undefined; + try { + const ctx = this.#traceContext(); + const entries = Object.entries(ctx).filter(([key]) => + ["traceparent", "tracestate", "baggage"].includes(key), + ); + if (entries.length === 0) return undefined; + return Object.fromEntries(entries); + } catch { + return undefined; } - return meta; } } diff --git a/packages/use-local-agent/src/testing/mock-agent.ts b/packages/use-local-agent/src/testing/mock-agent.ts index c6d19ba..9fe6372 100644 --- a/packages/use-local-agent/src/testing/mock-agent.ts +++ b/packages/use-local-agent/src/testing/mock-agent.ts @@ -227,6 +227,13 @@ export const connectMockAgent = async ( requestPermission: async (request) => dispatcher.onPermissionRequest(request), ...(dispatcher.onReadTextFile ? { readTextFile: dispatcher.onReadTextFile } : {}), ...(dispatcher.onWriteTextFile ? { writeTextFile: dispatcher.onWriteTextFile } : {}), + ...(dispatcher.onCreateTerminal ? { createTerminal: dispatcher.onCreateTerminal } : {}), + ...(dispatcher.onTerminalOutput ? { terminalOutput: dispatcher.onTerminalOutput } : {}), + ...(dispatcher.onReleaseTerminal ? { releaseTerminal: dispatcher.onReleaseTerminal } : {}), + ...(dispatcher.onWaitForTerminalExit + ? { waitForTerminalExit: dispatcher.onWaitForTerminalExit } + : {}), + ...(dispatcher.onKillTerminal ? { killTerminal: dispatcher.onKillTerminal } : {}), }), ndJsonStream(clientToAgent.writable, agentToClient.readable), ); diff --git a/packages/use-local-agent/src/types.ts b/packages/use-local-agent/src/types.ts index b264127..8c832e6 100644 --- a/packages/use-local-agent/src/types.ts +++ b/packages/use-local-agent/src/types.ts @@ -114,6 +114,7 @@ export interface PromptInput { export interface CreateSessionInput { readonly cwd?: string; readonly mcpServers?: readonly McpServer[]; + readonly additionalDirectories?: readonly string[]; readonly meta?: Record; readonly systemPrompt?: string; readonly signal?: AbortSignal; diff --git a/packages/use-local-agent/tests/additional-directories.test.ts b/packages/use-local-agent/tests/additional-directories.test.ts new file mode 100644 index 0000000..6c3595c --- /dev/null +++ b/packages/use-local-agent/tests/additional-directories.test.ts @@ -0,0 +1,62 @@ +import type { NewSessionRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { CapabilityNotSupportedError } from "../src/errors"; +import { connectMockAgent } from "../src/testing/mock-agent"; + +describe("additionalDirectories", () => { + it("forwards additionalDirectories when capability is advertised", async () => { + let captured: NewSessionRequest | undefined; + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { + sessionCapabilities: { additionalDirectories: {} }, + }, + }), + newSession: (request) => { + captured = request; + return { sessionId: "s1" }; + }, + }); + + await session.agent.createSession({ + cwd: "/tmp", + additionalDirectories: ["/extra/one", "/extra/two"], + }); + expect(captured?.additionalDirectories).toEqual(["/extra/one", "/extra/two"]); + await session.close(); + }); + + it("rejects additionalDirectories when capability is not advertised", async () => { + const session = await connectMockAgent({ + initialize: () => ({ protocolVersion: 1, agentCapabilities: {} }), + }); + + await expect( + session.agent.createSession({ + cwd: "/tmp", + additionalDirectories: ["/extra"], + }), + ).rejects.toBeInstanceOf(CapabilityNotSupportedError); + await session.close(); + }); + + it("rejects relative additional directory paths", async () => { + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { + sessionCapabilities: { additionalDirectories: {} }, + }, + }), + }); + + await expect( + session.agent.createSession({ + cwd: "/tmp", + additionalDirectories: ["./relative"], + }), + ).rejects.toThrow(/absolute paths/); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/auth-retry.test.ts b/packages/use-local-agent/tests/auth-retry.test.ts new file mode 100644 index 0000000..f881a4d --- /dev/null +++ b/packages/use-local-agent/tests/auth-retry.test.ts @@ -0,0 +1,62 @@ +import { RequestError } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { AgentUnauthenticatedError } from "../src/errors"; +import { connectMockAgent } from "../src/testing/mock-agent"; + +describe("onAuthRequired hook", () => { + it("retries newSession after authenticate when hook returns a method id", async () => { + let attempts = 0; + let authenticated: string | undefined; + const session = await connectMockAgent( + { + initialize: () => ({ + protocolVersion: 1, + authMethods: [{ id: "oauth", name: "OAuth" }], + }), + authenticate: (request) => { + authenticated = request.methodId; + return {}; + }, + newSession: () => { + attempts += 1; + if (attempts === 1) throw RequestError.authRequired(); + return { sessionId: "s1" }; + }, + }, + { + onAuthRequired: async (methods) => { + expect(methods.length).toBe(1); + return methods[0]!.id; + }, + }, + ); + + const sessionId = await session.agent.createSession({ cwd: "/tmp" }); + expect(sessionId).toBe("s1"); + expect(authenticated).toBe("oauth"); + expect(attempts).toBe(2); + await session.close(); + }); + + it("does not retry when hook returns undefined", async () => { + const session = await connectMockAgent( + { + initialize: () => ({ + protocolVersion: 1, + authMethods: [{ id: "oauth", name: "OAuth" }], + }), + newSession: () => { + throw RequestError.authRequired(); + }, + }, + { + onAuthRequired: async () => undefined, + }, + ); + + await expect(session.agent.createSession({ cwd: "/tmp" })).rejects.toBeInstanceOf( + AgentUnauthenticatedError, + ); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/list-sessions-pagination.test.ts b/packages/use-local-agent/tests/list-sessions-pagination.test.ts new file mode 100644 index 0000000..a822b90 --- /dev/null +++ b/packages/use-local-agent/tests/list-sessions-pagination.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { SessionInfo } from "@agentclientprotocol/sdk"; + +describe("listSessions cursor pagination", () => { + it("paginates through pages via streamAllSessions", async () => { + const pages = [ + { + sessions: [ + { sessionId: "s1", cwd: "/p" }, + { sessionId: "s2", cwd: "/p" }, + ] as SessionInfo[], + nextCursor: "c1", + }, + { + sessions: [{ sessionId: "s3", cwd: "/p" }] as SessionInfo[], + }, + ]; + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { list: {} } }, + }), + listSessions: (request) => { + if (!request.cursor) return pages[0]; + if (request.cursor === "c1") return pages[1]; + throw new Error(`unknown cursor ${request.cursor}`); + }, + }); + + const all: string[] = []; + for await (const info of session.agent.streamAllSessions()) { + all.push(info.sessionId); + } + expect(all).toEqual(["s1", "s2", "s3"]); + await session.close(); + }); + + it("forwards cwd filter to the agent", async () => { + let receivedCwd: string | undefined; + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { list: {} } }, + }), + listSessions: (request) => { + receivedCwd = request.cwd ?? undefined; + return { sessions: [] }; + }, + }); + await session.agent.listSessions({ cwd: "/scoped" }); + expect(receivedCwd).toBe("/scoped"); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/terminal.test.ts b/packages/use-local-agent/tests/terminal.test.ts new file mode 100644 index 0000000..ffbf045 --- /dev/null +++ b/packages/use-local-agent/tests/terminal.test.ts @@ -0,0 +1,64 @@ +import type { CreateTerminalRequest, InitializeRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { SessionId } from "../src/types"; + +describe("terminal capability", () => { + it("does not advertise terminal capability when handlers are absent", async () => { + let received: InitializeRequest | undefined; + const session = await connectMockAgent({ + initialize: (request) => { + received = request; + return { protocolVersion: 1 }; + }, + }); + expect(received?.clientCapabilities?.terminal).toBeFalsy(); + await session.close(); + }); + + it("advertises terminal capability and forwards createTerminal calls", async () => { + let received: InitializeRequest | undefined; + let captured: CreateTerminalRequest | undefined; + const session = await connectMockAgent( + { + initialize: (request) => { + received = request; + return { + protocolVersion: 1, + agentCapabilities: {}, + }; + }, + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + const result = await conn.createTerminal({ + sessionId: request.sessionId, + command: "echo", + args: ["hello"], + }); + captured = { + sessionId: request.sessionId, + command: "echo", + args: ["hello"], + }; + await result.release(); + return { stopReason: "end_turn" }; + }, + }, + { + terminal: { + createTerminal: async () => ({ terminalId: "term-1" }), + terminalOutput: async () => ({ output: "", truncated: false }), + releaseTerminal: async () => ({}), + waitForTerminalExit: async () => ({ exitCode: 0, signal: null }), + killTerminal: async () => ({}), + }, + }, + ); + + expect(received?.clientCapabilities?.terminal).toBe(true); + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await session.agent.prompt(sessionId, { prompt: "go" }).completion; + expect(captured?.command).toBe("echo"); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/trace-context.test.ts b/packages/use-local-agent/tests/trace-context.test.ts new file mode 100644 index 0000000..95da730 --- /dev/null +++ b/packages/use-local-agent/tests/trace-context.test.ts @@ -0,0 +1,47 @@ +import type { NewSessionRequest, PromptRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { SessionId } from "../src/types"; + +describe("_meta trace context propagation", () => { + it("injects traceparent / tracestate / baggage from traceContext into prompt _meta", async () => { + let capturedNew: NewSessionRequest | undefined; + let capturedPrompt: PromptRequest | undefined; + const session = await connectMockAgent( + { + newSession: (request) => { + capturedNew = request; + return { sessionId: "s1" }; + }, + prompt: (request) => { + capturedPrompt = request; + return { stopReason: "end_turn" }; + }, + }, + { + traceContext: () => ({ + traceparent: "00-aaaa-bbbb-01", + tracestate: "vendor=key", + baggage: "k=v", + // Should be filtered (non-reserved key): + custom: "ignored", + }), + }, + ); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await session.agent.prompt(sessionId, { prompt: "hi" }).completion; + + const newMeta = capturedNew?._meta as Record | undefined; + expect(newMeta?.traceparent).toBe("00-aaaa-bbbb-01"); + expect(newMeta?.tracestate).toBe("vendor=key"); + expect(newMeta?.baggage).toBe("k=v"); + expect(newMeta?.custom).toBeUndefined(); + + const promptMeta = capturedPrompt?._meta as Record | undefined; + expect(promptMeta?.traceparent).toBe("00-aaaa-bbbb-01"); + expect(promptMeta?.tracestate).toBe("vendor=key"); + expect(promptMeta?.baggage).toBe("k=v"); + await session.close(); + }); +}); From 7d77d739e4afed4192af420628f6471fa68cdfa8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 02:54:28 +0000 Subject: [PATCH 05/24] feat(use-local-agent): adapter robustness (Phase 5) - claude: ANTHROPIC_API_KEY env fallback bypasses 'claude auth status' parsing when the env is set; checkAuthenticated honors it. - copilot: GITHUB_TOKEN env fallback bypasses 'gh auth token' lookup; helpful error message updated. - gemini: GEMINI_API_KEY / GOOGLE_API_KEY env fallback before reading ~/.gemini/google_accounts.json; updated error message. (envFilter hook from Phase 2 already exposed for env scrubbing.) - New: tests/adapter-env-fallbacks.test.ts covering gemini env auth. Co-authored-by: Aiden Bai --- .../use-local-agent/src/adapters/claude.ts | 42 ++++++++++++------- .../use-local-agent/src/adapters/copilot.ts | 36 ++++++++++------ .../use-local-agent/src/adapters/gemini.ts | 8 +++- .../tests/adapter-env-fallbacks.test.ts | 26 ++++++++++++ 4 files changed, 83 insertions(+), 29 deletions(-) create mode 100644 packages/use-local-agent/tests/adapter-env-fallbacks.test.ts diff --git a/packages/use-local-agent/src/adapters/claude.ts b/packages/use-local-agent/src/adapters/claude.ts index e0b06cd..5ccfc6b 100644 --- a/packages/use-local-agent/src/adapters/claude.ts +++ b/packages/use-local-agent/src/adapters/claude.ts @@ -22,6 +22,12 @@ export const claude = (options: AdapterFactoryOptions = {}): AgentAdapter => ({ } }, checkAuthenticated: async () => { + if ( + typeof process.env.ANTHROPIC_API_KEY === "string" && + process.env.ANTHROPIC_API_KEY.trim().length > 0 + ) { + return true; + } try { const result = await runCommand("claude", ["auth", "status"], { timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, @@ -50,21 +56,27 @@ export const claude = (options: AdapterFactoryOptions = {}): AgentAdapter => ({ ); } - let authStatus: { loggedIn?: boolean } = {}; - try { - const result = await runCommand("claude", ["auth", "status"], { - timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, - }); - authStatus = JSON.parse(result.stdout || "{}") as { loggedIn?: boolean }; - } catch { - authStatus = {}; - } - if (!authStatus.loggedIn) { - throw new AgentUnauthenticatedError( - PROVIDER, - "Claude Code is not authenticated. Run `claude login` and try again.", - "claude login", - ); + const hasEnvKey = + typeof process.env.ANTHROPIC_API_KEY === "string" && + process.env.ANTHROPIC_API_KEY.trim().length > 0; + + if (!hasEnvKey) { + let authStatus: { loggedIn?: boolean } = {}; + try { + const result = await runCommand("claude", ["auth", "status"], { + timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, + }); + authStatus = JSON.parse(result.stdout || "{}") as { loggedIn?: boolean }; + } catch { + authStatus = {}; + } + if (!authStatus.loggedIn) { + throw new AgentUnauthenticatedError( + PROVIDER, + "Claude Code is not authenticated. Set ANTHROPIC_API_KEY or run `claude login` and try again.", + "claude login", + ); + } } const shimPath = resolvePackageEntry(SHIM_PACKAGE, SHIM_ENTRY); diff --git a/packages/use-local-agent/src/adapters/copilot.ts b/packages/use-local-agent/src/adapters/copilot.ts index 6893ab6..6fd59ee 100644 --- a/packages/use-local-agent/src/adapters/copilot.ts +++ b/packages/use-local-agent/src/adapters/copilot.ts @@ -19,6 +19,12 @@ export const copilot = (options: AdapterFactoryOptions = {}): AgentAdapter => ({ } }, checkAuthenticated: async () => { + if ( + typeof process.env.GITHUB_TOKEN === "string" && + process.env.GITHUB_TOKEN.trim().length > 0 + ) { + return true; + } try { const result = await runCommand("gh", ["auth", "token"], { timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, @@ -29,24 +35,28 @@ export const copilot = (options: AdapterFactoryOptions = {}): AgentAdapter => ({ } }, resolve: async () => { - try { - const result = await runCommand("gh", ["auth", "token"], { - timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, - }); - if (result.exitCode !== 0 || result.stdout.trim().length === 0) { + const hasEnvToken = + typeof process.env.GITHUB_TOKEN === "string" && process.env.GITHUB_TOKEN.trim().length > 0; + if (!hasEnvToken) { + try { + const result = await runCommand("gh", ["auth", "token"], { + timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, + }); + if (result.exitCode !== 0 || result.stdout.trim().length === 0) { + throw new AgentUnauthenticatedError( + PROVIDER, + "GitHub CLI auth token is empty. Set GITHUB_TOKEN or run `gh auth login` and try again.", + "gh auth login", + ); + } + } catch (cause) { + if (cause instanceof AgentUnauthenticatedError) throw cause; throw new AgentUnauthenticatedError( PROVIDER, - "GitHub CLI auth token is empty. Run `gh auth login` and try again.", + "Unable to verify GitHub Copilot auth. Set GITHUB_TOKEN or run `gh auth login`.", "gh auth login", ); } - } catch (cause) { - if (cause instanceof AgentUnauthenticatedError) throw cause; - throw new AgentUnauthenticatedError( - PROVIDER, - "Unable to verify GitHub Copilot auth via `gh auth token`.", - "gh auth login", - ); } if (options.binPath) { return { bin: options.binPath, args: ["--acp"], env: options.env ?? {} }; diff --git a/packages/use-local-agent/src/adapters/gemini.ts b/packages/use-local-agent/src/adapters/gemini.ts index 071e645..411f7c2 100644 --- a/packages/use-local-agent/src/adapters/gemini.ts +++ b/packages/use-local-agent/src/adapters/gemini.ts @@ -9,7 +9,13 @@ const SHIM_PACKAGE = "@google/gemini-cli"; const homedir = (): string | undefined => process.env.HOME ?? process.env.USERPROFILE; +const hasEnvKey = (): boolean => { + const candidates = [process.env.GEMINI_API_KEY, process.env.GOOGLE_API_KEY]; + return candidates.some((value) => typeof value === "string" && value.trim().length > 0); +}; + const isAuthenticated = async (): Promise => { + if (hasEnvKey()) return true; const home = homedir(); if (!home) return false; const accountsPath = path.join(home, ".gemini", "google_accounts.json"); @@ -42,7 +48,7 @@ export const gemini = (options: AdapterFactoryOptions = {}): AgentAdapter => ({ if (!(await isAuthenticated())) { throw new AgentUnauthenticatedError( PROVIDER, - "Gemini CLI is not authenticated. Run `gemini auth login` and try again.", + "Gemini CLI is not authenticated. Set GEMINI_API_KEY/GOOGLE_API_KEY or run `gemini auth login` and try again.", "gemini auth login", ); } diff --git a/packages/use-local-agent/tests/adapter-env-fallbacks.test.ts b/packages/use-local-agent/tests/adapter-env-fallbacks.test.ts new file mode 100644 index 0000000..da71179 --- /dev/null +++ b/packages/use-local-agent/tests/adapter-env-fallbacks.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { gemini } from "../src/adapters/gemini"; + +describe("adapter env-var fallbacks", () => { + const originalGemini = process.env.GEMINI_API_KEY; + const originalGoogle = process.env.GOOGLE_API_KEY; + + afterEach(() => { + process.env.GEMINI_API_KEY = originalGemini; + process.env.GOOGLE_API_KEY = originalGoogle; + }); + + it("gemini.checkAuthenticated returns true when GEMINI_API_KEY is set", async () => { + process.env.GEMINI_API_KEY = "fake-key"; + delete process.env.GOOGLE_API_KEY; + const adapter = gemini(); + expect(await adapter.checkAuthenticated?.()).toBe(true); + }); + + it("gemini.checkAuthenticated returns true when GOOGLE_API_KEY is set", async () => { + delete process.env.GEMINI_API_KEY; + process.env.GOOGLE_API_KEY = "fake-key"; + const adapter = gemini(); + expect(await adapter.checkAuthenticated?.()).toBe(true); + }); +}); From b24ee3c01e4946cf53247e202911166784b5654b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 02:57:51 +0000 Subject: [PATCH 06/24] docs(use-local-agent): README and changeset for ACP reliability overhaul (Phase 6) - Bump apps/playground SDK pin to ^0.20.0 for consistency - Document new options (terminal, additionalDirectories, listSessions pagination, traceContext, onAuthRequired, onTrace, disposeGraceMs, envFilter) in README - Document slash command helper and reliability features - Add .changeset entry summarizing the multi-phase upgrade - New tests: - tests/sdk-upgrade.test.ts (covers ordering through wrapper) End state: 70 unit tests green; 8/8 e2e Playwright tests against the real echo-agent subprocess green. Co-authored-by: Aiden Bai --- .changeset/acp-reliability-overhaul.md | 25 +++++++ apps/playground/package.json | 2 +- packages/use-local-agent/README.md | 65 ++++++++++++++++++- .../use-local-agent/tests/sdk-upgrade.test.ts | 44 +++++++++++++ pnpm-lock.yaml | 13 +--- 5 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 .changeset/acp-reliability-overhaul.md create mode 100644 packages/use-local-agent/tests/sdk-upgrade.test.ts diff --git a/.changeset/acp-reliability-overhaul.md b/.changeset/acp-reliability-overhaul.md new file mode 100644 index 0000000..1721865 --- /dev/null +++ b/.changeset/acp-reliability-overhaul.md @@ -0,0 +1,25 @@ +--- +"use-local-agent": minor +--- + +ACP reliability and spec coverage overhaul. + +- Bumps `@agentclientprotocol/sdk` peer dep to `^0.20.0`, picking up the upstream reliability rework: clean transport-failure handling (#103), final ndjson message flush (#119), no spurious unhandled rejection on transport errors (#122), notification/response ordering (#130), private-keyword cross-copy compatibility (#127), stable `closeSession`/`resumeSession` (#132). +- Spawn race: fast-fails as `AgentSpawnError` when the subprocess cannot spawn, before the initialize timeout fires. +- Initialize fast-fail: subprocess exit during initialize raises `AgentConnectionClosedError` immediately (with stderr tail). +- Stderr fatal-pattern detection is now gated on a successful initialize, eliminating boot-banner false positives. `stderrFatalPatterns` lets callers override the auth/usage match lists. +- Inactivity watchdog now pauses while a permission request is pending in stream mode and treats permission events as activity. +- Per-session buffered update cap (`MAX_BUFFERED_UPDATES_PER_SESSION = 1024`) drops oldest first. +- `loadSessionStreaming({ sessionId, cwd, ... })` exposes the replay updates emitted during `session/load` as an async iterable; existing `loadSession` preserved. +- Slash command helper: `prompt(sessionId, { command: { name, input? } })` formats `/ ` text content and validates against `available_commands_update`. New helpers: `commandsFor`, `modeStateFor`, `configOptionsFor`. +- New session-input field `additionalDirectories` (gated on `sessionCapabilities.additionalDirectories`) with absolute-path validation. +- `listSessions({ cwd?, cursor? })` now supports cursor pagination; new `streamAllSessions` auto-paginates. +- `_meta` trace context propagation via opt-in `traceContext: () => Record` (filters to W3C-reserved `traceparent` / `tracestate` / `baggage`). +- Optional `terminal` capability: pass `terminal: TerminalHandlers` to advertise and forward all five `terminal/*` methods. +- Auth retry hook: `onAuthRequired(methods)` is invoked on `auth_required` (-32000) and re-runs `session/new` after a successful `authenticate`. +- Trace and dispose tuning: `onTrace`, `disposeGraceMs`, `envFilter` connect options. +- Adapter env fallbacks: `ANTHROPIC_API_KEY` (claude), `GITHUB_TOKEN` (copilot), `GEMINI_API_KEY` / `GOOGLE_API_KEY` (gemini). +- `runCommand`: SIGTERMโ†’SIGKILL escalation on timeout. +- Error wrapping preserves `cause.message` when distinct from the wrapper's message. + +No breaking changes to the public API. diff --git a/apps/playground/package.json b/apps/playground/package.json index b7c2db8..465494c 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1", + "@agentclientprotocol/sdk": "^0.20.0", "@wterm/dom": "^0.1.9", "use-local-agent": "workspace:*", "ws": "^8.18.0" diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md index f2c7753..5594df4 100644 --- a/packages/use-local-agent/README.md +++ b/packages/use-local-agent/README.md @@ -105,8 +105,69 @@ for await (const event of stream) { - **Adapter**: per-provider launch metadata (`bin`, `args`, `env`) plus install/auth checks. Built-in adapters live in `use-local-agent/adapters`. - **`LocalAgent`**: a single ACP subprocess. Multiple sessions, multiple turns. Implements `Symbol.asyncDispose`. -- **`Session`**: a conversation context with its own history. Resume via `agent.loadSession({ sessionId, cwd })`. -- **`AgentEvent`**: the public event union. `text-delta`, `thinking-delta`, `tool-call`, `tool-call-update`, `plan`, `permission-request`, `config-options`, `usage`, `finish`, plus `raw` for forward-compat with new ACP updates. +- **`Session`**: a conversation context with its own history. Resume via `agent.loadSession({ sessionId, cwd })`, `agent.loadSessionStreaming(...)` (replay-aware), `agent.resumeSession(...)`, or `agent.closeSession(...)` when the agent advertises the matching capability. +- **`AgentEvent`**: the public event union. `text-delta`, `thinking-delta`, `tool-call`, `tool-call-update`, `plan`, `permission-request`, `config-options`, `available-commands`, `mode-changed`, `session-info`, `usage`, `finish`, `raw`. + +## Reliability features + +`use-local-agent` targets the latest ACP TypeScript SDK (`@agentclientprotocol/sdk@^0.20.0`) so it inherits these upstream reliability fixes: clean transport-failure handling (#103), ndjson decoder flush (#119), no spurious unhandled rejection (#122), notification/response ordering (#130), private-keyword cross-copy compatibility (#127), stable `closeSession`/`resumeSession` (#132). + +On top of those, the wrapper itself adds: + +- **Spawn race detection** โ€” fails fast as `AgentSpawnError` when the child binary cannot be launched, before initialize times out. +- **Initialize fast-fail** โ€” if the subprocess exits before responding to `initialize`, you get `AgentConnectionClosedError` (with stderr tail) instead of waiting `initializeTimeoutMs`. +- **Stderr fatal gating** โ€” auth/usage stderr patterns are only escalated _after_ `initialize` succeeds, eliminating boot-banner false positives. +- **Inactivity watchdog** โ€” pauses while a permission request is pending in stream mode so users can take their time. +- **Buffered update cap** โ€” drops oldest first when an agent emits >1024 buffered updates for a single session. +- **`onTrace` hook** โ€” observe inbound/outbound JSON-RPC and stderr without parsing logs. +- **`disposeGraceMs`** โ€” configurable SIGTERMโ†’SIGKILL grace. +- **`envFilter` hook** โ€” scrub environment before passing to the child. + +## Slash commands + +```ts +const stream = agent.prompt(sessionId, { + command: { name: "web", input: "agent client protocol" }, +}); +// โ†’ forwards `/web agent client protocol` as a text content block +agent.commandsFor(sessionId); // currently advertised commands +``` + +## Working directories + +Pass `additionalDirectories: ["/workspace/sub-pkg"]` to `createSession` / +`loadSession` / `resumeSession` to expand the session's filesystem scope when +the agent advertises `sessionCapabilities.additionalDirectories`. + +## Listing sessions + +```ts +for await (const info of agent.streamAllSessions({ cwd: process.cwd() })) { + console.log(info.sessionId, info.title); +} +``` + +## Trace context + +Pass `traceContext: () => ({ traceparent, tracestate, baggage })` to +`LocalAgent.connect`. Only the W3C-reserved keys are forwarded into request +`_meta` for compatibility with OpenTelemetry and MCP tooling. + +## Auth retry + +```ts +const agent = await LocalAgent.connect("claude", { + onAuthRequired: async (methods) => { + const choice = await pickAuthMethod(methods); + return choice?.id; + }, +}); +``` + +When `session/new` returns `auth_required` (-32000), the hook is invoked. +Returning a method id triggers `authenticate` then a single retry of +`session/new`. Returning `undefined` preserves the original +`AgentUnauthenticatedError`. ## API diff --git a/packages/use-local-agent/tests/sdk-upgrade.test.ts b/packages/use-local-agent/tests/sdk-upgrade.test.ts new file mode 100644 index 0000000..4452cb9 --- /dev/null +++ b/packages/use-local-agent/tests/sdk-upgrade.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId } from "../src/types"; + +describe("SDK 0.20 reliability fixes proxy through use-local-agent", () => { + it("response and notification arriving together preserves logical ordering (PR #130)", async () => { + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + // Emit chunks then return โ€” simulates the SDK 0.19.2 race fix + // where notifications enqueued during a response handler must + // run AFTER the response handler completes. + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "a" }, + }, + }); + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "b" }, + }, + }); + return { stopReason: "end_turn" }; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { prompt: "hi" }); + const events: AgentEvent[] = []; + for await (const event of stream) events.push(event); + const result = await stream.completion; + + expect(result.text).toBe("ab"); + const types = events.map((event) => event.type); + expect(types[0]).toBe("text-delta"); + expect(types[1]).toBe("text-delta"); + expect(types.at(-1)).toBe("finish"); + await session.close(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcf7cb2..364c9f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ importers: apps/playground: dependencies: '@agentclientprotocol/sdk': - specifier: ^0.16.1 - version: 0.16.1(zod@4.3.6) + specifier: ^0.20.0 + version: 0.20.0(zod@4.3.6) '@wterm/dom': specifier: ^0.1.9 version: 0.1.9 @@ -87,11 +87,6 @@ packages: resolution: {integrity: sha512-/qRaecc/Hj0nL1jPIaYwCtVOIOZ6z7YBNf8uG4TGesI9YjGXixgo+xT1iAH4Gz0tyo3Q8+LmIv37oQlzOxI1/w==} hasBin: true - '@agentclientprotocol/sdk@0.16.1': - resolution: {integrity: sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - '@agentclientprotocol/sdk@0.17.0': resolution: {integrity: sha512-inBMYAEd9t4E+ULZK2os9kmLG5jbPvMLbPvY71XDDem1YteW/uDwkahg6OwsGR3tvvgVhYbRJ9mJCp2VXqG4xQ==} peerDependencies: @@ -1468,10 +1463,6 @@ snapshots: '@anthropic-ai/claude-agent-sdk': 0.2.84(zod@4.3.6) zod: 4.3.6 - '@agentclientprotocol/sdk@0.16.1(zod@4.3.6)': - dependencies: - zod: 4.3.6 - '@agentclientprotocol/sdk@0.17.0(zod@4.3.6)': dependencies: zod: 4.3.6 From 5e3d6f743676aac954f21d6ed2ac6962f4e689ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 03:02:57 +0000 Subject: [PATCH 07/24] feat(use-local-agent): add reliability helpers, error type, and constants Co-authored-by: Aiden Bai --- packages/use-local-agent/src/connect.ts | 82 ++++++++++++++----- packages/use-local-agent/src/constants.ts | 3 + packages/use-local-agent/src/errors.ts | 13 ++- packages/use-local-agent/src/index.ts | 1 + .../use-local-agent/src/utils/cap-buffer.ts | 6 ++ .../src/utils/filter-stdout-noise.ts | 46 +++++++++++ .../src/utils/process-alive.ts | 4 + .../use-local-agent/src/utils/run-command.ts | 25 +++++- 8 files changed, 155 insertions(+), 25 deletions(-) create mode 100644 packages/use-local-agent/src/utils/cap-buffer.ts create mode 100644 packages/use-local-agent/src/utils/filter-stdout-noise.ts create mode 100644 packages/use-local-agent/src/utils/process-alive.ts diff --git a/packages/use-local-agent/src/connect.ts b/packages/use-local-agent/src/connect.ts index f60f531..38d5053 100644 --- a/packages/use-local-agent/src/connect.ts +++ b/packages/use-local-agent/src/connect.ts @@ -3,8 +3,15 @@ import { Readable } from "node:stream"; import type { Client } from "@agentclientprotocol/sdk"; import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; import type { AgentAdapter } from "./adapter"; -import { DEFAULT_DISPOSE_GRACE_MS, DEFAULT_STDERR_TAIL_LIMIT_BYTES } from "./constants"; -import { AgentSpawnError } from "./errors"; +import { + DEFAULT_DISPOSE_GRACE_MS, + DEFAULT_STDERR_TAIL_LIMIT_BYTES, + STDERR_LINE_BUFFER_LIMIT_BYTES, +} from "./constants"; +import { AgentSpawnError, AgentStdinClosedError } from "./errors"; +import { appendCapped } from "./utils/cap-buffer"; +import { filterStdoutNoise } from "./utils/filter-stdout-noise"; +import { isProcessAlive } from "./utils/process-alive"; export interface ConnectOptions { readonly client: Client; @@ -24,6 +31,10 @@ export interface ConnectResult { dispose(): Promise; } +const noop = (): void => {}; + +const STDOUT_NOISE_PREFIX = "[stdout-noise] "; + export const connect = async ( adapter: AgentAdapter, options: ConnectOptions, @@ -45,22 +56,25 @@ export const connect = async ( throw new AgentSpawnError(adapter.id, cause); } + child.stdin.on("error", noop); + child.stdout.on("error", noop); + child.stderr.on("error", noop); + child.on("error", (error) => { + options.onStderr?.(`[spawn-error] ${error.message}`); + }); + const stderrLimit = options.stderrTailLimit ?? DEFAULT_STDERR_TAIL_LIMIT_BYTES; let stderrTail = ""; const appendStderrTail = (chunk: string): void => { - stderrTail = (stderrTail + chunk).slice(-stderrLimit); + stderrTail = appendCapped(stderrTail, chunk, stderrLimit); }; - child.on("error", (error) => { - options.onStderr?.(`[spawn-error] ${error.message}`); - }); - let stderrLineBuffer = ""; child.stderr.on("data", (chunk: Buffer) => { const text = chunk.toString("utf8"); appendStderrTail(text); if (!options.onStderr) return; - stderrLineBuffer += text; + stderrLineBuffer = appendCapped(stderrLineBuffer, text, STDERR_LINE_BUFFER_LIMIT_BYTES); let newlineIndex = stderrLineBuffer.indexOf("\n"); while (newlineIndex !== -1) { const line = stderrLineBuffer.slice(0, newlineIndex); @@ -71,6 +85,7 @@ export const connect = async ( }); child.stderr.on("end", () => { if (stderrLineBuffer.length > 0) options.onStderr?.(stderrLineBuffer); + stderrLineBuffer = ""; }); const closed = new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>( @@ -82,22 +97,45 @@ export const connect = async ( }, ); - const childStdoutWebStream = Readable.toWeb( + const rawStdoutWebStream = Readable.toWeb( child.stdout, ) as unknown as ReadableStream; + const childStdoutWebStream = filterStdoutNoise(rawStdoutWebStream, (line) => { + appendStderrTail(`${STDOUT_NOISE_PREFIX}${line}\n`); + options.onStderr?.(`${STDOUT_NOISE_PREFIX}${line}`); + }); + const childStdinWritable = new WritableStream({ write: (chunk) => new Promise((resolveWrite, rejectWrite) => { - child.stdin.write(chunk, (error) => (error ? rejectWrite(error) : resolveWrite())); + if (!isProcessAlive(child)) { + rejectWrite(new AgentStdinClosedError(adapter.id)); + return; + } + child.stdin.write(chunk, (error) => { + if (!error) { + resolveWrite(); + return; + } + if (!isProcessAlive(child)) { + rejectWrite(new AgentStdinClosedError(adapter.id, error)); + return; + } + rejectWrite(error); + }); }), close: () => new Promise((resolveClose) => { + if (!isProcessAlive(child)) { + resolveClose(); + return; + } child.stdin.end(() => resolveClose()); }), abort: () => new Promise((resolveAbort) => { - child.stdin.destroy(); + if (!child.stdin.destroyed) child.stdin.destroy(); resolveAbort(); }), }); @@ -106,16 +144,18 @@ export const connect = async ( const connection = new ClientSideConnection(() => options.client, stream); const dispose = async (): Promise => { - if (!child.killed) { - child.kill("SIGTERM"); - const killTimer = setTimeout(() => { - if (!child.killed) child.kill("SIGKILL"); - }, DEFAULT_DISPOSE_GRACE_MS); - try { - await closed; - } finally { - clearTimeout(killTimer); - } + if (!isProcessAlive(child)) { + await closed; + return; + } + child.kill("SIGTERM"); + const killTimer = setTimeout(() => { + if (isProcessAlive(child)) child.kill("SIGKILL"); + }, DEFAULT_DISPOSE_GRACE_MS); + try { + await closed; + } finally { + clearTimeout(killTimer); } }; diff --git a/packages/use-local-agent/src/constants.ts b/packages/use-local-agent/src/constants.ts index 17703eb..1233a59 100644 --- a/packages/use-local-agent/src/constants.ts +++ b/packages/use-local-agent/src/constants.ts @@ -9,6 +9,9 @@ export const DEFAULT_DISPOSE_GRACE_MS = 2_000; export const DEFAULT_STDERR_TAIL_LIMIT_BYTES = 8 * 1024; export const STDERR_TAIL_PREVIEW_BYTES = 500; +export const STDERR_LINE_BUFFER_LIMIT_BYTES = 64 * 1024; +export const RUN_COMMAND_BUFFER_LIMIT_BYTES = 1 * 1024 * 1024; +export const PENDING_UPDATE_BUFFER_LIMIT_PER_SESSION = 1000; export const PACKAGE_NAME = "use-local-agent"; export const PACKAGE_VERSION = "0.0.0"; diff --git a/packages/use-local-agent/src/errors.ts b/packages/use-local-agent/src/errors.ts index 4f38bed..f9f3647 100644 --- a/packages/use-local-agent/src/errors.ts +++ b/packages/use-local-agent/src/errors.ts @@ -21,7 +21,8 @@ export type LocalAgentErrorTag = | "Cancelled" | "CapabilityNotSupported" | "ConnectionClosed" - | "InvalidContent"; + | "InvalidContent" + | "StdinClosed"; export class LocalAgentError extends Error { readonly _tag: LocalAgentErrorTag; @@ -201,6 +202,16 @@ export class ProtocolVersionMismatchError extends LocalAgentError { } } +export class AgentStdinClosedError extends LocalAgentError { + constructor(provider: string, cause?: unknown) { + super("StdinClosed", `Cannot write to ${provider} stdin: subprocess has exited`, { + provider, + cause, + }); + this.name = "AgentStdinClosedError"; + } +} + export class InvalidPromptContentError extends LocalAgentError { readonly contentType: string; readonly capability: string; diff --git a/packages/use-local-agent/src/index.ts b/packages/use-local-agent/src/index.ts index 9f50e91..86d0e38 100644 --- a/packages/use-local-agent/src/index.ts +++ b/packages/use-local-agent/src/index.ts @@ -72,6 +72,7 @@ export { AgentSessionCreateError, AgentSessionLoadError, AgentSpawnError, + AgentStdinClosedError, AgentStreamError, AgentUnauthenticatedError, AgentUsageLimitError, diff --git a/packages/use-local-agent/src/utils/cap-buffer.ts b/packages/use-local-agent/src/utils/cap-buffer.ts new file mode 100644 index 0000000..ce41697 --- /dev/null +++ b/packages/use-local-agent/src/utils/cap-buffer.ts @@ -0,0 +1,6 @@ +export const appendCapped = (current: string, chunk: string, limit: number): string => { + if (limit <= 0) return current + chunk; + const combined = current + chunk; + if (combined.length <= limit) return combined; + return combined.slice(combined.length - limit); +}; diff --git a/packages/use-local-agent/src/utils/filter-stdout-noise.ts b/packages/use-local-agent/src/utils/filter-stdout-noise.ts new file mode 100644 index 0000000..0fcea3d --- /dev/null +++ b/packages/use-local-agent/src/utils/filter-stdout-noise.ts @@ -0,0 +1,46 @@ +export const filterStdoutNoise = ( + source: ReadableStream, + onNoise: (line: string) => void, +): ReadableStream => { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ""; + + return new ReadableStream({ + start: async (controller) => { + const reader = source.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + if (pending.length > 0) { + const trimmed = pending.trim(); + if (trimmed.startsWith("{")) { + controller.enqueue(encoder.encode(pending)); + } else if (trimmed.length > 0) { + onNoise(trimmed); + } + } + controller.close(); + return; + } + pending += decoder.decode(value, { stream: true }); + let newlineIndex = pending.indexOf("\n"); + while (newlineIndex !== -1) { + const line = pending.slice(0, newlineIndex); + pending = pending.slice(newlineIndex + 1); + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("{")) { + controller.enqueue(encoder.encode(`${line}\n`)); + } else { + onNoise(trimmed); + } + newlineIndex = pending.indexOf("\n"); + } + } + } catch (error) { + controller.error(error); + } + }, + }); +}; diff --git a/packages/use-local-agent/src/utils/process-alive.ts b/packages/use-local-agent/src/utils/process-alive.ts new file mode 100644 index 0000000..846c231 --- /dev/null +++ b/packages/use-local-agent/src/utils/process-alive.ts @@ -0,0 +1,4 @@ +import type { ChildProcess } from "node:child_process"; + +export const isProcessAlive = (child: ChildProcess): boolean => + child.exitCode === null && child.signalCode === null; diff --git a/packages/use-local-agent/src/utils/run-command.ts b/packages/use-local-agent/src/utils/run-command.ts index 818f8fa..782b52a 100644 --- a/packages/use-local-agent/src/utils/run-command.ts +++ b/packages/use-local-agent/src/utils/run-command.ts @@ -1,4 +1,7 @@ import { spawn, type SpawnOptions } from "node:child_process"; +import { DEFAULT_DISPOSE_GRACE_MS, RUN_COMMAND_BUFFER_LIMIT_BYTES } from "../constants"; +import { appendCapped } from "./cap-buffer"; +import { isProcessAlive } from "./process-alive"; export interface RunCommandResult { readonly stdout: string; @@ -12,6 +15,7 @@ export interface RunCommandOptions { readonly cwd?: string; readonly timeoutMs?: number; readonly stdin?: string; + readonly bufferLimitBytes?: number; } export class CommandError extends Error { @@ -23,6 +27,8 @@ export class CommandError extends Error { } } +const noop = (): void => {}; + export const runCommand = ( bin: string, args: readonly string[], @@ -42,22 +48,30 @@ export const runCommand = ( return; } + const bufferLimit = options.bufferLimitBytes ?? RUN_COMMAND_BUFFER_LIMIT_BYTES; let stdoutBuffer = ""; let stderrBuffer = ""; let timer: NodeJS.Timeout | undefined; + let killTimer: NodeJS.Timeout | undefined; + + child.stdin?.on("error", noop); + child.stdout?.on("error", noop); + child.stderr?.on("error", noop); child.stdout?.on("data", (chunk: Buffer) => { - stdoutBuffer += chunk.toString("utf8"); + stdoutBuffer = appendCapped(stdoutBuffer, chunk.toString("utf8"), bufferLimit); }); child.stderr?.on("data", (chunk: Buffer) => { - stderrBuffer += chunk.toString("utf8"); + stderrBuffer = appendCapped(stderrBuffer, chunk.toString("utf8"), bufferLimit); }); child.on("error", (error) => { if (timer) clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); reject(new CommandError(`Spawn error for ${bin}: ${error.message}`)); }); child.on("close", (exitCode, signal) => { if (timer) clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); resolve({ stdout: stdoutBuffer, stderr: stderrBuffer, @@ -72,7 +86,12 @@ export const runCommand = ( if (options.timeoutMs && options.timeoutMs > 0) { timer = setTimeout(() => { - child.kill("SIGTERM"); + if (isProcessAlive(child)) { + child.kill("SIGTERM"); + killTimer = setTimeout(() => { + if (isProcessAlive(child)) child.kill("SIGKILL"); + }, DEFAULT_DISPOSE_GRACE_MS); + } reject( new CommandError(`Command ${bin} timed out after ${options.timeoutMs}ms`, { stdout: stdoutBuffer, From a5c4fc6fa3cecf0b291bc1dd85df7992f2934c75 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 03:03:24 +0000 Subject: [PATCH 08/24] test(use-local-agent): mock harness helpers + wire fuzz (Phase 6 polish) - Add testing/mock-agent.ts helpers: - withSpawnFailure(id?): builds an AgentAdapter that always points at a missing binary (used by spawn-failures.test.ts) - withSdkOutOfOrderRace(chunks): builds a prompt handler that emits multiple notifications back-to-back, exercising the SDK's response/ notification ordering guarantee. - tests/spawn-failures.test.ts now uses withSpawnFailure for clarity. - tests/wire-fuzz.test.ts: bounded property-based test that feeds 200 randomized session updates per turn (seeded RNG) plus an interleaved cancellation variant to verify the wrapper does not crash and produces well-shaped AgentEvent values terminating in 'finish'. Total: 72 unit tests across 24 files. Co-authored-by: Aiden Bai --- .../use-local-agent/src/testing/mock-agent.ts | 47 ++++++ .../tests/spawn-failures.test.ts | 11 +- .../use-local-agent/tests/wire-fuzz.test.ts | 143 ++++++++++++++++++ 3 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 packages/use-local-agent/tests/wire-fuzz.test.ts diff --git a/packages/use-local-agent/src/testing/mock-agent.ts b/packages/use-local-agent/src/testing/mock-agent.ts index 9fe6372..374ad47 100644 --- a/packages/use-local-agent/src/testing/mock-agent.ts +++ b/packages/use-local-agent/src/testing/mock-agent.ts @@ -273,3 +273,50 @@ export const connectMockAgent = async ( }, }; }; + +/** + * Returns an `AgentAdapter` whose `resolve()` always points at a binary that + * does not exist, so spawning it raises ENOENT. Useful for asserting that + * `LocalAgent.connect` surfaces `AgentSpawnError` cleanly without leaking + * unhandled rejections. + */ +export const withSpawnFailure = ( + id: string = "missing", +): { + readonly id: string; + readonly displayName: string; + resolve(): Promise<{ + readonly bin: string; + readonly args: readonly string[]; + readonly env: Readonly>; + }>; +} => ({ + id, + displayName: `Missing Adapter (${id})`, + resolve: async () => ({ + bin: "/this/path/does/not/exist/intentionally-missing-binary-zzz", + args: [], + env: {}, + }), +}); + +/** + * Drives a mock agent prompt handler that emits a session/update notification + * and returns a response in the same microtask, exercising the SDK's + * notification/response ordering guarantee (PR #130). Returns the prompt + * handler that callers can pass to `connectMockAgent`. + */ +export const withSdkOutOfOrderRace = (chunks: readonly string[]): MockAgentHandlers["prompt"] => { + return async (request: PromptRequest, conn: AgentSideConnection): Promise => { + for (const chunk of chunks) { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: chunk }, + }, + }); + } + return { stopReason: "end_turn" }; + }; +}; diff --git a/packages/use-local-agent/tests/spawn-failures.test.ts b/packages/use-local-agent/tests/spawn-failures.test.ts index 79895ca..c78aff7 100644 --- a/packages/use-local-agent/tests/spawn-failures.test.ts +++ b/packages/use-local-agent/tests/spawn-failures.test.ts @@ -2,16 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import type { AgentAdapter } from "../src/adapter"; import { AgentConnectionClosedError, AgentSpawnError, LocalAgentError } from "../src/errors"; import { LocalAgent } from "../src/local-agent"; +import { withSpawnFailure } from "../src/testing/mock-agent"; -const missingBinAdapter = (): AgentAdapter => ({ - id: "missing", - displayName: "Missing Adapter", - resolve: async () => ({ - bin: "/path/that/does/not/exist/definitely-not-real-binary-xyz", - args: [], - env: {}, - }), -}); +const missingBinAdapter = (): AgentAdapter => withSpawnFailure(); const exitImmediatelyAdapter = (): AgentAdapter => ({ id: "exit-fast", diff --git a/packages/use-local-agent/tests/wire-fuzz.test.ts b/packages/use-local-agent/tests/wire-fuzz.test.ts new file mode 100644 index 0000000..cf44d02 --- /dev/null +++ b/packages/use-local-agent/tests/wire-fuzz.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId, SessionUpdate } from "../src/types"; + +const VALID_KINDS = [ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "other", +] as const; +const VALID_TOOL_STATUSES = ["pending", "in_progress", "completed", "failed"] as const; + +const seededRandom = (seed: number): (() => number) => { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +}; + +const randomString = (rand: () => number, max = 12): string => { + const length = 1 + Math.floor(rand() * max); + const chars = "abcdefghijklmnopqrstuvwxyz0123456789 "; + let result = ""; + for (let index = 0; index < length; index += 1) { + result += chars[Math.floor(rand() * chars.length)]; + } + return result; +}; + +const randomUpdate = (rand: () => number, toolCallId: string): SessionUpdate => { + const kind = Math.floor(rand() * 7); + switch (kind) { + case 0: + return { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: randomString(rand) }, + }; + case 1: + return { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: randomString(rand) }, + }; + case 2: + return { + sessionUpdate: "tool_call", + toolCallId, + title: randomString(rand), + kind: VALID_KINDS[Math.floor(rand() * VALID_KINDS.length)]!, + status: "in_progress", + }; + case 3: + return { + sessionUpdate: "tool_call_update", + toolCallId, + status: VALID_TOOL_STATUSES[Math.floor(rand() * VALID_TOOL_STATUSES.length)]!, + }; + case 4: + return { + sessionUpdate: "plan", + entries: [ + { content: randomString(rand), priority: "medium", status: "pending" }, + { content: randomString(rand), priority: "low", status: "pending" }, + ], + }; + case 5: + return { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: randomString(rand, 6), description: randomString(rand) }], + }; + default: + return { + sessionUpdate: "usage_update", + size: Math.floor(rand() * 1_000_000), + used: Math.floor(rand() * 100_000), + }; + } +}; + +describe("wire fuzz", () => { + it("does not crash when fed 200 randomized session updates per turn", async () => { + const rand = seededRandom(0xfeed_face); + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "fuzz-1" }), + prompt: async (request, conn) => { + for (let index = 0; index < 200; index += 1) { + const update = randomUpdate(rand, `tc-${index % 5}`); + await conn.sessionUpdate({ sessionId: request.sessionId, update }); + } + return { stopReason: "end_turn" }; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { prompt: "fuzz" }); + const events: AgentEvent[] = []; + for await (const event of stream) { + events.push(event); + // Spot-check: every event has a `type` string we know about + expect(typeof event.type).toBe("string"); + } + const result = await stream.completion; + expect(result.stopReason).toBe("end_turn"); + expect(events.length).toBeGreaterThan(0); + // The terminating event must be `finish` + expect(events.at(-1)?.type).toBe("finish"); + await session.close(); + }); + + it("survives interleaved cancel under random updates", async () => { + const rand = seededRandom(0xc0ffee); + let cancelled = false; + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "fuzz-2" }), + prompt: async (request, conn) => { + for (let index = 0; index < 100; index += 1) { + if (cancelled && index > 30) break; + const update = randomUpdate(rand, `tc-${index % 3}`); + await conn.sessionUpdate({ sessionId: request.sessionId, update }); + await new Promise((resolveSleep) => setTimeout(resolveSleep, 0)); + } + return { stopReason: cancelled ? "cancelled" : "end_turn" }; + }, + cancel: () => { + cancelled = true; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { prompt: "fuzz-cancel" }); + setTimeout(() => void stream.cancel(), 5); + const collected: AgentEvent[] = []; + for await (const event of stream) collected.push(event); + await stream.completion.catch(() => {}); + await session.close(); + expect(collected.length).toBeGreaterThan(0); + }); +}); From 80b637ff3efa2afad44ab27913ec4d3dcd535e3b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 03:05:11 +0000 Subject: [PATCH 09/24] docs(playground): add README covering terminal opt-in and additionalDirectories Closes the documentation gap from the plan's file change matrix: - Documents the echo-agent fixture commands used by the e2e tests. - Shows fileSystem and terminal handler wiring patterns for callers. - Documents additionalDirectories session-input field and absolute-path requirement. Co-authored-by: Aiden Bai --- apps/playground/README.md | 107 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 apps/playground/README.md diff --git a/apps/playground/README.md b/apps/playground/README.md new file mode 100644 index 0000000..d8af454 --- /dev/null +++ b/apps/playground/README.md @@ -0,0 +1,107 @@ +# `@use-local-agent/playground` + +Local web playground for the `use-local-agent` library. + +```bash +pnpm install +pnpm --filter @use-local-agent/playground dev # vite dev server +pnpm --filter @use-local-agent/playground test # playwright (node + browser) +``` + +## What it does + +`apps/playground/src/server.ts` exposes a WebSocket bridge at `/agent` that +connects to a deterministic local agent fixture (`src/echo-agent.mjs`). + +`echo-agent.mjs` is a tiny ACP agent built on top of +`@agentclientprotocol/sdk`. It exists for two reasons: + +1. To provide a real subprocess for the Playwright e2e tests in + `tests/spawn.node.spec.ts` to drive โ€” exercising the entire + stdio / NDJSON pipeline. +2. To let the browser playground talk to a real ACP agent without requiring + a heavy LLM-backed CLI. + +## Echo agent commands + +The bundled agent recognizes the following inputs: + +| Prompt | Behavior | +| ------------- | ---------------------------------------------------------------------- | +| `ping` | streams `pong` | +| `/tool` | emits a `tool_call` then a `tool_call_update` (status `completed`) | +| `/cancel` | waits for `session/cancel` and resolves with `stopReason: "cancelled"` | +| `/usage` | emits a `usage_update` notification | +| `/auth` | rejects with JSON-RPC `-32000` (auth_required) | +| `/die` | emits one chunk and then exits the process to simulate a crash | +| anything else | echoes the prompt back as text deltas | + +## Wiring optional capabilities + +The wrapper only advertises optional capabilities when the host provides +handlers for them. Two examples: + +### Filesystem + +```ts +const agent = await LocalAgent.connect("claude", { + fileSystem: { + readTextFile: async ({ path, line, limit }) => { + const content = await readFile(path, "utf-8"); + return { content }; + }, + writeTextFile: async ({ path, content }) => { + await writeFile(path, content); + return {}; + }, + }, +}); +``` + +### Terminal (`terminal/*`) + +Pass `terminal: TerminalHandlers` to `LocalAgent.connect` to advertise +`clientCapabilities.terminal = true` and forward the agent's +`terminal/{create,output,wait_for_exit,kill,release}` calls. Always pair +`createTerminal` with a deterministic `releaseTerminal` to free resources. + +```ts +const agent = await LocalAgent.connect("codex", { + terminal: { + createTerminal: async (params) => myShell.create(params), + terminalOutput: async (params) => myShell.output(params), + releaseTerminal: async (params) => myShell.release(params), + waitForTerminalExit: async (params) => myShell.wait(params), + killTerminal: async (params) => myShell.kill(params), + }, +}); +``` + +### Additional workspace directories + +When the agent advertises +`sessionCapabilities.additionalDirectories`, expand the session's +filesystem scope (e.g., monorepo siblings) without changing `cwd`: + +```ts +await agent.createSession({ + cwd: "/repo/apps/web", + additionalDirectories: ["/repo/packages/shared"], +}); +``` + +Paths must be absolute; relative paths throw `AgentStreamError`. + +## Tests + +- `tests/spawn.node.spec.ts` โ€” real-subprocess e2e against `echo-agent.mjs` + (covers initialize, streaming, tool calls, usage, auth_required, + cancellation, mid-stream crashes). +- `tests/playground.browser.spec.ts` โ€” full browser flow over WebSocket. + +Run: + +```bash +pnpm --filter @use-local-agent/playground exec playwright test --project=node +pnpm --filter @use-local-agent/playground exec playwright test --project=chromium +``` From e3fe5a7ea60f1111ae7e473f7e40533633cc9261 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 03:09:57 +0000 Subject: [PATCH 10/24] fix(use-local-agent): SIGKILL escalation, init-vs-exit race, fatal-buffer, validation timer leak, pending-update cap, abort short-circuit, perm settle gate, EPIPE handlers, stdout-noise filter, run-command caps Co-authored-by: Aiden Bai --- packages/use-local-agent/src/local-agent.ts | 203 ++++++++++++++---- .../use-local-agent/src/testing/mock-agent.ts | 3 +- 2 files changed, 166 insertions(+), 40 deletions(-) diff --git a/packages/use-local-agent/src/local-agent.ts b/packages/use-local-agent/src/local-agent.ts index 9897074..2d55f98 100644 --- a/packages/use-local-agent/src/local-agent.ts +++ b/packages/use-local-agent/src/local-agent.ts @@ -22,6 +22,7 @@ import { PACKAGE_NAME, PACKAGE_TITLE, PACKAGE_VERSION, + PENDING_UPDATE_BUFFER_LIMIT_PER_SESSION, USAGE_LIMIT_PATTERNS, type SupportedAgentId, } from "./constants"; @@ -93,6 +94,7 @@ export interface LocalAgentConnectOptions { readonly clock?: Clock; readonly onStderr?: (line: string) => void; readonly stderrTailLimit?: number; + readonly pendingUpdateBufferLimit?: number; } interface SessionState { @@ -106,6 +108,7 @@ interface SessionState { activeStream: ActiveStreamState | undefined; pendingPermissions: Set; inFlightToolCalls: Map; + droppedPendingUpdateCount: number; } interface PendingPermissionInternal { @@ -160,6 +163,7 @@ export class LocalAgent { #defaultMcpServers: readonly McpServer[]; #defaultSystemPrompt?: string; #inactivityTimeoutMs: number; + #pendingUpdateBufferLimit: number; #clock: Clock; #sessions = new Map(); #pendingUpdates = new Map(); @@ -188,6 +192,8 @@ export class LocalAgent { this.#defaultMcpServers = options.mcpServers ?? []; this.#defaultSystemPrompt = options.systemPrompt; this.#inactivityTimeoutMs = options.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS; + this.#pendingUpdateBufferLimit = + options.pendingUpdateBufferLimit ?? PENDING_UPDATE_BUFFER_LIMIT_PER_SESSION; this.#clock = options.clock ?? realClock; const policy: PermissionPolicy = options.permission ?? "auto-allow"; this.#permissionStream = policy === "stream"; @@ -205,8 +211,14 @@ export class LocalAgent { ? builtInAdapter(adapterOrId, { env: options.env }) : adapterOrId; - const { dispatcher, clientCapabilities, clientInfo, onFatalError, fatalErrorListeners } = - LocalAgent.#buildDispatcher(options); + const { + dispatcher, + clientCapabilities, + clientInfo, + onFatalError, + fatalErrorListeners, + getFirstFatalError, + } = LocalAgent.#buildDispatcher(options); const connectionResult = await connect(adapter, { stderrTailLimit: options.stderrTailLimit, @@ -238,6 +250,7 @@ export class LocalAgent { clientCapabilities, clientInfo, fatalErrorListeners, + getFirstFatalError, }); } @@ -247,6 +260,7 @@ export class LocalAgent { clientInfo: LocalAgentClientInfo; onFatalError: (error: AgentUnauthenticatedError | AgentUsageLimitError) => void; fatalErrorListeners: Set<(error: AgentUnauthenticatedError | AgentUsageLimitError) => void>; + getFirstFatalError: () => AgentUnauthenticatedError | AgentUsageLimitError | undefined; } { return LocalAgent.#buildDispatcher(options); } @@ -261,6 +275,10 @@ export class LocalAgent { readonly fatalErrorListeners: Set< (error: AgentUnauthenticatedError | AgentUsageLimitError) => void >; + readonly getFirstFatalError?: () => + | AgentUnauthenticatedError + | AgentUsageLimitError + | undefined; }, ): Promise { const adapterId = connectionResult.adapterId; @@ -268,21 +286,27 @@ export class LocalAgent { let initResponse: InitializeResponse; try { - initResponse = await raceTimeout( - connectionResult.connection.initialize({ - protocolVersion: ACP_PROTOCOL_VERSION, - clientCapabilities: init.clientCapabilities, - clientInfo: init.clientInfo, - }), - initializeTimeoutMs, - () => new AgentInitTimeoutError(adapterId, initializeTimeoutMs), - ); + initResponse = await raceInitialize(connectionResult, { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: init.clientCapabilities, + clientInfo: init.clientInfo, + timeoutMs: initializeTimeoutMs, + }); } catch (cause) { await connectionResult.dispose(); + const fatal = init.getFirstFatalError?.(); + if (fatal) throw fatal; if (cause instanceof AgentInitTimeoutError) throw cause; + if (cause instanceof LocalAgentError) throw cause; throw new AgentInitError(adapterId, cause); } + const fatalDuringInit = init.getFirstFatalError?.(); + if (fatalDuringInit) { + await connectionResult.dispose(); + throw fatalDuringInit; + } + if ( typeof initResponse.protocolVersion === "number" && initResponse.protocolVersion > ACP_PROTOCOL_VERSION @@ -319,6 +343,7 @@ export class LocalAgent { clientInfo: LocalAgentClientInfo; onFatalError: (error: AgentUnauthenticatedError | AgentUsageLimitError) => void; fatalErrorListeners: Set<(error: AgentUnauthenticatedError | AgentUsageLimitError) => void>; + getFirstFatalError: () => AgentUnauthenticatedError | AgentUsageLimitError | undefined; } { const fs = options.fileSystem; const clientCapabilities: schema.ClientCapabilities = { @@ -347,11 +372,22 @@ export class LocalAgent { const fatalErrorListeners = new Set< (error: AgentUnauthenticatedError | AgentUsageLimitError) => void >(); + let firstFatalError: AgentUnauthenticatedError | AgentUsageLimitError | undefined; const onFatalError = (error: AgentUnauthenticatedError | AgentUsageLimitError): void => { + if (!firstFatalError) firstFatalError = error; for (const listener of fatalErrorListeners) listener(error); }; + const getFirstFatalError = (): AgentUnauthenticatedError | AgentUsageLimitError | undefined => + firstFatalError; - return { dispatcher, clientCapabilities, clientInfo, onFatalError, fatalErrorListeners }; + return { + dispatcher, + clientCapabilities, + clientInfo, + onFatalError, + fatalErrorListeners, + getFirstFatalError, + }; } async authenticate(methodId: string): Promise { @@ -396,6 +432,7 @@ export class LocalAgent { activeStream: undefined, pendingPermissions: new Set(), inFlightToolCalls: new Map(), + droppedPendingUpdateCount: 0, }; this.#sessions.set(sessionId, state); return sessionId; @@ -427,6 +464,7 @@ export class LocalAgent { activeStream: undefined, pendingPermissions: new Set(), inFlightToolCalls: new Map(), + droppedPendingUpdateCount: 0, }; this.#sessions.set(input.sessionId, state); return input.sessionId; @@ -465,6 +503,7 @@ export class LocalAgent { activeStream: undefined, pendingPermissions: new Set(), inFlightToolCalls: new Map(), + droppedPendingUpdateCount: 0, }; this.#sessions.set(input.sessionId, state); return input.sessionId; @@ -542,6 +581,46 @@ export class LocalAgent { state.activeStream = activeStream; this.#scheduleInactivityCheck(state, activeStream); + const systemPrompt = input.systemPrompt ?? state.systemPrompt; + let promptBlocks: ContentBlock[]; + try { + promptBlocks = this.#buildPromptBlocks(input.prompt, systemPrompt); + this.#validatePromptCapabilities(promptBlocks); + } catch (validationError) { + activeStream.inactivityTimer?.clear(); + events.fail(validationError); + state.activeStream = undefined; + const failed = Promise.reject(validationError); + failed.catch(() => {}); + return makeAgentStream(sessionId, events, failed, async () => {}); + } + + if (input.signal?.aborted) { + activeStream.cancelled = true; + activeStream.inactivityTimer?.clear(); + events.push({ type: "finish", stopReason: "cancelled" }); + events.end(); + state.activeStream = undefined; + return makeAgentStream(sessionId, events, Promise.resolve({ + text: "", + thinking: "", + stopReason: "cancelled", + sessionId, + }), async () => {}); + } + + if (state.droppedPendingUpdateCount > 0) { + const droppedCount = state.droppedPendingUpdateCount; + state.droppedPendingUpdateCount = 0; + activeStream.events.push({ + type: "raw", + update: { + sessionUpdate: "session_info_update", + _meta: { droppedNotifications: droppedCount }, + } as unknown as SessionUpdate, + }); + } + const buffered = this.#pendingUpdates.get(sessionId); if (buffered) { this.#pendingUpdates.delete(sessionId); @@ -557,19 +636,6 @@ export class LocalAgent { state.pendingConfigOptions = undefined; } - const systemPrompt = input.systemPrompt ?? state.systemPrompt; - let promptBlocks: ContentBlock[]; - try { - promptBlocks = this.#buildPromptBlocks(input.prompt, systemPrompt); - this.#validatePromptCapabilities(promptBlocks); - } catch (validationError) { - events.fail(validationError); - state.activeStream = undefined; - const failed = Promise.reject(validationError); - failed.catch(() => {}); - return makeAgentStream(sessionId, events, failed, async () => {}); - } - let abortListener: (() => void) | undefined; const completion = (async (): Promise => { @@ -719,10 +785,11 @@ export class LocalAgent { state.activeStream?.events.end(); } state.activeStream = undefined; - for (const pending of state.pendingPermissions) { + const pendingSnapshot = [...state.pendingPermissions]; + state.pendingPermissions.clear(); + for (const pending of pendingSnapshot) { pending.resolve({ outcome: { outcome: "cancelled" } }); } - state.pendingPermissions.clear(); state.inFlightToolCalls.clear(); } @@ -775,21 +842,29 @@ export class LocalAgent { #dispatchSessionUpdate(notification: SessionNotification): void { const sessionId = notification.sessionId as SessionId; const state = this.#sessions.get(sessionId); - if (!state) { - const buffered = this.#pendingUpdates.get(notification.sessionId) ?? []; - buffered.push(notification); - this.#pendingUpdates.set(notification.sessionId, buffered); - return; - } - if (!state.activeStream) { - const buffered = this.#pendingUpdates.get(notification.sessionId) ?? []; - buffered.push(notification); - this.#pendingUpdates.set(notification.sessionId, buffered); + if (!state || !state.activeStream) { + this.#bufferSessionUpdate(notification, state); return; } this.#emitFromUpdate(state, state.activeStream, notification.update); } + #bufferSessionUpdate( + notification: SessionNotification, + state: SessionState | undefined, + ): void { + const buffered = this.#pendingUpdates.get(notification.sessionId) ?? []; + buffered.push(notification); + if (this.#pendingUpdateBufferLimit > 0) { + const overflow = buffered.length - this.#pendingUpdateBufferLimit; + if (overflow > 0) { + buffered.splice(0, overflow); + if (state) state.droppedPendingUpdateCount += overflow; + } + } + this.#pendingUpdates.set(notification.sessionId, buffered); + } + #emitFromUpdate(state: SessionState, active: ActiveStreamState, update: SessionUpdate): void { if (isMeaningfulActivity(update)) { active.lastActivityAt = this.#clock.now(); @@ -906,10 +981,11 @@ export class LocalAgent { async #cancelActiveStream(state: SessionState): Promise { if (!state.activeStream || state.activeStream.cancelled) return; state.activeStream.cancelled = true; - for (const pending of state.pendingPermissions) { + const pendingSnapshot = [...state.pendingPermissions]; + state.pendingPermissions.clear(); + for (const pending of pendingSnapshot) { pending.resolve({ outcome: { outcome: "cancelled" } }); } - state.pendingPermissions.clear(); for (const [toolCallId, info] of state.inFlightToolCalls) { state.activeStream.events.push({ type: "tool-call-cancelled", @@ -1041,3 +1117,52 @@ const raceTimeout = ( ); }); }; + +interface InitializeRaceOptions { + readonly protocolVersion: number; + readonly clientCapabilities: schema.ClientCapabilities; + readonly clientInfo: LocalAgentClientInfo; + readonly timeoutMs: number; +} + +const raceInitialize = ( + connectionResult: ConnectResult, + options: InitializeRaceOptions, +): Promise => { + const initPromise = connectionResult.connection.initialize({ + protocolVersion: options.protocolVersion, + clientCapabilities: options.clientCapabilities, + clientInfo: options.clientInfo, + }); + const timed = raceTimeout( + initPromise, + options.timeoutMs, + () => new AgentInitTimeoutError(connectionResult.adapterId, options.timeoutMs), + ); + return new Promise((resolveRace, rejectRace) => { + let settled = false; + const settle = ( + action: () => void, + ): void => { + if (settled) return; + settled = true; + action(); + }; + timed.then( + (response) => settle(() => resolveRace(response)), + (error) => settle(() => rejectRace(error)), + ); + connectionResult.closed.then((exit) => { + settle(() => + rejectRace( + new AgentConnectionClosedError( + connectionResult.adapterId, + exit.exitCode, + exit.signal, + connectionResult.stderrTail(), + ), + ), + ); + }); + }); +}; diff --git a/packages/use-local-agent/src/testing/mock-agent.ts b/packages/use-local-agent/src/testing/mock-agent.ts index 60bc630..c125ce5 100644 --- a/packages/use-local-agent/src/testing/mock-agent.ts +++ b/packages/use-local-agent/src/testing/mock-agent.ts @@ -218,7 +218,7 @@ export const connectMockAgent = async ( ndJsonStream(agentToClient.writable, clientToAgent.readable), ); - const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners } = + const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, getFirstFatalError } = LocalAgent.buildDispatcher(options); const connection = new ClientSideConnectionCtor( @@ -256,6 +256,7 @@ export const connectMockAgent = async ( clientCapabilities, clientInfo, fatalErrorListeners, + getFirstFatalError, }); return { From 1b1c38b1c9434766ecc7a364def131e38d9aaab7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 03:12:30 +0000 Subject: [PATCH 11/24] test(use-local-agent): add reliability test suite (14 tests) Co-authored-by: Aiden Bai --- .../use-local-agent/tests/reliability.test.ts | 448 ++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 packages/use-local-agent/tests/reliability.test.ts diff --git a/packages/use-local-agent/tests/reliability.test.ts b/packages/use-local-agent/tests/reliability.test.ts new file mode 100644 index 0000000..e32f7af --- /dev/null +++ b/packages/use-local-agent/tests/reliability.test.ts @@ -0,0 +1,448 @@ +import { spawn } from "node:child_process"; +import { + AgentSideConnection as AgentSideConnectionCtor, + ClientSideConnection as ClientSideConnectionCtor, + ndJsonStream, +} from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { + RUN_COMMAND_BUFFER_LIMIT_BYTES, + STDERR_LINE_BUFFER_LIMIT_BYTES, +} from "../src/constants"; +import type { ConnectResult } from "../src/connect"; +import { + AgentConnectionClosedError, + AgentInitTimeoutError, + AgentUnauthenticatedError, + AgentUsageLimitError, + InvalidPromptContentError, +} from "../src/errors"; +import { LocalAgent } from "../src/local-agent"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId } from "../src/types"; +import { appendCapped } from "../src/utils/cap-buffer"; +import { filterStdoutNoise } from "../src/utils/filter-stdout-noise"; +import { isProcessAlive } from "../src/utils/process-alive"; +import { runCommand } from "../src/utils/run-command"; +import type { Clock } from "../src/utils/clock"; + +const makeFakeClock = (): { + clock: Clock; + advance: (ms: number) => void; + pendingHandleCount: () => number; +} => { + let current = 0; + const handles = new Set<{ deadline: number; callback: () => void }>(); + const clock: Clock = { + now: () => current, + setTimeout: (callback, ms) => { + const handle = { deadline: current + ms, callback }; + handles.add(handle); + return { + clear: () => handles.delete(handle), + }; + }, + }; + const advance = (ms: number): void => { + current += ms; + for (const handle of [...handles]) { + if (current >= handle.deadline) { + handles.delete(handle); + handle.callback(); + } + } + }; + return { clock, advance, pendingHandleCount: () => handles.size }; +}; + +interface ManualConnectResult extends ConnectResult { + resolveExit: (exit: { exitCode: number | null; signal: NodeJS.Signals | null }) => void; +} + +const buildManualConnect = (options: { + initializeBehavior: "hang" | "ok"; + adapterId?: string; + stderrTail?: string; +}): { + connectionResult: ManualConnectResult; + agentSide: ReturnType; +} => { + const adapterId = options.adapterId ?? "manual"; + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + + const agentSide = connectMockAgentSide( + { + initialize: (_request) => { + if (options.initializeBehavior === "hang") { + return new Promise(() => {}) as never; + } + return { + protocolVersion: 1, + agentCapabilities: {}, + }; + }, + }, + agentToClient.writable, + clientToAgent.readable, + ); + + const connection = new ClientSideConnectionCtor( + () => ({ + sessionUpdate: async () => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + }), + ndJsonStream(clientToAgent.writable, agentToClient.readable), + ); + + let resolveExit: (exit: { exitCode: number | null; signal: NodeJS.Signals | null }) => void = () => {}; + const closed = new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>( + (resolveClosed) => { + resolveExit = resolveClosed; + }, + ); + + const connectionResult: ManualConnectResult = { + connection, + child: undefined as unknown as ConnectResult["child"], + adapterId, + adapterDisplayName: adapterId, + closed, + stderrTail: () => options.stderrTail ?? "", + dispose: async () => { + resolveExit({ exitCode: 0, signal: null }); + }, + resolveExit, + }; + + return { connectionResult, agentSide }; +}; + +const connectMockAgentSide = ( + handlers: { + initialize?: (request: unknown) => Promise | unknown; + }, + writable: WritableStream, + readable: ReadableStream, +): unknown => { + return new AgentSideConnectionCtor( + () => ({ + initialize: async (request: unknown) => { + if (handlers.initialize) return handlers.initialize(request); + return { protocolVersion: 1 }; + }, + authenticate: async () => ({}), + newSession: async () => ({ sessionId: "manual-session" }), + loadSession: async () => ({}), + unstable_resumeSession: async () => ({}), + unstable_closeSession: async () => ({}), + listSessions: async () => ({ sessions: [] }), + setSessionMode: async () => ({}), + unstable_setSessionModel: async () => ({}), + setSessionConfigOption: async () => ({ configOptions: [] }), + prompt: async () => ({ stopReason: "end_turn" as const }), + cancel: async () => {}, + extMethod: async () => ({}), + extNotification: async () => {}, + }), + ndJsonStream(writable, readable), + ); +}; + +describe("reliability: helpers", () => { + it("appendCapped truncates from the front, preserving the tail", () => { + expect(appendCapped("hello", " world", 100)).toBe("hello world"); + expect(appendCapped("a".repeat(80), "b".repeat(50), 100)).toHaveLength(100); + expect(appendCapped("a".repeat(80), "b".repeat(50), 100).endsWith("b".repeat(50))).toBe(true); + expect(appendCapped("anything", "x", 0)).toBe("anythingx"); + }); + + it("filterStdoutNoise lets JSON lines pass and routes non-JSON to onNoise", async () => { + const noise: string[] = []; + const passed: string[] = []; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const source = new ReadableStream({ + start: (controller) => { + controller.enqueue(encoder.encode("\u2713 Connected\n")); + controller.enqueue(encoder.encode('{"jsonrpc":"2.0","id":1}\n')); + controller.enqueue(encoder.encode("starting up...\n")); + controller.enqueue(encoder.encode('{"jsonrpc":"2.0","id":2}\n')); + controller.close(); + }, + }); + + const filtered = filterStdoutNoise(source, (line) => noise.push(line)); + const reader = filtered.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + passed.push(decoder.decode(value)); + } + + expect(noise).toEqual(["\u2713 Connected", "starting up..."]); + expect(passed.join("")).toBe('{"jsonrpc":"2.0","id":1}\n{"jsonrpc":"2.0","id":2}\n'); + }); +}); + +describe("reliability: process-alive helper", () => { + it("isProcessAlive returns false after a child has exited", async () => { + const child = spawn(process.execPath, ["-e", "process.exit(0)"], { stdio: "ignore" }); + await new Promise((resolveExit) => child.once("close", resolveExit)); + expect(isProcessAlive(child)).toBe(false); + }); + + it("isProcessAlive returns true while the child is running", async () => { + const child = spawn(process.execPath, ["-e", "setTimeout(()=>{}, 5000)"], { stdio: "ignore" }); + try { + expect(isProcessAlive(child)).toBe(true); + } finally { + child.kill("SIGKILL"); + await new Promise((resolveExit) => child.once("close", resolveExit)); + } + }); +}); + +describe("reliability: runCommand caps", () => { + it("caps oversized stderr at the configured limit and exits cleanly", async () => { + const result = await runCommand( + process.execPath, + ["-e", "process.stderr.write('x'.repeat(2_000_000)); process.exit(0)"], + { timeoutMs: 10_000 }, + ); + expect(result.exitCode).toBe(0); + expect(result.stderr.length).toBeLessThanOrEqual(RUN_COMMAND_BUFFER_LIMIT_BYTES); + expect(result.stderr.length).toBeGreaterThan(0); + }); +}); + +describe("reliability: prompt validation does not leak inactivity timer", () => { + it("clears scheduled inactivity handle when validation fails", async () => { + const fake = makeFakeClock(); + const session = await connectMockAgent( + { + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { promptCapabilities: {} }, + }), + newSession: () => ({ sessionId: "s1" }), + }, + { inactivityTimeoutMs: 1000, clock: fake.clock }, + ); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { + prompt: [{ type: "image", mimeType: "image/png", data: "AAAA" }], + }); + await expect(stream.completion).rejects.toBeInstanceOf(InvalidPromptContentError); + expect(fake.pendingHandleCount()).toBe(0); + await session.close(); + }); +}); + +describe("reliability: pending-update buffer cap", () => { + it("caps buffered notifications and emits a dropped-count warning on first prompt", async () => { + const session = await connectMockAgent( + { + newSession: () => ({ sessionId: "s1" }), + prompt: () => ({ stopReason: "end_turn" }), + }, + { pendingUpdateBufferLimit: 5 }, + ); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + for (let chunkIndex = 0; chunkIndex < 12; chunkIndex += 1) { + await session.mockConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `${chunkIndex}-` }, + }, + }); + } + await new Promise((resolveSleep) => setTimeout(resolveSleep, 20)); + + const stream = session.agent.prompt(sessionId, { prompt: "x" }); + const events: AgentEvent[] = []; + for await (const event of stream) events.push(event); + + const dropWarning = events.find( + (event) => + event.type === "raw" && + (event.update as unknown as { sessionUpdate: string }).sessionUpdate === + "session_info_update", + ); + expect(dropWarning).toBeDefined(); + if (dropWarning?.type === "raw") { + const meta = (dropWarning.update as unknown as { _meta?: { droppedNotifications?: number } }) + ._meta; + expect(meta?.droppedNotifications).toBe(7); + } + + const textDeltas = events.filter((event) => event.type === "text-delta"); + expect(textDeltas).toHaveLength(5); + await session.close(); + }); +}); + +describe("reliability: AbortSignal short-circuit", () => { + it("does not call the agent when prompt() is invoked with an already-aborted signal", async () => { + let promptInvocations = 0; + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: () => { + promptInvocations += 1; + return { stopReason: "end_turn" }; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const controller = new AbortController(); + controller.abort(); + + const stream = session.agent.prompt(sessionId, { + prompt: "hi", + signal: controller.signal, + }); + const events: AgentEvent[] = []; + for await (const event of stream) events.push(event); + const result = await stream.completion; + + expect(promptInvocations).toBe(0); + expect(result.stopReason).toBe("cancelled"); + expect(result.text).toBe(""); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("finish"); + if (events[0]?.type === "finish") expect(events[0].stopReason).toBe("cancelled"); + await session.close(); + }); +}); + +describe("reliability: config-options preserved across failed validation", () => { + it("config-options remain pending if first prompt fails validation, surface on second prompt", async () => { + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { promptCapabilities: { embeddedContext: true } }, + }), + newSession: () => ({ + sessionId: "s1", + configOptions: [ + { + id: "model", + name: "Model", + type: "select", + currentValue: "fast", + options: [{ value: "fast", name: "Fast" }], + }, + ], + }), + prompt: () => ({ stopReason: "end_turn" }), + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const failed = session.agent.prompt(sessionId, { + prompt: [{ type: "image", mimeType: "image/png", data: "AAAA" }], + }); + await expect(failed.completion).rejects.toBeInstanceOf(InvalidPromptContentError); + + const second = session.agent.prompt(sessionId, { prompt: "hi" }); + const events: AgentEvent[] = []; + for await (const event of second) events.push(event); + expect(events[0]?.type).toBe("config-options"); + if (events[0]?.type === "config-options") { + expect(events[0].options).toHaveLength(1); + expect(events[0].options[0]?.id).toBe("model"); + } + await session.close(); + }); +}); + +describe("reliability: init races against subprocess exit", () => { + it("rejects fast with AgentConnectionClosedError when the subprocess exits before responding", async () => { + const { connectionResult } = buildManualConnect({ + initializeBehavior: "hang", + stderrTail: "boom", + }); + const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, getFirstFatalError } = + LocalAgent.buildDispatcher({}); + + setTimeout(() => connectionResult.resolveExit({ exitCode: 7, signal: null }), 25); + + const start = Date.now(); + await expect( + LocalAgent.fromConnectResult(connectionResult, { + options: { initializeTimeoutMs: 30_000 }, + dispatcher, + clientCapabilities, + clientInfo, + fatalErrorListeners, + getFirstFatalError, + }), + ).rejects.toBeInstanceOf(AgentConnectionClosedError); + expect(Date.now() - start).toBeLessThan(5_000); + }); + + it("preserves AgentInitTimeoutError when no subprocess exit happens", async () => { + const { connectionResult } = buildManualConnect({ initializeBehavior: "hang" }); + const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, getFirstFatalError } = + LocalAgent.buildDispatcher({}); + + await expect( + LocalAgent.fromConnectResult(connectionResult, { + options: { initializeTimeoutMs: 50 }, + dispatcher, + clientCapabilities, + clientInfo, + fatalErrorListeners, + getFirstFatalError, + }), + ).rejects.toBeInstanceOf(AgentInitTimeoutError); + }); +}); + +describe("reliability: fatal stderr during init", () => { + it("surfaces fatal auth error captured before initialize resolves", async () => { + const { connectionResult } = buildManualConnect({ initializeBehavior: "ok" }); + const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, onFatalError, getFirstFatalError } = + LocalAgent.buildDispatcher({}); + + onFatalError(new AgentUnauthenticatedError("manual", "boot-time auth failure")); + + await expect( + LocalAgent.fromConnectResult(connectionResult, { + options: { initializeTimeoutMs: 1_000 }, + dispatcher, + clientCapabilities, + clientInfo, + fatalErrorListeners, + getFirstFatalError, + }), + ).rejects.toBeInstanceOf(AgentUnauthenticatedError); + }); + + it("surfaces usage-limit error captured before initialize resolves", async () => { + const { connectionResult } = buildManualConnect({ initializeBehavior: "ok" }); + const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, onFatalError, getFirstFatalError } = + LocalAgent.buildDispatcher({}); + + onFatalError(new AgentUsageLimitError("manual", "limits exceeded for plan")); + + await expect( + LocalAgent.fromConnectResult(connectionResult, { + options: { initializeTimeoutMs: 1_000 }, + dispatcher, + clientCapabilities, + clientInfo, + fatalErrorListeners, + getFirstFatalError, + }), + ).rejects.toBeInstanceOf(AgentUsageLimitError); + }); +}); + +describe("reliability: stderr line buffer cap (constant exported)", () => { + it("STDERR_LINE_BUFFER_LIMIT_BYTES is sane", () => { + expect(STDERR_LINE_BUFFER_LIMIT_BYTES).toBeGreaterThanOrEqual(8 * 1024); + }); +}); From a20c0e8b93a55d19714436cc5ce6e3b1f222e2fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 03:14:01 +0000 Subject: [PATCH 12/24] chore: format + add reliability hardening changeset Co-authored-by: Aiden Bai --- .changeset/reliability-hardening.md | 27 +++++++++++++++++ packages/use-local-agent/src/connect.ts | 4 +-- packages/use-local-agent/src/local-agent.ts | 24 +++++++-------- .../use-local-agent/tests/reliability.test.ts | 30 +++++++++++++------ 4 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 .changeset/reliability-hardening.md diff --git a/.changeset/reliability-hardening.md b/.changeset/reliability-hardening.md new file mode 100644 index 0000000..5ae72aa --- /dev/null +++ b/.changeset/reliability-hardening.md @@ -0,0 +1,27 @@ +--- +"use-local-agent": patch +--- + +Reliability hardening for `LocalAgent` and the underlying subprocess plumbing. + +Bug fixes: + +- **SIGKILL escalation now actually fires.** Both `connect.dispose()` and `runCommand` previously gated their SIGKILL fallback on `child.killed`, which Node sets as soon as the signal is *sent*, not when the child dies. If an agent ignored SIGTERM, the host would hang forever on `agent.close()`. Replaced with a real "is the child still alive?" check via `child.exitCode === null && child.signalCode === null`. +- **Init no longer waits the full timeout when the subprocess crashes.** `LocalAgent.connect`/`fromConnectResult` now race the ACP `initialize` request against the subprocess close event, so a child that exits 5ms after spawn surfaces an `AgentConnectionClosedError` immediately (with the captured stderr tail) instead of timing out. +- **Fatal stderr signals captured during `initialize` now surface as the right error.** Previously, "Authentication failed" / "limits exceeded" lines printed during boot fired into an empty listener set because the `LocalAgent` listener was registered after construction; the auth/usage-limit error was silently lost and a misleading `AgentInitError` (or even success) followed. The dispatcher now buffers the first fatal error and `fromConnectResult` checks the buffer before/after init. +- **`AbortSignal` already-aborted at `prompt()` is honored without sending `session/prompt`.** Previously we dispatched the prompt and immediately tried to `session/cancel` it. Now we synthesize a `finish` event with `stopReason: "cancelled"` and never call the agent. +- **Inactivity-watchdog timer no longer leaks** when prompt-content validation throws (image/audio/resource without the matching capability). +- **`pendingConfigOptions` are no longer consumed by failed prompts.** Validation now runs before the config-options event is enqueued, so a subsequent successful prompt still receives them. +- **Pending permissions can no longer double-resolve.** A concurrent `respond()` from a `stream`-mode caller and a session cleanup (cancel / disconnect / fatal) used to race; the cleanup paths now snapshot-and-clear the pending set before resolving. + +Hardening: + +- **Bounded buffers everywhere.** Per-session `session/update` notifications buffered before the first prompt are now capped (default 1000, configurable via `pendingUpdateBufferLimit`); when the cap is hit, the oldest are dropped and a single `raw` event surfaces the dropped count via `_meta.droppedNotifications` on the next prompt drain. The internal stderr line buffer is capped at 64 KiB; `runCommand` stdout/stderr buffers are capped at 1 MiB, both truncating from the front (preserving the most recent output). +- **Defensive stdout-noise filter.** ACP requires agents to write only ACP messages to stdout, but real CLIs occasionally print banners (`โœ” Connected to ...`). Previously, the first non-JSON line crashed the connection with an opaque `AgentInitError`. Lines that don't start with `{` are now routed to `onStderr` with a `[stdout-noise]` prefix and dropped from the JSON-RPC stream; lines that do start with `{` pass through unchanged. +- **EPIPE / closed-stdin errors no longer escalate to `uncaughtException`.** `child.stdin`, `child.stdout`, and `child.stderr` all carry no-op error listeners. Writes against a dead subprocess fast-fail with a typed `AgentStdinClosedError` instead of either hanging or raising on the host process. + +New error type: `AgentStdinClosedError` (`_tag: "StdinClosed"`). + +New options: `LocalAgentConnectOptions.pendingUpdateBufferLimit`. + +No public API changes other than the additions above. All 42 existing tests still pass; 14 new reliability tests cover the fixes. diff --git a/packages/use-local-agent/src/connect.ts b/packages/use-local-agent/src/connect.ts index 38d5053..3e64b7c 100644 --- a/packages/use-local-agent/src/connect.ts +++ b/packages/use-local-agent/src/connect.ts @@ -97,9 +97,7 @@ export const connect = async ( }, ); - const rawStdoutWebStream = Readable.toWeb( - child.stdout, - ) as unknown as ReadableStream; + const rawStdoutWebStream = Readable.toWeb(child.stdout) as unknown as ReadableStream; const childStdoutWebStream = filterStdoutNoise(rawStdoutWebStream, (line) => { appendStderrTail(`${STDOUT_NOISE_PREFIX}${line}\n`); diff --git a/packages/use-local-agent/src/local-agent.ts b/packages/use-local-agent/src/local-agent.ts index 2d55f98..07f3304 100644 --- a/packages/use-local-agent/src/local-agent.ts +++ b/packages/use-local-agent/src/local-agent.ts @@ -601,12 +601,17 @@ export class LocalAgent { events.push({ type: "finish", stopReason: "cancelled" }); events.end(); state.activeStream = undefined; - return makeAgentStream(sessionId, events, Promise.resolve({ - text: "", - thinking: "", - stopReason: "cancelled", + return makeAgentStream( sessionId, - }), async () => {}); + events, + Promise.resolve({ + text: "", + thinking: "", + stopReason: "cancelled", + sessionId, + }), + async () => {}, + ); } if (state.droppedPendingUpdateCount > 0) { @@ -849,10 +854,7 @@ export class LocalAgent { this.#emitFromUpdate(state, state.activeStream, notification.update); } - #bufferSessionUpdate( - notification: SessionNotification, - state: SessionState | undefined, - ): void { + #bufferSessionUpdate(notification: SessionNotification, state: SessionState | undefined): void { const buffered = this.#pendingUpdates.get(notification.sessionId) ?? []; buffered.push(notification); if (this.#pendingUpdateBufferLimit > 0) { @@ -1141,9 +1143,7 @@ const raceInitialize = ( ); return new Promise((resolveRace, rejectRace) => { let settled = false; - const settle = ( - action: () => void, - ): void => { + const settle = (action: () => void): void => { if (settled) return; settled = true; action(); diff --git a/packages/use-local-agent/tests/reliability.test.ts b/packages/use-local-agent/tests/reliability.test.ts index e32f7af..540f931 100644 --- a/packages/use-local-agent/tests/reliability.test.ts +++ b/packages/use-local-agent/tests/reliability.test.ts @@ -5,10 +5,7 @@ import { ndJsonStream, } from "@agentclientprotocol/sdk"; import { describe, expect, it } from "vite-plus/test"; -import { - RUN_COMMAND_BUFFER_LIMIT_BYTES, - STDERR_LINE_BUFFER_LIMIT_BYTES, -} from "../src/constants"; +import { RUN_COMMAND_BUFFER_LIMIT_BYTES, STDERR_LINE_BUFFER_LIMIT_BYTES } from "../src/constants"; import type { ConnectResult } from "../src/connect"; import { AgentConnectionClosedError, @@ -95,7 +92,10 @@ const buildManualConnect = (options: { ndJsonStream(clientToAgent.writable, agentToClient.readable), ); - let resolveExit: (exit: { exitCode: number | null; signal: NodeJS.Signals | null }) => void = () => {}; + let resolveExit: (exit: { + exitCode: number | null; + signal: NodeJS.Signals | null; + }) => void = () => {}; const closed = new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>( (resolveClosed) => { resolveExit = resolveClosed; @@ -404,8 +404,14 @@ describe("reliability: init races against subprocess exit", () => { describe("reliability: fatal stderr during init", () => { it("surfaces fatal auth error captured before initialize resolves", async () => { const { connectionResult } = buildManualConnect({ initializeBehavior: "ok" }); - const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, onFatalError, getFirstFatalError } = - LocalAgent.buildDispatcher({}); + const { + dispatcher, + clientCapabilities, + clientInfo, + fatalErrorListeners, + onFatalError, + getFirstFatalError, + } = LocalAgent.buildDispatcher({}); onFatalError(new AgentUnauthenticatedError("manual", "boot-time auth failure")); @@ -423,8 +429,14 @@ describe("reliability: fatal stderr during init", () => { it("surfaces usage-limit error captured before initialize resolves", async () => { const { connectionResult } = buildManualConnect({ initializeBehavior: "ok" }); - const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, onFatalError, getFirstFatalError } = - LocalAgent.buildDispatcher({}); + const { + dispatcher, + clientCapabilities, + clientInfo, + fatalErrorListeners, + onFatalError, + getFirstFatalError, + } = LocalAgent.buildDispatcher({}); onFatalError(new AgentUsageLimitError("manual", "limits exceeded for plan")); From 3dcfb97cccaf70c6f6c3bbc23edc21aa1b075303 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:06:16 -0700 Subject: [PATCH 13/24] docs(use-local-agent): align README with consolidated reliability features --- packages/use-local-agent/README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md index 5594df4..ecde834 100644 --- a/packages/use-local-agent/README.md +++ b/packages/use-local-agent/README.md @@ -115,13 +115,18 @@ for await (const event of stream) { On top of those, the wrapper itself adds: - **Spawn race detection** โ€” fails fast as `AgentSpawnError` when the child binary cannot be launched, before initialize times out. -- **Initialize fast-fail** โ€” if the subprocess exits before responding to `initialize`, you get `AgentConnectionClosedError` (with stderr tail) instead of waiting `initializeTimeoutMs`. -- **Stderr fatal gating** โ€” auth/usage stderr patterns are only escalated _after_ `initialize` succeeds, eliminating boot-banner false positives. -- **Inactivity watchdog** โ€” pauses while a permission request is pending in stream mode so users can take their time. -- **Buffered update cap** โ€” drops oldest first when an agent emits >1024 buffered updates for a single session. +- **Initialize fast-fail** โ€” `initialize` races against subprocess close, so a child that exits before responding raises `AgentConnectionClosedError` (with stderr tail) within tens of ms instead of waiting `initializeTimeoutMs`. +- **Real SIGKILL escalation** โ€” `dispose()` and `runCommand` track liveness via `exitCode` / `signalCode` (not the misleading `child.killed` flag), so a stubborn child is actually killed within `disposeGraceMs`. +- **Stderr fatal gating** โ€” auth/usage stderr patterns are only escalated _after_ `initialize` succeeds, eliminating boot-banner false positives. Fatal stderr captured _during_ init is buffered and surfaces as the right typed error (e.g. `AgentUnauthenticatedError`). +- **Inactivity watchdog** โ€” pauses while a permission request is pending in stream mode so users can take their time. The validation-failure path no longer leaks the timer. +- **Bounded buffers** โ€” pre-prompt `session/update` notifications are capped (default 1000, configurable via `pendingUpdateBufferLimit`); when the cap is hit the oldest are dropped and a single `raw` event surfaces the dropped count via `_meta.droppedNotifications` on the next prompt drain. Internal stderr line buffer caps at 64 KiB; `runCommand` stdout/stderr cap at 1 MiB. +- **EPIPE-safe writes** โ€” child stream `error` listeners are always installed; writes against a dead subprocess fail with a typed `AgentStdinClosedError` instead of escalating to `uncaughtException`. +- **Stdout-noise tolerance** โ€” non-JSON banner lines on stdout are routed to `onStderr` with a `[stdout-noise]` prefix and dropped from the JSON-RPC stream instead of crashing init. +- **AbortSignal short-circuit** โ€” a `prompt()` invoked with an already-aborted signal synthesizes a `cancelled` finish without sending `session/prompt`. - **`onTrace` hook** โ€” observe inbound/outbound JSON-RPC and stderr without parsing logs. - **`disposeGraceMs`** โ€” configurable SIGTERMโ†’SIGKILL grace. - **`envFilter` hook** โ€” scrub environment before passing to the child. +- **`stderrFatalPatterns`** โ€” override the auth/usage match lists. ## Slash commands From ba90b516de35861dd1b8533b82c806230d47198c Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:32:17 -0700 Subject: [PATCH 14/24] feat(use-local-agent): become a Vercel AI SDK provider; drop streamAgent/generateAgent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the LanguageModelV3 spec (`@ai-sdk/provider` v3.0.x) so `use-local-agent` plugs directly into `streamText` / `generateText` from the `ai` package, eliminating the parallel one-shot helper API: import { streamText } from "ai"; import { localAgent } from "use-local-agent"; const { textStream } = streamText({ model: localAgent("claude"), prompt: "Refactor src/auth.ts", }); New module `src/ai-sdk/`: - `convert-prompt.ts` maps a `LanguageModelV3Prompt` (system/user/ assistant/tool messages) to ACP `ContentBlock[]`. System messages flatten into `systemPrompt`; image/audio file parts become ACP image/ audio blocks; unsupported file mediaTypes, assistant tool-call replays, and tool-result messages emit `unsupported` warnings. - `local-agent-language-model.ts` implements `LanguageModelV3`: - `doGenerate` returns ordered `reasoning` + `text` + `tool-call` content with mapped `LanguageModelV3FinishReason` and usage. - `doStream` emits `stream-start` (with warnings), `response-metadata` (carrying ACP `sessionId`), grouped `text-start/-delta/-end` and `reasoning-start/-delta/-end`, `tool-call`, `tool-result`, `finish`, and `error` parts. Opt-in raw passthrough via `includeRawChunks`. - Takes a `connect` factory so it's testable through the existing `connectMockAgent` harness without spawning a subprocess. - `provider.ts` exports `createLocalAgentProvider()` (a callable `ProviderV3` with a `.languageModel()` method, plus `.fromAdapter()` for custom ACP-speaking subprocesses) and a default `localAgent` instance. `embeddingModel` / `imageModel` throw `NoSuchModelError`. Default `permission: "auto-allow"` so `streamText` Just Works. Mappings: - ACP `text-delta` โ†’ V3 `text-delta` (start/end framed by message id) - ACP `thinking-delta` โ†’ V3 `reasoning-delta` - ACP `tool-call` โ†’ V3 `tool-call` with `providerExecuted: true` - ACP `tool-call-update` (completed/failed) โ†’ V3 `tool-result` (with `isError` on failure) - ACP stop reason โ†’ `{ unified, raw }` - Unsupported V3 call options (temperature, topP/topK, seed, stopSequences, responseFormat:json, tools, etc.) โ†’ `stream-start` warnings. Removed: - `src/stream.ts` (`streamAgent`, `generateAgent`, `OneShotAgentStream`, `StreamAgentOptions`, `GenerateAgentResult`) โ€” fully redundant with `streamText` / `generateText` from `ai`. Wiring: - New deps: `@ai-sdk/provider@^3.0.8`, `@ai-sdk/provider-utils@^4.0.23`. - Subpath export `use-local-agent/ai-sdk` and a top-level re-export. - `vite.config.ts` adds `src/ai-sdk/index.ts` as a build entry. - README leads with `streamText({ model: localAgent('claude') })`; the `LocalAgent` API moves to an "Advanced" section for stateful multi-turn, permission gating, slash commands, and session resume. Tests: - New `tests/ai-sdk-provider.test.ts` covers prompt conversion, provider shape, `NoSuchModelError`, `doGenerate` (reasoning+text+tool-call ordering, finish-reason mapping), `doStream` part ordering, and unsupported-option warnings. Quality gates: `pnpm typecheck` clean, `pnpm lint` 0/0 on 72 files, and all 96 tests across 26 files pass. Also drops the leftover `.changeset/acp-reliability-overhaul.md` per prior cleanup. --- .changeset/acp-reliability-overhaul.md | 44 --- README.md | 186 ++++++++++- .../agent-client-protocol/architecture.md | 2 +- packages/use-local-agent/README.md | 208 ++++++------ packages/use-local-agent/package.json | 10 +- .../src/ai-sdk/convert-prompt.ts | 118 +++++++ packages/use-local-agent/src/ai-sdk/index.ts | 14 + .../src/ai-sdk/local-agent-language-model.ts | 301 ++++++++++++++++++ .../use-local-agent/src/ai-sdk/provider.ts | 116 +++++++ packages/use-local-agent/src/index.ts | 16 +- packages/use-local-agent/src/stream.ts | 105 ------ .../tests/ai-sdk-provider.test.ts | 270 ++++++++++++++++ packages/use-local-agent/vite.config.ts | 7 +- pnpm-lock.yaml | 38 +++ 14 files changed, 1166 insertions(+), 269 deletions(-) delete mode 100644 .changeset/acp-reliability-overhaul.md create mode 100644 packages/use-local-agent/src/ai-sdk/convert-prompt.ts create mode 100644 packages/use-local-agent/src/ai-sdk/index.ts create mode 100644 packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts create mode 100644 packages/use-local-agent/src/ai-sdk/provider.ts delete mode 100644 packages/use-local-agent/src/stream.ts create mode 100644 packages/use-local-agent/tests/ai-sdk-provider.test.ts diff --git a/.changeset/acp-reliability-overhaul.md b/.changeset/acp-reliability-overhaul.md deleted file mode 100644 index 5594eaf..0000000 --- a/.changeset/acp-reliability-overhaul.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"use-local-agent": minor ---- - -ACP reliability + spec coverage overhaul, plus reliability hardening for the subprocess plumbing. - -## Spec coverage & SDK upgrade - -- Bumps `@agentclientprotocol/sdk` peer dep to `^0.20.0`, picking up the upstream reliability rework: clean transport-failure handling (#103), final ndjson message flush (#119), no spurious unhandled rejection on transport errors (#122), notification/response ordering (#130), private-keyword cross-copy compatibility (#127), stable `closeSession`/`resumeSession` (#132). -- `loadSessionStreaming({ sessionId, cwd, ... })` exposes the replay updates emitted during `session/load` as an async iterable; existing `loadSession` preserved. -- Slash command helper: `prompt(sessionId, { command: { name, input? } })` formats `/ ` text content and validates against `available_commands_update`. New helpers: `commandsFor`, `modeStateFor`, `configOptionsFor`. -- New session-input field `additionalDirectories` (gated on `sessionCapabilities.additionalDirectories`) with absolute-path validation. -- `listSessions({ cwd?, cursor? })` now supports cursor pagination; new `streamAllSessions` auto-paginates. -- `_meta` trace context propagation via opt-in `traceContext: () => Record` (filters to W3C-reserved `traceparent` / `tracestate` / `baggage`). -- Optional `terminal` capability: pass `terminal: TerminalHandlers` to advertise and forward all five `terminal/*` methods. -- Auth retry hook: `onAuthRequired(methods)` is invoked on `auth_required` (-32000) and re-runs `session/new` after a successful `authenticate`. -- Trace and dispose tuning: `onTrace`, `disposeGraceMs`, `envFilter` connect options. -- Adapter env fallbacks: `ANTHROPIC_API_KEY` (claude), `GITHUB_TOKEN` (copilot), `GEMINI_API_KEY` / `GOOGLE_API_KEY` (gemini). - -## Reliability fixes - -- **SIGKILL escalation now actually fires.** Both `connect.dispose()` and `runCommand` previously gated their SIGKILL fallback on `child.killed`, which Node sets as soon as the signal is *sent*, not when the child dies. If an agent ignored SIGTERM the host would hang forever on `agent.close()`. Replaced with a real "is the child still alive?" check via `child.exitCode === null && child.signalCode === null`. -- **Init no longer waits the full timeout when the subprocess crashes.** `LocalAgent.connect` / `fromConnectResult` now race the ACP `initialize` request against the subprocess close event, so a child that exits 5ms after spawn surfaces an `AgentConnectionClosedError` immediately (with the captured stderr tail) instead of timing out. -- **Spawn race**: fast-fails as `AgentSpawnError` when the subprocess cannot spawn, before the initialize timeout fires. -- **Fatal stderr signals captured during `initialize` now surface as the right error.** Previously, "Authentication failed" / "limits exceeded" lines printed during boot fired into an empty listener set; the auth/usage-limit error was silently lost. The dispatcher now buffers the first fatal error and `fromConnectResult` checks the buffer before/after init. Stderr fatal-pattern detection is also gated on a successful initialize, eliminating boot-banner false positives. `stderrFatalPatterns` lets callers override the auth/usage match lists. -- **`AbortSignal` already-aborted at `prompt()` is honored without sending `session/prompt`.** Synthesizes a `finish` event with `stopReason: "cancelled"`. -- **Inactivity-watchdog timer no longer leaks** when prompt-content validation throws. The watchdog also pauses while a permission request is pending in stream mode and treats permission events as activity. -- **`pendingConfigOptions` are no longer consumed by failed prompts.** Validation now runs before the config-options event is enqueued. -- **Pending permissions can no longer double-resolve.** Cleanup paths now snapshot-and-clear the pending set before resolving. - -## Hardening - -- **Bounded buffers everywhere.** Per-session `session/update` notifications buffered before the first prompt are capped (default 1000, configurable via `pendingUpdateBufferLimit`); when the cap is hit the oldest are dropped and a single `raw` event surfaces the dropped count via `_meta.droppedNotifications` on the next prompt drain. The internal stderr line buffer is capped at 64 KiB; `runCommand` stdout/stderr buffers are capped at 1 MiB. -- **Defensive stdout-noise filter.** Lines that don't start with `{` are now routed to `onStderr` with a `[stdout-noise]` prefix and dropped from the JSON-RPC stream; valid JSON lines pass through unchanged. -- **EPIPE / closed-stdin errors no longer escalate to `uncaughtException`.** `child.stdin`, `child.stdout`, `child.stderr` carry no-op error listeners. Writes against a dead subprocess fast-fail with a typed `AgentStdinClosedError`. -- **`runCommand` SIGTERMโ†’SIGKILL escalation on timeout.** -- **Error wrapping preserves `cause.message`** when distinct from the wrapper's message. - -## New API surface - -New error types: `AgentStdinClosedError` (`_tag: "StdinClosed"`). -New options: `pendingUpdateBufferLimit`, `disposeGraceMs`, `onTrace`, `envFilter`, `stderrFatalPatterns`, `traceContext`, `onAuthRequired`, `terminal`. - -No breaking changes to the public API. diff --git a/README.md b/README.md index bf19988..4109e80 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,189 @@ # use-local-agent -A pnpm monorepo scaffold. +[![version](https://img.shields.io/npm/v/use-local-agent?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/use-local-agent) +[![downloads](https://img.shields.io/npm/dt/use-local-agent.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/use-local-agent) -## Quick start +An API for accessing any locally-installed coding agent + +How? Spawn Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and stream prompts, tool calls, and permissions over the [Agent Client Protocol](https://agentclientprotocol.com). + +You bring the prompt โ€” your user's existing CLI does the work. No API keys, no hosted billing, no rate limits beyond what their agent already enforces. + +## Install + +```bash +npm install use-local-agent ai +``` + +Optional peer deps for agents that ship as ACP shims rather than native ACP CLIs: ```bash -pnpm install -pnpm dev +npm install @agentclientprotocol/claude-agent-acp # Claude Code +npm install @zed-industries/codex-acp # Codex +``` + +## Usage + +`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider. Drop it into `streamText` / `generateText` like any other model: + +```ts +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; + +const { textStream } = streamText({ + model: localAgent("claude"), + prompt: "Refactor src/auth.ts to use the new session API", +}); + +for await (const chunk of textStream) { + process.stdout.write(chunk); +} +``` + +Or non-streaming: + +```ts +import { generateText } from "ai"; +import { localAgent } from "use-local-agent"; + +const { text, finishReason } = await generateText({ + model: localAgent("codex"), + prompt: "Summarize README.md in three bullets", +}); +``` + +The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, raw chunks, multi-modal user input (image/audio file parts), reasoning content, and tool-call streaming. Settings can be passed per-call: + +```ts +streamText({ + model: localAgent("claude", { + cwd: "/Users/me/project", + permission: "auto-allow", + inactivityTimeoutMs: 5 * 60 * 1000, + onTrace: (direction, payload) => console.log(direction, payload), + }), + prompt: "...", +}); +``` + +Or build a pre-configured provider: + +```ts +import { createLocalAgentProvider } from "use-local-agent"; + +const codingAgent = createLocalAgentProvider({ + cwd: process.cwd(), + permission: "auto-allow", +}); + +streamText({ model: codingAgent("claude"), prompt: "..." }); +streamText({ model: codingAgent("codex"), prompt: "..." }); +``` + +## Supported agents + +| ID | Display name | Notes | +| ---------- | ------------------- | -------------------------------------------------- | +| `claude` | Claude Code | requires `@agentclientprotocol/claude-agent-acp` | +| `codex` | Codex | requires `@zed-industries/codex-acp` | +| `cursor` | Cursor Agent | native ACP | +| `copilot` | GitHub Copilot CLI | native ACP | +| `gemini` | Gemini CLI | native ACP | +| `opencode` | OpenCode | native ACP | +| `droid` | Factory Droid | native ACP | +| `pi` | Pi | native ACP | + +Detect what's installed on the user's machine: + +```ts +import { detectAvailableAgents, toAgentDisplayName } from "use-local-agent"; + +console.log(detectAvailableAgents().map(toAgentDisplayName)); +// ["Claude Code", "Codex", "Cursor Agent", ...] ``` -## Structure +## Custom adapter + +Wire up any ACP-speaking subprocess that isn't built in: + +```ts +import { localAgent } from "use-local-agent"; +import type { AgentAdapter } from "use-local-agent"; +const myAgent: AgentAdapter = { + id: "my-agent", + displayName: "My Agent", + resolve: async () => ({ + bin: "/usr/local/bin/myagent", + args: ["--acp"], + env: {}, + }), +}; + +streamText({ model: localAgent.fromAdapter(myAgent), prompt: "..." }); ``` -apps/ # Playgrounds, sites, extensions -packages/ # Libraries, tools + +## Advanced: stateful sessions, permissions, slash commands + +When you need ACP semantics that don't map onto a single `streamText` call โ€” multi-turn sessions on a single subprocess, human-in-the-loop permission gating, slash commands, session resume โ€” drop down to the `LocalAgent` API: + +```ts +import { LocalAgent } from "use-local-agent"; + +await using agent = await LocalAgent.connect("codex", { + cwd: process.cwd(), + permission: "stream", +}); + +const sessionId = await agent.createSession(); +const stream = agent.prompt(sessionId, { prompt: "delete all .log files" }); + +for await (const event of stream) { + if (event.type === "permission-request") { + const ok = await askUser(event.request.tool, event.request.options); + event.request.respond( + ok ? event.request.options[0].optionId : event.request.options.at(-1)!.optionId, + ); + } +} + +// slash commands, follow-up turns on the same subprocess +await agent.prompt(sessionId, { + command: { name: "web", input: "agent client protocol" }, +}); + +// resume / list past sessions +for await (const info of agent.streamAllSessions({ cwd: process.cwd() })) { + console.log(info.sessionId, info.title); +} ``` -See [CONTRIBUTING.md](./CONTRIBUTING.md) for development guidelines and [AGENTS.md](./AGENTS.md) for code style rules. +`LocalAgent` implements `Symbol.asyncDispose`, so `await using` cleanly tears down the subprocess on scope exit. + +## Reliability + +- **Spawn race detection** โ€” fails fast as `AgentSpawnError` when the binary can't launch. +- **Initialize fast-fail** โ€” child exits before responding raise `AgentConnectionClosedError` (with stderr tail) within tens of ms. +- **Real SIGKILL escalation** โ€” uses `exitCode`/`signalCode` instead of the misleading `child.killed` flag, so a stubborn child is actually killed within `disposeGraceMs`. +- **Stderr fatal gating** โ€” auth/usage stderr patterns only escalate after `initialize` succeeds; fatal stderr captured _during_ init surfaces as the right typed error. +- **Bounded buffers** โ€” pre-prompt notifications, stderr line buffer, and `runCommand` stdout/stderr are all capped (configurable via `pendingUpdateBufferLimit`). +- **EPIPE-safe writes** โ€” writes against a dead subprocess fail with a typed `AgentStdinClosedError` instead of crashing the host. +- **Stdout-noise tolerance** โ€” non-JSON banner lines are routed to `onStderr` instead of crashing init. +- **AbortSignal short-circuit** โ€” `prompt()` with an already-aborted signal synthesizes a `cancelled` finish without sending anything. +- **`onTrace` / `envFilter` / `traceContext` / `onAuthRequired`** hooks for observability and auth retry. + +## API + +See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full public surface โ€” `LocalAgent`, `AgentEvent`, `AgentAdapter`, all error types, and every option on `LocalAgentConnectOptions`. + +## Resources & Contributing Back + +Looking to contribute back? Check out the [Contributing Guide](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md). + +Find a bug? Head over to our [issue tracker](https://github.com/millionco/use-local-agent/issues) and we'll do our best to help. We love pull requests, too! + +[**โ†’ Start contributing on GitHub**](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md) + +### License + +use-local-agent is MIT-licensed open-source software. diff --git a/masterdocs/agent-client-protocol/architecture.md b/masterdocs/agent-client-protocol/architecture.md index 079991f..9142997 100644 --- a/masterdocs/agent-client-protocol/architecture.md +++ b/masterdocs/agent-client-protocol/architecture.md @@ -103,7 +103,7 @@ Concept mapping: `LocalAgent.connect` is the high-level entry point. It resolves an adapter, launches/connects to an ACP-compatible command, initializes the protocol, records capabilities, and installs dispatcher handlers for updates and permission requests. -`streamAgent` is a convenience layer for one-shot prompt execution. `LocalAgent` is the long-lived connection shape for multiple sessions and turns. +`LocalAgent` is the long-lived connection shape for multiple sessions and turns. For one-shot prompt execution, use the AI SDK provider (`streamText({ model: localAgent('claude'), prompt: '...' })`). References: diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md index ecde834..4109e80 100644 --- a/packages/use-local-agent/README.md +++ b/packages/use-local-agent/README.md @@ -1,75 +1,114 @@ # use-local-agent -Talk to any locally-installed coding agent โ€” Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, Pi โ€” over the [Agent Client Protocol](https://agentclientprotocol.com). +[![version](https://img.shields.io/npm/v/use-local-agent?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/use-local-agent) +[![downloads](https://img.shields.io/npm/dt/use-local-agent.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/use-local-agent) + +An API for accessing any locally-installed coding agent + +How? Spawn Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and stream prompts, tool calls, and permissions over the [Agent Client Protocol](https://agentclientprotocol.com). + +You bring the prompt โ€” your user's existing CLI does the work. No API keys, no hosted billing, no rate limits beyond what their agent already enforces. + +## Install ```bash -npm install use-local-agent +npm install use-local-agent ai ``` -Optional peer deps for the agents that ship as ACP shims rather than as native ACP CLIs: +Optional peer deps for agents that ship as ACP shims rather than native ACP CLIs: ```bash -npm install @agentclientprotocol/claude-agent-acp # for Claude -npm install @zed-industries/codex-acp # for Codex +npm install @agentclientprotocol/claude-agent-acp # Claude Code +npm install @zed-industries/codex-acp # Codex ``` -## Quick start +## Usage -### One-shot streaming +`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider. Drop it into `streamText` / `generateText` like any other model: ```ts -import { streamAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; -const run = streamAgent({ - agent: "claude", +const { textStream } = streamText({ + model: localAgent("claude"), prompt: "Refactor src/auth.ts to use the new session API", - cwd: process.cwd(), }); -for await (const event of run) { - if (event.type === "text-delta") process.stdout.write(event.text); - if (event.type === "tool-call") console.log(`\n[tool] ${event.tool}`); +for await (const chunk of textStream) { + process.stdout.write(chunk); } +``` + +Or non-streaming: -const { text, stopReason, sessionId } = await run; +```ts +import { generateText } from "ai"; +import { localAgent } from "use-local-agent"; + +const { text, finishReason } = await generateText({ + model: localAgent("codex"), + prompt: "Summarize README.md in three bullets", +}); ``` -### Long-lived connection (resume sessions, multiple turns) +The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, raw chunks, multi-modal user input (image/audio file parts), reasoning content, and tool-call streaming. Settings can be passed per-call: ```ts -import { LocalAgent } from "use-local-agent"; +streamText({ + model: localAgent("claude", { + cwd: "/Users/me/project", + permission: "auto-allow", + inactivityTimeoutMs: 5 * 60 * 1000, + onTrace: (direction, payload) => console.log(direction, payload), + }), + prompt: "...", +}); +``` -await using agent = await LocalAgent.connect("codex", { +Or build a pre-configured provider: + +```ts +import { createLocalAgentProvider } from "use-local-agent"; + +const codingAgent = createLocalAgentProvider({ cwd: process.cwd(), permission: "auto-allow", - inactivityTimeoutMs: 3 * 60 * 1000, }); -const sessionId = await agent.createSession(); +streamText({ model: codingAgent("claude"), prompt: "..." }); +streamText({ model: codingAgent("codex"), prompt: "..." }); +``` -const stream = agent.prompt(sessionId, { prompt: "list TODO comments grouped by file" }); -for await (const event of stream) { - // ... -} -const result = await stream.completion; +## Supported agents -const followUp = agent.prompt(sessionId, { prompt: "now fix the highest-priority one" }); -await followUp.completion; -``` +| ID | Display name | Notes | +| ---------- | ------------------- | -------------------------------------------------- | +| `claude` | Claude Code | requires `@agentclientprotocol/claude-agent-acp` | +| `codex` | Codex | requires `@zed-industries/codex-acp` | +| `cursor` | Cursor Agent | native ACP | +| `copilot` | GitHub Copilot CLI | native ACP | +| `gemini` | Gemini CLI | native ACP | +| `opencode` | OpenCode | native ACP | +| `droid` | Factory Droid | native ACP | +| `pi` | Pi | native ACP | -### Detect installed agents +Detect what's installed on the user's machine: ```ts import { detectAvailableAgents, toAgentDisplayName } from "use-local-agent"; -const agents = detectAvailableAgents(); -console.log(agents.map(toAgentDisplayName)); // ["Claude Code", "Codex", ...] +console.log(detectAvailableAgents().map(toAgentDisplayName)); +// ["Claude Code", "Codex", "Cursor Agent", ...] ``` -### Custom adapter +## Custom adapter + +Wire up any ACP-speaking subprocess that isn't built in: ```ts -import { LocalAgent, type AgentAdapter } from "use-local-agent"; +import { localAgent } from "use-local-agent"; +import type { AgentAdapter } from "use-local-agent"; const myAgent: AgentAdapter = { id: "my-agent", @@ -81,13 +120,21 @@ const myAgent: AgentAdapter = { }), }; -const agent = await LocalAgent.connect(myAgent); +streamText({ model: localAgent.fromAdapter(myAgent), prompt: "..." }); ``` -### Human-in-the-loop permissions +## Advanced: stateful sessions, permissions, slash commands + +When you need ACP semantics that don't map onto a single `streamText` call โ€” multi-turn sessions on a single subprocess, human-in-the-loop permission gating, slash commands, session resume โ€” drop down to the `LocalAgent` API: ```ts -const agent = await LocalAgent.connect("claude", { permission: "stream" }); +import { LocalAgent } from "use-local-agent"; + +await using agent = await LocalAgent.connect("codex", { + cwd: process.cwd(), + permission: "stream", +}); + const sessionId = await agent.createSession(); const stream = agent.prompt(sessionId, { prompt: "delete all .log files" }); @@ -99,85 +146,44 @@ for await (const event of stream) { ); } } -``` - -## Concepts - -- **Adapter**: per-provider launch metadata (`bin`, `args`, `env`) plus install/auth checks. Built-in adapters live in `use-local-agent/adapters`. -- **`LocalAgent`**: a single ACP subprocess. Multiple sessions, multiple turns. Implements `Symbol.asyncDispose`. -- **`Session`**: a conversation context with its own history. Resume via `agent.loadSession({ sessionId, cwd })`, `agent.loadSessionStreaming(...)` (replay-aware), `agent.resumeSession(...)`, or `agent.closeSession(...)` when the agent advertises the matching capability. -- **`AgentEvent`**: the public event union. `text-delta`, `thinking-delta`, `tool-call`, `tool-call-update`, `plan`, `permission-request`, `config-options`, `available-commands`, `mode-changed`, `session-info`, `usage`, `finish`, `raw`. - -## Reliability features - -`use-local-agent` targets the latest ACP TypeScript SDK (`@agentclientprotocol/sdk@^0.20.0`) so it inherits these upstream reliability fixes: clean transport-failure handling (#103), ndjson decoder flush (#119), no spurious unhandled rejection (#122), notification/response ordering (#130), private-keyword cross-copy compatibility (#127), stable `closeSession`/`resumeSession` (#132). - -On top of those, the wrapper itself adds: - -- **Spawn race detection** โ€” fails fast as `AgentSpawnError` when the child binary cannot be launched, before initialize times out. -- **Initialize fast-fail** โ€” `initialize` races against subprocess close, so a child that exits before responding raises `AgentConnectionClosedError` (with stderr tail) within tens of ms instead of waiting `initializeTimeoutMs`. -- **Real SIGKILL escalation** โ€” `dispose()` and `runCommand` track liveness via `exitCode` / `signalCode` (not the misleading `child.killed` flag), so a stubborn child is actually killed within `disposeGraceMs`. -- **Stderr fatal gating** โ€” auth/usage stderr patterns are only escalated _after_ `initialize` succeeds, eliminating boot-banner false positives. Fatal stderr captured _during_ init is buffered and surfaces as the right typed error (e.g. `AgentUnauthenticatedError`). -- **Inactivity watchdog** โ€” pauses while a permission request is pending in stream mode so users can take their time. The validation-failure path no longer leaks the timer. -- **Bounded buffers** โ€” pre-prompt `session/update` notifications are capped (default 1000, configurable via `pendingUpdateBufferLimit`); when the cap is hit the oldest are dropped and a single `raw` event surfaces the dropped count via `_meta.droppedNotifications` on the next prompt drain. Internal stderr line buffer caps at 64 KiB; `runCommand` stdout/stderr cap at 1 MiB. -- **EPIPE-safe writes** โ€” child stream `error` listeners are always installed; writes against a dead subprocess fail with a typed `AgentStdinClosedError` instead of escalating to `uncaughtException`. -- **Stdout-noise tolerance** โ€” non-JSON banner lines on stdout are routed to `onStderr` with a `[stdout-noise]` prefix and dropped from the JSON-RPC stream instead of crashing init. -- **AbortSignal short-circuit** โ€” a `prompt()` invoked with an already-aborted signal synthesizes a `cancelled` finish without sending `session/prompt`. -- **`onTrace` hook** โ€” observe inbound/outbound JSON-RPC and stderr without parsing logs. -- **`disposeGraceMs`** โ€” configurable SIGTERMโ†’SIGKILL grace. -- **`envFilter` hook** โ€” scrub environment before passing to the child. -- **`stderrFatalPatterns`** โ€” override the auth/usage match lists. - -## Slash commands -```ts -const stream = agent.prompt(sessionId, { +// slash commands, follow-up turns on the same subprocess +await agent.prompt(sessionId, { command: { name: "web", input: "agent client protocol" }, }); -// โ†’ forwards `/web agent client protocol` as a text content block -agent.commandsFor(sessionId); // currently advertised commands -``` - -## Working directories - -Pass `additionalDirectories: ["/workspace/sub-pkg"]` to `createSession` / -`loadSession` / `resumeSession` to expand the session's filesystem scope when -the agent advertises `sessionCapabilities.additionalDirectories`. -## Listing sessions - -```ts +// resume / list past sessions for await (const info of agent.streamAllSessions({ cwd: process.cwd() })) { console.log(info.sessionId, info.title); } ``` -## Trace context +`LocalAgent` implements `Symbol.asyncDispose`, so `await using` cleanly tears down the subprocess on scope exit. -Pass `traceContext: () => ({ traceparent, tracestate, baggage })` to -`LocalAgent.connect`. Only the W3C-reserved keys are forwarded into request -`_meta` for compatibility with OpenTelemetry and MCP tooling. +## Reliability -## Auth retry +- **Spawn race detection** โ€” fails fast as `AgentSpawnError` when the binary can't launch. +- **Initialize fast-fail** โ€” child exits before responding raise `AgentConnectionClosedError` (with stderr tail) within tens of ms. +- **Real SIGKILL escalation** โ€” uses `exitCode`/`signalCode` instead of the misleading `child.killed` flag, so a stubborn child is actually killed within `disposeGraceMs`. +- **Stderr fatal gating** โ€” auth/usage stderr patterns only escalate after `initialize` succeeds; fatal stderr captured _during_ init surfaces as the right typed error. +- **Bounded buffers** โ€” pre-prompt notifications, stderr line buffer, and `runCommand` stdout/stderr are all capped (configurable via `pendingUpdateBufferLimit`). +- **EPIPE-safe writes** โ€” writes against a dead subprocess fail with a typed `AgentStdinClosedError` instead of crashing the host. +- **Stdout-noise tolerance** โ€” non-JSON banner lines are routed to `onStderr` instead of crashing init. +- **AbortSignal short-circuit** โ€” `prompt()` with an already-aborted signal synthesizes a `cancelled` finish without sending anything. +- **`onTrace` / `envFilter` / `traceContext` / `onAuthRequired`** hooks for observability and auth retry. -```ts -const agent = await LocalAgent.connect("claude", { - onAuthRequired: async (methods) => { - const choice = await pickAuthMethod(methods); - return choice?.id; - }, -}); -``` +## API -When `session/new` returns `auth_required` (-32000), the hook is invoked. -Returning a method id triggers `authenticate` then a single retry of -`session/new`. Returning `undefined` preserves the original -`AgentUnauthenticatedError`. +See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full public surface โ€” `LocalAgent`, `AgentEvent`, `AgentAdapter`, all error types, and every option on `LocalAgentConnectOptions`. -## API +## Resources & Contributing Back + +Looking to contribute back? Check out the [Contributing Guide](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md). + +Find a bug? Head over to our [issue tracker](https://github.com/millionco/use-local-agent/issues) and we'll do our best to help. We love pull requests, too! -See [`src/index.ts`](./src/index.ts) for the full public surface. +[**โ†’ Start contributing on GitHub**](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md) -## License +### License -MIT +use-local-agent is MIT-licensed open-source software. diff --git a/packages/use-local-agent/package.json b/packages/use-local-agent/package.json index 3e73aad..b0ef7da 100644 --- a/packages/use-local-agent/package.json +++ b/packages/use-local-agent/package.json @@ -1,7 +1,7 @@ { "name": "use-local-agent", "version": "0.0.0", - "description": "Talk to any locally-installed coding agent (Claude Code, Codex, Cursor, Copilot, Gemini, OpenCode, Droid, Pi) over the Agent Client Protocol.", + "description": "An API for accessing any locally-installed coding agent (Claude Code, Codex, Cursor, Copilot, Gemini, OpenCode, Droid, Pi) over the Agent Client Protocol.", "license": "MIT", "files": [ "dist", @@ -17,6 +17,10 @@ "types": "./dist/adapters/index.d.mts", "default": "./dist/adapters/index.mjs" }, + "./ai-sdk": { + "types": "./dist/ai-sdk/index.d.mts", + "default": "./dist/ai-sdk/index.mjs" + }, "./testing": { "types": "./dist/testing/mock-agent.d.mts", "default": "./dist/testing/mock-agent.mjs" @@ -33,7 +37,9 @@ "check": "vp check" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.20.0" + "@agentclientprotocol/sdk": "^0.20.0", + "@ai-sdk/provider": "^3.0.8", + "@ai-sdk/provider-utils": "^4.0.23" }, "devDependencies": { "@types/node": "^22.19.17", diff --git a/packages/use-local-agent/src/ai-sdk/convert-prompt.ts b/packages/use-local-agent/src/ai-sdk/convert-prompt.ts new file mode 100644 index 0000000..4e2d7de --- /dev/null +++ b/packages/use-local-agent/src/ai-sdk/convert-prompt.ts @@ -0,0 +1,118 @@ +import type { + LanguageModelV3FilePart, + LanguageModelV3Prompt, + SharedV3Warning, +} from "@ai-sdk/provider"; +import type { ContentBlock } from "../types"; + +export interface PromptConversionResult { + readonly systemPrompt: string | undefined; + readonly blocks: ContentBlock[]; + readonly warnings: SharedV3Warning[]; +} + +export const convertPromptToContentBlocks = ( + prompt: LanguageModelV3Prompt, +): PromptConversionResult => { + const systemParts: string[] = []; + const blocks: ContentBlock[] = []; + const warnings: SharedV3Warning[] = []; + + for (const message of prompt) { + switch (message.role) { + case "system": { + systemParts.push(message.content); + break; + } + case "user": { + for (const part of message.content) { + if (part.type === "text") { + blocks.push({ type: "text", text: part.text }); + } else if (part.type === "file") { + const fileBlock = mapFilePart(part); + if (fileBlock) { + blocks.push(fileBlock); + } else { + warnings.push({ + type: "unsupported", + feature: "file-part", + details: `Unsupported file mediaType "${part.mediaType}" for ACP`, + }); + } + } + } + break; + } + case "assistant": { + const text = message.content + .filter((part) => part.type === "text") + .map((part) => (part as { text: string }).text) + .join(""); + if (text.length > 0) { + blocks.push({ type: "text", text: `Assistant: ${text}` }); + } + const toolCallNames = message.content + .filter((part) => part.type === "tool-call") + .map((part) => (part as { toolName: string }).toolName); + if (toolCallNames.length > 0) { + warnings.push({ + type: "unsupported", + feature: "assistant-tool-call-replay", + details: `Cannot replay prior assistant tool calls (${toolCallNames.join(", ")}) into an ACP session; use loadSession or resumeSession to continue an existing conversation`, + }); + } + break; + } + case "tool": { + warnings.push({ + type: "unsupported", + feature: "tool-result-message", + details: + "ACP runs tools internally via MCP servers; tool result messages cannot be replayed inline", + }); + break; + } + } + } + + return { + systemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined, + blocks, + warnings, + }; +}; + +const mapFilePart = (part: LanguageModelV3FilePart): ContentBlock | undefined => { + const { data, mediaType, filename } = part; + if (mediaType.startsWith("image/")) { + return { + type: "image", + data: dataAsBase64(data), + mimeType: mediaType, + ...(data instanceof URL ? { uri: data.toString() } : {}), + }; + } + if (mediaType.startsWith("audio/")) { + return { + type: "audio", + data: dataAsBase64(data), + mimeType: mediaType, + }; + } + if (data instanceof URL) { + return { + type: "resource_link", + uri: data.toString(), + mimeType: mediaType, + name: filename ?? data.toString(), + }; + } + return undefined; +}; + +const dataAsBase64 = (data: LanguageModelV3FilePart["data"]): string => { + if (typeof data === "string") return data; + if (data instanceof URL) return data.toString(); + if (data instanceof Uint8Array) return Buffer.from(data).toString("base64"); + throw new Error("Unsupported file data format for ACP content block"); +}; diff --git a/packages/use-local-agent/src/ai-sdk/index.ts b/packages/use-local-agent/src/ai-sdk/index.ts new file mode 100644 index 0000000..d83c80d --- /dev/null +++ b/packages/use-local-agent/src/ai-sdk/index.ts @@ -0,0 +1,14 @@ +export { + LocalAgentLanguageModel, + type LocalAgentLanguageModelConfig, +} from "./local-agent-language-model"; +export { + createLocalAgentProvider, + localAgent, + type LocalAgentProvider, + type LocalAgentProviderSettings, +} from "./provider"; +export { + convertPromptToContentBlocks, + type PromptConversionResult, +} from "./convert-prompt"; diff --git a/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts b/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts new file mode 100644 index 0000000..372181a --- /dev/null +++ b/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts @@ -0,0 +1,301 @@ +import type { + JSONObject, + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3Content, + LanguageModelV3FinishReason, + LanguageModelV3GenerateResult, + LanguageModelV3StreamPart, + LanguageModelV3StreamResult, + LanguageModelV3Usage, +} from "@ai-sdk/provider"; +import { generateId } from "@ai-sdk/provider-utils"; +import { LocalAgent } from "../local-agent"; +import type { StopReason, UsageReport } from "../types"; +import { convertPromptToContentBlocks } from "./convert-prompt"; + +const PROVIDER_NAME = "use-local-agent"; + +export interface LocalAgentLanguageModelConfig { + readonly modelId: string; + /** + * Async factory that produces a fresh `LocalAgent` for a single turn. The + * model owns the agent's lifecycle and will `close()` it after the turn + * completes (success, error, or abort). The factory receives the resolved + * system prompt extracted from the AI SDK prompt messages so callers can + * forward it as a `LocalAgentConnectOptions.systemPrompt`. + */ + readonly connect: (input: { systemPrompt: string | undefined }) => Promise; + /** + * Override the provider name reported on the model (default `use-local-agent`). + */ + readonly provider?: string; +} + +export class LocalAgentLanguageModel implements LanguageModelV3 { + readonly specificationVersion = "v3" as const; + readonly provider: string; + readonly modelId: string; + readonly supportedUrls: Record = {}; + + readonly #connect: LocalAgentLanguageModelConfig["connect"]; + + constructor(config: LocalAgentLanguageModelConfig) { + this.modelId = config.modelId; + this.provider = config.provider ?? PROVIDER_NAME; + this.#connect = config.connect; + } + + async doGenerate(options: LanguageModelV3CallOptions): Promise { + const { systemPrompt, blocks, warnings } = convertPromptToContentBlocks(options.prompt); + const allWarnings = [...warnings, ...callOptionWarnings(options)]; + const agent = await this.#connect({ systemPrompt }); + try { + const sessionId = await agent.createSession(); + const stream = agent.prompt(sessionId, { + prompt: blocks, + ...(options.abortSignal ? { signal: options.abortSignal } : {}), + }); + const toolCalls: LanguageModelV3Content[] = []; + for await (const event of stream) { + if (event.type === "tool-call") { + toolCalls.push({ + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.tool, + input: stringifyInput(event.input), + providerExecuted: true, + }); + } + } + const result = await stream.completion; + const content: LanguageModelV3Content[] = []; + if (result.thinking.length > 0) { + content.push({ type: "reasoning", text: result.thinking }); + } + if (result.text.length > 0) { + content.push({ type: "text", text: result.text }); + } + content.push(...toolCalls); + return { + content, + finishReason: mapStopReason(result.stopReason), + usage: mapUsage(result.usage), + warnings: allWarnings, + response: { id: result.sessionId }, + }; + } finally { + await agent.close(); + } + } + + async doStream(options: LanguageModelV3CallOptions): Promise { + const { systemPrompt, blocks, warnings } = convertPromptToContentBlocks(options.prompt); + const allWarnings = [...warnings, ...callOptionWarnings(options)]; + const promptBlocks = blocks; + const abortSignal = options.abortSignal; + const connectAgent = this.#connect; + const includeRaw = options.includeRawChunks; + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue({ type: "stream-start", warnings: allWarnings }); + let agent: LocalAgent | undefined; + let textBlockId: string | undefined; + let reasoningBlockId: string | undefined; + try { + agent = await connectAgent({ systemPrompt }); + const sessionId = await agent.createSession(); + controller.enqueue({ type: "response-metadata", id: sessionId }); + const turn = agent.prompt(sessionId, { + prompt: promptBlocks, + ...(abortSignal ? { signal: abortSignal } : {}), + }); + for await (const event of turn) { + switch (event.type) { + case "text-delta": { + if (!textBlockId) { + textBlockId = event.messageId ?? generateId(); + controller.enqueue({ type: "text-start", id: textBlockId }); + } + controller.enqueue({ + type: "text-delta", + id: textBlockId, + delta: event.text, + }); + break; + } + case "thinking-delta": { + if (!reasoningBlockId) { + reasoningBlockId = event.messageId ?? generateId(); + controller.enqueue({ type: "reasoning-start", id: reasoningBlockId }); + } + controller.enqueue({ + type: "reasoning-delta", + id: reasoningBlockId, + delta: event.text, + }); + break; + } + case "tool-call": { + controller.enqueue({ + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.tool, + input: stringifyInput(event.input), + providerExecuted: true, + }); + break; + } + case "tool-call-update": { + if (event.status === "completed" && event.output !== undefined) { + controller.enqueue({ + type: "tool-result", + toolCallId: event.toolCallId, + toolName: event.title ?? "", + result: toJsonValue(event.output), + }); + } else if (event.status === "failed") { + controller.enqueue({ + type: "tool-result", + toolCallId: event.toolCallId, + toolName: event.title ?? "", + result: toJsonValue(event.output ?? "tool failed"), + isError: true, + }); + } + break; + } + default: { + if (includeRaw) { + controller.enqueue({ type: "raw", rawValue: event }); + } + } + } + } + if (textBlockId) controller.enqueue({ type: "text-end", id: textBlockId }); + if (reasoningBlockId) + controller.enqueue({ type: "reasoning-end", id: reasoningBlockId }); + const result = await turn.completion; + controller.enqueue({ + type: "finish", + finishReason: mapStopReason(result.stopReason), + usage: mapUsage(result.usage), + }); + controller.close(); + } catch (cause) { + controller.enqueue({ type: "error", error: cause }); + controller.close(); + } finally { + if (agent) await agent.close(); + } + }, + }); + + return { stream }; + } + +} + +const stringifyInput = (input: unknown): string => { + if (input === undefined || input === null) return "{}"; + if (typeof input === "string") return input; + try { + return JSON.stringify(input); + } catch { + return "{}"; + } +}; + +const toJsonValue = (value: unknown): NonNullable => { + if (value === undefined || value === null) return ""; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + Array.isArray(value) || + (typeof value === "object" && value !== null) + ) { + return value as NonNullable; + } + return String(value); +}; + +const callOptionWarnings = ( + options: LanguageModelV3CallOptions, +): Array<{ type: "unsupported"; feature: string; details?: string }> => { + const out: Array<{ type: "unsupported"; feature: string; details?: string }> = []; + if (options.maxOutputTokens !== undefined) + out.push({ type: "unsupported", feature: "maxOutputTokens" }); + if (options.temperature !== undefined) + out.push({ type: "unsupported", feature: "temperature" }); + if (options.topP !== undefined) out.push({ type: "unsupported", feature: "topP" }); + if (options.topK !== undefined) out.push({ type: "unsupported", feature: "topK" }); + if (options.presencePenalty !== undefined) + out.push({ type: "unsupported", feature: "presencePenalty" }); + if (options.frequencyPenalty !== undefined) + out.push({ type: "unsupported", feature: "frequencyPenalty" }); + if (options.stopSequences !== undefined && options.stopSequences.length > 0) + out.push({ type: "unsupported", feature: "stopSequences" }); + if (options.responseFormat?.type === "json") + out.push({ type: "unsupported", feature: "responseFormat:json" }); + if (options.seed !== undefined) out.push({ type: "unsupported", feature: "seed" }); + if (options.tools && options.tools.length > 0) + out.push({ + type: "unsupported", + feature: "tools", + details: "ACP agents run tools internally via MCP servers, not via AI SDK tool definitions", + }); + return out; +}; + +const mapStopReason = (reason: StopReason | undefined): LanguageModelV3FinishReason => { + let unified: LanguageModelV3FinishReason["unified"]; + switch (reason) { + case "end_turn": + unified = "stop"; + break; + case "max_tokens": + unified = "length"; + break; + case "max_turn_requests": + unified = "length"; + break; + case "refusal": + unified = "content-filter"; + break; + case "cancelled": + unified = "other"; + break; + default: + unified = "other"; + } + return { unified, raw: reason }; +}; + +const mapUsage = (usage: UsageReport | undefined): LanguageModelV3Usage => ({ + inputTokens: { + total: usage?.used, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { + total: undefined, + text: undefined, + reasoning: undefined, + }, + ...(usage ? { raw: jsonifyUsage(usage) } : {}), +}); + +const jsonifyUsage = (usage: UsageReport): JSONObject => { + const out: JSONObject = { size: usage.size, used: usage.used }; + if (usage.cost) { + try { + out.cost = JSON.parse(JSON.stringify(usage.cost)) as JSONObject; + } catch { + // ignore + } + } + return out; +}; diff --git a/packages/use-local-agent/src/ai-sdk/provider.ts b/packages/use-local-agent/src/ai-sdk/provider.ts new file mode 100644 index 0000000..498dae9 --- /dev/null +++ b/packages/use-local-agent/src/ai-sdk/provider.ts @@ -0,0 +1,116 @@ +import { NoSuchModelError } from "@ai-sdk/provider"; +import type { + EmbeddingModelV3, + ImageModelV3, + LanguageModelV3, + ProviderV3, +} from "@ai-sdk/provider"; +import type { AgentAdapter } from "../adapter"; +import type { SupportedAgentId } from "../constants"; +import { LocalAgent, type LocalAgentConnectOptions } from "../local-agent"; +import { LocalAgentLanguageModel } from "./local-agent-language-model"; + +export type LocalAgentProviderSettings = LocalAgentConnectOptions; + +export interface LocalAgentProvider extends ProviderV3 { + (modelId: SupportedAgentId | string, settings?: LocalAgentProviderSettings): LanguageModelV3; + languageModel( + modelId: SupportedAgentId | string, + settings?: LocalAgentProviderSettings, + ): LanguageModelV3; + /** + * Create a model from a custom adapter (any ACP-speaking subprocess). + */ + fromAdapter(adapter: AgentAdapter, settings?: LocalAgentProviderSettings): LanguageModelV3; +} + +const mergeSystemPrompt = ( + base: string | undefined, + added: string | undefined, +): string | undefined => { + if (added === undefined) return base; + if (!base) return added; + return `${base}\n\n${added}`; +}; + +const mergeConnectOptions = ( + base: LocalAgentConnectOptions, + override: LocalAgentConnectOptions | undefined, + systemPrompt: string | undefined, +): LocalAgentConnectOptions => { + const merged: LocalAgentConnectOptions = { ...base, ...(override ?? {}) }; + const mergedSystem = mergeSystemPrompt(merged.systemPrompt, systemPrompt); + return { + ...merged, + ...(mergedSystem !== undefined ? { systemPrompt: mergedSystem } : {}), + permission: merged.permission ?? "auto-allow", + }; +}; + +export const createLocalAgentProvider = ( + defaults: LocalAgentProviderSettings = {}, +): LocalAgentProvider => { + const buildLanguageModel = ( + modelId: SupportedAgentId | string, + settings?: LocalAgentProviderSettings, + ): LanguageModelV3 => + new LocalAgentLanguageModel({ + modelId, + connect: ({ systemPrompt }) => + LocalAgent.connect( + modelId as SupportedAgentId, + mergeConnectOptions(defaults, settings, systemPrompt), + ), + }); + + const buildFromAdapter = ( + adapter: AgentAdapter, + settings?: LocalAgentProviderSettings, + ): LanguageModelV3 => + new LocalAgentLanguageModel({ + modelId: adapter.id, + connect: ({ systemPrompt }) => + LocalAgent.connect(adapter, mergeConnectOptions(defaults, settings, systemPrompt)), + }); + + const provider = (( + modelId: SupportedAgentId | string, + settings?: LocalAgentProviderSettings, + ) => buildLanguageModel(modelId, settings)) as LocalAgentProvider; + + Object.defineProperties(provider, { + specificationVersion: { value: "v3", enumerable: true }, + languageModel: { value: buildLanguageModel, enumerable: true }, + fromAdapter: { value: buildFromAdapter, enumerable: true }, + embeddingModel: { + value: (modelId: string): EmbeddingModelV3 => { + throw new NoSuchModelError({ modelId, modelType: "embeddingModel" }); + }, + enumerable: true, + }, + imageModel: { + value: (modelId: string): ImageModelV3 => { + throw new NoSuchModelError({ modelId, modelType: "imageModel" }); + }, + enumerable: true, + }, + }); + + return provider; +}; + +/** + * Default `use-local-agent` provider for the [Vercel AI SDK](https://ai-sdk.dev). + * + * @example + * ```ts + * import { streamText } from "ai"; + * import { localAgent } from "use-local-agent"; + * + * const { textStream } = streamText({ + * model: localAgent("claude"), + * prompt: "Refactor src/auth.ts", + * }); + * ``` + */ +export const localAgent: LocalAgentProvider = createLocalAgentProvider(); diff --git a/packages/use-local-agent/src/index.ts b/packages/use-local-agent/src/index.ts index 1cdbad9..052770f 100644 --- a/packages/use-local-agent/src/index.ts +++ b/packages/use-local-agent/src/index.ts @@ -5,16 +5,18 @@ export { type FileSystemHandlers, type TerminalHandlers, } from "./local-agent"; -export { - streamAgent, - generateAgent, - type StreamAgentOptions, - type OneShotAgentStream, - type GenerateAgentResult, -} from "./stream"; export { connect, type ConnectOptions, type ConnectResult } from "./connect"; +export { + createLocalAgentProvider, + localAgent, + LocalAgentLanguageModel, + type LocalAgentLanguageModelConfig, + type LocalAgentProvider, + type LocalAgentProviderSettings, +} from "./ai-sdk"; + export type { AgentAdapter, AdapterFactoryOptions, ResolvedAdapter } from "./adapter"; export { autoAllow, autoAllowOnce, autoReject, type PermissionPolicy } from "./permission"; diff --git a/packages/use-local-agent/src/stream.ts b/packages/use-local-agent/src/stream.ts deleted file mode 100644 index 8fc751a..0000000 --- a/packages/use-local-agent/src/stream.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { AgentAdapter } from "./adapter"; -import type { SupportedAgentId } from "./constants"; -import { LocalAgent, type LocalAgentConnectOptions } from "./local-agent"; -import type { AgentEvent, AgentStream, ModelPreference, PromptInput, TurnResult } from "./types"; - -export interface StreamAgentOptions extends LocalAgentConnectOptions { - readonly agent: AgentAdapter | SupportedAgentId; - readonly prompt: PromptInput["prompt"]; - readonly meta?: Record; - readonly modelPreference?: ModelPreference; - readonly signal?: AbortSignal; -} - -export interface OneShotAgentStream extends AsyncIterable, PromiseLike { - readonly completion: Promise; - cancel(): Promise; -} - -export const streamAgent = (options: StreamAgentOptions): OneShotAgentStream => { - const completion = (async (): Promise<{ - inner: AgentStream; - agent: LocalAgent; - }> => { - const agent = await LocalAgent.connect(options.agent, { - cwd: options.cwd, - env: options.env, - mcpServers: options.mcpServers, - permission: options.permission, - inactivityTimeoutMs: options.inactivityTimeoutMs, - systemPrompt: options.systemPrompt, - onStderr: options.onStderr, - }); - const sessionId = await agent.createSession({ cwd: options.cwd }); - const inner = agent.prompt(sessionId, { - prompt: options.prompt, - meta: options.meta, - modelPreference: options.modelPreference, - signal: options.signal, - }); - return { inner, agent }; - })(); - - const completionPromise: Promise = completion.then( - async ({ inner, agent }) => { - try { - return await inner.completion; - } finally { - await agent.close(); - } - }, - async (error) => { - throw error; - }, - ); - - const oneShot: OneShotAgentStream = { - completion: completionPromise, - cancel: async () => { - const { inner } = await completion.catch(() => ({ - inner: undefined as AgentStream | undefined, - })); - if (inner) await inner.cancel(); - }, - then: (onFulfilled, onRejected) => completionPromise.then(onFulfilled, onRejected), - [Symbol.asyncIterator]: () => { - let innerIterator: AsyncIterator | undefined; - const ensureInner = async (): Promise> => { - if (!innerIterator) { - const { inner } = await completion; - innerIterator = inner[Symbol.asyncIterator](); - } - return innerIterator; - }; - return { - next: async () => (await ensureInner()).next(), - return: async (value?: unknown) => { - if (innerIterator?.return) return innerIterator.return(value); - return { value: undefined, done: true }; - }, - }; - }, - }; - return oneShot; -}; - -export interface GenerateAgentResult { - readonly text: string; - readonly thinking: string; - readonly stopReason: string; - readonly sessionId: string; -} - -export const generateAgent = async (options: StreamAgentOptions): Promise => { - const stream = streamAgent(options); - for await (const _ of stream) { - void _; - } - const result = await stream.completion; - return { - text: result.text, - thinking: result.thinking, - stopReason: result.stopReason, - sessionId: result.sessionId, - }; -}; diff --git a/packages/use-local-agent/tests/ai-sdk-provider.test.ts b/packages/use-local-agent/tests/ai-sdk-provider.test.ts new file mode 100644 index 0000000..a78662c --- /dev/null +++ b/packages/use-local-agent/tests/ai-sdk-provider.test.ts @@ -0,0 +1,270 @@ +import type { LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { NoSuchModelError } from "@ai-sdk/provider"; +import { describe, expect, it } from "vite-plus/test"; +import { LocalAgentLanguageModel } from "../src/ai-sdk/local-agent-language-model"; +import { createLocalAgentProvider, localAgent } from "../src/ai-sdk/provider"; +import { convertPromptToContentBlocks } from "../src/ai-sdk/convert-prompt"; +import { connectMockAgent, type MockAgentHandlers } from "../src/testing/mock-agent"; + +const collectStream = async ( + stream: ReadableStream, +): Promise => { + const parts: LanguageModelV3StreamPart[] = []; + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + } finally { + reader.releaseLock(); + } + return parts; +}; + +const buildModelWithMock = async (handlers: MockAgentHandlers) => { + const session = await connectMockAgent(handlers); + const model = new LocalAgentLanguageModel({ + modelId: "mock", + connect: async () => session.agent, + }); + return { model, session }; +}; + +const stringPrompt = (text: string): LanguageModelV3CallOptions => ({ + prompt: [{ role: "user", content: [{ type: "text", text }] }], +}); + +describe("convertPromptToContentBlocks", () => { + it("flattens system messages into a single systemPrompt and emits user text blocks", () => { + const result = convertPromptToContentBlocks([ + { role: "system", content: "Be terse." }, + { role: "system", content: "Reply only with code." }, + { role: "user", content: [{ type: "text", text: "hello" }] }, + ]); + expect(result.systemPrompt).toBe("Be terse.\n\nReply only with code."); + expect(result.blocks).toEqual([{ type: "text", text: "hello" }]); + expect(result.warnings).toHaveLength(0); + }); + + it("warns on unsupported file mediaTypes and tool result messages", () => { + const result = convertPromptToContentBlocks([ + { + role: "user", + content: [ + { + type: "file", + data: new Uint8Array([1, 2, 3]), + mediaType: "application/x-binary-blob", + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "t1", + toolName: "search", + output: { type: "text", value: "ok" }, + }, + ], + }, + ]); + expect(result.blocks).toHaveLength(0); + expect(result.warnings.map((warning) => warning.feature)).toEqual([ + "file-part", + "tool-result-message", + ]); + }); + + it("maps image and audio file parts to ACP content blocks", () => { + const imageBytes = new Uint8Array([0xff, 0xd8, 0xff]); + const result = convertPromptToContentBlocks([ + { + role: "user", + content: [ + { type: "file", data: imageBytes, mediaType: "image/jpeg" }, + { type: "file", data: "base64audio==", mediaType: "audio/mp3" }, + ], + }, + ]); + expect(result.blocks).toEqual([ + { type: "image", data: Buffer.from(imageBytes).toString("base64"), mimeType: "image/jpeg" }, + { type: "audio", data: "base64audio==", mimeType: "audio/mp3" }, + ]); + expect(result.warnings).toHaveLength(0); + }); +}); + +describe("createLocalAgentProvider", () => { + it("exposes ProviderV3 shape with specificationVersion 'v3'", () => { + expect(localAgent.specificationVersion).toBe("v3"); + const model = localAgent("claude"); + expect(model.specificationVersion).toBe("v3"); + expect(model.modelId).toBe("claude"); + expect(model.provider).toBe("use-local-agent"); + }); + + it("throws NoSuchModelError for unsupported model types", () => { + expect(() => localAgent.embeddingModel("any")).toThrow(NoSuchModelError); + expect(() => localAgent.imageModel("any")).toThrow(NoSuchModelError); + }); + + it("languageModel and call form return equivalent models", () => { + const fromCall = localAgent("codex"); + const fromMethod = localAgent.languageModel("codex"); + expect(fromCall.modelId).toBe(fromMethod.modelId); + }); + + it("fromAdapter wires a custom adapter through to the model id", () => { + const provider = createLocalAgentProvider(); + const adapter = { + id: "custom", + displayName: "Custom", + resolve: async () => ({ bin: "/bin/true", args: [], env: {} }), + } as const; + const model = provider.fromAdapter(adapter); + expect(model.modelId).toBe("custom"); + }); +}); + +describe("LocalAgentLanguageModel.doGenerate", () => { + it("returns reasoning, text, and tool-call content from a single turn", async () => { + const { model, session } = await buildModelWithMock({ + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "let me think..." }, + }, + }); + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello " }, + }, + }); + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "world" }, + }, + }); + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "t1", + title: "list", + kind: "execute", + status: "pending", + rawInput: { dir: "/tmp" }, + }, + }); + return { stopReason: "end_turn" }; + }, + }); + + try { + const result = await model.doGenerate(stringPrompt("Hi")); + expect(result.content).toEqual([ + { type: "reasoning", text: "let me think..." }, + { type: "text", text: "Hello world" }, + { + type: "tool-call", + toolCallId: "t1", + toolName: "list", + input: JSON.stringify({ dir: "/tmp" }), + providerExecuted: true, + }, + ]); + expect(result.finishReason).toEqual({ unified: "stop", raw: "end_turn" }); + expect(result.warnings).toHaveLength(0); + } finally { + await session.close(); + } + }); + + it("surfaces a warning when AI SDK call options are unsupported by ACP", async () => { + const { model, session } = await buildModelWithMock({ + newSession: () => ({ sessionId: "s1" }), + prompt: () => ({ stopReason: "end_turn" }), + }); + try { + const result = await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + temperature: 0.7, + maxOutputTokens: 100, + tools: [ + { + type: "function", + name: "noop", + description: "noop", + inputSchema: { type: "object" }, + }, + ], + }); + const features = result.warnings.map((warning) => + warning.type === "unsupported" ? warning.feature : undefined, + ); + expect(features).toContain("temperature"); + expect(features).toContain("maxOutputTokens"); + expect(features).toContain("tools"); + } finally { + await session.close(); + } + }); +}); + +describe("LocalAgentLanguageModel.doStream", () => { + it("emits text-start/-delta/-end and a finish stream part", async () => { + const { model, session } = await buildModelWithMock({ + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello " }, + }, + }); + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "world" }, + }, + }); + return { stopReason: "end_turn" }; + }, + }); + + try { + const { stream } = await model.doStream(stringPrompt("Hi")); + const parts = await collectStream(stream); + const types = parts.map((part) => part.type); + expect(types[0]).toBe("stream-start"); + expect(types).toContain("text-start"); + expect(types).toContain("text-delta"); + expect(types).toContain("text-end"); + expect(types[types.length - 1]).toBe("finish"); + const deltas = parts.filter((part) => part.type === "text-delta"); + expect(deltas.map((part) => (part as { delta: string }).delta).join("")).toBe( + "Hello world", + ); + const finish = parts.find((part) => part.type === "finish"); + expect(finish).toBeDefined(); + if (finish && finish.type === "finish") { + expect(finish.finishReason.unified).toBe("stop"); + } + } finally { + await session.close(); + } + }); +}); diff --git a/packages/use-local-agent/vite.config.ts b/packages/use-local-agent/vite.config.ts index 887bfc3..69c7b68 100644 --- a/packages/use-local-agent/vite.config.ts +++ b/packages/use-local-agent/vite.config.ts @@ -2,7 +2,12 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ pack: { - entry: ["src/index.ts", "src/adapters/index.ts", "src/testing/mock-agent.ts"], + entry: [ + "src/index.ts", + "src/adapters/index.ts", + "src/ai-sdk/index.ts", + "src/testing/mock-agent.ts", + ], format: ["esm"], dts: true, sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 364c9f8..0d573d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,12 @@ importers: '@agentclientprotocol/sdk': specifier: ^0.20.0 version: 0.20.0(zod@4.3.6) + '@ai-sdk/provider': + specifier: ^3.0.8 + version: 3.0.8 + '@ai-sdk/provider-utils': + specifier: ^4.0.23 + version: 4.0.23(zod@4.3.6) '@zed-industries/codex-acp': specifier: ^0.10.0 version: 0.10.0 @@ -97,6 +103,16 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/provider-utils@4.0.23': + resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@anthropic-ai/claude-agent-sdk@0.2.84': resolution: {integrity: sha512-rvp3kZJM4IgDBE1zwj30H3N0bI3pYRF28tDJoyAVuWTLiWls7diNVCyFz7GeXZEAYYD87lCBE3vnQplLLluNHg==} engines: {node: '>=18.0.0'} @@ -961,6 +977,10 @@ packages: engines: {node: '>=4'} hasBin: true + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -1060,6 +1080,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -1471,6 +1494,17 @@ snapshots: dependencies: zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.23(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.8 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@anthropic-ai/claude-agent-sdk@0.2.84(zod@4.3.6)': dependencies: zod: 4.3.6 @@ -2156,6 +2190,8 @@ snapshots: esprima@4.0.1: {} + eventsource-parser@3.0.8: {} + extendable-error@0.1.7: {} fast-glob@3.3.3: @@ -2249,6 +2285,8 @@ snapshots: dependencies: argparse: 2.0.1 + json-schema@0.4.0: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 From b97e46d9cc3401f3775acb7acb75e309bc50fd6b Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:43:10 -0700 Subject: [PATCH 15/24] docs: polish README - add MCP, multi-modal, configuration table; tighten reliability section --- README.md | 115 +++++++++++++++++++---------- packages/use-local-agent/README.md | 115 +++++++++++++++++++---------- 2 files changed, 156 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 4109e80..ed458ed 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ An API for accessing any locally-installed coding agent -How? Spawn Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and stream prompts, tool calls, and permissions over the [Agent Client Protocol](https://agentclientprotocol.com). +How? `use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider that spawns Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams prompts, tool calls, and permissions over the [Agent Client Protocol](https://agentclientprotocol.com). You bring the prompt โ€” your user's existing CLI does the work. No API keys, no hosted billing, no rate limits beyond what their agent already enforces. @@ -15,7 +15,7 @@ You bring the prompt โ€” your user's existing CLI does the work. No API keys, no npm install use-local-agent ai ``` -Optional peer deps for agents that ship as ACP shims rather than native ACP CLIs: +The user also needs the actual agent CLI installed locally โ€” e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex. Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: ```bash npm install @agentclientprotocol/claude-agent-acp # Claude Code @@ -24,7 +24,7 @@ npm install @zed-industries/codex-acp # Codex ## Usage -`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider. Drop it into `streamText` / `generateText` like any other model: +Drop the model into `streamText` / `generateText` like any other AI SDK provider: ```ts import { streamText } from "ai"; @@ -52,32 +52,84 @@ const { text, finishReason } = await generateText({ }); ``` -The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, raw chunks, multi-modal user input (image/audio file parts), reasoning content, and tool-call streaming. Settings can be passed per-call: +The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. + +## Configuration + +Pass settings as the second argument, or build a pre-configured provider: + +```ts +import { createLocalAgentProvider } from "use-local-agent"; + +const provider = createLocalAgentProvider({ + cwd: process.cwd(), + permission: "auto-allow", + inactivityTimeoutMs: 5 * 60 * 1000, +}); + +streamText({ model: provider("claude"), prompt: "..." }); +streamText({ model: provider("codex"), prompt: "..." }); +``` + +| Setting | Effect | +| --- | --- | +| `cwd` | Working directory the agent will operate in. | +| `permission` | `"auto-allow"` (default) / `"auto-allow-once"` / `"auto-reject"` / `"stream"`. Stream mode surfaces permission prompts via `LocalAgent` (see Advanced). | +| `mcpServers` | Array of MCP server configs the agent should connect to for extra tools. | +| `additionalDirectories` | Extra absolute paths the agent is allowed to read/write. | +| `systemPrompt` | Prepended to user prompts (or merged with system messages from the AI SDK prompt array). | +| `inactivityTimeoutMs` | Kill the turn if the agent goes silent (default 3 min). | +| `onTrace` / `onStderr` | Observe the JSON-RPC wire and stderr without parsing logs. | +| `envFilter` | Scrub the env before passing it to the child process. | +| `traceContext` | Forward W3C `traceparent` / `tracestate` / `baggage` into request `_meta`. | +| `onAuthRequired` | Hook called on `auth_required`; return a method id to retry after `authenticate`. | + +## MCP servers + +Give the agent extra tools by wiring up MCP servers โ€” the agent calls them internally and the results flow back through `streamText` as `tool-call` parts: ```ts streamText({ model: localAgent("claude", { - cwd: "/Users/me/project", - permission: "auto-allow", - inactivityTimeoutMs: 5 * 60 * 1000, - onTrace: (direction, payload) => console.log(direction, payload), + mcpServers: [ + { + type: "stdio", + name: "filesystem", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + }, + ], }), - prompt: "...", + prompt: "List files in /tmp and tell me which are stale", }); ``` -Or build a pre-configured provider: +## Multi-modal input -```ts -import { createLocalAgentProvider } from "use-local-agent"; +Image and audio parts in the AI SDK prompt are forwarded to ACP as `image` / `audio` content blocks (when the agent advertises the matching `promptCapabilities`): -const codingAgent = createLocalAgentProvider({ - cwd: process.cwd(), - permission: "auto-allow", +```ts +streamText({ + model: localAgent("claude"), + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What's in this screenshot?" }, + { type: "file", data: pngBytes, mediaType: "image/png" }, + ], + }, + ], }); +``` -streamText({ model: codingAgent("claude"), prompt: "..." }); -streamText({ model: codingAgent("codex"), prompt: "..." }); +## Detect installed agents + +```ts +import { detectAvailableAgents, toAgentDisplayName } from "use-local-agent"; + +console.log(detectAvailableAgents().map(toAgentDisplayName)); +// ["Claude Code", "Codex", "Cursor Agent", ...] ``` ## Supported agents @@ -93,15 +145,6 @@ streamText({ model: codingAgent("codex"), prompt: "..." }); | `droid` | Factory Droid | native ACP | | `pi` | Pi | native ACP | -Detect what's installed on the user's machine: - -```ts -import { detectAvailableAgents, toAgentDisplayName } from "use-local-agent"; - -console.log(detectAvailableAgents().map(toAgentDisplayName)); -// ["Claude Code", "Codex", "Cursor Agent", ...] -``` - ## Custom adapter Wire up any ACP-speaking subprocess that isn't built in: @@ -125,7 +168,7 @@ streamText({ model: localAgent.fromAdapter(myAgent), prompt: "..." }); ## Advanced: stateful sessions, permissions, slash commands -When you need ACP semantics that don't map onto a single `streamText` call โ€” multi-turn sessions on a single subprocess, human-in-the-loop permission gating, slash commands, session resume โ€” drop down to the `LocalAgent` API: +When you need ACP semantics that don't fit a single `streamText` call โ€” multi-turn sessions on one subprocess, human-in-the-loop permission gating, slash commands, session resume, terminal handlers โ€” drop down to the `LocalAgent` API: ```ts import { LocalAgent } from "use-local-agent"; @@ -162,19 +205,17 @@ for await (const info of agent.streamAllSessions({ cwd: process.cwd() })) { ## Reliability -- **Spawn race detection** โ€” fails fast as `AgentSpawnError` when the binary can't launch. -- **Initialize fast-fail** โ€” child exits before responding raise `AgentConnectionClosedError` (with stderr tail) within tens of ms. -- **Real SIGKILL escalation** โ€” uses `exitCode`/`signalCode` instead of the misleading `child.killed` flag, so a stubborn child is actually killed within `disposeGraceMs`. -- **Stderr fatal gating** โ€” auth/usage stderr patterns only escalate after `initialize` succeeds; fatal stderr captured _during_ init surfaces as the right typed error. -- **Bounded buffers** โ€” pre-prompt notifications, stderr line buffer, and `runCommand` stdout/stderr are all capped (configurable via `pendingUpdateBufferLimit`). -- **EPIPE-safe writes** โ€” writes against a dead subprocess fail with a typed `AgentStdinClosedError` instead of crashing the host. -- **Stdout-noise tolerance** โ€” non-JSON banner lines are routed to `onStderr` instead of crashing init. -- **AbortSignal short-circuit** โ€” `prompt()` with an already-aborted signal synthesizes a `cancelled` finish without sending anything. -- **`onTrace` / `envFilter` / `traceContext` / `onAuthRequired`** hooks for observability and auth retry. +`use-local-agent` is built for unattended production use โ€” long-lived servers spawning hundreds of agent turns. The wrapper hardens every subprocess interaction: + +- Spawn race detection, initialize fast-fail (no waiting full timeout when a child crashes early), real SIGKILL escalation on dispose. +- Bounded buffers everywhere (pre-prompt notifications, stderr line buffer, `runCommand` stdout/stderr) so a misbehaving agent can't OOM the host. +- EPIPE-safe stdin writes (`AgentStdinClosedError`) and stdout-noise tolerance (banner lines routed to `onStderr` instead of crashing init). +- Inactivity watchdog that pauses while a permission prompt is open. +- Typed errors with `_tag` discriminators: `AgentSpawnError`, `AgentInitTimeoutError`, `AgentConnectionClosedError`, `AgentUnauthenticatedError`, `AgentUsageLimitError`, `AgentCancelledError`, `AgentInactivityError`, `AgentStdinClosedError`, `InvalidPromptContentError`, `CapabilityNotSupportedError`, `ProtocolVersionMismatchError`. ## API -See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full public surface โ€” `LocalAgent`, `AgentEvent`, `AgentAdapter`, all error types, and every option on `LocalAgentConnectOptions`. +See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full public surface. ## Resources & Contributing Back diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md index 4109e80..ed458ed 100644 --- a/packages/use-local-agent/README.md +++ b/packages/use-local-agent/README.md @@ -5,7 +5,7 @@ An API for accessing any locally-installed coding agent -How? Spawn Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and stream prompts, tool calls, and permissions over the [Agent Client Protocol](https://agentclientprotocol.com). +How? `use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider that spawns Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams prompts, tool calls, and permissions over the [Agent Client Protocol](https://agentclientprotocol.com). You bring the prompt โ€” your user's existing CLI does the work. No API keys, no hosted billing, no rate limits beyond what their agent already enforces. @@ -15,7 +15,7 @@ You bring the prompt โ€” your user's existing CLI does the work. No API keys, no npm install use-local-agent ai ``` -Optional peer deps for agents that ship as ACP shims rather than native ACP CLIs: +The user also needs the actual agent CLI installed locally โ€” e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex. Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: ```bash npm install @agentclientprotocol/claude-agent-acp # Claude Code @@ -24,7 +24,7 @@ npm install @zed-industries/codex-acp # Codex ## Usage -`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider. Drop it into `streamText` / `generateText` like any other model: +Drop the model into `streamText` / `generateText` like any other AI SDK provider: ```ts import { streamText } from "ai"; @@ -52,32 +52,84 @@ const { text, finishReason } = await generateText({ }); ``` -The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, raw chunks, multi-modal user input (image/audio file parts), reasoning content, and tool-call streaming. Settings can be passed per-call: +The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. + +## Configuration + +Pass settings as the second argument, or build a pre-configured provider: + +```ts +import { createLocalAgentProvider } from "use-local-agent"; + +const provider = createLocalAgentProvider({ + cwd: process.cwd(), + permission: "auto-allow", + inactivityTimeoutMs: 5 * 60 * 1000, +}); + +streamText({ model: provider("claude"), prompt: "..." }); +streamText({ model: provider("codex"), prompt: "..." }); +``` + +| Setting | Effect | +| --- | --- | +| `cwd` | Working directory the agent will operate in. | +| `permission` | `"auto-allow"` (default) / `"auto-allow-once"` / `"auto-reject"` / `"stream"`. Stream mode surfaces permission prompts via `LocalAgent` (see Advanced). | +| `mcpServers` | Array of MCP server configs the agent should connect to for extra tools. | +| `additionalDirectories` | Extra absolute paths the agent is allowed to read/write. | +| `systemPrompt` | Prepended to user prompts (or merged with system messages from the AI SDK prompt array). | +| `inactivityTimeoutMs` | Kill the turn if the agent goes silent (default 3 min). | +| `onTrace` / `onStderr` | Observe the JSON-RPC wire and stderr without parsing logs. | +| `envFilter` | Scrub the env before passing it to the child process. | +| `traceContext` | Forward W3C `traceparent` / `tracestate` / `baggage` into request `_meta`. | +| `onAuthRequired` | Hook called on `auth_required`; return a method id to retry after `authenticate`. | + +## MCP servers + +Give the agent extra tools by wiring up MCP servers โ€” the agent calls them internally and the results flow back through `streamText` as `tool-call` parts: ```ts streamText({ model: localAgent("claude", { - cwd: "/Users/me/project", - permission: "auto-allow", - inactivityTimeoutMs: 5 * 60 * 1000, - onTrace: (direction, payload) => console.log(direction, payload), + mcpServers: [ + { + type: "stdio", + name: "filesystem", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + }, + ], }), - prompt: "...", + prompt: "List files in /tmp and tell me which are stale", }); ``` -Or build a pre-configured provider: +## Multi-modal input -```ts -import { createLocalAgentProvider } from "use-local-agent"; +Image and audio parts in the AI SDK prompt are forwarded to ACP as `image` / `audio` content blocks (when the agent advertises the matching `promptCapabilities`): -const codingAgent = createLocalAgentProvider({ - cwd: process.cwd(), - permission: "auto-allow", +```ts +streamText({ + model: localAgent("claude"), + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What's in this screenshot?" }, + { type: "file", data: pngBytes, mediaType: "image/png" }, + ], + }, + ], }); +``` -streamText({ model: codingAgent("claude"), prompt: "..." }); -streamText({ model: codingAgent("codex"), prompt: "..." }); +## Detect installed agents + +```ts +import { detectAvailableAgents, toAgentDisplayName } from "use-local-agent"; + +console.log(detectAvailableAgents().map(toAgentDisplayName)); +// ["Claude Code", "Codex", "Cursor Agent", ...] ``` ## Supported agents @@ -93,15 +145,6 @@ streamText({ model: codingAgent("codex"), prompt: "..." }); | `droid` | Factory Droid | native ACP | | `pi` | Pi | native ACP | -Detect what's installed on the user's machine: - -```ts -import { detectAvailableAgents, toAgentDisplayName } from "use-local-agent"; - -console.log(detectAvailableAgents().map(toAgentDisplayName)); -// ["Claude Code", "Codex", "Cursor Agent", ...] -``` - ## Custom adapter Wire up any ACP-speaking subprocess that isn't built in: @@ -125,7 +168,7 @@ streamText({ model: localAgent.fromAdapter(myAgent), prompt: "..." }); ## Advanced: stateful sessions, permissions, slash commands -When you need ACP semantics that don't map onto a single `streamText` call โ€” multi-turn sessions on a single subprocess, human-in-the-loop permission gating, slash commands, session resume โ€” drop down to the `LocalAgent` API: +When you need ACP semantics that don't fit a single `streamText` call โ€” multi-turn sessions on one subprocess, human-in-the-loop permission gating, slash commands, session resume, terminal handlers โ€” drop down to the `LocalAgent` API: ```ts import { LocalAgent } from "use-local-agent"; @@ -162,19 +205,17 @@ for await (const info of agent.streamAllSessions({ cwd: process.cwd() })) { ## Reliability -- **Spawn race detection** โ€” fails fast as `AgentSpawnError` when the binary can't launch. -- **Initialize fast-fail** โ€” child exits before responding raise `AgentConnectionClosedError` (with stderr tail) within tens of ms. -- **Real SIGKILL escalation** โ€” uses `exitCode`/`signalCode` instead of the misleading `child.killed` flag, so a stubborn child is actually killed within `disposeGraceMs`. -- **Stderr fatal gating** โ€” auth/usage stderr patterns only escalate after `initialize` succeeds; fatal stderr captured _during_ init surfaces as the right typed error. -- **Bounded buffers** โ€” pre-prompt notifications, stderr line buffer, and `runCommand` stdout/stderr are all capped (configurable via `pendingUpdateBufferLimit`). -- **EPIPE-safe writes** โ€” writes against a dead subprocess fail with a typed `AgentStdinClosedError` instead of crashing the host. -- **Stdout-noise tolerance** โ€” non-JSON banner lines are routed to `onStderr` instead of crashing init. -- **AbortSignal short-circuit** โ€” `prompt()` with an already-aborted signal synthesizes a `cancelled` finish without sending anything. -- **`onTrace` / `envFilter` / `traceContext` / `onAuthRequired`** hooks for observability and auth retry. +`use-local-agent` is built for unattended production use โ€” long-lived servers spawning hundreds of agent turns. The wrapper hardens every subprocess interaction: + +- Spawn race detection, initialize fast-fail (no waiting full timeout when a child crashes early), real SIGKILL escalation on dispose. +- Bounded buffers everywhere (pre-prompt notifications, stderr line buffer, `runCommand` stdout/stderr) so a misbehaving agent can't OOM the host. +- EPIPE-safe stdin writes (`AgentStdinClosedError`) and stdout-noise tolerance (banner lines routed to `onStderr` instead of crashing init). +- Inactivity watchdog that pauses while a permission prompt is open. +- Typed errors with `_tag` discriminators: `AgentSpawnError`, `AgentInitTimeoutError`, `AgentConnectionClosedError`, `AgentUnauthenticatedError`, `AgentUsageLimitError`, `AgentCancelledError`, `AgentInactivityError`, `AgentStdinClosedError`, `InvalidPromptContentError`, `CapabilityNotSupportedError`, `ProtocolVersionMismatchError`. ## API -See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full public surface โ€” `LocalAgent`, `AgentEvent`, `AgentAdapter`, all error types, and every option on `LocalAgentConnectOptions`. +See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full public surface. ## Resources & Contributing Back From f59d021a66d6c9d6ca89407caa0fe98886dd2a30 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:46:07 -0700 Subject: [PATCH 16/24] feat(use-local-agent): bundle ai; re-export streamText/generateText/streamObject/generateObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `ai@^6.0.168` as a regular dependency and re-exports its top-level helpers from `use-local-agent`'s main entry, so callers get the end-to-end story from a single install: import { streamText, localAgent } from "use-local-agent"; streamText({ model: localAgent("claude", { cwd: "/path/to/project" }), prompt: "...", }); Bringing your own `ai` still works โ€” importing from `"ai"` directly is unchanged and resolves to the same versions transitively. README updated to: - show the new single-import pattern as the headline usage, - demonstrate inline per-call settings on the model factory (`localAgent("codex", { cwd, permission, inactivityTimeoutMs, onTrace })`), - call out that users can still import from `"ai"` if they prefer. --- README.md | 38 +++++++++++++------- packages/use-local-agent/README.md | 38 +++++++++++++------- packages/use-local-agent/package.json | 3 +- packages/use-local-agent/src/index.ts | 19 ++++++++++ pnpm-lock.yaml | 51 ++++++++++++++++++++++++--- 5 files changed, 120 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index ed458ed..4dc779d 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ You bring the prompt โ€” your user's existing CLI does the work. No API keys, no ## Install ```bash -npm install use-local-agent ai +npm install use-local-agent ``` +`use-local-agent` re-exports `streamText`, `generateText`, `streamObject`, and `generateObject` from the [Vercel AI SDK](https://ai-sdk.dev), so you don't need to install `ai` separately. (If you already depend on `ai` directly, that works too โ€” they're the same functions.) + The user also needs the actual agent CLI installed locally โ€” e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex. Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: ```bash @@ -24,11 +26,8 @@ npm install @zed-industries/codex-acp # Codex ## Usage -Drop the model into `streamText` / `generateText` like any other AI SDK provider: - ```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; +import { streamText, localAgent } from "use-local-agent"; const { textStream } = streamText({ model: localAgent("claude"), @@ -40,26 +39,37 @@ for await (const chunk of textStream) { } ``` -Or non-streaming: +Or non-streaming, with options passed inline at the call site: ```ts -import { generateText } from "ai"; -import { localAgent } from "use-local-agent"; +import { generateText, localAgent } from "use-local-agent"; const { text, finishReason } = await generateText({ - model: localAgent("codex"), + model: localAgent("codex", { + cwd: "/Users/me/project", + permission: "auto-allow", + inactivityTimeoutMs: 5 * 60 * 1000, + onTrace: (direction, payload) => console.log(direction, payload), + }), prompt: "Summarize README.md in three bullets", }); ``` The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. +If you'd rather not get `ai` from us โ€” e.g. you already have a pinned version โ€” import it yourself; nothing about the model changes: + +```ts +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; +``` + ## Configuration -Pass settings as the second argument, or build a pre-configured provider: +Pass settings as the second argument, or build a pre-configured provider for repeated use: ```ts -import { createLocalAgentProvider } from "use-local-agent"; +import { streamText, createLocalAgentProvider } from "use-local-agent"; const provider = createLocalAgentProvider({ cwd: process.cwd(), @@ -89,6 +99,8 @@ streamText({ model: provider("codex"), prompt: "..." }); Give the agent extra tools by wiring up MCP servers โ€” the agent calls them internally and the results flow back through `streamText` as `tool-call` parts: ```ts +import { streamText, localAgent } from "use-local-agent"; + streamText({ model: localAgent("claude", { mcpServers: [ @@ -109,6 +121,8 @@ streamText({ Image and audio parts in the AI SDK prompt are forwarded to ACP as `image` / `audio` content blocks (when the agent advertises the matching `promptCapabilities`): ```ts +import { streamText, localAgent } from "use-local-agent"; + streamText({ model: localAgent("claude"), messages: [ @@ -150,7 +164,7 @@ console.log(detectAvailableAgents().map(toAgentDisplayName)); Wire up any ACP-speaking subprocess that isn't built in: ```ts -import { localAgent } from "use-local-agent"; +import { streamText, localAgent } from "use-local-agent"; import type { AgentAdapter } from "use-local-agent"; const myAgent: AgentAdapter = { diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md index ed458ed..4dc779d 100644 --- a/packages/use-local-agent/README.md +++ b/packages/use-local-agent/README.md @@ -12,9 +12,11 @@ You bring the prompt โ€” your user's existing CLI does the work. No API keys, no ## Install ```bash -npm install use-local-agent ai +npm install use-local-agent ``` +`use-local-agent` re-exports `streamText`, `generateText`, `streamObject`, and `generateObject` from the [Vercel AI SDK](https://ai-sdk.dev), so you don't need to install `ai` separately. (If you already depend on `ai` directly, that works too โ€” they're the same functions.) + The user also needs the actual agent CLI installed locally โ€” e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex. Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: ```bash @@ -24,11 +26,8 @@ npm install @zed-industries/codex-acp # Codex ## Usage -Drop the model into `streamText` / `generateText` like any other AI SDK provider: - ```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; +import { streamText, localAgent } from "use-local-agent"; const { textStream } = streamText({ model: localAgent("claude"), @@ -40,26 +39,37 @@ for await (const chunk of textStream) { } ``` -Or non-streaming: +Or non-streaming, with options passed inline at the call site: ```ts -import { generateText } from "ai"; -import { localAgent } from "use-local-agent"; +import { generateText, localAgent } from "use-local-agent"; const { text, finishReason } = await generateText({ - model: localAgent("codex"), + model: localAgent("codex", { + cwd: "/Users/me/project", + permission: "auto-allow", + inactivityTimeoutMs: 5 * 60 * 1000, + onTrace: (direction, payload) => console.log(direction, payload), + }), prompt: "Summarize README.md in three bullets", }); ``` The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. +If you'd rather not get `ai` from us โ€” e.g. you already have a pinned version โ€” import it yourself; nothing about the model changes: + +```ts +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; +``` + ## Configuration -Pass settings as the second argument, or build a pre-configured provider: +Pass settings as the second argument, or build a pre-configured provider for repeated use: ```ts -import { createLocalAgentProvider } from "use-local-agent"; +import { streamText, createLocalAgentProvider } from "use-local-agent"; const provider = createLocalAgentProvider({ cwd: process.cwd(), @@ -89,6 +99,8 @@ streamText({ model: provider("codex"), prompt: "..." }); Give the agent extra tools by wiring up MCP servers โ€” the agent calls them internally and the results flow back through `streamText` as `tool-call` parts: ```ts +import { streamText, localAgent } from "use-local-agent"; + streamText({ model: localAgent("claude", { mcpServers: [ @@ -109,6 +121,8 @@ streamText({ Image and audio parts in the AI SDK prompt are forwarded to ACP as `image` / `audio` content blocks (when the agent advertises the matching `promptCapabilities`): ```ts +import { streamText, localAgent } from "use-local-agent"; + streamText({ model: localAgent("claude"), messages: [ @@ -150,7 +164,7 @@ console.log(detectAvailableAgents().map(toAgentDisplayName)); Wire up any ACP-speaking subprocess that isn't built in: ```ts -import { localAgent } from "use-local-agent"; +import { streamText, localAgent } from "use-local-agent"; import type { AgentAdapter } from "use-local-agent"; const myAgent: AgentAdapter = { diff --git a/packages/use-local-agent/package.json b/packages/use-local-agent/package.json index b0ef7da..9e180ae 100644 --- a/packages/use-local-agent/package.json +++ b/packages/use-local-agent/package.json @@ -39,7 +39,8 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.20.0", "@ai-sdk/provider": "^3.0.8", - "@ai-sdk/provider-utils": "^4.0.23" + "@ai-sdk/provider-utils": "^4.0.23", + "ai": "^6.0.168" }, "devDependencies": { "@types/node": "^22.19.17", diff --git a/packages/use-local-agent/src/index.ts b/packages/use-local-agent/src/index.ts index 052770f..b15dc2c 100644 --- a/packages/use-local-agent/src/index.ts +++ b/packages/use-local-agent/src/index.ts @@ -17,6 +17,25 @@ export { type LocalAgentProviderSettings, } from "./ai-sdk"; +/** + * Re-exports from the [Vercel AI SDK](https://ai-sdk.dev) so callers don't + * have to install `ai` separately. + * + * If you already depend on `ai` directly, importing from `"ai"` works + * identically โ€” these are just pass-throughs. + * + * @example + * ```ts + * import { streamText, localAgent } from "use-local-agent"; + * + * const { textStream } = streamText({ + * model: localAgent("claude"), + * prompt: "Refactor src/auth.ts", + * }); + * ``` + */ +export { streamText, generateText, generateObject, streamObject } from "ai"; + export type { AgentAdapter, AdapterFactoryOptions, ResolvedAdapter } from "./adapter"; export { autoAllow, autoAllowOnce, autoReject, type PermissionPolicy } from "./permission"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d573d0..aeb9c58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ importers: version: 5.9.3 vite-plus: specifier: ^0.1.12 - version: 0.1.19(@types/node@25.6.0)(typescript@5.9.3)(vite@8.0.10(@types/node@25.6.0)) + version: 0.1.19(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(typescript@5.9.3)(vite@8.0.10(@types/node@25.6.0)) apps/playground: dependencies: @@ -79,6 +79,9 @@ importers: '@zed-industries/codex-acp': specifier: ^0.10.0 version: 0.10.0 + ai: + specifier: ^6.0.168 + version: 6.0.168(zod@4.3.6) devDependencies: '@types/node': specifier: ^22.19.17 @@ -103,6 +106,12 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/gateway@3.0.104': + resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.23': resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==} engines: {node: '>=18'} @@ -309,6 +318,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@oxc-project/runtime@0.126.0': resolution: {integrity: sha512-oksjxfqDNmIYMGlIgLzYgnz5YjZax27RtQezsPpKEGo9AC5LOaIGHsivCCeaAWdCtPnRyjZXM/7svreCC8kZVQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -731,6 +744,10 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@voidzero-dev/vite-plus-core@0.1.19': resolution: {integrity: sha512-BTmz50juSDolIN4Vtu5iVaPONV1XSrMB5V+9IoBhhxdogfvp7PBhaHuAcPjTN2RTVowhLZXoo8mn+aHjq//bkw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -916,6 +933,12 @@ packages: resolution: {integrity: sha512-vzwAUSHR7TaJh62JoE+6UD/HVm8fJbmMGsMBBMcHrKBIL7MF8yevlPDWVdoaDaGOsgqVZYRv9KhdT8ari0I4mg==} hasBin: true + ai@6.0.168: + resolution: {integrity: sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1494,6 +1517,13 @@ snapshots: dependencies: zod: 4.3.6 + '@ai-sdk/gateway@3.0.104(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + '@vercel/oidc': 3.2.0 + zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.23(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -1784,6 +1814,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api@1.9.0': {} + '@oxc-project/runtime@0.126.0': {} '@oxc-project/types@0.126.0': {} @@ -2025,6 +2057,8 @@ snapshots: dependencies: '@types/node': 22.19.17 + '@vercel/oidc@3.2.0': {} + '@voidzero-dev/vite-plus-core@0.1.19(@types/node@22.19.17)(typescript@5.9.3)': dependencies: '@oxc-project/runtime': 0.126.0 @@ -2065,7 +2099,7 @@ snapshots: '@voidzero-dev/vite-plus-linux-x64-musl@0.1.19': optional: true - '@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(typescript@5.9.3)(vite@8.0.10(@types/node@25.6.0))': + '@voidzero-dev/vite-plus-test@0.1.19(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(typescript@5.9.3)(vite@8.0.10(@types/node@25.6.0))': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 @@ -2082,6 +2116,7 @@ snapshots: vite: 8.0.10(@types/node@25.6.0) ws: 8.20.0 optionalDependencies: + '@opentelemetry/api': 1.9.0 '@types/node': 25.6.0 transitivePeerDependencies: - '@arethetypeswrong/core' @@ -2143,6 +2178,14 @@ snapshots: '@zed-industries/codex-acp-win32-arm64': 0.10.0 '@zed-industries/codex-acp-win32-x64': 0.10.0 + ai@6.0.168(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 3.0.104(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + '@opentelemetry/api': 1.9.0 + zod: 4.3.6 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -2591,11 +2634,11 @@ snapshots: universalify@0.1.2: {} - vite-plus@0.1.19(@types/node@25.6.0)(typescript@5.9.3)(vite@8.0.10(@types/node@25.6.0)): + vite-plus@0.1.19(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(typescript@5.9.3)(vite@8.0.10(@types/node@25.6.0)): dependencies: '@oxc-project/types': 0.126.0 '@voidzero-dev/vite-plus-core': 0.1.19(@types/node@25.6.0)(typescript@5.9.3) - '@voidzero-dev/vite-plus-test': 0.1.19(@types/node@25.6.0)(typescript@5.9.3)(vite@8.0.10(@types/node@25.6.0)) + '@voidzero-dev/vite-plus-test': 0.1.19(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(typescript@5.9.3)(vite@8.0.10(@types/node@25.6.0)) oxfmt: 0.45.0 oxlint: 1.60.0(oxlint-tsgolint@0.21.1) oxlint-tsgolint: 0.21.1 From 2244e1a5de0d61c76ae32ee152e29660500b4dac Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:55:17 -0700 Subject: [PATCH 17/24] revert(use-local-agent): require `ai` as a peer dep instead of bundling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `ai` from `dependencies` to `peerDependencies` (required, not optional) and drops the top-level re-exports of `streamText`, `generateText`, `streamObject`, `generateObject`. Callers install `ai` themselves and import it directly: npm install use-local-agent ai import { streamText } from "ai"; import { localAgent } from "use-local-agent"; Same behavior as `@ai-sdk/openai`, `@ai-sdk/anthropic`, etc. โ€” keeps the provider package small and avoids dragging in `ai` for callers that might not use it transitively. README updated to use separate imports throughout. --- README.md | 31 ++++++++++++--------------- packages/use-local-agent/README.md | 31 ++++++++++++--------------- packages/use-local-agent/package.json | 7 +++--- packages/use-local-agent/src/index.ts | 19 ---------------- pnpm-lock.yaml | 6 +++--- 5 files changed, 35 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 4dc779d..2438ed6 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,10 @@ You bring the prompt โ€” your user's existing CLI does the work. No API keys, no ## Install ```bash -npm install use-local-agent +npm install use-local-agent ai ``` -`use-local-agent` re-exports `streamText`, `generateText`, `streamObject`, and `generateObject` from the [Vercel AI SDK](https://ai-sdk.dev), so you don't need to install `ai` separately. (If you already depend on `ai` directly, that works too โ€” they're the same functions.) - -The user also needs the actual agent CLI installed locally โ€” e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex. Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: +`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider, so it works alongside the `ai` package (`^6.0.0`). The user also needs the actual agent CLI installed locally โ€” e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex. Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: ```bash npm install @agentclientprotocol/claude-agent-acp # Claude Code @@ -27,7 +25,8 @@ npm install @zed-industries/codex-acp # Codex ## Usage ```ts -import { streamText, localAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; const { textStream } = streamText({ model: localAgent("claude"), @@ -42,7 +41,8 @@ for await (const chunk of textStream) { Or non-streaming, with options passed inline at the call site: ```ts -import { generateText, localAgent } from "use-local-agent"; +import { generateText } from "ai"; +import { localAgent } from "use-local-agent"; const { text, finishReason } = await generateText({ model: localAgent("codex", { @@ -57,19 +57,13 @@ const { text, finishReason } = await generateText({ The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. -If you'd rather not get `ai` from us โ€” e.g. you already have a pinned version โ€” import it yourself; nothing about the model changes: - -```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; -``` - ## Configuration Pass settings as the second argument, or build a pre-configured provider for repeated use: ```ts -import { streamText, createLocalAgentProvider } from "use-local-agent"; +import { streamText } from "ai"; +import { createLocalAgentProvider } from "use-local-agent"; const provider = createLocalAgentProvider({ cwd: process.cwd(), @@ -99,7 +93,8 @@ streamText({ model: provider("codex"), prompt: "..." }); Give the agent extra tools by wiring up MCP servers โ€” the agent calls them internally and the results flow back through `streamText` as `tool-call` parts: ```ts -import { streamText, localAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; streamText({ model: localAgent("claude", { @@ -121,7 +116,8 @@ streamText({ Image and audio parts in the AI SDK prompt are forwarded to ACP as `image` / `audio` content blocks (when the agent advertises the matching `promptCapabilities`): ```ts -import { streamText, localAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; streamText({ model: localAgent("claude"), @@ -164,7 +160,8 @@ console.log(detectAvailableAgents().map(toAgentDisplayName)); Wire up any ACP-speaking subprocess that isn't built in: ```ts -import { streamText, localAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; import type { AgentAdapter } from "use-local-agent"; const myAgent: AgentAdapter = { diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md index 4dc779d..2438ed6 100644 --- a/packages/use-local-agent/README.md +++ b/packages/use-local-agent/README.md @@ -12,12 +12,10 @@ You bring the prompt โ€” your user's existing CLI does the work. No API keys, no ## Install ```bash -npm install use-local-agent +npm install use-local-agent ai ``` -`use-local-agent` re-exports `streamText`, `generateText`, `streamObject`, and `generateObject` from the [Vercel AI SDK](https://ai-sdk.dev), so you don't need to install `ai` separately. (If you already depend on `ai` directly, that works too โ€” they're the same functions.) - -The user also needs the actual agent CLI installed locally โ€” e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex. Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: +`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider, so it works alongside the `ai` package (`^6.0.0`). The user also needs the actual agent CLI installed locally โ€” e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex. Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: ```bash npm install @agentclientprotocol/claude-agent-acp # Claude Code @@ -27,7 +25,8 @@ npm install @zed-industries/codex-acp # Codex ## Usage ```ts -import { streamText, localAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; const { textStream } = streamText({ model: localAgent("claude"), @@ -42,7 +41,8 @@ for await (const chunk of textStream) { Or non-streaming, with options passed inline at the call site: ```ts -import { generateText, localAgent } from "use-local-agent"; +import { generateText } from "ai"; +import { localAgent } from "use-local-agent"; const { text, finishReason } = await generateText({ model: localAgent("codex", { @@ -57,19 +57,13 @@ const { text, finishReason } = await generateText({ The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. -If you'd rather not get `ai` from us โ€” e.g. you already have a pinned version โ€” import it yourself; nothing about the model changes: - -```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; -``` - ## Configuration Pass settings as the second argument, or build a pre-configured provider for repeated use: ```ts -import { streamText, createLocalAgentProvider } from "use-local-agent"; +import { streamText } from "ai"; +import { createLocalAgentProvider } from "use-local-agent"; const provider = createLocalAgentProvider({ cwd: process.cwd(), @@ -99,7 +93,8 @@ streamText({ model: provider("codex"), prompt: "..." }); Give the agent extra tools by wiring up MCP servers โ€” the agent calls them internally and the results flow back through `streamText` as `tool-call` parts: ```ts -import { streamText, localAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; streamText({ model: localAgent("claude", { @@ -121,7 +116,8 @@ streamText({ Image and audio parts in the AI SDK prompt are forwarded to ACP as `image` / `audio` content blocks (when the agent advertises the matching `promptCapabilities`): ```ts -import { streamText, localAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; streamText({ model: localAgent("claude"), @@ -164,7 +160,8 @@ console.log(detectAvailableAgents().map(toAgentDisplayName)); Wire up any ACP-speaking subprocess that isn't built in: ```ts -import { streamText, localAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { localAgent } from "use-local-agent"; import type { AgentAdapter } from "use-local-agent"; const myAgent: AgentAdapter = { diff --git a/packages/use-local-agent/package.json b/packages/use-local-agent/package.json index 9e180ae..3891bba 100644 --- a/packages/use-local-agent/package.json +++ b/packages/use-local-agent/package.json @@ -39,16 +39,17 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.20.0", "@ai-sdk/provider": "^3.0.8", - "@ai-sdk/provider-utils": "^4.0.23", - "ai": "^6.0.168" + "@ai-sdk/provider-utils": "^4.0.23" }, "devDependencies": { "@types/node": "^22.19.17", + "ai": "^6.0.168", "typescript": "^5.7.0" }, "peerDependencies": { "@agentclientprotocol/claude-agent-acp": "^0.24.0", - "@zed-industries/codex-acp": "^0.10.0" + "@zed-industries/codex-acp": "^0.10.0", + "ai": "^6.0.0" }, "peerDependenciesMeta": { "@agentclientprotocol/claude-agent-acp": { diff --git a/packages/use-local-agent/src/index.ts b/packages/use-local-agent/src/index.ts index b15dc2c..052770f 100644 --- a/packages/use-local-agent/src/index.ts +++ b/packages/use-local-agent/src/index.ts @@ -17,25 +17,6 @@ export { type LocalAgentProviderSettings, } from "./ai-sdk"; -/** - * Re-exports from the [Vercel AI SDK](https://ai-sdk.dev) so callers don't - * have to install `ai` separately. - * - * If you already depend on `ai` directly, importing from `"ai"` works - * identically โ€” these are just pass-throughs. - * - * @example - * ```ts - * import { streamText, localAgent } from "use-local-agent"; - * - * const { textStream } = streamText({ - * model: localAgent("claude"), - * prompt: "Refactor src/auth.ts", - * }); - * ``` - */ -export { streamText, generateText, generateObject, streamObject } from "ai"; - export type { AgentAdapter, AdapterFactoryOptions, ResolvedAdapter } from "./adapter"; export { autoAllow, autoAllowOnce, autoReject, type PermissionPolicy } from "./permission"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aeb9c58..79b25fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,13 +79,13 @@ importers: '@zed-industries/codex-acp': specifier: ^0.10.0 version: 0.10.0 - ai: - specifier: ^6.0.168 - version: 6.0.168(zod@4.3.6) devDependencies: '@types/node': specifier: ^22.19.17 version: 22.19.17 + ai: + specifier: ^6.0.168 + version: 6.0.168(zod@4.3.6) typescript: specifier: ^5.7.0 version: 5.9.3 From 6f8075b77459ffe578b1a4ee523407ee9f4e4f2b Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:57:34 -0700 Subject: [PATCH 18/24] docs: remove em-dashes from READMEs and test/playground docs --- README.md | 12 ++++++------ apps/playground/README.md | 6 +++--- packages/use-local-agent/README.md | 12 ++++++------ packages/use-local-agent/tests/sdk-upgrade.test.ts | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2438ed6..4440fb6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ An API for accessing any locally-installed coding agent How? `use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider that spawns Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams prompts, tool calls, and permissions over the [Agent Client Protocol](https://agentclientprotocol.com). -You bring the prompt โ€” your user's existing CLI does the work. No API keys, no hosted billing, no rate limits beyond what their agent already enforces. +You bring the prompt. Your user's existing CLI does the work. No API keys, no hosted billing, no rate limits beyond what their agent already enforces. ## Install @@ -15,7 +15,7 @@ You bring the prompt โ€” your user's existing CLI does the work. No API keys, no npm install use-local-agent ai ``` -`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider, so it works alongside the `ai` package (`^6.0.0`). The user also needs the actual agent CLI installed locally โ€” e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex. Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: +`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider, so it works alongside the `ai` package (`^6.0.0`). The user also needs the actual agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex). Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: ```bash npm install @agentclientprotocol/claude-agent-acp # Claude Code @@ -55,7 +55,7 @@ const { text, finishReason } = await generateText({ }); ``` -The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. +The provider implements `LanguageModelV3` so every AI SDK feature works: message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. ## Configuration @@ -90,7 +90,7 @@ streamText({ model: provider("codex"), prompt: "..." }); ## MCP servers -Give the agent extra tools by wiring up MCP servers โ€” the agent calls them internally and the results flow back through `streamText` as `tool-call` parts: +Give the agent extra tools by wiring up MCP servers. The agent calls them internally and the results flow back through `streamText` as `tool-call` parts: ```ts import { streamText } from "ai"; @@ -179,7 +179,7 @@ streamText({ model: localAgent.fromAdapter(myAgent), prompt: "..." }); ## Advanced: stateful sessions, permissions, slash commands -When you need ACP semantics that don't fit a single `streamText` call โ€” multi-turn sessions on one subprocess, human-in-the-loop permission gating, slash commands, session resume, terminal handlers โ€” drop down to the `LocalAgent` API: +When you need ACP semantics that don't fit a single `streamText` call (multi-turn sessions on one subprocess, human-in-the-loop permission gating, slash commands, session resume, terminal handlers), drop down to the `LocalAgent` API: ```ts import { LocalAgent } from "use-local-agent"; @@ -216,7 +216,7 @@ for await (const info of agent.streamAllSessions({ cwd: process.cwd() })) { ## Reliability -`use-local-agent` is built for unattended production use โ€” long-lived servers spawning hundreds of agent turns. The wrapper hardens every subprocess interaction: +`use-local-agent` is built for unattended production use: long-lived servers spawning hundreds of agent turns. The wrapper hardens every subprocess interaction: - Spawn race detection, initialize fast-fail (no waiting full timeout when a child crashes early), real SIGKILL escalation on dispose. - Bounded buffers everywhere (pre-prompt notifications, stderr line buffer, `runCommand` stdout/stderr) so a misbehaving agent can't OOM the host. diff --git a/apps/playground/README.md b/apps/playground/README.md index d8af454..fad9ee9 100644 --- a/apps/playground/README.md +++ b/apps/playground/README.md @@ -17,7 +17,7 @@ connects to a deterministic local agent fixture (`src/echo-agent.mjs`). `@agentclientprotocol/sdk`. It exists for two reasons: 1. To provide a real subprocess for the Playwright e2e tests in - `tests/spawn.node.spec.ts` to drive โ€” exercising the entire + `tests/spawn.node.spec.ts` to drive, exercising the entire stdio / NDJSON pipeline. 2. To let the browser playground talk to a real ACP agent without requiring a heavy LLM-backed CLI. @@ -94,10 +94,10 @@ Paths must be absolute; relative paths throw `AgentStreamError`. ## Tests -- `tests/spawn.node.spec.ts` โ€” real-subprocess e2e against `echo-agent.mjs` +- `tests/spawn.node.spec.ts`: real-subprocess e2e against `echo-agent.mjs` (covers initialize, streaming, tool calls, usage, auth_required, cancellation, mid-stream crashes). -- `tests/playground.browser.spec.ts` โ€” full browser flow over WebSocket. +- `tests/playground.browser.spec.ts`: full browser flow over WebSocket. Run: diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md index 2438ed6..4440fb6 100644 --- a/packages/use-local-agent/README.md +++ b/packages/use-local-agent/README.md @@ -7,7 +7,7 @@ An API for accessing any locally-installed coding agent How? `use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider that spawns Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams prompts, tool calls, and permissions over the [Agent Client Protocol](https://agentclientprotocol.com). -You bring the prompt โ€” your user's existing CLI does the work. No API keys, no hosted billing, no rate limits beyond what their agent already enforces. +You bring the prompt. Your user's existing CLI does the work. No API keys, no hosted billing, no rate limits beyond what their agent already enforces. ## Install @@ -15,7 +15,7 @@ You bring the prompt โ€” your user's existing CLI does the work. No API keys, no npm install use-local-agent ai ``` -`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider, so it works alongside the `ai` package (`^6.0.0`). The user also needs the actual agent CLI installed locally โ€” e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex. Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: +`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider, so it works alongside the `ai` package (`^6.0.0`). The user also needs the actual agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex). Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: ```bash npm install @agentclientprotocol/claude-agent-acp # Claude Code @@ -55,7 +55,7 @@ const { text, finishReason } = await generateText({ }); ``` -The provider implements `LanguageModelV3` so every AI SDK feature works โ€” message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. +The provider implements `LanguageModelV3` so every AI SDK feature works: message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. ## Configuration @@ -90,7 +90,7 @@ streamText({ model: provider("codex"), prompt: "..." }); ## MCP servers -Give the agent extra tools by wiring up MCP servers โ€” the agent calls them internally and the results flow back through `streamText` as `tool-call` parts: +Give the agent extra tools by wiring up MCP servers. The agent calls them internally and the results flow back through `streamText` as `tool-call` parts: ```ts import { streamText } from "ai"; @@ -179,7 +179,7 @@ streamText({ model: localAgent.fromAdapter(myAgent), prompt: "..." }); ## Advanced: stateful sessions, permissions, slash commands -When you need ACP semantics that don't fit a single `streamText` call โ€” multi-turn sessions on one subprocess, human-in-the-loop permission gating, slash commands, session resume, terminal handlers โ€” drop down to the `LocalAgent` API: +When you need ACP semantics that don't fit a single `streamText` call (multi-turn sessions on one subprocess, human-in-the-loop permission gating, slash commands, session resume, terminal handlers), drop down to the `LocalAgent` API: ```ts import { LocalAgent } from "use-local-agent"; @@ -216,7 +216,7 @@ for await (const info of agent.streamAllSessions({ cwd: process.cwd() })) { ## Reliability -`use-local-agent` is built for unattended production use โ€” long-lived servers spawning hundreds of agent turns. The wrapper hardens every subprocess interaction: +`use-local-agent` is built for unattended production use: long-lived servers spawning hundreds of agent turns. The wrapper hardens every subprocess interaction: - Spawn race detection, initialize fast-fail (no waiting full timeout when a child crashes early), real SIGKILL escalation on dispose. - Bounded buffers everywhere (pre-prompt notifications, stderr line buffer, `runCommand` stdout/stderr) so a misbehaving agent can't OOM the host. diff --git a/packages/use-local-agent/tests/sdk-upgrade.test.ts b/packages/use-local-agent/tests/sdk-upgrade.test.ts index 4452cb9..7cb8e8f 100644 --- a/packages/use-local-agent/tests/sdk-upgrade.test.ts +++ b/packages/use-local-agent/tests/sdk-upgrade.test.ts @@ -7,7 +7,7 @@ describe("SDK 0.20 reliability fixes proxy through use-local-agent", () => { const session = await connectMockAgent({ newSession: () => ({ sessionId: "s1" }), prompt: async (request, conn) => { - // Emit chunks then return โ€” simulates the SDK 0.19.2 race fix + // Emit chunks then return; simulates the SDK 0.19.2 race fix // where notifications enqueued during a response handler must // run AFTER the response handler completes. await conn.sessionUpdate({ From f5185850ce43a531337032421782eec035ceb3f6 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 21:58:40 -0700 Subject: [PATCH 19/24] docs: cut README from 242 to 108 lines; remove implementation-detail sections --- README.md | 179 ++++------------------------- packages/use-local-agent/README.md | 179 ++++------------------------- 2 files changed, 46 insertions(+), 312 deletions(-) diff --git a/README.md b/README.md index 4440fb6..cbe1457 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,7 @@ An API for accessing any locally-installed coding agent -How? `use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider that spawns Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams prompts, tool calls, and permissions over the [Agent Client Protocol](https://agentclientprotocol.com). - -You bring the prompt. Your user's existing CLI does the work. No API keys, no hosted billing, no rate limits beyond what their agent already enforces. +A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams over the [Agent Client Protocol](https://agentclientprotocol.com). No API keys, no hosted billing. ## Install @@ -15,7 +13,7 @@ You bring the prompt. Your user's existing CLI does the work. No API keys, no ho npm install use-local-agent ai ``` -`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider, so it works alongside the `ai` package (`^6.0.0`). The user also needs the actual agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex). Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: +The user also needs the agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code`). Claude Code and Codex additionally need a small ACP shim: ```bash npm install @agentclientprotocol/claude-agent-acp # Claude Code @@ -38,66 +36,16 @@ for await (const chunk of textStream) { } ``` -Or non-streaming, with options passed inline at the call site: +Pass settings inline at the call site, or build a pre-configured provider with `createLocalAgentProvider`: ```ts import { generateText } from "ai"; import { localAgent } from "use-local-agent"; -const { text, finishReason } = await generateText({ +const { text } = await generateText({ model: localAgent("codex", { cwd: "/Users/me/project", permission: "auto-allow", - inactivityTimeoutMs: 5 * 60 * 1000, - onTrace: (direction, payload) => console.log(direction, payload), - }), - prompt: "Summarize README.md in three bullets", -}); -``` - -The provider implements `LanguageModelV3` so every AI SDK feature works: message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. - -## Configuration - -Pass settings as the second argument, or build a pre-configured provider for repeated use: - -```ts -import { streamText } from "ai"; -import { createLocalAgentProvider } from "use-local-agent"; - -const provider = createLocalAgentProvider({ - cwd: process.cwd(), - permission: "auto-allow", - inactivityTimeoutMs: 5 * 60 * 1000, -}); - -streamText({ model: provider("claude"), prompt: "..." }); -streamText({ model: provider("codex"), prompt: "..." }); -``` - -| Setting | Effect | -| --- | --- | -| `cwd` | Working directory the agent will operate in. | -| `permission` | `"auto-allow"` (default) / `"auto-allow-once"` / `"auto-reject"` / `"stream"`. Stream mode surfaces permission prompts via `LocalAgent` (see Advanced). | -| `mcpServers` | Array of MCP server configs the agent should connect to for extra tools. | -| `additionalDirectories` | Extra absolute paths the agent is allowed to read/write. | -| `systemPrompt` | Prepended to user prompts (or merged with system messages from the AI SDK prompt array). | -| `inactivityTimeoutMs` | Kill the turn if the agent goes silent (default 3 min). | -| `onTrace` / `onStderr` | Observe the JSON-RPC wire and stderr without parsing logs. | -| `envFilter` | Scrub the env before passing it to the child process. | -| `traceContext` | Forward W3C `traceparent` / `tracestate` / `baggage` into request `_meta`. | -| `onAuthRequired` | Hook called on `auth_required`; return a method id to retry after `authenticate`. | - -## MCP servers - -Give the agent extra tools by wiring up MCP servers. The agent calls them internally and the results flow back through `streamText` as `tool-call` parts: - -```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; - -streamText({ - model: localAgent("claude", { mcpServers: [ { type: "stdio", @@ -107,40 +55,18 @@ streamText({ }, ], }), - prompt: "List files in /tmp and tell me which are stale", -}); -``` - -## Multi-modal input - -Image and audio parts in the AI SDK prompt are forwarded to ACP as `image` / `audio` content blocks (when the agent advertises the matching `promptCapabilities`): - -```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; - -streamText({ - model: localAgent("claude"), - messages: [ - { - role: "user", - content: [ - { type: "text", text: "What's in this screenshot?" }, - { type: "file", data: pngBytes, mediaType: "image/png" }, - ], - }, - ], + prompt: "Summarize README.md in three bullets", }); ``` -## Detect installed agents - -```ts -import { detectAvailableAgents, toAgentDisplayName } from "use-local-agent"; - -console.log(detectAvailableAgents().map(toAgentDisplayName)); -// ["Claude Code", "Codex", "Cursor Agent", ...] -``` +| Setting | Effect | +| --- | --- | +| `cwd` | Working directory the agent operates in. | +| `permission` | `"auto-allow"` (default) / `"auto-allow-once"` / `"auto-reject"` / `"stream"`. | +| `mcpServers` | MCP server configs the agent connects to for extra tools. | +| `additionalDirectories` | Extra absolute paths the agent can read/write. | +| `systemPrompt` | Prepended to user prompts. | +| `inactivityTimeoutMs` | Kill the turn if the agent goes silent (default 3 min). | ## Supported agents @@ -155,87 +81,28 @@ console.log(detectAvailableAgents().map(toAgentDisplayName)); | `droid` | Factory Droid | native ACP | | `pi` | Pi | native ACP | -## Custom adapter - -Wire up any ACP-speaking subprocess that isn't built in: - -```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; -import type { AgentAdapter } from "use-local-agent"; - -const myAgent: AgentAdapter = { - id: "my-agent", - displayName: "My Agent", - resolve: async () => ({ - bin: "/usr/local/bin/myagent", - args: ["--acp"], - env: {}, - }), -}; - -streamText({ model: localAgent.fromAdapter(myAgent), prompt: "..." }); -``` +For a custom ACP-speaking subprocess, use `localAgent.fromAdapter(...)`. -## Advanced: stateful sessions, permissions, slash commands +## Stateful sessions -When you need ACP semantics that don't fit a single `streamText` call (multi-turn sessions on one subprocess, human-in-the-loop permission gating, slash commands, session resume, terminal handlers), drop down to the `LocalAgent` API: +`streamText` is one-shot. For multi-turn sessions, human-in-the-loop permission prompts, slash commands, or session resume, drop down to `LocalAgent`: ```ts import { LocalAgent } from "use-local-agent"; -await using agent = await LocalAgent.connect("codex", { - cwd: process.cwd(), - permission: "stream", -}); - +await using agent = await LocalAgent.connect("codex", { permission: "stream" }); const sessionId = await agent.createSession(); -const stream = agent.prompt(sessionId, { prompt: "delete all .log files" }); - -for await (const event of stream) { - if (event.type === "permission-request") { - const ok = await askUser(event.request.tool, event.request.options); - event.request.respond( - ok ? event.request.options[0].optionId : event.request.options.at(-1)!.optionId, - ); - } -} - -// slash commands, follow-up turns on the same subprocess -await agent.prompt(sessionId, { - command: { name: "web", input: "agent client protocol" }, -}); -// resume / list past sessions -for await (const info of agent.streamAllSessions({ cwd: process.cwd() })) { - console.log(info.sessionId, info.title); -} +await agent.prompt(sessionId, { prompt: "list TODOs" }); +await agent.prompt(sessionId, { command: { name: "web", input: "..." } }); ``` -`LocalAgent` implements `Symbol.asyncDispose`, so `await using` cleanly tears down the subprocess on scope exit. - -## Reliability - -`use-local-agent` is built for unattended production use: long-lived servers spawning hundreds of agent turns. The wrapper hardens every subprocess interaction: - -- Spawn race detection, initialize fast-fail (no waiting full timeout when a child crashes early), real SIGKILL escalation on dispose. -- Bounded buffers everywhere (pre-prompt notifications, stderr line buffer, `runCommand` stdout/stderr) so a misbehaving agent can't OOM the host. -- EPIPE-safe stdin writes (`AgentStdinClosedError`) and stdout-noise tolerance (banner lines routed to `onStderr` instead of crashing init). -- Inactivity watchdog that pauses while a permission prompt is open. -- Typed errors with `_tag` discriminators: `AgentSpawnError`, `AgentInitTimeoutError`, `AgentConnectionClosedError`, `AgentUnauthenticatedError`, `AgentUsageLimitError`, `AgentCancelledError`, `AgentInactivityError`, `AgentStdinClosedError`, `InvalidPromptContentError`, `CapabilityNotSupportedError`, `ProtocolVersionMismatchError`. - -## API - -See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full public surface. - -## Resources & Contributing Back - -Looking to contribute back? Check out the [Contributing Guide](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md). +See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full API. -Find a bug? Head over to our [issue tracker](https://github.com/millionco/use-local-agent/issues) and we'll do our best to help. We love pull requests, too! +## Contributing -[**โ†’ Start contributing on GitHub**](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md) +[Contributing Guide](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md) ยท [Issues](https://github.com/millionco/use-local-agent/issues) ### License -use-local-agent is MIT-licensed open-source software. +MIT diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md index 4440fb6..cbe1457 100644 --- a/packages/use-local-agent/README.md +++ b/packages/use-local-agent/README.md @@ -5,9 +5,7 @@ An API for accessing any locally-installed coding agent -How? `use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider that spawns Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams prompts, tool calls, and permissions over the [Agent Client Protocol](https://agentclientprotocol.com). - -You bring the prompt. Your user's existing CLI does the work. No API keys, no hosted billing, no rate limits beyond what their agent already enforces. +A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams over the [Agent Client Protocol](https://agentclientprotocol.com). No API keys, no hosted billing. ## Install @@ -15,7 +13,7 @@ You bring the prompt. Your user's existing CLI does the work. No API keys, no ho npm install use-local-agent ai ``` -`use-local-agent` is a [Vercel AI SDK](https://ai-sdk.dev) provider, so it works alongside the `ai` package (`^6.0.0`). The user also needs the actual agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code` for Claude, or `brew install codex` for Codex). Native ACP CLIs (Cursor, Copilot, Gemini, OpenCode, Droid, Pi) work out of the box; Claude Code and Codex need a small ACP shim: +The user also needs the agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code`). Claude Code and Codex additionally need a small ACP shim: ```bash npm install @agentclientprotocol/claude-agent-acp # Claude Code @@ -38,66 +36,16 @@ for await (const chunk of textStream) { } ``` -Or non-streaming, with options passed inline at the call site: +Pass settings inline at the call site, or build a pre-configured provider with `createLocalAgentProvider`: ```ts import { generateText } from "ai"; import { localAgent } from "use-local-agent"; -const { text, finishReason } = await generateText({ +const { text } = await generateText({ model: localAgent("codex", { cwd: "/Users/me/project", permission: "auto-allow", - inactivityTimeoutMs: 5 * 60 * 1000, - onTrace: (direction, payload) => console.log(direction, payload), - }), - prompt: "Summarize README.md in three bullets", -}); -``` - -The provider implements `LanguageModelV3` so every AI SDK feature works: message arrays, abort signals, `includeRawChunks`, multi-modal user input (image / audio file parts), reasoning content, and tool-call streaming. - -## Configuration - -Pass settings as the second argument, or build a pre-configured provider for repeated use: - -```ts -import { streamText } from "ai"; -import { createLocalAgentProvider } from "use-local-agent"; - -const provider = createLocalAgentProvider({ - cwd: process.cwd(), - permission: "auto-allow", - inactivityTimeoutMs: 5 * 60 * 1000, -}); - -streamText({ model: provider("claude"), prompt: "..." }); -streamText({ model: provider("codex"), prompt: "..." }); -``` - -| Setting | Effect | -| --- | --- | -| `cwd` | Working directory the agent will operate in. | -| `permission` | `"auto-allow"` (default) / `"auto-allow-once"` / `"auto-reject"` / `"stream"`. Stream mode surfaces permission prompts via `LocalAgent` (see Advanced). | -| `mcpServers` | Array of MCP server configs the agent should connect to for extra tools. | -| `additionalDirectories` | Extra absolute paths the agent is allowed to read/write. | -| `systemPrompt` | Prepended to user prompts (or merged with system messages from the AI SDK prompt array). | -| `inactivityTimeoutMs` | Kill the turn if the agent goes silent (default 3 min). | -| `onTrace` / `onStderr` | Observe the JSON-RPC wire and stderr without parsing logs. | -| `envFilter` | Scrub the env before passing it to the child process. | -| `traceContext` | Forward W3C `traceparent` / `tracestate` / `baggage` into request `_meta`. | -| `onAuthRequired` | Hook called on `auth_required`; return a method id to retry after `authenticate`. | - -## MCP servers - -Give the agent extra tools by wiring up MCP servers. The agent calls them internally and the results flow back through `streamText` as `tool-call` parts: - -```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; - -streamText({ - model: localAgent("claude", { mcpServers: [ { type: "stdio", @@ -107,40 +55,18 @@ streamText({ }, ], }), - prompt: "List files in /tmp and tell me which are stale", -}); -``` - -## Multi-modal input - -Image and audio parts in the AI SDK prompt are forwarded to ACP as `image` / `audio` content blocks (when the agent advertises the matching `promptCapabilities`): - -```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; - -streamText({ - model: localAgent("claude"), - messages: [ - { - role: "user", - content: [ - { type: "text", text: "What's in this screenshot?" }, - { type: "file", data: pngBytes, mediaType: "image/png" }, - ], - }, - ], + prompt: "Summarize README.md in three bullets", }); ``` -## Detect installed agents - -```ts -import { detectAvailableAgents, toAgentDisplayName } from "use-local-agent"; - -console.log(detectAvailableAgents().map(toAgentDisplayName)); -// ["Claude Code", "Codex", "Cursor Agent", ...] -``` +| Setting | Effect | +| --- | --- | +| `cwd` | Working directory the agent operates in. | +| `permission` | `"auto-allow"` (default) / `"auto-allow-once"` / `"auto-reject"` / `"stream"`. | +| `mcpServers` | MCP server configs the agent connects to for extra tools. | +| `additionalDirectories` | Extra absolute paths the agent can read/write. | +| `systemPrompt` | Prepended to user prompts. | +| `inactivityTimeoutMs` | Kill the turn if the agent goes silent (default 3 min). | ## Supported agents @@ -155,87 +81,28 @@ console.log(detectAvailableAgents().map(toAgentDisplayName)); | `droid` | Factory Droid | native ACP | | `pi` | Pi | native ACP | -## Custom adapter - -Wire up any ACP-speaking subprocess that isn't built in: - -```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; -import type { AgentAdapter } from "use-local-agent"; - -const myAgent: AgentAdapter = { - id: "my-agent", - displayName: "My Agent", - resolve: async () => ({ - bin: "/usr/local/bin/myagent", - args: ["--acp"], - env: {}, - }), -}; - -streamText({ model: localAgent.fromAdapter(myAgent), prompt: "..." }); -``` +For a custom ACP-speaking subprocess, use `localAgent.fromAdapter(...)`. -## Advanced: stateful sessions, permissions, slash commands +## Stateful sessions -When you need ACP semantics that don't fit a single `streamText` call (multi-turn sessions on one subprocess, human-in-the-loop permission gating, slash commands, session resume, terminal handlers), drop down to the `LocalAgent` API: +`streamText` is one-shot. For multi-turn sessions, human-in-the-loop permission prompts, slash commands, or session resume, drop down to `LocalAgent`: ```ts import { LocalAgent } from "use-local-agent"; -await using agent = await LocalAgent.connect("codex", { - cwd: process.cwd(), - permission: "stream", -}); - +await using agent = await LocalAgent.connect("codex", { permission: "stream" }); const sessionId = await agent.createSession(); -const stream = agent.prompt(sessionId, { prompt: "delete all .log files" }); - -for await (const event of stream) { - if (event.type === "permission-request") { - const ok = await askUser(event.request.tool, event.request.options); - event.request.respond( - ok ? event.request.options[0].optionId : event.request.options.at(-1)!.optionId, - ); - } -} - -// slash commands, follow-up turns on the same subprocess -await agent.prompt(sessionId, { - command: { name: "web", input: "agent client protocol" }, -}); -// resume / list past sessions -for await (const info of agent.streamAllSessions({ cwd: process.cwd() })) { - console.log(info.sessionId, info.title); -} +await agent.prompt(sessionId, { prompt: "list TODOs" }); +await agent.prompt(sessionId, { command: { name: "web", input: "..." } }); ``` -`LocalAgent` implements `Symbol.asyncDispose`, so `await using` cleanly tears down the subprocess on scope exit. - -## Reliability - -`use-local-agent` is built for unattended production use: long-lived servers spawning hundreds of agent turns. The wrapper hardens every subprocess interaction: - -- Spawn race detection, initialize fast-fail (no waiting full timeout when a child crashes early), real SIGKILL escalation on dispose. -- Bounded buffers everywhere (pre-prompt notifications, stderr line buffer, `runCommand` stdout/stderr) so a misbehaving agent can't OOM the host. -- EPIPE-safe stdin writes (`AgentStdinClosedError`) and stdout-noise tolerance (banner lines routed to `onStderr` instead of crashing init). -- Inactivity watchdog that pauses while a permission prompt is open. -- Typed errors with `_tag` discriminators: `AgentSpawnError`, `AgentInitTimeoutError`, `AgentConnectionClosedError`, `AgentUnauthenticatedError`, `AgentUsageLimitError`, `AgentCancelledError`, `AgentInactivityError`, `AgentStdinClosedError`, `InvalidPromptContentError`, `CapabilityNotSupportedError`, `ProtocolVersionMismatchError`. - -## API - -See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full public surface. - -## Resources & Contributing Back - -Looking to contribute back? Check out the [Contributing Guide](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md). +See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full API. -Find a bug? Head over to our [issue tracker](https://github.com/millionco/use-local-agent/issues) and we'll do our best to help. We love pull requests, too! +## Contributing -[**โ†’ Start contributing on GitHub**](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md) +[Contributing Guide](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md) ยท [Issues](https://github.com/millionco/use-local-agent/issues) ### License -use-local-agent is MIT-licensed open-source software. +MIT From 32111fbb03960c7337017a76b4f78e428187f3e8 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 22:07:47 -0700 Subject: [PATCH 20/24] feat(use-local-agent): add createLocalAgentSession for stateful streamText Adds an AI-SDK-shaped path for multi-turn ACP sessions so callers don't have to drop down to the LocalAgent API for follow-up turns: import { streamText } from "ai"; import { createLocalAgentSession } from "use-local-agent"; await using session = await createLocalAgentSession("codex"); await streamText({ model: session.model, prompt: "list TODOs" }); await streamText({ model: session.model, prompt: "now fix the highest one" }); await streamText({ model: session.model, prompt: "agent client protocol", providerOptions: { useLocalAgent: { command: "web" } }, }); Implementation: - LocalAgentLanguageModel: replace the single-purpose `connect` factory with a more general `acquire` factory that returns { agent, sessionId, historyAlreadyApplied, release }. One-shot mode (default `localAgent("...")`) creates a fresh agent + session and closes both on release. Session-bound mode (createLocalAgentSession) reuses an existing agent + session and makes release a no-op. - convertPromptToContentBlocks: new `lastUserOnly` option used by session-bound models to skip prior conversation history (the ACP session already holds it). Emits a `compatibility` warning describing how many messages were skipped, plus an `unsupported` warning if the prompt has no trailing user message at all. - providerOptions.useLocalAgent.command: forwards a slash command to the underlying ACP session (object form `{ name, input? }` or bare string). - createLocalAgentSession opens one LocalAgent + one session, exposes `.model` (LanguageModelV3 bound to the session), `.agent` and `.sessionId` for ACP-specific surfaces, and implements `Symbol.asyncDispose` so `await using` cleans up the subprocess. 3 new tests cover: trailing-user-only behavior with a compatibility warning, slash command via `{ name, input }`, and the bare-string command shorthand. Existing 96 tests updated to the new acquire shape. Total: 99 tests across 26 files passing; pnpm typecheck and lint clean. README: replaces the LocalAgent-based stateful example with a session + streamText example; LocalAgent escape hatch still mentioned for permission prompts / terminal handlers / session resume. --- README.md | 23 ++- packages/use-local-agent/README.md | 23 ++- .../src/ai-sdk/convert-prompt.ts | 55 ++++++- packages/use-local-agent/src/ai-sdk/index.ts | 5 + .../src/ai-sdk/local-agent-language-model.ts | 102 +++++++++---- .../use-local-agent/src/ai-sdk/provider.ts | 143 +++++++++++++++++- packages/use-local-agent/src/index.ts | 3 + .../tests/ai-sdk-provider.test.ts | 115 +++++++++++++- 8 files changed, 414 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index cbe1457..4323e00 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ An API for accessing any locally-installed coding agent -A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams over the [Agent Client Protocol](https://agentclientprotocol.com). No API keys, no hosted billing. +A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams over the [Agent Client Protocol](https://agentclientprotocol.com). ## Install @@ -85,19 +85,26 @@ For a custom ACP-speaking subprocess, use `localAgent.fromAdapter(...)`. ## Stateful sessions -`streamText` is one-shot. For multi-turn sessions, human-in-the-loop permission prompts, slash commands, or session resume, drop down to `LocalAgent`: +For multi-turn conversations on a single subprocess, use `createLocalAgentSession`. Each `streamText` call against `session.model` sends one `session/prompt` turn, so the agent's conversation memory is preserved across turns: ```ts -import { LocalAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { createLocalAgentSession } from "use-local-agent"; + +await using session = await createLocalAgentSession("codex"); -await using agent = await LocalAgent.connect("codex", { permission: "stream" }); -const sessionId = await agent.createSession(); +await streamText({ model: session.model, prompt: "list TODOs" }); +await streamText({ model: session.model, prompt: "now fix the highest one" }); -await agent.prompt(sessionId, { prompt: "list TODOs" }); -await agent.prompt(sessionId, { command: { name: "web", input: "..." } }); +// slash commands via providerOptions +await streamText({ + model: session.model, + prompt: "agent client protocol", + providerOptions: { useLocalAgent: { command: "web" } }, +}); ``` -See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full API. +For human-in-the-loop permission prompts, terminal handlers, and session resume, the `session.agent` field exposes the underlying `LocalAgent`. See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full API. ## Contributing diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md index cbe1457..4323e00 100644 --- a/packages/use-local-agent/README.md +++ b/packages/use-local-agent/README.md @@ -5,7 +5,7 @@ An API for accessing any locally-installed coding agent -A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams over the [Agent Client Protocol](https://agentclientprotocol.com). No API keys, no hosted billing. +A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams over the [Agent Client Protocol](https://agentclientprotocol.com). ## Install @@ -85,19 +85,26 @@ For a custom ACP-speaking subprocess, use `localAgent.fromAdapter(...)`. ## Stateful sessions -`streamText` is one-shot. For multi-turn sessions, human-in-the-loop permission prompts, slash commands, or session resume, drop down to `LocalAgent`: +For multi-turn conversations on a single subprocess, use `createLocalAgentSession`. Each `streamText` call against `session.model` sends one `session/prompt` turn, so the agent's conversation memory is preserved across turns: ```ts -import { LocalAgent } from "use-local-agent"; +import { streamText } from "ai"; +import { createLocalAgentSession } from "use-local-agent"; + +await using session = await createLocalAgentSession("codex"); -await using agent = await LocalAgent.connect("codex", { permission: "stream" }); -const sessionId = await agent.createSession(); +await streamText({ model: session.model, prompt: "list TODOs" }); +await streamText({ model: session.model, prompt: "now fix the highest one" }); -await agent.prompt(sessionId, { prompt: "list TODOs" }); -await agent.prompt(sessionId, { command: { name: "web", input: "..." } }); +// slash commands via providerOptions +await streamText({ + model: session.model, + prompt: "agent client protocol", + providerOptions: { useLocalAgent: { command: "web" } }, +}); ``` -See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full API. +For human-in-the-loop permission prompts, terminal handlers, and session resume, the `session.agent` field exposes the underlying `LocalAgent`. See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full API. ## Contributing diff --git a/packages/use-local-agent/src/ai-sdk/convert-prompt.ts b/packages/use-local-agent/src/ai-sdk/convert-prompt.ts index 4e2d7de..9ec3c26 100644 --- a/packages/use-local-agent/src/ai-sdk/convert-prompt.ts +++ b/packages/use-local-agent/src/ai-sdk/convert-prompt.ts @@ -5,6 +5,18 @@ import type { } from "@ai-sdk/provider"; import type { ContentBlock } from "../types"; +export interface PromptConversionOptions { + /** + * When true, only the trailing user message of the prompt is converted to + * ACP content blocks. Earlier user / assistant / tool messages are assumed + * to live in the agent's session memory already, so replaying them would + * double-feed the conversation. + * + * Use this in session-bound mode (after the first turn). Default `false`. + */ + readonly lastUserOnly?: boolean; +} + export interface PromptConversionResult { readonly systemPrompt: string | undefined; readonly blocks: ContentBlock[]; @@ -13,12 +25,31 @@ export interface PromptConversionResult { export const convertPromptToContentBlocks = ( prompt: LanguageModelV3Prompt, + options: PromptConversionOptions = {}, ): PromptConversionResult => { const systemParts: string[] = []; const blocks: ContentBlock[] = []; const warnings: SharedV3Warning[] = []; - for (const message of prompt) { + const messages = options.lastUserOnly ? extractTrailingUserSlice(prompt) : prompt; + if (options.lastUserOnly && messages.length === 0 && prompt.length > 0) { + warnings.push({ + type: "unsupported", + feature: "no-trailing-user-message", + details: + "Session-bound mode requires a trailing user message in the prompt; nothing was sent over session/prompt", + }); + } + if (options.lastUserOnly && messages.length < prompt.length) { + const skipped = prompt.length - messages.length; + warnings.push({ + type: "compatibility", + feature: "session-history-replay", + details: `Skipping ${skipped} message(s) of conversation history; the ACP session already holds them`, + }); + } + + for (const message of messages) { switch (message.role) { case "system": { systemParts.push(message.content); @@ -116,3 +147,25 @@ const dataAsBase64 = (data: LanguageModelV3FilePart["data"]): string => { if (data instanceof Uint8Array) return Buffer.from(data).toString("base64"); throw new Error("Unsupported file data format for ACP content block"); }; + +/** + * Returns the trailing run of `user`-role messages from the end of the prompt. + * Stops at the first non-user message scanning right-to-left. + * + * Used by session-bound models so a single `streamText({ messages: [...history, + * newUser] })` call only forwards the new user turn to ACP; the preceding + * messages are assumed to already live in the session's memory. + */ +const extractTrailingUserSlice = ( + prompt: LanguageModelV3Prompt, +): LanguageModelV3Prompt => { + let cut = prompt.length; + for (let index = prompt.length - 1; index >= 0; index -= 1) { + if (prompt[index].role === "user" || prompt[index].role === "system") { + cut = index; + } else { + break; + } + } + return prompt.slice(cut); +}; diff --git a/packages/use-local-agent/src/ai-sdk/index.ts b/packages/use-local-agent/src/ai-sdk/index.ts index d83c80d..3a1d165 100644 --- a/packages/use-local-agent/src/ai-sdk/index.ts +++ b/packages/use-local-agent/src/ai-sdk/index.ts @@ -1,14 +1,19 @@ export { LocalAgentLanguageModel, + type AcquiredSession, type LocalAgentLanguageModelConfig, } from "./local-agent-language-model"; export { createLocalAgentProvider, + createLocalAgentSession, localAgent, type LocalAgentProvider, type LocalAgentProviderSettings, + type LocalAgentSession, + type LocalAgentSessionOptions, } from "./provider"; export { convertPromptToContentBlocks, + type PromptConversionOptions, type PromptConversionResult, } from "./convert-prompt"; diff --git a/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts b/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts index 372181a..2ca3132 100644 --- a/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts +++ b/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts @@ -10,50 +10,76 @@ import type { LanguageModelV3Usage, } from "@ai-sdk/provider"; import { generateId } from "@ai-sdk/provider-utils"; -import { LocalAgent } from "../local-agent"; -import type { StopReason, UsageReport } from "../types"; +import type { LocalAgent } from "../local-agent"; +import type { SessionId, SlashCommandInput, StopReason, UsageReport } from "../types"; import { convertPromptToContentBlocks } from "./convert-prompt"; const PROVIDER_NAME = "use-local-agent"; +const PROVIDER_KEY = "useLocalAgent"; + +export interface AcquiredSession { + readonly agent: LocalAgent; + readonly sessionId: SessionId; + /** + * Whether the model should treat the prompt's history as already known to + * the session (`true`) or as a fresh single-turn payload (`false`). + * + * In "bound" mode (`historyAlreadyApplied: true`) only the trailing user + * message is sent over `session/prompt`; older messages are assumed to live + * in the agent's session memory already. + */ + readonly historyAlreadyApplied: boolean; + /** + * Cleanup hook called after the turn completes (success, error, or abort). + * For one-shot models this disposes the underlying `LocalAgent`. For + * session-bound models this is a no-op so the same session can be reused. + */ + release(): Promise; +} export interface LocalAgentLanguageModelConfig { readonly modelId: string; /** - * Async factory that produces a fresh `LocalAgent` for a single turn. The - * model owns the agent's lifecycle and will `close()` it after the turn - * completes (success, error, or abort). The factory receives the resolved - * system prompt extracted from the AI SDK prompt messages so callers can - * forward it as a `LocalAgentConnectOptions.systemPrompt`. + * Async factory that returns the `LocalAgent` + session this turn should + * run on. */ - readonly connect: (input: { systemPrompt: string | undefined }) => Promise; + readonly acquire: (input: { systemPrompt: string | undefined }) => Promise; /** * Override the provider name reported on the model (default `use-local-agent`). */ readonly provider?: string; } +interface LocalAgentProviderOptions { + readonly command?: SlashCommandInput | string; +} + export class LocalAgentLanguageModel implements LanguageModelV3 { readonly specificationVersion = "v3" as const; readonly provider: string; readonly modelId: string; readonly supportedUrls: Record = {}; - readonly #connect: LocalAgentLanguageModelConfig["connect"]; + readonly #acquire: LocalAgentLanguageModelConfig["acquire"]; constructor(config: LocalAgentLanguageModelConfig) { this.modelId = config.modelId; this.provider = config.provider ?? PROVIDER_NAME; - this.#connect = config.connect; + this.#acquire = config.acquire; } async doGenerate(options: LanguageModelV3CallOptions): Promise { - const { systemPrompt, blocks, warnings } = convertPromptToContentBlocks(options.prompt); - const allWarnings = [...warnings, ...callOptionWarnings(options)]; - const agent = await this.#connect({ systemPrompt }); + const command = parseCommand(providerOptionsFor(options).command); + const initialConversion = convertPromptToContentBlocks(options.prompt); + const acquired = await this.#acquire({ systemPrompt: initialConversion.systemPrompt }); + const conversion = acquired.historyAlreadyApplied + ? convertPromptToContentBlocks(options.prompt, { lastUserOnly: true }) + : initialConversion; + const allWarnings = [...conversion.warnings, ...callOptionWarnings(options)]; try { - const sessionId = await agent.createSession(); - const stream = agent.prompt(sessionId, { - prompt: blocks, + const stream = acquired.agent.prompt(acquired.sessionId, { + prompt: conversion.blocks, + ...(command ? { command } : {}), ...(options.abortSignal ? { signal: options.abortSignal } : {}), }); const toolCalls: LanguageModelV3Content[] = []; @@ -85,30 +111,36 @@ export class LocalAgentLanguageModel implements LanguageModelV3 { response: { id: result.sessionId }, }; } finally { - await agent.close(); + await acquired.release(); } } async doStream(options: LanguageModelV3CallOptions): Promise { - const { systemPrompt, blocks, warnings } = convertPromptToContentBlocks(options.prompt); - const allWarnings = [...warnings, ...callOptionWarnings(options)]; - const promptBlocks = blocks; + const command = parseCommand(providerOptionsFor(options).command); + const initialConversion = convertPromptToContentBlocks(options.prompt); + const optionWarnings = callOptionWarnings(options); const abortSignal = options.abortSignal; - const connectAgent = this.#connect; + const acquire = this.#acquire; const includeRaw = options.includeRawChunks; const stream = new ReadableStream({ async start(controller) { - controller.enqueue({ type: "stream-start", warnings: allWarnings }); - let agent: LocalAgent | undefined; + let acquired: AcquiredSession | undefined; let textBlockId: string | undefined; let reasoningBlockId: string | undefined; try { - agent = await connectAgent({ systemPrompt }); - const sessionId = await agent.createSession(); - controller.enqueue({ type: "response-metadata", id: sessionId }); - const turn = agent.prompt(sessionId, { - prompt: promptBlocks, + acquired = await acquire({ systemPrompt: initialConversion.systemPrompt }); + const conversion = acquired.historyAlreadyApplied + ? convertPromptToContentBlocks(options.prompt, { lastUserOnly: true }) + : initialConversion; + controller.enqueue({ + type: "stream-start", + warnings: [...conversion.warnings, ...optionWarnings], + }); + controller.enqueue({ type: "response-metadata", id: acquired.sessionId }); + const turn = acquired.agent.prompt(acquired.sessionId, { + prompt: conversion.blocks, + ...(command ? { command } : {}), ...(abortSignal ? { signal: abortSignal } : {}), }); for await (const event of turn) { @@ -187,16 +219,26 @@ export class LocalAgentLanguageModel implements LanguageModelV3 { controller.enqueue({ type: "error", error: cause }); controller.close(); } finally { - if (agent) await agent.close(); + if (acquired) await acquired.release(); } }, }); return { stream }; } - } +const providerOptionsFor = (options: LanguageModelV3CallOptions): LocalAgentProviderOptions => + (options.providerOptions?.[PROVIDER_KEY] ?? {}) as LocalAgentProviderOptions; + +const parseCommand = ( + raw: SlashCommandInput | string | undefined, +): SlashCommandInput | undefined => { + if (raw === undefined) return undefined; + if (typeof raw === "string") return { name: raw }; + return raw; +}; + const stringifyInput = (input: unknown): string => { if (input === undefined || input === null) return "{}"; if (typeof input === "string") return input; diff --git a/packages/use-local-agent/src/ai-sdk/provider.ts b/packages/use-local-agent/src/ai-sdk/provider.ts index 498dae9..c1cc5b1 100644 --- a/packages/use-local-agent/src/ai-sdk/provider.ts +++ b/packages/use-local-agent/src/ai-sdk/provider.ts @@ -8,7 +8,11 @@ import type { import type { AgentAdapter } from "../adapter"; import type { SupportedAgentId } from "../constants"; import { LocalAgent, type LocalAgentConnectOptions } from "../local-agent"; -import { LocalAgentLanguageModel } from "./local-agent-language-model"; +import type { CreateSessionInput, SessionId } from "../types"; +import { + type AcquiredSession, + LocalAgentLanguageModel, +} from "./local-agent-language-model"; export type LocalAgentProviderSettings = LocalAgentConnectOptions; @@ -24,6 +28,36 @@ export interface LocalAgentProvider extends ProviderV3 { fromAdapter(adapter: AgentAdapter, settings?: LocalAgentProviderSettings): LanguageModelV3; } +export interface LocalAgentSessionOptions extends LocalAgentProviderSettings { + /** + * Per-session input forwarded to `agent.createSession()`. Use this for + * `additionalDirectories`, custom `mcpServers` per session, etc. + */ + readonly session?: CreateSessionInput; +} + +export interface LocalAgentSession extends AsyncDisposable { + /** + * The underlying `LocalAgent`. Useful for slash commands, permissions, + * `loadSession`, terminal handlers, and other ACP-specific surfaces. + */ + readonly agent: LocalAgent; + /** + * The pre-created session id this model is bound to. + */ + readonly sessionId: SessionId; + /** + * AI SDK `LanguageModelV3` bound to this session. Pass to `streamText` / + * `generateText` as many times as you need; each call sends one + * `session/prompt` turn over the same subprocess + session. + */ + readonly model: LanguageModelV3; + /** + * Dispose the underlying `LocalAgent`. Idempotent. + */ + close(): Promise; +} + const mergeSystemPrompt = ( base: string | undefined, added: string | undefined, @@ -56,11 +90,19 @@ export const createLocalAgentProvider = ( ): LanguageModelV3 => new LocalAgentLanguageModel({ modelId, - connect: ({ systemPrompt }) => - LocalAgent.connect( + acquire: async ({ systemPrompt }): Promise => { + const agent = await LocalAgent.connect( modelId as SupportedAgentId, mergeConnectOptions(defaults, settings, systemPrompt), - ), + ); + const sessionId = await agent.createSession(); + return { + agent, + sessionId, + historyAlreadyApplied: false, + release: () => agent.close(), + }; + }, }); const buildFromAdapter = ( @@ -69,8 +111,19 @@ export const createLocalAgentProvider = ( ): LanguageModelV3 => new LocalAgentLanguageModel({ modelId: adapter.id, - connect: ({ systemPrompt }) => - LocalAgent.connect(adapter, mergeConnectOptions(defaults, settings, systemPrompt)), + acquire: async ({ systemPrompt }): Promise => { + const agent = await LocalAgent.connect( + adapter, + mergeConnectOptions(defaults, settings, systemPrompt), + ); + const sessionId = await agent.createSession(); + return { + agent, + sessionId, + historyAlreadyApplied: false, + release: () => agent.close(), + }; + }, }); const provider = (( @@ -114,3 +167,81 @@ export const createLocalAgentProvider = ( * ``` */ export const localAgent: LocalAgentProvider = createLocalAgentProvider(); + +/** + * Open a long-lived `LocalAgent` and bind a `LanguageModelV3` to a single + * pre-created session. Successive `streamText` / `generateText` calls reuse + * the same subprocess and session, so the agent's conversation memory is + * preserved across turns. + * + * @example + * ```ts + * import { streamText } from "ai"; + * import { createLocalAgentSession } from "use-local-agent"; + * + * await using session = await createLocalAgentSession("codex", { + * permission: "auto-allow", + * }); + * + * await streamText({ model: session.model, prompt: "list TODOs" }); + * await streamText({ model: session.model, prompt: "now fix the highest one" }); + * ``` + * + * Slash commands are sent via `providerOptions`: + * + * ```ts + * await streamText({ + * model: session.model, + * prompt: "agent client protocol", + * providerOptions: { useLocalAgent: { command: "web" } }, + * }); + * ``` + */ +export const createLocalAgentSession = async ( + modelOrAdapter: SupportedAgentId | string | AgentAdapter, + options: LocalAgentSessionOptions = {}, +): Promise => { + const { session: sessionInput, ...connectOptions } = options; + const target = ( + typeof modelOrAdapter === "string" ? modelOrAdapter : modelOrAdapter + ) as SupportedAgentId | AgentAdapter; + const agent = await LocalAgent.connect(target, { + permission: "auto-allow", + ...connectOptions, + }); + let sessionId: SessionId; + try { + sessionId = await agent.createSession(sessionInput); + } catch (cause) { + await agent.close(); + throw cause; + } + + let closed = false; + const close = async (): Promise => { + if (closed) return; + closed = true; + await agent.close(); + }; + + const modelId = + typeof modelOrAdapter === "string" ? modelOrAdapter : modelOrAdapter.id; + const model = new LocalAgentLanguageModel({ + modelId, + acquire: async (): Promise => ({ + agent, + sessionId, + historyAlreadyApplied: true, + release: async () => {}, + }), + }); + + const session: LocalAgentSession = { + agent, + sessionId, + model, + close, + [Symbol.asyncDispose]: () => close(), + }; + return session; +}; diff --git a/packages/use-local-agent/src/index.ts b/packages/use-local-agent/src/index.ts index 052770f..6380834 100644 --- a/packages/use-local-agent/src/index.ts +++ b/packages/use-local-agent/src/index.ts @@ -10,11 +10,14 @@ export { connect, type ConnectOptions, type ConnectResult } from "./connect"; export { createLocalAgentProvider, + createLocalAgentSession, localAgent, LocalAgentLanguageModel, type LocalAgentLanguageModelConfig, type LocalAgentProvider, type LocalAgentProviderSettings, + type LocalAgentSession, + type LocalAgentSessionOptions, } from "./ai-sdk"; export type { AgentAdapter, AdapterFactoryOptions, ResolvedAdapter } from "./adapter"; diff --git a/packages/use-local-agent/tests/ai-sdk-provider.test.ts b/packages/use-local-agent/tests/ai-sdk-provider.test.ts index a78662c..ae9faac 100644 --- a/packages/use-local-agent/tests/ai-sdk-provider.test.ts +++ b/packages/use-local-agent/tests/ai-sdk-provider.test.ts @@ -23,11 +23,20 @@ const collectStream = async ( return parts; }; -const buildModelWithMock = async (handlers: MockAgentHandlers) => { +const buildModelWithMock = async ( + handlers: MockAgentHandlers, + modelOptions: { historyAlreadyApplied?: boolean } = {}, +) => { const session = await connectMockAgent(handlers); + const sessionId = await session.agent.createSession(); const model = new LocalAgentLanguageModel({ modelId: "mock", - connect: async () => session.agent, + acquire: async () => ({ + agent: session.agent, + sessionId, + historyAlreadyApplied: modelOptions.historyAlreadyApplied ?? false, + release: async () => {}, + }), }); return { model, session }; }; @@ -268,3 +277,105 @@ describe("LocalAgentLanguageModel.doStream", () => { } }); }); + +describe("session-bound model (historyAlreadyApplied)", () => { + it("only forwards the trailing user message and skips prior history", async () => { + const promptCalls: string[][] = []; + const { model, session } = await buildModelWithMock( + { + newSession: () => ({ sessionId: "s-bound" }), + prompt: async (request) => { + promptCalls.push( + request.prompt.flatMap((part) => + part.type === "text" ? [part.text] : [], + ), + ); + return { stopReason: "end_turn" }; + }, + }, + { historyAlreadyApplied: true }, + ); + + try { + const result = await model.doGenerate({ + prompt: [ + { role: "system", content: "Be terse." }, + { role: "user", content: [{ type: "text", text: "first" }] }, + { + role: "assistant", + content: [{ type: "text", text: "first reply" }], + }, + { role: "user", content: [{ type: "text", text: "follow up" }] }, + ], + }); + expect(promptCalls).toHaveLength(1); + expect(promptCalls[0]).toEqual(["follow up"]); + const compatibility = result.warnings.find( + (warning) => warning.type === "compatibility", + ); + expect(compatibility?.feature).toBe("session-history-replay"); + } finally { + await session.close(); + } + }); + + it("forwards a slash command via providerOptions.useLocalAgent.command", async () => { + const seenPrompts: string[][] = []; + const { model, session } = await buildModelWithMock( + { + newSession: () => ({ sessionId: "s-cmd" }), + prompt: async (request) => { + seenPrompts.push( + request.prompt.flatMap((part) => + part.type === "text" ? [part.text] : [], + ), + ); + return { stopReason: "end_turn" }; + }, + }, + { historyAlreadyApplied: true }, + ); + + try { + await model.doGenerate({ + prompt: [ + { + role: "user", + content: [{ type: "text", text: "agent client protocol" }], + }, + ], + providerOptions: { + useLocalAgent: { command: { name: "web", input: "agent client protocol" } }, + }, + }); + expect(seenPrompts).toHaveLength(1); + expect(seenPrompts[0][0]).toMatch(/^\/web agent client protocol/); + } finally { + await session.close(); + } + }); + + it("supports the bare-string command form", async () => { + const seen: string[] = []; + const { model, session } = await buildModelWithMock( + { + newSession: () => ({ sessionId: "s-cmd2" }), + prompt: async (request) => { + for (const part of request.prompt) + if (part.type === "text") seen.push(part.text); + return { stopReason: "end_turn" }; + }, + }, + { historyAlreadyApplied: true }, + ); + try { + await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "go" }] }], + providerOptions: { useLocalAgent: { command: "compact" } }, + }); + expect(seen.some((line) => line.startsWith("/compact"))).toBe(true); + } finally { + await session.close(); + } + }); +}); From 71a238dba91562bd2787dac1d1f90d5586b3c0e2 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 22:16:48 -0700 Subject: [PATCH 21/24] fix(use-local-agent): address AGENTS.md review findings on AI SDK provider High severity: - LocalAgentLanguageModel: emit a `system-message-on-bound-session` warning when a session-bound model sees a per-turn system message. Previously the system prompt was silently dropped because the underlying ACP session was already opened. - createLocalAgentSession: drop the no-op `typeof === "string" ? x : x` ternary; the parameter is already correctly typed. Medium severity: - provider.ts: extract a shared `buildOneShotAcquire` so `buildLanguageModel` and `buildFromAdapter` no longer duplicate the same 14-line acquire factory body (DRY). - LocalAgentProvider: tighten `modelId` parameter from `SupportedAgentId | string` to just `SupportedAgentId`; arbitrary strings now go through `fromAdapter(...)` so typos like `localAgent("clauder")` fail at compile time instead of runtime. - Add tests covering: - system-message-on-bound-session warning - no-trailing-user-message warning + zero-block prompt to ACP - abort during doStream calls release exactly once - toJsonValue -> toJsonResult: returns NonNullable (matches LanguageModelV3ToolResult.result) by JSON-roundtripping with a String(value) fallback. Drops the over-permissive NonNullable contract. Low severity / style: - Hoist provider name / providerOptions key to `constants.ts` as `AI_SDK_PROVIDER_NAME` and `AI_SDK_PROVIDER_OPTIONS_KEY`. - Rename `historyAlreadyApplied` -> `isSessionBound` everywhere (config, AcquiredSession, tests). - LocalAgentSessionOptions now `extends LocalAgentConnectOptions` directly instead of going through the type alias. - Inline `.filter((p) => p.type === ...)` + `.map(...)` -> single `.map` / `.flatMap` to drop two `as { ... }` casts in convert-prompt. - Trim JSDoc that restated field / function names per AGENTS.md "default to NO comments". Comments on `release()` (idempotency semantic) and the public `localAgent` / `createLocalAgentSession` examples are kept. Verified: pnpm typecheck clean, pnpm lint 0/0 on 72 files, pnpm test 102 passing across 26 files. --- .../src/ai-sdk/convert-prompt.ts | 47 +++--- .../src/ai-sdk/local-agent-language-model.ts | 69 ++++----- .../use-local-agent/src/ai-sdk/provider.ts | 134 ++++++------------ packages/use-local-agent/src/constants.ts | 3 + .../tests/ai-sdk-provider.test.ts | 125 +++++++++++++++- 5 files changed, 207 insertions(+), 171 deletions(-) diff --git a/packages/use-local-agent/src/ai-sdk/convert-prompt.ts b/packages/use-local-agent/src/ai-sdk/convert-prompt.ts index 9ec3c26..ccf534d 100644 --- a/packages/use-local-agent/src/ai-sdk/convert-prompt.ts +++ b/packages/use-local-agent/src/ai-sdk/convert-prompt.ts @@ -6,15 +6,8 @@ import type { import type { ContentBlock } from "../types"; export interface PromptConversionOptions { - /** - * When true, only the trailing user message of the prompt is converted to - * ACP content blocks. Earlier user / assistant / tool messages are assumed - * to live in the agent's session memory already, so replaying them would - * double-feed the conversation. - * - * Use this in session-bound mode (after the first turn). Default `false`. - */ readonly lastUserOnly?: boolean; + readonly warnOnSystemMessage?: boolean; } export interface PromptConversionResult { @@ -52,7 +45,16 @@ export const convertPromptToContentBlocks = ( for (const message of messages) { switch (message.role) { case "system": { - systemParts.push(message.content); + if (options.warnOnSystemMessage) { + warnings.push({ + type: "unsupported", + feature: "system-message-on-bound-session", + details: + "System messages can only be applied at session creation. Pass `systemPrompt` to `createLocalAgentSession` instead", + }); + } else { + systemParts.push(message.content); + } break; } case "user": { @@ -75,16 +77,15 @@ export const convertPromptToContentBlocks = ( break; } case "assistant": { - const text = message.content - .filter((part) => part.type === "text") - .map((part) => (part as { text: string }).text) + const assistantText = message.content + .map((part) => (part.type === "text" ? part.text : "")) .join(""); - if (text.length > 0) { - blocks.push({ type: "text", text: `Assistant: ${text}` }); + if (assistantText.length > 0) { + blocks.push({ type: "text", text: `Assistant: ${assistantText}` }); } - const toolCallNames = message.content - .filter((part) => part.type === "tool-call") - .map((part) => (part as { toolName: string }).toolName); + const toolCallNames = message.content.flatMap((part) => + part.type === "tool-call" ? [part.toolName] : [], + ); if (toolCallNames.length > 0) { warnings.push({ type: "unsupported", @@ -148,17 +149,7 @@ const dataAsBase64 = (data: LanguageModelV3FilePart["data"]): string => { throw new Error("Unsupported file data format for ACP content block"); }; -/** - * Returns the trailing run of `user`-role messages from the end of the prompt. - * Stops at the first non-user message scanning right-to-left. - * - * Used by session-bound models so a single `streamText({ messages: [...history, - * newUser] })` call only forwards the new user turn to ACP; the preceding - * messages are assumed to already live in the session's memory. - */ -const extractTrailingUserSlice = ( - prompt: LanguageModelV3Prompt, -): LanguageModelV3Prompt => { +const extractTrailingUserSlice = (prompt: LanguageModelV3Prompt): LanguageModelV3Prompt => { let cut = prompt.length; for (let index = prompt.length - 1; index >= 0; index -= 1) { if (prompt[index].role === "user" || prompt[index].role === "system") { diff --git a/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts b/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts index 2ca3132..5f6d863 100644 --- a/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts +++ b/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts @@ -1,5 +1,6 @@ import type { JSONObject, + JSONValue, LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3Content, @@ -10,43 +11,21 @@ import type { LanguageModelV3Usage, } from "@ai-sdk/provider"; import { generateId } from "@ai-sdk/provider-utils"; +import { AI_SDK_PROVIDER_NAME, AI_SDK_PROVIDER_OPTIONS_KEY } from "../constants"; import type { LocalAgent } from "../local-agent"; import type { SessionId, SlashCommandInput, StopReason, UsageReport } from "../types"; import { convertPromptToContentBlocks } from "./convert-prompt"; -const PROVIDER_NAME = "use-local-agent"; -const PROVIDER_KEY = "useLocalAgent"; - export interface AcquiredSession { readonly agent: LocalAgent; readonly sessionId: SessionId; - /** - * Whether the model should treat the prompt's history as already known to - * the session (`true`) or as a fresh single-turn payload (`false`). - * - * In "bound" mode (`historyAlreadyApplied: true`) only the trailing user - * message is sent over `session/prompt`; older messages are assumed to live - * in the agent's session memory already. - */ - readonly historyAlreadyApplied: boolean; - /** - * Cleanup hook called after the turn completes (success, error, or abort). - * For one-shot models this disposes the underlying `LocalAgent`. For - * session-bound models this is a no-op so the same session can be reused. - */ + readonly isSessionBound: boolean; release(): Promise; } export interface LocalAgentLanguageModelConfig { readonly modelId: string; - /** - * Async factory that returns the `LocalAgent` + session this turn should - * run on. - */ readonly acquire: (input: { systemPrompt: string | undefined }) => Promise; - /** - * Override the provider name reported on the model (default `use-local-agent`). - */ readonly provider?: string; } @@ -64,7 +43,7 @@ export class LocalAgentLanguageModel implements LanguageModelV3 { constructor(config: LocalAgentLanguageModelConfig) { this.modelId = config.modelId; - this.provider = config.provider ?? PROVIDER_NAME; + this.provider = config.provider ?? AI_SDK_PROVIDER_NAME; this.#acquire = config.acquire; } @@ -72,8 +51,11 @@ export class LocalAgentLanguageModel implements LanguageModelV3 { const command = parseCommand(providerOptionsFor(options).command); const initialConversion = convertPromptToContentBlocks(options.prompt); const acquired = await this.#acquire({ systemPrompt: initialConversion.systemPrompt }); - const conversion = acquired.historyAlreadyApplied - ? convertPromptToContentBlocks(options.prompt, { lastUserOnly: true }) + const conversion = acquired.isSessionBound + ? convertPromptToContentBlocks(options.prompt, { + lastUserOnly: true, + warnOnSystemMessage: true, + }) : initialConversion; const allWarnings = [...conversion.warnings, ...callOptionWarnings(options)]; try { @@ -130,8 +112,11 @@ export class LocalAgentLanguageModel implements LanguageModelV3 { let reasoningBlockId: string | undefined; try { acquired = await acquire({ systemPrompt: initialConversion.systemPrompt }); - const conversion = acquired.historyAlreadyApplied - ? convertPromptToContentBlocks(options.prompt, { lastUserOnly: true }) + const conversion = acquired.isSessionBound + ? convertPromptToContentBlocks(options.prompt, { + lastUserOnly: true, + warnOnSystemMessage: true, + }) : initialConversion; controller.enqueue({ type: "stream-start", @@ -185,14 +170,14 @@ export class LocalAgentLanguageModel implements LanguageModelV3 { type: "tool-result", toolCallId: event.toolCallId, toolName: event.title ?? "", - result: toJsonValue(event.output), + result: toJsonResult(event.output), }); } else if (event.status === "failed") { controller.enqueue({ type: "tool-result", toolCallId: event.toolCallId, toolName: event.title ?? "", - result: toJsonValue(event.output ?? "tool failed"), + result: toJsonResult(event.output ?? "tool failed"), isError: true, }); } @@ -229,7 +214,7 @@ export class LocalAgentLanguageModel implements LanguageModelV3 { } const providerOptionsFor = (options: LanguageModelV3CallOptions): LocalAgentProviderOptions => - (options.providerOptions?.[PROVIDER_KEY] ?? {}) as LocalAgentProviderOptions; + (options.providerOptions?.[AI_SDK_PROVIDER_OPTIONS_KEY] ?? {}) as LocalAgentProviderOptions; const parseCommand = ( raw: SlashCommandInput | string | undefined, @@ -249,18 +234,14 @@ const stringifyInput = (input: unknown): string => { } }; -const toJsonValue = (value: unknown): NonNullable => { - if (value === undefined || value === null) return ""; - if ( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" || - Array.isArray(value) || - (typeof value === "object" && value !== null) - ) { - return value as NonNullable; +const toJsonResult = (value: unknown): NonNullable => { + try { + const roundTripped = JSON.parse(JSON.stringify(value)) as JSONValue | null; + if (roundTripped === null) return String(value); + return roundTripped; + } catch { + return String(value); } - return String(value); }; const callOptionWarnings = ( @@ -336,7 +317,7 @@ const jsonifyUsage = (usage: UsageReport): JSONObject => { try { out.cost = JSON.parse(JSON.stringify(usage.cost)) as JSONObject; } catch { - // ignore + out.cost = null; } } return out; diff --git a/packages/use-local-agent/src/ai-sdk/provider.ts b/packages/use-local-agent/src/ai-sdk/provider.ts index c1cc5b1..01cd8fd 100644 --- a/packages/use-local-agent/src/ai-sdk/provider.ts +++ b/packages/use-local-agent/src/ai-sdk/provider.ts @@ -17,63 +17,38 @@ import { export type LocalAgentProviderSettings = LocalAgentConnectOptions; export interface LocalAgentProvider extends ProviderV3 { - (modelId: SupportedAgentId | string, settings?: LocalAgentProviderSettings): LanguageModelV3; + (modelId: SupportedAgentId, settings?: LocalAgentProviderSettings): LanguageModelV3; languageModel( - modelId: SupportedAgentId | string, + modelId: SupportedAgentId, settings?: LocalAgentProviderSettings, ): LanguageModelV3; - /** - * Create a model from a custom adapter (any ACP-speaking subprocess). - */ fromAdapter(adapter: AgentAdapter, settings?: LocalAgentProviderSettings): LanguageModelV3; } -export interface LocalAgentSessionOptions extends LocalAgentProviderSettings { - /** - * Per-session input forwarded to `agent.createSession()`. Use this for - * `additionalDirectories`, custom `mcpServers` per session, etc. - */ +export interface LocalAgentSessionOptions extends LocalAgentConnectOptions { readonly session?: CreateSessionInput; } export interface LocalAgentSession extends AsyncDisposable { - /** - * The underlying `LocalAgent`. Useful for slash commands, permissions, - * `loadSession`, terminal handlers, and other ACP-specific surfaces. - */ readonly agent: LocalAgent; - /** - * The pre-created session id this model is bound to. - */ readonly sessionId: SessionId; - /** - * AI SDK `LanguageModelV3` bound to this session. Pass to `streamText` / - * `generateText` as many times as you need; each call sends one - * `session/prompt` turn over the same subprocess + session. - */ readonly model: LanguageModelV3; - /** - * Dispose the underlying `LocalAgent`. Idempotent. - */ close(): Promise; } -const mergeSystemPrompt = ( - base: string | undefined, - added: string | undefined, -): string | undefined => { - if (added === undefined) return base; - if (!base) return added; - return `${base}\n\n${added}`; -}; - const mergeConnectOptions = ( base: LocalAgentConnectOptions, override: LocalAgentConnectOptions | undefined, systemPrompt: string | undefined, ): LocalAgentConnectOptions => { const merged: LocalAgentConnectOptions = { ...base, ...(override ?? {}) }; - const mergedSystem = mergeSystemPrompt(merged.systemPrompt, systemPrompt); + const baseSystem = merged.systemPrompt; + const mergedSystem = + systemPrompt === undefined + ? baseSystem + : baseSystem + ? `${baseSystem}\n\n${systemPrompt}` + : systemPrompt; return { ...merged, ...(mergedSystem !== undefined ? { systemPrompt: mergedSystem } : {}), @@ -81,28 +56,36 @@ const mergeConnectOptions = ( }; }; +const buildOneShotAcquire = + ( + target: SupportedAgentId | AgentAdapter, + defaults: LocalAgentConnectOptions, + settings: LocalAgentConnectOptions | undefined, + ) => + async ({ systemPrompt }: { systemPrompt: string | undefined }): Promise => { + const agent = await LocalAgent.connect( + target, + mergeConnectOptions(defaults, settings, systemPrompt), + ); + const sessionId = await agent.createSession(); + return { + agent, + sessionId, + isSessionBound: false, + release: () => agent.close(), + }; + }; + export const createLocalAgentProvider = ( defaults: LocalAgentProviderSettings = {}, ): LocalAgentProvider => { const buildLanguageModel = ( - modelId: SupportedAgentId | string, + modelId: SupportedAgentId, settings?: LocalAgentProviderSettings, ): LanguageModelV3 => new LocalAgentLanguageModel({ modelId, - acquire: async ({ systemPrompt }): Promise => { - const agent = await LocalAgent.connect( - modelId as SupportedAgentId, - mergeConnectOptions(defaults, settings, systemPrompt), - ); - const sessionId = await agent.createSession(); - return { - agent, - sessionId, - historyAlreadyApplied: false, - release: () => agent.close(), - }; - }, + acquire: buildOneShotAcquire(modelId, defaults, settings), }); const buildFromAdapter = ( @@ -111,25 +94,11 @@ export const createLocalAgentProvider = ( ): LanguageModelV3 => new LocalAgentLanguageModel({ modelId: adapter.id, - acquire: async ({ systemPrompt }): Promise => { - const agent = await LocalAgent.connect( - adapter, - mergeConnectOptions(defaults, settings, systemPrompt), - ); - const sessionId = await agent.createSession(); - return { - agent, - sessionId, - historyAlreadyApplied: false, - release: () => agent.close(), - }; - }, + acquire: buildOneShotAcquire(adapter, defaults, settings), }); - const provider = (( - modelId: SupportedAgentId | string, - settings?: LocalAgentProviderSettings, - ) => buildLanguageModel(modelId, settings)) as LocalAgentProvider; + const provider = ((modelId: SupportedAgentId, settings?: LocalAgentProviderSettings) => + buildLanguageModel(modelId, settings)) as LocalAgentProvider; Object.defineProperties(provider, { specificationVersion: { value: "v3", enumerable: true }, @@ -171,41 +140,24 @@ export const localAgent: LocalAgentProvider = createLocalAgentProvider(); /** * Open a long-lived `LocalAgent` and bind a `LanguageModelV3` to a single * pre-created session. Successive `streamText` / `generateText` calls reuse - * the same subprocess and session, so the agent's conversation memory is - * preserved across turns. + * the same subprocess and session. * * @example * ```ts * import { streamText } from "ai"; * import { createLocalAgentSession } from "use-local-agent"; * - * await using session = await createLocalAgentSession("codex", { - * permission: "auto-allow", - * }); - * + * await using session = await createLocalAgentSession("codex"); * await streamText({ model: session.model, prompt: "list TODOs" }); * await streamText({ model: session.model, prompt: "now fix the highest one" }); * ``` - * - * Slash commands are sent via `providerOptions`: - * - * ```ts - * await streamText({ - * model: session.model, - * prompt: "agent client protocol", - * providerOptions: { useLocalAgent: { command: "web" } }, - * }); - * ``` */ export const createLocalAgentSession = async ( - modelOrAdapter: SupportedAgentId | string | AgentAdapter, + modelOrAdapter: SupportedAgentId | AgentAdapter, options: LocalAgentSessionOptions = {}, ): Promise => { const { session: sessionInput, ...connectOptions } = options; - const target = ( - typeof modelOrAdapter === "string" ? modelOrAdapter : modelOrAdapter - ) as SupportedAgentId | AgentAdapter; - const agent = await LocalAgent.connect(target, { + const agent = await LocalAgent.connect(modelOrAdapter, { permission: "auto-allow", ...connectOptions, }); @@ -224,24 +176,22 @@ export const createLocalAgentSession = async ( await agent.close(); }; - const modelId = - typeof modelOrAdapter === "string" ? modelOrAdapter : modelOrAdapter.id; + const modelId = typeof modelOrAdapter === "string" ? modelOrAdapter : modelOrAdapter.id; const model = new LocalAgentLanguageModel({ modelId, acquire: async (): Promise => ({ agent, sessionId, - historyAlreadyApplied: true, + isSessionBound: true, release: async () => {}, }), }); - const session: LocalAgentSession = { + return { agent, sessionId, model, close, [Symbol.asyncDispose]: () => close(), }; - return session; }; diff --git a/packages/use-local-agent/src/constants.ts b/packages/use-local-agent/src/constants.ts index 1233a59..e50ee8e 100644 --- a/packages/use-local-agent/src/constants.ts +++ b/packages/use-local-agent/src/constants.ts @@ -17,6 +17,9 @@ export const PACKAGE_NAME = "use-local-agent"; export const PACKAGE_VERSION = "0.0.0"; export const PACKAGE_TITLE = "Use Local Agent"; +export const AI_SDK_PROVIDER_NAME = "use-local-agent"; +export const AI_SDK_PROVIDER_OPTIONS_KEY = "useLocalAgent"; + export const SUPPORTED_AGENT_IDS = [ "claude", "codex", diff --git a/packages/use-local-agent/tests/ai-sdk-provider.test.ts b/packages/use-local-agent/tests/ai-sdk-provider.test.ts index ae9faac..314a084 100644 --- a/packages/use-local-agent/tests/ai-sdk-provider.test.ts +++ b/packages/use-local-agent/tests/ai-sdk-provider.test.ts @@ -25,7 +25,7 @@ const collectStream = async ( const buildModelWithMock = async ( handlers: MockAgentHandlers, - modelOptions: { historyAlreadyApplied?: boolean } = {}, + modelOptions: { isSessionBound?: boolean; releaseSpy?: () => void } = {}, ) => { const session = await connectMockAgent(handlers); const sessionId = await session.agent.createSession(); @@ -34,8 +34,8 @@ const buildModelWithMock = async ( acquire: async () => ({ agent: session.agent, sessionId, - historyAlreadyApplied: modelOptions.historyAlreadyApplied ?? false, - release: async () => {}, + isSessionBound: modelOptions.isSessionBound ?? false, + release: async () => modelOptions.releaseSpy?.(), }), }); return { model, session }; @@ -278,7 +278,7 @@ describe("LocalAgentLanguageModel.doStream", () => { }); }); -describe("session-bound model (historyAlreadyApplied)", () => { +describe("session-bound model (isSessionBound)", () => { it("only forwards the trailing user message and skips prior history", async () => { const promptCalls: string[][] = []; const { model, session } = await buildModelWithMock( @@ -293,7 +293,7 @@ describe("session-bound model (historyAlreadyApplied)", () => { return { stopReason: "end_turn" }; }, }, - { historyAlreadyApplied: true }, + { isSessionBound: true }, ); try { @@ -333,7 +333,7 @@ describe("session-bound model (historyAlreadyApplied)", () => { return { stopReason: "end_turn" }; }, }, - { historyAlreadyApplied: true }, + { isSessionBound: true }, ); try { @@ -366,7 +366,7 @@ describe("session-bound model (historyAlreadyApplied)", () => { return { stopReason: "end_turn" }; }, }, - { historyAlreadyApplied: true }, + { isSessionBound: true }, ); try { await model.doGenerate({ @@ -378,4 +378,115 @@ describe("session-bound model (historyAlreadyApplied)", () => { await session.close(); } }); + + it("emits a system-message-on-bound-session warning when system messages appear on a bound model", async () => { + const { model, session } = await buildModelWithMock( + { + newSession: () => ({ sessionId: "s-sysbound" }), + prompt: () => ({ stopReason: "end_turn" }), + }, + { isSessionBound: true }, + ); + try { + const result = await model.doGenerate({ + prompt: [ + { + role: "user", + content: [{ type: "text", text: "first" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "ok" }], + }, + { role: "system", content: "Now be more verbose." }, + { role: "user", content: [{ type: "text", text: "follow up" }] }, + ], + }); + const features = result.warnings + .filter((warning) => warning.type === "unsupported") + .map((warning) => warning.feature); + expect(features).toContain("system-message-on-bound-session"); + } finally { + await session.close(); + } + }); + + it("emits no-trailing-user-message warning when bound prompt has no trailing user message", async () => { + const promptCalls: number[] = []; + const { model, session } = await buildModelWithMock( + { + newSession: () => ({ sessionId: "s-nouser" }), + prompt: (request) => { + promptCalls.push(request.prompt.length); + return { stopReason: "end_turn" }; + }, + }, + { isSessionBound: true }, + ); + try { + const result = await model.doGenerate({ + prompt: [ + { role: "user", content: [{ type: "text", text: "first" }] }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + ], + }); + const features = result.warnings + .filter((warning) => warning.type === "unsupported") + .map((warning) => warning.feature); + expect(features).toContain("no-trailing-user-message"); + expect(promptCalls).toEqual([0]); + } finally { + await session.close(); + } + }); +}); + +describe("LocalAgentLanguageModel.doStream cancellation", () => { + it("calls release exactly once when the prompt is aborted mid-stream", async () => { + let releaseCount = 0; + const { model, session } = await buildModelWithMock( + { + newSession: () => ({ sessionId: "s-abort" }), + prompt: async (request, conn) => { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "first chunk" }, + }, + }); + await new Promise((resolveSleep) => setTimeout(resolveSleep, 200)); + return { stopReason: "end_turn" }; + }, + cancel: () => {}, + }, + { releaseSpy: () => releaseCount += 1 }, + ); + + try { + const controller = new AbortController(); + const { stream } = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "go" }] }], + abortSignal: controller.signal, + }); + const reader = stream.getReader(); + const parts: LanguageModelV3StreamPart[] = []; + let abortFired = false; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + if (!abortFired && value.type === "text-delta") { + abortFired = true; + controller.abort(); + } + } + reader.releaseLock(); + expect(releaseCount).toBe(1); + const finishOrError = parts[parts.length - 1]; + expect(["finish", "error"]).toContain(finishOrError.type); + } finally { + await session.close(); + } + }); }); From 460e5777228ad08830c23c51d33d2d6ecf483207 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 22:40:05 -0700 Subject: [PATCH 22/24] feat(spawn-agent)!: rename package use-local-agent -> spawn-agent Renames the package and aligns the AI SDK provider surface with the Vercel AI SDK community convention (factory drops the `Provider` suffix: `createLocalAgentProvider` -> `createSpawnAgent`). Public API surface: - `localAgent` -> `spawnAgent` - `createLocalAgentProvider` -> `createSpawnAgent` - `createLocalAgentSession` -> `createSpawnAgentSession` - `LocalAgent` (runtime class) -> `SpawnAgent` - `LocalAgent{Provider,LanguageModel,Session,ConnectOptions,ClientInfo,Error,...}` -> `SpawnAgent*` - `providerOptions: { useLocalAgent: { command } }` -> `providerOptions: { spawnAgent: { command } }` - `model.provider` is now `"spawn-agent"` Constants in src/constants.ts updated: `PACKAGE_NAME`, `PACKAGE_TITLE`, `AI_SDK_PROVIDER_NAME`, `AI_SDK_PROVIDER_OPTIONS_KEY`. Files renamed via `git mv` to preserve history: - packages/use-local-agent/ -> packages/spawn-agent/ - src/local-agent.ts -> src/spawn-agent.ts - src/ai-sdk/local-agent-language-model.ts -> spawn-agent-language-model.ts - masterdocs/.../integration-notes-for-use-local-agent.md -> integration-notes-for-spawn-agent.md Includes review-pass cleanup: local `spawnAgent` variables renamed to `agent` to avoid shadowing the public default export, error re-exports re-sorted alphabetically, and a major-version changeset documenting the migration. --- .changeset/spawn-agent-rename.md | 22 ++++ CONTRIBUTING.md | 6 +- README.md | 70 +++++------ apps/playground/README.md | 18 +-- apps/playground/index.html | 4 +- apps/playground/package.json | 4 +- apps/playground/src/server.ts | 6 +- apps/playground/tests/spawn.node.spec.ts | 22 ++-- apps/playground/vite.config.ts | 2 +- masterdocs/agent-client-protocol/README.md | 4 +- .../agent-client-protocol/architecture.md | 28 ++--- .../client-side-methods.md | 6 +- .../content-tooling-permissions.md | 12 +- ...d => integration-notes-for-spawn-agent.md} | 60 ++++----- .../sdk-schema-reference.md | 4 +- package.json | 6 +- packages/spawn-agent/README.md | 115 ++++++++++++++++++ .../package.json | 4 +- .../src/adapter.ts | 0 .../src/adapters/claude.ts | 0 .../src/adapters/codex.ts | 0 .../src/adapters/copilot.ts | 0 .../src/adapters/cursor.ts | 0 .../src/adapters/droid.ts | 0 .../src/adapters/gemini.ts | 0 .../src/adapters/index.ts | 0 .../src/adapters/opencode.ts | 0 .../src/adapters/pi.ts | 0 .../src/ai-sdk/convert-prompt.ts | 2 +- packages/spawn-agent/src/ai-sdk/index.ts | 19 +++ .../src/ai-sdk/provider.ts | 87 ++++++------- .../src/ai-sdk/spawn-agent-language-model.ts} | 24 ++-- .../src/connect.ts | 0 .../src/constants.ts | 8 +- .../src/detect.ts | 0 .../src/errors.ts | 44 +++---- .../src/index.ts | 30 ++--- .../src/permission.ts | 0 .../src/spawn-agent.ts} | 62 +++++----- .../src/testing/mock-agent.ts | 16 +-- .../src/types.ts | 0 .../src/utils/async-queue.ts | 0 .../src/utils/cap-buffer.ts | 0 .../src/utils/clock.ts | 0 .../src/utils/extract-error-details.ts | 0 .../src/utils/filter-stdout-noise.ts | 0 .../src/utils/has-string-message.ts | 0 .../src/utils/is-command-available.ts | 0 .../src/utils/is-command-timeout.ts | 0 .../src/utils/is-spawn-not-found.ts | 0 .../src/utils/json-rpc-error.ts | 0 .../src/utils/process-alive.ts | 0 .../src/utils/resolve-package.ts | 0 .../src/utils/run-command.ts | 0 .../src/utils/swallow-rejection.ts | 0 .../tests/adapter-env-fallbacks.test.ts | 0 .../tests/additional-directories.test.ts | 0 .../tests/ai-sdk-provider.test.ts | 57 ++++----- .../tests/auth-retry.test.ts | 0 .../tests/buffer-cap.test.ts | 4 +- .../tests/cancellation.test.ts | 0 .../tests/concurrent-sessions.test.ts | 0 .../tests/inactivity-watchdog.test.ts | 0 .../tests/initialize.test.ts | 2 +- .../tests/late-updates.test.ts | 0 .../tests/list-sessions-pagination.test.ts | 0 .../tests/load-session-replay.test.ts | 0 .../tests/permissions.test.ts | 0 .../tests/prompt-capabilities.test.ts | 0 .../tests/prompt-events.test.ts | 0 .../tests/reliability.test.ts | 18 +-- .../tests/resume-close.test.ts | 0 .../tests/sdk-upgrade.test.ts | 2 +- .../tests/slash-commands.test.ts | 0 .../tests/spawn-failures.test.ts | 12 +- .../tests/stderr-fatal-detection.test.ts | 6 +- .../tests/system-prompt.test.ts | 0 .../tests/terminal.test.ts | 0 .../tests/trace-context.test.ts | 0 .../tests/watchdog-permission.test.ts | 0 .../tests/wire-fuzz.test.ts | 0 .../tsconfig.json | 0 .../vite.config.ts | 0 packages/use-local-agent/README.md | 115 ------------------ packages/use-local-agent/src/ai-sdk/index.ts | 19 --- pnpm-lock.yaml | 6 +- 86 files changed, 463 insertions(+), 463 deletions(-) create mode 100644 .changeset/spawn-agent-rename.md rename masterdocs/agent-client-protocol/{integration-notes-for-use-local-agent.md => integration-notes-for-spawn-agent.md} (87%) create mode 100644 packages/spawn-agent/README.md rename packages/{use-local-agent => spawn-agent}/package.json (87%) rename packages/{use-local-agent => spawn-agent}/src/adapter.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/adapters/claude.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/adapters/codex.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/adapters/copilot.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/adapters/cursor.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/adapters/droid.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/adapters/gemini.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/adapters/index.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/adapters/opencode.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/adapters/pi.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/ai-sdk/convert-prompt.ts (98%) create mode 100644 packages/spawn-agent/src/ai-sdk/index.ts rename packages/{use-local-agent => spawn-agent}/src/ai-sdk/provider.ts (62%) rename packages/{use-local-agent/src/ai-sdk/local-agent-language-model.ts => spawn-agent/src/ai-sdk/spawn-agent-language-model.ts} (94%) rename packages/{use-local-agent => spawn-agent}/src/connect.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/constants.ts (84%) rename packages/{use-local-agent => spawn-agent}/src/detect.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/errors.ts (84%) rename packages/{use-local-agent => spawn-agent}/src/index.ts (81%) rename packages/{use-local-agent => spawn-agent}/src/permission.ts (100%) rename packages/{use-local-agent/src/local-agent.ts => spawn-agent/src/spawn-agent.ts} (97%) rename packages/{use-local-agent => spawn-agent}/src/testing/mock-agent.ts (96%) rename packages/{use-local-agent => spawn-agent}/src/types.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/async-queue.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/cap-buffer.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/clock.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/extract-error-details.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/filter-stdout-noise.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/has-string-message.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/is-command-available.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/is-command-timeout.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/is-spawn-not-found.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/json-rpc-error.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/process-alive.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/resolve-package.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/run-command.ts (100%) rename packages/{use-local-agent => spawn-agent}/src/utils/swallow-rejection.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/adapter-env-fallbacks.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/additional-directories.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/ai-sdk-provider.test.ts (90%) rename packages/{use-local-agent => spawn-agent}/tests/auth-retry.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/buffer-cap.test.ts (92%) rename packages/{use-local-agent => spawn-agent}/tests/cancellation.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/concurrent-sessions.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/inactivity-watchdog.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/initialize.test.ts (98%) rename packages/{use-local-agent => spawn-agent}/tests/late-updates.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/list-sessions-pagination.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/load-session-replay.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/permissions.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/prompt-capabilities.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/prompt-events.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/reliability.test.ts (97%) rename packages/{use-local-agent => spawn-agent}/tests/resume-close.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/sdk-upgrade.test.ts (95%) rename packages/{use-local-agent => spawn-agent}/tests/slash-commands.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/spawn-failures.test.ts (84%) rename packages/{use-local-agent => spawn-agent}/tests/stderr-fatal-detection.test.ts (95%) rename packages/{use-local-agent => spawn-agent}/tests/system-prompt.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/terminal.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/trace-context.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/watchdog-permission.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tests/wire-fuzz.test.ts (100%) rename packages/{use-local-agent => spawn-agent}/tsconfig.json (100%) rename packages/{use-local-agent => spawn-agent}/vite.config.ts (100%) delete mode 100644 packages/use-local-agent/README.md delete mode 100644 packages/use-local-agent/src/ai-sdk/index.ts diff --git a/.changeset/spawn-agent-rename.md b/.changeset/spawn-agent-rename.md new file mode 100644 index 0000000..7a36d2e --- /dev/null +++ b/.changeset/spawn-agent-rename.md @@ -0,0 +1,22 @@ +--- +"spawn-agent": major +--- + +Rename package `use-local-agent` to `spawn-agent` and align the AI SDK provider surface with the Vercel AI SDK convention (factory `createSpawnAgent` drops the `Provider` suffix). + +### Migration + +| Before (`use-local-agent`) | After (`spawn-agent`) | +| ---------------------------------------------------------- | ---------------------------------------------------------- | +| `localAgent` | `spawnAgent` | +| `createLocalAgentProvider` | `createSpawnAgent` | +| `createLocalAgentSession` | `createSpawnAgentSession` | +| `LocalAgent` (class) | `SpawnAgent` | +| `LocalAgentProvider`, `LocalAgentProviderSettings` | `SpawnAgentProvider`, `SpawnAgentProviderSettings` | +| `LocalAgentLanguageModel`, `LocalAgentLanguageModelConfig` | `SpawnAgentLanguageModel`, `SpawnAgentLanguageModelConfig` | +| `LocalAgentSession`, `LocalAgentSessionOptions` | `SpawnAgentSession`, `SpawnAgentSessionOptions` | +| `LocalAgentConnectOptions`, `LocalAgentClientInfo` | `SpawnAgentConnectOptions`, `SpawnAgentClientInfo` | +| `LocalAgentError`, `LocalAgentErrorTag` | `SpawnAgentError`, `SpawnAgentErrorTag` | +| `providerOptions: { useLocalAgent: { command } }` | `providerOptions: { spawnAgent: { command } }` | + +`model.provider` is now `"spawn-agent"`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a7daa0..6fec141 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to use-local-agent +# Contributing to spawn-agent Thanks for your interest in contributing! This document provides guidelines and instructions for contributing. @@ -14,8 +14,8 @@ Thanks for your interest in contributing! This document provides guidelines and 1. Fork and clone the repository: ```bash -git clone https://github.com/YOUR_USERNAME/use-local-agent.git -cd use-local-agent +git clone https://github.com/YOUR_USERNAME/spawn-agent.git +cd spawn-agent ``` 2. Install dependencies using [@antfu/ni](https://github.com/antfu/ni): diff --git a/README.md b/README.md index 4323e00..676111d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# use-local-agent +# spawn-agent -[![version](https://img.shields.io/npm/v/use-local-agent?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/use-local-agent) -[![downloads](https://img.shields.io/npm/dt/use-local-agent.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/use-local-agent) +[![version](https://img.shields.io/npm/v/spawn-agent?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/spawn-agent) +[![downloads](https://img.shields.io/npm/dt/spawn-agent.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/spawn-agent) -An API for accessing any locally-installed coding agent +Spawn any locally-installed coding agent as a Vercel AI SDK provider. A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams over the [Agent Client Protocol](https://agentclientprotocol.com). ## Install ```bash -npm install use-local-agent ai +npm install spawn-agent ai ``` The user also needs the agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code`). Claude Code and Codex additionally need a small ACP shim: @@ -24,10 +24,10 @@ npm install @zed-industries/codex-acp # Codex ```ts import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; +import { spawnAgent } from "spawn-agent"; const { textStream } = streamText({ - model: localAgent("claude"), + model: spawnAgent("claude"), prompt: "Refactor src/auth.ts to use the new session API", }); @@ -36,14 +36,14 @@ for await (const chunk of textStream) { } ``` -Pass settings inline at the call site, or build a pre-configured provider with `createLocalAgentProvider`: +Pass settings inline at the call site, or build a pre-configured provider with `createSpawnAgent`: ```ts import { generateText } from "ai"; -import { localAgent } from "use-local-agent"; +import { spawnAgent } from "spawn-agent"; const { text } = await generateText({ - model: localAgent("codex", { + model: spawnAgent("codex", { cwd: "/Users/me/project", permission: "auto-allow", mcpServers: [ @@ -59,39 +59,39 @@ const { text } = await generateText({ }); ``` -| Setting | Effect | -| --- | --- | -| `cwd` | Working directory the agent operates in. | -| `permission` | `"auto-allow"` (default) / `"auto-allow-once"` / `"auto-reject"` / `"stream"`. | -| `mcpServers` | MCP server configs the agent connects to for extra tools. | -| `additionalDirectories` | Extra absolute paths the agent can read/write. | -| `systemPrompt` | Prepended to user prompts. | -| `inactivityTimeoutMs` | Kill the turn if the agent goes silent (default 3 min). | +| Setting | Effect | +| ----------------------- | ------------------------------------------------------------------------------ | +| `cwd` | Working directory the agent operates in. | +| `permission` | `"auto-allow"` (default) / `"auto-allow-once"` / `"auto-reject"` / `"stream"`. | +| `mcpServers` | MCP server configs the agent connects to for extra tools. | +| `additionalDirectories` | Extra absolute paths the agent can read/write. | +| `systemPrompt` | Prepended to user prompts. | +| `inactivityTimeoutMs` | Kill the turn if the agent goes silent (default 3 min). | ## Supported agents -| ID | Display name | Notes | -| ---------- | ------------------- | -------------------------------------------------- | -| `claude` | Claude Code | requires `@agentclientprotocol/claude-agent-acp` | -| `codex` | Codex | requires `@zed-industries/codex-acp` | -| `cursor` | Cursor Agent | native ACP | -| `copilot` | GitHub Copilot CLI | native ACP | -| `gemini` | Gemini CLI | native ACP | -| `opencode` | OpenCode | native ACP | -| `droid` | Factory Droid | native ACP | -| `pi` | Pi | native ACP | +| ID | Display name | Notes | +| ---------- | ------------------ | ------------------------------------------------ | +| `claude` | Claude Code | requires `@agentclientprotocol/claude-agent-acp` | +| `codex` | Codex | requires `@zed-industries/codex-acp` | +| `cursor` | Cursor Agent | native ACP | +| `copilot` | GitHub Copilot CLI | native ACP | +| `gemini` | Gemini CLI | native ACP | +| `opencode` | OpenCode | native ACP | +| `droid` | Factory Droid | native ACP | +| `pi` | Pi | native ACP | -For a custom ACP-speaking subprocess, use `localAgent.fromAdapter(...)`. +For a custom ACP-speaking subprocess, use `spawnAgent.fromAdapter(...)`. ## Stateful sessions -For multi-turn conversations on a single subprocess, use `createLocalAgentSession`. Each `streamText` call against `session.model` sends one `session/prompt` turn, so the agent's conversation memory is preserved across turns: +For multi-turn conversations on a single subprocess, use `createSpawnAgentSession`. Each `streamText` call against `session.model` sends one `session/prompt` turn, so the agent's conversation memory is preserved across turns: ```ts import { streamText } from "ai"; -import { createLocalAgentSession } from "use-local-agent"; +import { createSpawnAgentSession } from "spawn-agent"; -await using session = await createLocalAgentSession("codex"); +await using session = await createSpawnAgentSession("codex"); await streamText({ model: session.model, prompt: "list TODOs" }); await streamText({ model: session.model, prompt: "now fix the highest one" }); @@ -100,15 +100,15 @@ await streamText({ model: session.model, prompt: "now fix the highest one" }); await streamText({ model: session.model, prompt: "agent client protocol", - providerOptions: { useLocalAgent: { command: "web" } }, + providerOptions: { spawnAgent: { command: "web" } }, }); ``` -For human-in-the-loop permission prompts, terminal handlers, and session resume, the `session.agent` field exposes the underlying `LocalAgent`. See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full API. +For human-in-the-loop permission prompts, terminal handlers, and session resume, the `session.agent` field exposes the underlying `SpawnAgent`. See [`packages/spawn-agent/src/index.ts`](https://github.com/millionco/spawn-agent/blob/main/packages/spawn-agent/src/index.ts) for the full API. ## Contributing -[Contributing Guide](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md) ยท [Issues](https://github.com/millionco/use-local-agent/issues) +[Contributing Guide](https://github.com/millionco/spawn-agent/blob/main/CONTRIBUTING.md) ยท [Issues](https://github.com/millionco/spawn-agent/issues) ### License diff --git a/apps/playground/README.md b/apps/playground/README.md index fad9ee9..57f879c 100644 --- a/apps/playground/README.md +++ b/apps/playground/README.md @@ -1,11 +1,11 @@ -# `@use-local-agent/playground` +# `@spawn-agent/playground` -Local web playground for the `use-local-agent` library. +Local web playground for the `spawn-agent` library. ```bash pnpm install -pnpm --filter @use-local-agent/playground dev # vite dev server -pnpm --filter @use-local-agent/playground test # playwright (node + browser) +pnpm --filter @spawn-agent/playground dev # vite dev server +pnpm --filter @spawn-agent/playground test # playwright (node + browser) ``` ## What it does @@ -44,7 +44,7 @@ handlers for them. Two examples: ### Filesystem ```ts -const agent = await LocalAgent.connect("claude", { +const agent = await SpawnAgent.connect("claude", { fileSystem: { readTextFile: async ({ path, line, limit }) => { const content = await readFile(path, "utf-8"); @@ -60,13 +60,13 @@ const agent = await LocalAgent.connect("claude", { ### Terminal (`terminal/*`) -Pass `terminal: TerminalHandlers` to `LocalAgent.connect` to advertise +Pass `terminal: TerminalHandlers` to `SpawnAgent.connect` to advertise `clientCapabilities.terminal = true` and forward the agent's `terminal/{create,output,wait_for_exit,kill,release}` calls. Always pair `createTerminal` with a deterministic `releaseTerminal` to free resources. ```ts -const agent = await LocalAgent.connect("codex", { +const agent = await SpawnAgent.connect("codex", { terminal: { createTerminal: async (params) => myShell.create(params), terminalOutput: async (params) => myShell.output(params), @@ -102,6 +102,6 @@ Paths must be absolute; relative paths throw `AgentStreamError`. Run: ```bash -pnpm --filter @use-local-agent/playground exec playwright test --project=node -pnpm --filter @use-local-agent/playground exec playwright test --project=chromium +pnpm --filter @spawn-agent/playground exec playwright test --project=node +pnpm --filter @spawn-agent/playground exec playwright test --project=chromium ``` diff --git a/apps/playground/index.html b/apps/playground/index.html index 4eaae00..d7b360d 100644 --- a/apps/playground/index.html +++ b/apps/playground/index.html @@ -3,12 +3,12 @@ - use-local-agent playground + spawn-agent playground
-

use-local-agent playground

+

spawn-agent playground

disconnected
diff --git a/apps/playground/package.json b/apps/playground/package.json index 465494c..e28d424 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -1,5 +1,5 @@ { - "name": "@use-local-agent/playground", + "name": "@spawn-agent/playground", "version": "0.0.0", "private": true, "type": "module", @@ -14,7 +14,7 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.20.0", "@wterm/dom": "^0.1.9", - "use-local-agent": "workspace:*", + "spawn-agent": "workspace:*", "ws": "^8.18.0" }, "devDependencies": { diff --git a/apps/playground/src/server.ts b/apps/playground/src/server.ts index f8a9a11..5c9596b 100644 --- a/apps/playground/src/server.ts +++ b/apps/playground/src/server.ts @@ -2,7 +2,7 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import type { IncomingMessage, Server as HttpServer } from "node:http"; import { WebSocketServer, type WebSocket } from "ws"; -import { LocalAgent, type AgentAdapter, type AgentEvent, type SessionId } from "use-local-agent"; +import { SpawnAgent, type AgentAdapter, type AgentEvent, type SessionId } from "spawn-agent"; const here = dirname(fileURLToPath(import.meta.url)); const echoAgentPath = resolve(here, "./echo-agent.mjs"); @@ -68,7 +68,7 @@ const send = (socket: WebSocket, payload: OutgoingMessageJson): void => { }; const handleConnection = async (socket: WebSocket): Promise => { - let agent: LocalAgent | undefined; + let agent: SpawnAgent | undefined; let sessionId: SessionId | undefined; let activeStreamCancel: (() => Promise) | undefined; @@ -78,7 +78,7 @@ const handleConnection = async (socket: WebSocket): Promise => { }; try { - agent = await LocalAgent.connect(echoAdapter, { + agent = await SpawnAgent.connect(echoAdapter, { cwd: process.cwd(), permission: "auto-allow", inactivityTimeoutMs: 0, diff --git a/apps/playground/tests/spawn.node.spec.ts b/apps/playground/tests/spawn.node.spec.ts index 600291f..e85ee69 100644 --- a/apps/playground/tests/spawn.node.spec.ts +++ b/apps/playground/tests/spawn.node.spec.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { expect, test } from "@playwright/test"; -import { LocalAgent, type AgentAdapter, type AgentEvent, type SessionId } from "use-local-agent"; +import { SpawnAgent, type AgentAdapter, type AgentEvent, type SessionId } from "spawn-agent"; const here = dirname(fileURLToPath(import.meta.url)); const echoAgentPath = resolve(here, "../src/echo-agent.mjs"); @@ -24,7 +24,7 @@ const collect = async (events: AsyncIterable): Promise { test("initialize negotiates capabilities and exposes agent info", async () => { - const agent = await LocalAgent.connect(adapter, { + const agent = await SpawnAgent.connect(adapter, { cwd: process.cwd(), inactivityTimeoutMs: 0, }); @@ -36,7 +36,7 @@ test.describe("e2e: real subprocess", () => { }); test("prompt streams text deltas through real stdio", async () => { - const agent = await LocalAgent.connect(adapter, { + const agent = await SpawnAgent.connect(adapter, { cwd: process.cwd(), inactivityTimeoutMs: 0, }); @@ -58,7 +58,7 @@ test.describe("e2e: real subprocess", () => { }); test("emits synthetic config-options from newSession on first prompt", async () => { - const agent = await LocalAgent.connect(adapter, { + const agent = await SpawnAgent.connect(adapter, { cwd: process.cwd(), inactivityTimeoutMs: 0, }); @@ -73,7 +73,7 @@ test.describe("e2e: real subprocess", () => { }); test("handles tool_call and tool_call_update", async () => { - const agent = await LocalAgent.connect(adapter, { + const agent = await SpawnAgent.connect(adapter, { cwd: process.cwd(), inactivityTimeoutMs: 0, }); @@ -88,7 +88,7 @@ test.describe("e2e: real subprocess", () => { }); test("usage_update produces typed usage event", async () => { - const agent = await LocalAgent.connect(adapter, { + const agent = await SpawnAgent.connect(adapter, { cwd: process.cwd(), inactivityTimeoutMs: 0, }); @@ -104,8 +104,8 @@ test.describe("e2e: real subprocess", () => { }); test("auth_required JSON-RPC error becomes AgentUnauthenticatedError", async () => { - const { AgentUnauthenticatedError } = await import("use-local-agent"); - const agent = await LocalAgent.connect(adapter, { + const { AgentUnauthenticatedError } = await import("spawn-agent"); + const agent = await SpawnAgent.connect(adapter, { cwd: process.cwd(), inactivityTimeoutMs: 0, }); @@ -116,7 +116,7 @@ test.describe("e2e: real subprocess", () => { }); test("session/cancel resolves with cancelled stop reason", async () => { - const agent = await LocalAgent.connect(adapter, { + const agent = await SpawnAgent.connect(adapter, { cwd: process.cwd(), inactivityTimeoutMs: 0, }); @@ -129,8 +129,8 @@ test.describe("e2e: real subprocess", () => { }); test("subprocess exit fails active stream with AgentConnectionClosedError", async () => { - const { AgentConnectionClosedError, AgentStreamError } = await import("use-local-agent"); - const agent = await LocalAgent.connect(adapter, { + const { AgentConnectionClosedError, AgentStreamError } = await import("spawn-agent"); + const agent = await SpawnAgent.connect(adapter, { cwd: process.cwd(), inactivityTimeoutMs: 0, }); diff --git a/apps/playground/vite.config.ts b/apps/playground/vite.config.ts index a9defa9..ca836c2 100644 --- a/apps/playground/vite.config.ts +++ b/apps/playground/vite.config.ts @@ -3,7 +3,7 @@ import type { ViteDevServer } from "vite"; import { defineConfig } from "vite"; const wsBridgePlugin = () => ({ - name: "use-local-agent-ws-bridge", + name: "spawn-agent-ws-bridge", async configureServer(server: ViteDevServer) { if (!server.httpServer) return; const { attachWebSocketBridge } = await import("./src/server"); diff --git a/masterdocs/agent-client-protocol/README.md b/masterdocs/agent-client-protocol/README.md index 4b66ed4..0ece355 100644 --- a/masterdocs/agent-client-protocol/README.md +++ b/masterdocs/agent-client-protocol/README.md @@ -1,6 +1,6 @@ # Agent Client Protocol masterdocs -This directory is a durable reference for understanding Agent Client Protocol (ACP), the upstream schema SDK, and how those concepts map to this repository's `use-local-agent` package. +This directory is a durable reference for understanding Agent Client Protocol (ACP), the upstream schema SDK, and how those concepts map to this repository's `spawn-agent` package. ACP standardizes communication between coding-agent clients, usually editors or host applications, and local or remote coding agents that modify source code. The upstream protocol is JSON-RPC 2.0 based, transport-agnostic, and most commonly run over stdio with the agent as a subprocess. @@ -27,7 +27,7 @@ ACP standardizes communication between coding-agent clients, usually editors or 9. [Unstable drafts and RFDs](./unstable-drafts-and-rfds.md) 10. [SDK schema reference](./sdk-schema-reference.md) 11. [GitHub history, issues, and PRs](./github-history-issues-prs.md) -12. [Integration notes for use-local-agent](./integration-notes-for-use-local-agent.md) +12. [Integration notes for spawn-agent](./integration-notes-for-spawn-agent.md) ## Primary upstream references diff --git a/masterdocs/agent-client-protocol/architecture.md b/masterdocs/agent-client-protocol/architecture.md index 9142997..d108c5f 100644 --- a/masterdocs/agent-client-protocol/architecture.md +++ b/masterdocs/agent-client-protocol/architecture.md @@ -86,30 +86,30 @@ References: - Session list docs: [`docs/protocol/session-list.mdx`](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/protocol/session-list.mdx) - Session modes docs: [`docs/protocol/session-modes.mdx`](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/protocol/session-modes.mdx) -## How this maps to `use-local-agent` +## How this maps to `spawn-agent` This repository is a TypeScript client-side wrapper around local ACP agents. Concept mapping: -| ACP concept | `use-local-agent` concept | Source | -| ----------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Agent subprocess | Adapter resolution and connection | [`packages/use-local-agent/src/adapter.ts`](../../packages/use-local-agent/src/adapter.ts), [`connect.ts`](../../packages/use-local-agent/src/connect.ts) | -| Client-side ACP wrapper | `LocalAgent` | [`local-agent.ts`](../../packages/use-local-agent/src/local-agent.ts) | -| Session ID | Branded `SessionId` string | [`types.ts`](../../packages/use-local-agent/src/types.ts) | -| Prompt turn stream | `AgentStream` and `AgentEvent` | [`types.ts`](../../packages/use-local-agent/src/types.ts) | -| Permission handling | `PermissionPolicy`, `PendingPermission` | [`permission.ts`](../../packages/use-local-agent/src/permission.ts), [`types.ts`](../../packages/use-local-agent/src/types.ts) | -| Agent launch variants | Built-in adapters for Claude, Codex, Cursor, Gemini, etc. | [`packages/use-local-agent/src/adapters`](../../packages/use-local-agent/src/adapters) | +| ACP concept | `spawn-agent` concept | Source | +| ----------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Agent subprocess | Adapter resolution and connection | [`packages/spawn-agent/src/adapter.ts`](../../packages/spawn-agent/src/adapter.ts), [`connect.ts`](../../packages/spawn-agent/src/connect.ts) | +| Client-side ACP wrapper | `SpawnAgent` | [`spawn-agent.ts`](../../packages/spawn-agent/src/spawn-agent.ts) | +| Session ID | Branded `SessionId` string | [`types.ts`](../../packages/spawn-agent/src/types.ts) | +| Prompt turn stream | `AgentStream` and `AgentEvent` | [`types.ts`](../../packages/spawn-agent/src/types.ts) | +| Permission handling | `PermissionPolicy`, `PendingPermission` | [`permission.ts`](../../packages/spawn-agent/src/permission.ts), [`types.ts`](../../packages/spawn-agent/src/types.ts) | +| Agent launch variants | Built-in adapters for Claude, Codex, Cursor, Gemini, etc. | [`packages/spawn-agent/src/adapters`](../../packages/spawn-agent/src/adapters) | -`LocalAgent.connect` is the high-level entry point. It resolves an adapter, launches/connects to an ACP-compatible command, initializes the protocol, records capabilities, and installs dispatcher handlers for updates and permission requests. +`SpawnAgent.connect` is the high-level entry point. It resolves an adapter, launches/connects to an ACP-compatible command, initializes the protocol, records capabilities, and installs dispatcher handlers for updates and permission requests. -`LocalAgent` is the long-lived connection shape for multiple sessions and turns. For one-shot prompt execution, use the AI SDK provider (`streamText({ model: localAgent('claude'), prompt: '...' })`). +`SpawnAgent` is the long-lived connection shape for multiple sessions and turns. For one-shot prompt execution, use the AI SDK provider (`streamText({ model: spawnAgent('claude'), prompt: '...' })`). References: -- Package README: [`packages/use-local-agent/README.md`](../../packages/use-local-agent/README.md) -- Public exports: [`packages/use-local-agent/src/index.ts`](../../packages/use-local-agent/src/index.ts) -- Core client implementation: [`packages/use-local-agent/src/local-agent.ts`](../../packages/use-local-agent/src/local-agent.ts) +- Package README: [`packages/spawn-agent/README.md`](../../packages/spawn-agent/README.md) +- Public exports: [`packages/spawn-agent/src/index.ts`](../../packages/spawn-agent/src/index.ts) +- Core client implementation: [`packages/spawn-agent/src/spawn-agent.ts`](../../packages/spawn-agent/src/spawn-agent.ts) ## Design implications for implementers diff --git a/masterdocs/agent-client-protocol/client-side-methods.md b/masterdocs/agent-client-protocol/client-side-methods.md index 29a2b9c..52cba3d 100644 --- a/masterdocs/agent-client-protocol/client-side-methods.md +++ b/masterdocs/agent-client-protocol/client-side-methods.md @@ -79,12 +79,12 @@ Critical cancellation requirement: This prevents deadlocks where the agent waits forever for a permission answer on a cancelled turn. -`use-local-agent` mapping: +`spawn-agent` mapping: -- `LocalAgent` exposes streamed permission requests as `AgentEvent` with `type: "permission-request"`. +- `SpawnAgent` exposes streamed permission requests as `AgentEvent` with `type: "permission-request"`. - `PendingPermission.respond(optionId)` sends a selected outcome. - `PendingPermission.cancel()` sends the cancelled outcome. -- Permission policies are implemented in `packages/use-local-agent/src/permission.ts`. +- Permission policies are implemented in `packages/spawn-agent/src/permission.ts`. ## Filesystem methods diff --git a/masterdocs/agent-client-protocol/content-tooling-permissions.md b/masterdocs/agent-client-protocol/content-tooling-permissions.md index 7349a83..e86d274 100644 --- a/masterdocs/agent-client-protocol/content-tooling-permissions.md +++ b/masterdocs/agent-client-protocol/content-tooling-permissions.md @@ -205,10 +205,10 @@ turn with `RequestPermissionOutcome::Cancelled`. This is not optional. Without this response, the agent may remain blocked waiting for a permission decision while the client believes the turn has been cancelled. -## Mapping to `use-local-agent` +## Mapping to `spawn-agent` -`use-local-agent` maps ACP session updates into public `AgentEvent` variants in -[`packages/use-local-agent/src/types.ts`](../../packages/use-local-agent/src/types.ts): +`spawn-agent` maps ACP session updates into public `AgentEvent` variants in +[`packages/spawn-agent/src/types.ts`](../../packages/spawn-agent/src/types.ts): - `agent_message_chunk` โ†’ `text-delta` - `agent_thought_chunk` โ†’ `thinking-delta` @@ -232,9 +232,9 @@ Permission events appear as `permission-request` and expose a `PendingPermission See: -- [`packages/use-local-agent/src/local-agent.ts`](../../packages/use-local-agent/src/local-agent.ts) -- [`packages/use-local-agent/src/permission.ts`](../../packages/use-local-agent/src/permission.ts) -- [`packages/use-local-agent/src/types.ts`](../../packages/use-local-agent/src/types.ts) +- [`packages/spawn-agent/src/spawn-agent.ts`](../../packages/spawn-agent/src/spawn-agent.ts) +- [`packages/spawn-agent/src/permission.ts`](../../packages/spawn-agent/src/permission.ts) +- [`packages/spawn-agent/src/types.ts`](../../packages/spawn-agent/src/types.ts) ## Implementation checklist diff --git a/masterdocs/agent-client-protocol/integration-notes-for-use-local-agent.md b/masterdocs/agent-client-protocol/integration-notes-for-spawn-agent.md similarity index 87% rename from masterdocs/agent-client-protocol/integration-notes-for-use-local-agent.md rename to masterdocs/agent-client-protocol/integration-notes-for-spawn-agent.md index a4d8f2d..0650a65 100644 --- a/masterdocs/agent-client-protocol/integration-notes-for-use-local-agent.md +++ b/masterdocs/agent-client-protocol/integration-notes-for-spawn-agent.md @@ -1,25 +1,25 @@ -# Integration notes for `use-local-agent` +# Integration notes for `spawn-agent` This repository is a TypeScript wrapper around locally installed ACP agents. ACP is -the protocol boundary; `use-local-agent` supplies process launching, JSON-RPC +the protocol boundary; `spawn-agent` supplies process launching, JSON-RPC connection management, session convenience methods, streaming event conversion, and permission policy handling. References: -- `packages/use-local-agent/README.md` -- `packages/use-local-agent/src/local-agent.ts` -- `packages/use-local-agent/src/connect.ts` -- `packages/use-local-agent/src/types.ts` -- `packages/use-local-agent/src/stream.ts` -- `packages/use-local-agent/src/permission.ts` -- `packages/use-local-agent/src/adapters/` +- `packages/spawn-agent/README.md` +- `packages/spawn-agent/src/spawn-agent.ts` +- `packages/spawn-agent/src/connect.ts` +- `packages/spawn-agent/src/types.ts` +- `packages/spawn-agent/src/stream.ts` +- `packages/spawn-agent/src/permission.ts` +- `packages/spawn-agent/src/adapters/` - Upstream TypeScript SDK docs: - Upstream schema source: ## ACP package boundary -`packages/use-local-agent/src/types.ts` imports wire types from +`packages/spawn-agent/src/types.ts` imports wire types from `@agentclientprotocol/sdk` and re-exports or wraps them in the public API: - `StopReason` @@ -58,13 +58,13 @@ The wrapper's custom public types are mostly ergonomic views: ## Adapter launch model -ACP assumes a bidirectional transport. `use-local-agent` primarily targets local +ACP assumes a bidirectional transport. `spawn-agent` primarily targets local subprocess transports: -- Built-in adapters live under `packages/use-local-agent/src/adapters/`. +- Built-in adapters live under `packages/spawn-agent/src/adapters/`. - Each adapter resolves launch metadata: binary, args, and environment. - Some tools speak ACP natively; others use ACP shim packages. -- `LocalAgent.connect(adapterOrId, options)` starts the process through `connect`. +- `SpawnAgent.connect(adapterOrId, options)` starts the process through `connect`. Relevant adapter references: @@ -79,7 +79,7 @@ Relevant adapter references: ## Connection initialization -`LocalAgent.connect` maps to ACP initialization: +`SpawnAgent.connect` maps to ACP initialization: 1. Resolve the adapter and launch the process. 2. Build client capabilities from options. @@ -105,12 +105,12 @@ Important ACP implications: ## Client capabilities exposed by this wrapper -`LocalAgentConnectOptions` includes: +`SpawnAgentConnectOptions` includes: - `fileSystem?: FileSystemHandlers` - `permission?: PermissionPolicy` - `mcpServers?: readonly McpServer[]` -- `clientInfo?: LocalAgentClientInfo` +- `clientInfo?: SpawnAgentClientInfo` - timeouts and environment options The wrapper can expose filesystem handlers when supplied: @@ -122,7 +122,7 @@ ACP requires agents to call filesystem methods only if the corresponding client capability is advertised. If handlers are not supplied, the wrapper should not advertise support or should reject unsupported requests consistently. -Terminal methods are not represented in `LocalAgentConnectOptions` in the same way. +Terminal methods are not represented in `SpawnAgentConnectOptions` in the same way. If terminal support is added later, it should be all-or-nothing for the ACP `terminal/*` method family unless the protocol changes. @@ -135,7 +135,7 @@ Public methods map to ACP session methods: - Prompting a session โ†’ `session/prompt` - Stream cancellation โ†’ `session/cancel` -Session state in `local-agent.ts` tracks: +Session state in `spawn-agent.ts` tracks: - session id - cwd @@ -160,7 +160,7 @@ ACP implications: ## Prompt streaming and event mapping ACP `session/prompt` returns only when the prompt turn completes. While it runs, the -agent streams `session/update` notifications. `use-local-agent` converts those into +agent streams `session/update` notifications. `spawn-agent` converts those into `AgentEvent` values. Current mapping in `AgentEvent`: @@ -185,7 +185,7 @@ through as `raw` until the wrapper adds first-class events. ## Activity and inactivity -`local-agent.ts` defines meaningful activity as: +`spawn-agent.ts` defines meaningful activity as: - agent message chunks - thought chunks @@ -208,7 +208,7 @@ ACP permission requests are agent-to-client JSON-RPC requests: - request includes session id, tool call update, and options - response includes either `selected` or `cancelled` -`use-local-agent` supports permission policies: +`spawn-agent` supports permission policies: - auto allow - auto allow once @@ -271,7 +271,7 @@ This maps naturally to ACP `session/set_config_option`, not the unstable ## Error mapping -`local-agent.ts` imports error helpers: +`spawn-agent.ts` imports error helpers: - `isAuthRequiredJsonRpcError` - `isMethodNotFoundJsonRpcError` @@ -355,14 +355,14 @@ NES is unstable and separate from chat sessions: Local source: -- Public exports: `packages/use-local-agent/src/index.ts` -- Public wire and wrapper types: `packages/use-local-agent/src/types.ts` -- Main implementation: `packages/use-local-agent/src/local-agent.ts` -- Process/RPC connection: `packages/use-local-agent/src/connect.ts` -- One-shot streaming helper: `packages/use-local-agent/src/stream.ts` -- Permission policies: `packages/use-local-agent/src/permission.ts` -- Built-in adapters: `packages/use-local-agent/src/adapters/` -- Tests: `packages/use-local-agent/tests/` +- Public exports: `packages/spawn-agent/src/index.ts` +- Public wire and wrapper types: `packages/spawn-agent/src/types.ts` +- Main implementation: `packages/spawn-agent/src/spawn-agent.ts` +- Process/RPC connection: `packages/spawn-agent/src/connect.ts` +- One-shot streaming helper: `packages/spawn-agent/src/stream.ts` +- Permission policies: `packages/spawn-agent/src/permission.ts` +- Built-in adapters: `packages/spawn-agent/src/adapters/` +- Tests: `packages/spawn-agent/tests/` Upstream source: diff --git a/masterdocs/agent-client-protocol/sdk-schema-reference.md b/masterdocs/agent-client-protocol/sdk-schema-reference.md index 8ba32bf..4bb84cb 100644 --- a/masterdocs/agent-client-protocol/sdk-schema-reference.md +++ b/masterdocs/agent-client-protocol/sdk-schema-reference.md @@ -74,8 +74,8 @@ From the upstream README: This repo imports the TypeScript SDK as `@agentclientprotocol/sdk` in: -- `packages/use-local-agent/src/types.ts` -- `packages/use-local-agent/src/local-agent.ts` +- `packages/spawn-agent/src/types.ts` +- `packages/spawn-agent/src/spawn-agent.ts` ## How to read the schema diff --git a/package.json b/package.json index 5d9023a..ca7d451 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "type": "module", "scripts": { "build": "turbo run build", - "dev": "turbo run dev --filter=@use-local-agent/playground", - "test": "turbo run test --filter=use-local-agent", - "test:e2e": "pnpm --filter @use-local-agent/playground test", + "dev": "turbo run dev --filter=@spawn-agent/playground", + "test": "turbo run test --filter=spawn-agent", + "test:e2e": "pnpm --filter @spawn-agent/playground test", "typecheck": "turbo run typecheck", "lint": "vp lint", "lint:fix": "vp lint --fix", diff --git a/packages/spawn-agent/README.md b/packages/spawn-agent/README.md new file mode 100644 index 0000000..d2f87fa --- /dev/null +++ b/packages/spawn-agent/README.md @@ -0,0 +1,115 @@ +# spawn-agent + +[![version](https://img.shields.io/npm/v/spawn-agent?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/spawn-agent) +[![downloads](https://img.shields.io/npm/dt/spawn-agent.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/spawn-agent) + +Spawn any locally-installed coding agent as a Vercel AI SDK provider. + +A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams over the [Agent Client Protocol](https://agentclientprotocol.com). + +## Install + +```bash +npm install spawn-agent ai +``` + +The user also needs the agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code`). Claude Code and Codex additionally need a small ACP shim: + +```bash +npm install @agentclientprotocol/claude-agent-acp # Claude Code +npm install @zed-industries/codex-acp # Codex +``` + +## Usage + +```ts +import { streamText } from "ai"; +import { spawnAgent } from "spawn-agent"; + +const { textStream } = streamText({ + model: spawnAgent("claude"), + prompt: "Refactor src/auth.ts to use the new session API", +}); + +for await (const chunk of textStream) { + process.stdout.write(chunk); +} +``` + +Pass settings inline at the call site, or build a pre-configured provider with `createSpawnAgent`: + +```ts +import { generateText } from "ai"; +import { spawnAgent } from "spawn-agent"; + +const { text } = await generateText({ + model: spawnAgent("codex", { + cwd: "/Users/me/project", + permission: "auto-allow", + mcpServers: [ + { + type: "stdio", + name: "filesystem", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + }, + ], + }), + prompt: "Summarize README.md in three bullets", +}); +``` + +| Setting | Effect | +| ----------------------- | ------------------------------------------------------------------------------ | +| `cwd` | Working directory the agent operates in. | +| `permission` | `"auto-allow"` (default) / `"auto-allow-once"` / `"auto-reject"` / `"stream"`. | +| `mcpServers` | MCP server configs the agent connects to for extra tools. | +| `additionalDirectories` | Extra absolute paths the agent can read/write. | +| `systemPrompt` | Prepended to user prompts. | +| `inactivityTimeoutMs` | Kill the turn if the agent goes silent (default 3 min). | + +## Supported agents + +| ID | Display name | Notes | +| ---------- | ------------------ | ------------------------------------------------ | +| `claude` | Claude Code | requires `@agentclientprotocol/claude-agent-acp` | +| `codex` | Codex | requires `@zed-industries/codex-acp` | +| `cursor` | Cursor Agent | native ACP | +| `copilot` | GitHub Copilot CLI | native ACP | +| `gemini` | Gemini CLI | native ACP | +| `opencode` | OpenCode | native ACP | +| `droid` | Factory Droid | native ACP | +| `pi` | Pi | native ACP | + +For a custom ACP-speaking subprocess, use `spawnAgent.fromAdapter(...)`. + +## Stateful sessions + +For multi-turn conversations on a single subprocess, use `createSpawnAgentSession`. Each `streamText` call against `session.model` sends one `session/prompt` turn, so the agent's conversation memory is preserved across turns: + +```ts +import { streamText } from "ai"; +import { createSpawnAgentSession } from "spawn-agent"; + +await using session = await createSpawnAgentSession("codex"); + +await streamText({ model: session.model, prompt: "list TODOs" }); +await streamText({ model: session.model, prompt: "now fix the highest one" }); + +// slash commands via providerOptions +await streamText({ + model: session.model, + prompt: "agent client protocol", + providerOptions: { spawnAgent: { command: "web" } }, +}); +``` + +For human-in-the-loop permission prompts, terminal handlers, and session resume, the `session.agent` field exposes the underlying `SpawnAgent`. See [`src/index.ts`](https://github.com/millionco/spawn-agent/blob/main/packages/spawn-agent/src/index.ts) for the full API. + +## Contributing + +[Contributing Guide](https://github.com/millionco/spawn-agent/blob/main/CONTRIBUTING.md) ยท [Issues](https://github.com/millionco/spawn-agent/issues) + +### License + +MIT diff --git a/packages/use-local-agent/package.json b/packages/spawn-agent/package.json similarity index 87% rename from packages/use-local-agent/package.json rename to packages/spawn-agent/package.json index 3891bba..79b0645 100644 --- a/packages/use-local-agent/package.json +++ b/packages/spawn-agent/package.json @@ -1,7 +1,7 @@ { - "name": "use-local-agent", + "name": "spawn-agent", "version": "0.0.0", - "description": "An API for accessing any locally-installed coding agent (Claude Code, Codex, Cursor, Copilot, Gemini, OpenCode, Droid, Pi) over the Agent Client Protocol.", + "description": "Spawn any locally-installed coding agent (Claude Code, Codex, Cursor, Copilot, Gemini, OpenCode, Droid, Pi) as a Vercel AI SDK provider over the Agent Client Protocol.", "license": "MIT", "files": [ "dist", diff --git a/packages/use-local-agent/src/adapter.ts b/packages/spawn-agent/src/adapter.ts similarity index 100% rename from packages/use-local-agent/src/adapter.ts rename to packages/spawn-agent/src/adapter.ts diff --git a/packages/use-local-agent/src/adapters/claude.ts b/packages/spawn-agent/src/adapters/claude.ts similarity index 100% rename from packages/use-local-agent/src/adapters/claude.ts rename to packages/spawn-agent/src/adapters/claude.ts diff --git a/packages/use-local-agent/src/adapters/codex.ts b/packages/spawn-agent/src/adapters/codex.ts similarity index 100% rename from packages/use-local-agent/src/adapters/codex.ts rename to packages/spawn-agent/src/adapters/codex.ts diff --git a/packages/use-local-agent/src/adapters/copilot.ts b/packages/spawn-agent/src/adapters/copilot.ts similarity index 100% rename from packages/use-local-agent/src/adapters/copilot.ts rename to packages/spawn-agent/src/adapters/copilot.ts diff --git a/packages/use-local-agent/src/adapters/cursor.ts b/packages/spawn-agent/src/adapters/cursor.ts similarity index 100% rename from packages/use-local-agent/src/adapters/cursor.ts rename to packages/spawn-agent/src/adapters/cursor.ts diff --git a/packages/use-local-agent/src/adapters/droid.ts b/packages/spawn-agent/src/adapters/droid.ts similarity index 100% rename from packages/use-local-agent/src/adapters/droid.ts rename to packages/spawn-agent/src/adapters/droid.ts diff --git a/packages/use-local-agent/src/adapters/gemini.ts b/packages/spawn-agent/src/adapters/gemini.ts similarity index 100% rename from packages/use-local-agent/src/adapters/gemini.ts rename to packages/spawn-agent/src/adapters/gemini.ts diff --git a/packages/use-local-agent/src/adapters/index.ts b/packages/spawn-agent/src/adapters/index.ts similarity index 100% rename from packages/use-local-agent/src/adapters/index.ts rename to packages/spawn-agent/src/adapters/index.ts diff --git a/packages/use-local-agent/src/adapters/opencode.ts b/packages/spawn-agent/src/adapters/opencode.ts similarity index 100% rename from packages/use-local-agent/src/adapters/opencode.ts rename to packages/spawn-agent/src/adapters/opencode.ts diff --git a/packages/use-local-agent/src/adapters/pi.ts b/packages/spawn-agent/src/adapters/pi.ts similarity index 100% rename from packages/use-local-agent/src/adapters/pi.ts rename to packages/spawn-agent/src/adapters/pi.ts diff --git a/packages/use-local-agent/src/ai-sdk/convert-prompt.ts b/packages/spawn-agent/src/ai-sdk/convert-prompt.ts similarity index 98% rename from packages/use-local-agent/src/ai-sdk/convert-prompt.ts rename to packages/spawn-agent/src/ai-sdk/convert-prompt.ts index ccf534d..c0e96c1 100644 --- a/packages/use-local-agent/src/ai-sdk/convert-prompt.ts +++ b/packages/spawn-agent/src/ai-sdk/convert-prompt.ts @@ -50,7 +50,7 @@ export const convertPromptToContentBlocks = ( type: "unsupported", feature: "system-message-on-bound-session", details: - "System messages can only be applied at session creation. Pass `systemPrompt` to `createLocalAgentSession` instead", + "System messages can only be applied at session creation. Pass `systemPrompt` to `createSpawnAgentSession` instead", }); } else { systemParts.push(message.content); diff --git a/packages/spawn-agent/src/ai-sdk/index.ts b/packages/spawn-agent/src/ai-sdk/index.ts new file mode 100644 index 0000000..999fc5c --- /dev/null +++ b/packages/spawn-agent/src/ai-sdk/index.ts @@ -0,0 +1,19 @@ +export { + SpawnAgentLanguageModel, + type AcquiredSession, + type SpawnAgentLanguageModelConfig, +} from "./spawn-agent-language-model"; +export { + createSpawnAgent, + createSpawnAgentSession, + spawnAgent, + type SpawnAgentProvider, + type SpawnAgentProviderSettings, + type SpawnAgentSession, + type SpawnAgentSessionOptions, +} from "./provider"; +export { + convertPromptToContentBlocks, + type PromptConversionOptions, + type PromptConversionResult, +} from "./convert-prompt"; diff --git a/packages/use-local-agent/src/ai-sdk/provider.ts b/packages/spawn-agent/src/ai-sdk/provider.ts similarity index 62% rename from packages/use-local-agent/src/ai-sdk/provider.ts rename to packages/spawn-agent/src/ai-sdk/provider.ts index 01cd8fd..23d80f7 100644 --- a/packages/use-local-agent/src/ai-sdk/provider.ts +++ b/packages/spawn-agent/src/ai-sdk/provider.ts @@ -1,47 +1,36 @@ import { NoSuchModelError } from "@ai-sdk/provider"; -import type { - EmbeddingModelV3, - ImageModelV3, - LanguageModelV3, - ProviderV3, -} from "@ai-sdk/provider"; +import type { EmbeddingModelV3, ImageModelV3, LanguageModelV3, ProviderV3 } from "@ai-sdk/provider"; import type { AgentAdapter } from "../adapter"; import type { SupportedAgentId } from "../constants"; -import { LocalAgent, type LocalAgentConnectOptions } from "../local-agent"; +import { SpawnAgent, type SpawnAgentConnectOptions } from "../spawn-agent"; import type { CreateSessionInput, SessionId } from "../types"; -import { - type AcquiredSession, - LocalAgentLanguageModel, -} from "./local-agent-language-model"; +import { type AcquiredSession, SpawnAgentLanguageModel } from "./spawn-agent-language-model"; -export type LocalAgentProviderSettings = LocalAgentConnectOptions; +export type SpawnAgentProviderSettings = SpawnAgentConnectOptions; -export interface LocalAgentProvider extends ProviderV3 { - (modelId: SupportedAgentId, settings?: LocalAgentProviderSettings): LanguageModelV3; - languageModel( - modelId: SupportedAgentId, - settings?: LocalAgentProviderSettings, - ): LanguageModelV3; - fromAdapter(adapter: AgentAdapter, settings?: LocalAgentProviderSettings): LanguageModelV3; +export interface SpawnAgentProvider extends ProviderV3 { + (modelId: SupportedAgentId, settings?: SpawnAgentProviderSettings): LanguageModelV3; + languageModel(modelId: SupportedAgentId, settings?: SpawnAgentProviderSettings): LanguageModelV3; + fromAdapter(adapter: AgentAdapter, settings?: SpawnAgentProviderSettings): LanguageModelV3; } -export interface LocalAgentSessionOptions extends LocalAgentConnectOptions { +export interface SpawnAgentSessionOptions extends SpawnAgentConnectOptions { readonly session?: CreateSessionInput; } -export interface LocalAgentSession extends AsyncDisposable { - readonly agent: LocalAgent; +export interface SpawnAgentSession extends AsyncDisposable { + readonly agent: SpawnAgent; readonly sessionId: SessionId; readonly model: LanguageModelV3; close(): Promise; } const mergeConnectOptions = ( - base: LocalAgentConnectOptions, - override: LocalAgentConnectOptions | undefined, + base: SpawnAgentConnectOptions, + override: SpawnAgentConnectOptions | undefined, systemPrompt: string | undefined, -): LocalAgentConnectOptions => { - const merged: LocalAgentConnectOptions = { ...base, ...(override ?? {}) }; +): SpawnAgentConnectOptions => { + const merged: SpawnAgentConnectOptions = { ...base, ...(override ?? {}) }; const baseSystem = merged.systemPrompt; const mergedSystem = systemPrompt === undefined @@ -59,11 +48,11 @@ const mergeConnectOptions = ( const buildOneShotAcquire = ( target: SupportedAgentId | AgentAdapter, - defaults: LocalAgentConnectOptions, - settings: LocalAgentConnectOptions | undefined, + defaults: SpawnAgentConnectOptions, + settings: SpawnAgentConnectOptions | undefined, ) => async ({ systemPrompt }: { systemPrompt: string | undefined }): Promise => { - const agent = await LocalAgent.connect( + const agent = await SpawnAgent.connect( target, mergeConnectOptions(defaults, settings, systemPrompt), ); @@ -76,29 +65,27 @@ const buildOneShotAcquire = }; }; -export const createLocalAgentProvider = ( - defaults: LocalAgentProviderSettings = {}, -): LocalAgentProvider => { +export const createSpawnAgent = (defaults: SpawnAgentProviderSettings = {}): SpawnAgentProvider => { const buildLanguageModel = ( modelId: SupportedAgentId, - settings?: LocalAgentProviderSettings, + settings?: SpawnAgentProviderSettings, ): LanguageModelV3 => - new LocalAgentLanguageModel({ + new SpawnAgentLanguageModel({ modelId, acquire: buildOneShotAcquire(modelId, defaults, settings), }); const buildFromAdapter = ( adapter: AgentAdapter, - settings?: LocalAgentProviderSettings, + settings?: SpawnAgentProviderSettings, ): LanguageModelV3 => - new LocalAgentLanguageModel({ + new SpawnAgentLanguageModel({ modelId: adapter.id, acquire: buildOneShotAcquire(adapter, defaults, settings), }); - const provider = ((modelId: SupportedAgentId, settings?: LocalAgentProviderSettings) => - buildLanguageModel(modelId, settings)) as LocalAgentProvider; + const provider = ((modelId: SupportedAgentId, settings?: SpawnAgentProviderSettings) => + buildLanguageModel(modelId, settings)) as SpawnAgentProvider; Object.defineProperties(provider, { specificationVersion: { value: "v3", enumerable: true }, @@ -122,42 +109,42 @@ export const createLocalAgentProvider = ( }; /** - * Default `use-local-agent` provider for the [Vercel AI SDK](https://ai-sdk.dev). + * Default `spawn-agent` provider for the [Vercel AI SDK](https://ai-sdk.dev). * * @example * ```ts * import { streamText } from "ai"; - * import { localAgent } from "use-local-agent"; + * import { spawnAgent } from "spawn-agent"; * * const { textStream } = streamText({ - * model: localAgent("claude"), + * model: spawnAgent("claude"), * prompt: "Refactor src/auth.ts", * }); * ``` */ -export const localAgent: LocalAgentProvider = createLocalAgentProvider(); +export const spawnAgent: SpawnAgentProvider = createSpawnAgent(); /** - * Open a long-lived `LocalAgent` and bind a `LanguageModelV3` to a single + * Open a long-lived `SpawnAgent` and bind a `LanguageModelV3` to a single * pre-created session. Successive `streamText` / `generateText` calls reuse * the same subprocess and session. * * @example * ```ts * import { streamText } from "ai"; - * import { createLocalAgentSession } from "use-local-agent"; + * import { createSpawnAgentSession } from "spawn-agent"; * - * await using session = await createLocalAgentSession("codex"); + * await using session = await createSpawnAgentSession("codex"); * await streamText({ model: session.model, prompt: "list TODOs" }); * await streamText({ model: session.model, prompt: "now fix the highest one" }); * ``` */ -export const createLocalAgentSession = async ( +export const createSpawnAgentSession = async ( modelOrAdapter: SupportedAgentId | AgentAdapter, - options: LocalAgentSessionOptions = {}, -): Promise => { + options: SpawnAgentSessionOptions = {}, +): Promise => { const { session: sessionInput, ...connectOptions } = options; - const agent = await LocalAgent.connect(modelOrAdapter, { + const agent = await SpawnAgent.connect(modelOrAdapter, { permission: "auto-allow", ...connectOptions, }); @@ -177,7 +164,7 @@ export const createLocalAgentSession = async ( }; const modelId = typeof modelOrAdapter === "string" ? modelOrAdapter : modelOrAdapter.id; - const model = new LocalAgentLanguageModel({ + const model = new SpawnAgentLanguageModel({ modelId, acquire: async (): Promise => ({ agent, diff --git a/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts b/packages/spawn-agent/src/ai-sdk/spawn-agent-language-model.ts similarity index 94% rename from packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts rename to packages/spawn-agent/src/ai-sdk/spawn-agent-language-model.ts index 5f6d863..a94e3aa 100644 --- a/packages/use-local-agent/src/ai-sdk/local-agent-language-model.ts +++ b/packages/spawn-agent/src/ai-sdk/spawn-agent-language-model.ts @@ -12,36 +12,36 @@ import type { } from "@ai-sdk/provider"; import { generateId } from "@ai-sdk/provider-utils"; import { AI_SDK_PROVIDER_NAME, AI_SDK_PROVIDER_OPTIONS_KEY } from "../constants"; -import type { LocalAgent } from "../local-agent"; +import type { SpawnAgent } from "../spawn-agent"; import type { SessionId, SlashCommandInput, StopReason, UsageReport } from "../types"; import { convertPromptToContentBlocks } from "./convert-prompt"; export interface AcquiredSession { - readonly agent: LocalAgent; + readonly agent: SpawnAgent; readonly sessionId: SessionId; readonly isSessionBound: boolean; release(): Promise; } -export interface LocalAgentLanguageModelConfig { +export interface SpawnAgentLanguageModelConfig { readonly modelId: string; readonly acquire: (input: { systemPrompt: string | undefined }) => Promise; readonly provider?: string; } -interface LocalAgentProviderOptions { +interface SpawnAgentProviderOptions { readonly command?: SlashCommandInput | string; } -export class LocalAgentLanguageModel implements LanguageModelV3 { +export class SpawnAgentLanguageModel implements LanguageModelV3 { readonly specificationVersion = "v3" as const; readonly provider: string; readonly modelId: string; readonly supportedUrls: Record = {}; - readonly #acquire: LocalAgentLanguageModelConfig["acquire"]; + readonly #acquire: SpawnAgentLanguageModelConfig["acquire"]; - constructor(config: LocalAgentLanguageModelConfig) { + constructor(config: SpawnAgentLanguageModelConfig) { this.modelId = config.modelId; this.provider = config.provider ?? AI_SDK_PROVIDER_NAME; this.#acquire = config.acquire; @@ -191,8 +191,7 @@ export class LocalAgentLanguageModel implements LanguageModelV3 { } } if (textBlockId) controller.enqueue({ type: "text-end", id: textBlockId }); - if (reasoningBlockId) - controller.enqueue({ type: "reasoning-end", id: reasoningBlockId }); + if (reasoningBlockId) controller.enqueue({ type: "reasoning-end", id: reasoningBlockId }); const result = await turn.completion; controller.enqueue({ type: "finish", @@ -213,8 +212,8 @@ export class LocalAgentLanguageModel implements LanguageModelV3 { } } -const providerOptionsFor = (options: LanguageModelV3CallOptions): LocalAgentProviderOptions => - (options.providerOptions?.[AI_SDK_PROVIDER_OPTIONS_KEY] ?? {}) as LocalAgentProviderOptions; +const providerOptionsFor = (options: LanguageModelV3CallOptions): SpawnAgentProviderOptions => + (options.providerOptions?.[AI_SDK_PROVIDER_OPTIONS_KEY] ?? {}) as SpawnAgentProviderOptions; const parseCommand = ( raw: SlashCommandInput | string | undefined, @@ -250,8 +249,7 @@ const callOptionWarnings = ( const out: Array<{ type: "unsupported"; feature: string; details?: string }> = []; if (options.maxOutputTokens !== undefined) out.push({ type: "unsupported", feature: "maxOutputTokens" }); - if (options.temperature !== undefined) - out.push({ type: "unsupported", feature: "temperature" }); + if (options.temperature !== undefined) out.push({ type: "unsupported", feature: "temperature" }); if (options.topP !== undefined) out.push({ type: "unsupported", feature: "topP" }); if (options.topK !== undefined) out.push({ type: "unsupported", feature: "topK" }); if (options.presencePenalty !== undefined) diff --git a/packages/use-local-agent/src/connect.ts b/packages/spawn-agent/src/connect.ts similarity index 100% rename from packages/use-local-agent/src/connect.ts rename to packages/spawn-agent/src/connect.ts diff --git a/packages/use-local-agent/src/constants.ts b/packages/spawn-agent/src/constants.ts similarity index 84% rename from packages/use-local-agent/src/constants.ts rename to packages/spawn-agent/src/constants.ts index e50ee8e..4bb95e7 100644 --- a/packages/use-local-agent/src/constants.ts +++ b/packages/spawn-agent/src/constants.ts @@ -13,12 +13,12 @@ export const STDERR_LINE_BUFFER_LIMIT_BYTES = 64 * 1024; export const RUN_COMMAND_BUFFER_LIMIT_BYTES = 1 * 1024 * 1024; export const PENDING_UPDATE_BUFFER_LIMIT_PER_SESSION = 1000; -export const PACKAGE_NAME = "use-local-agent"; +export const PACKAGE_NAME = "spawn-agent"; export const PACKAGE_VERSION = "0.0.0"; -export const PACKAGE_TITLE = "Use Local Agent"; +export const PACKAGE_TITLE = "Spawn Agent"; -export const AI_SDK_PROVIDER_NAME = "use-local-agent"; -export const AI_SDK_PROVIDER_OPTIONS_KEY = "useLocalAgent"; +export const AI_SDK_PROVIDER_NAME = "spawn-agent"; +export const AI_SDK_PROVIDER_OPTIONS_KEY = "spawnAgent"; export const SUPPORTED_AGENT_IDS = [ "claude", diff --git a/packages/use-local-agent/src/detect.ts b/packages/spawn-agent/src/detect.ts similarity index 100% rename from packages/use-local-agent/src/detect.ts rename to packages/spawn-agent/src/detect.ts diff --git a/packages/use-local-agent/src/errors.ts b/packages/spawn-agent/src/errors.ts similarity index 84% rename from packages/use-local-agent/src/errors.ts rename to packages/spawn-agent/src/errors.ts index 08e877b..464ba61 100644 --- a/packages/use-local-agent/src/errors.ts +++ b/packages/spawn-agent/src/errors.ts @@ -5,7 +5,7 @@ import { } from "./constants"; import { getJsonRpcErrorCode } from "./utils/json-rpc-error"; -export type LocalAgentErrorTag = +export type SpawnAgentErrorTag = | "NotInstalled" | "Unauthenticated" | "UsageLimit" @@ -24,25 +24,25 @@ export type LocalAgentErrorTag = | "InvalidContent" | "StdinClosed"; -export class LocalAgentError extends Error { - readonly _tag: LocalAgentErrorTag; +export class SpawnAgentError extends Error { + readonly _tag: SpawnAgentErrorTag; readonly provider?: string; readonly cause?: unknown; constructor( - tag: LocalAgentErrorTag, + tag: SpawnAgentErrorTag, message: string, options?: { provider?: string; cause?: unknown }, ) { super(message); - this.name = "LocalAgentError"; + this.name = "SpawnAgentError"; this._tag = tag; this.provider = options?.provider; this.cause = options?.cause; } } -export class AgentNotInstalledError extends LocalAgentError { +export class AgentNotInstalledError extends SpawnAgentError { readonly install?: string; constructor(provider: string, message: string, install?: string) { super("NotInstalled", message, { provider }); @@ -51,7 +51,7 @@ export class AgentNotInstalledError extends LocalAgentError { } } -export class AgentUnauthenticatedError extends LocalAgentError { +export class AgentUnauthenticatedError extends SpawnAgentError { readonly loginCommand?: string; constructor(provider: string, message: string, loginCommand?: string) { super("Unauthenticated", message, { provider }); @@ -60,21 +60,21 @@ export class AgentUnauthenticatedError extends LocalAgentError { } } -export class AgentUsageLimitError extends LocalAgentError { +export class AgentUsageLimitError extends SpawnAgentError { constructor(provider: string, message?: string) { super("UsageLimit", message ?? `Usage limits exceeded for ${provider}.`, { provider }); this.name = "AgentUsageLimitError"; } } -export class AgentSpawnError extends LocalAgentError { +export class AgentSpawnError extends SpawnAgentError { constructor(provider: string, cause: unknown) { super("Spawn", `Failed to spawn ${provider}: ${stringifyCause(cause)}`, { provider, cause }); this.name = "AgentSpawnError"; } } -export class AgentInitError extends LocalAgentError { +export class AgentInitError extends SpawnAgentError { constructor(provider: string, cause: unknown) { super("Init", `ACP initialize failed for ${provider}: ${stringifyCause(cause)}`, { provider, @@ -84,7 +84,7 @@ export class AgentInitError extends LocalAgentError { } } -export class AgentSessionCreateError extends LocalAgentError { +export class AgentSessionCreateError extends SpawnAgentError { constructor(provider: string, cause: unknown) { super("SessionCreate", `Failed to create session: ${stringifyCause(cause)}`, { provider, @@ -94,21 +94,21 @@ export class AgentSessionCreateError extends LocalAgentError { } } -export class AgentSessionLoadError extends LocalAgentError { +export class AgentSessionLoadError extends SpawnAgentError { constructor(provider: string, cause: unknown) { super("SessionLoad", `Failed to load session: ${stringifyCause(cause)}`, { provider, cause }); this.name = "AgentSessionLoadError"; } } -export class AgentStreamError extends LocalAgentError { +export class AgentStreamError extends SpawnAgentError { constructor(provider: string, cause: unknown) { super("Stream", `Stream failed: ${stringifyCause(cause)}`, { provider, cause }); this.name = "AgentStreamError"; } } -export class AgentInactivityError extends LocalAgentError { +export class AgentInactivityError extends SpawnAgentError { readonly sessionId: string; readonly elapsedMs: number; constructor(provider: string, sessionId: string, elapsedMs: number) { @@ -123,7 +123,7 @@ export class AgentInactivityError extends LocalAgentError { } } -export class AdapterNotFoundError extends LocalAgentError { +export class AdapterNotFoundError extends SpawnAgentError { readonly packageName: string; constructor(packageName: string, cause?: unknown) { super("AdapterNotFound", `Adapter package not resolvable: ${packageName}`, { cause }); @@ -132,14 +132,14 @@ export class AdapterNotFoundError extends LocalAgentError { } } -export class AgentCancelledError extends LocalAgentError { +export class AgentCancelledError extends SpawnAgentError { constructor(provider: string) { super("Cancelled", `${provider} stream cancelled`, { provider }); this.name = "AgentCancelledError"; } } -export class CapabilityNotSupportedError extends LocalAgentError { +export class CapabilityNotSupportedError extends SpawnAgentError { readonly capability: string; constructor(provider: string, capability: string) { super( @@ -152,7 +152,7 @@ export class CapabilityNotSupportedError extends LocalAgentError { } } -export class AgentConnectionClosedError extends LocalAgentError { +export class AgentConnectionClosedError extends SpawnAgentError { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; readonly stderrTail: string; @@ -176,7 +176,7 @@ export class AgentConnectionClosedError extends LocalAgentError { } } -export class AgentInitTimeoutError extends LocalAgentError { +export class AgentInitTimeoutError extends SpawnAgentError { readonly timeoutMs: number; constructor(provider: string, timeoutMs: number) { super("InitTimeout", `${provider} did not respond to initialize within ${timeoutMs}ms`, { @@ -187,7 +187,7 @@ export class AgentInitTimeoutError extends LocalAgentError { } } -export class ProtocolVersionMismatchError extends LocalAgentError { +export class ProtocolVersionMismatchError extends SpawnAgentError { readonly clientVersion: number; readonly agentVersion: number; constructor(provider: string, clientVersion: number, agentVersion: number) { @@ -202,7 +202,7 @@ export class ProtocolVersionMismatchError extends LocalAgentError { } } -export class AgentStdinClosedError extends LocalAgentError { +export class AgentStdinClosedError extends SpawnAgentError { constructor(provider: string, cause?: unknown) { super("StdinClosed", `Cannot write to ${provider} stdin: subprocess has exited`, { provider, @@ -212,7 +212,7 @@ export class AgentStdinClosedError extends LocalAgentError { } } -export class InvalidPromptContentError extends LocalAgentError { +export class InvalidPromptContentError extends SpawnAgentError { readonly contentType: string; readonly capability: string; constructor(provider: string, contentType: string, capability: string) { diff --git a/packages/use-local-agent/src/index.ts b/packages/spawn-agent/src/index.ts similarity index 81% rename from packages/use-local-agent/src/index.ts rename to packages/spawn-agent/src/index.ts index 6380834..a96ab0f 100644 --- a/packages/use-local-agent/src/index.ts +++ b/packages/spawn-agent/src/index.ts @@ -1,23 +1,23 @@ export { - LocalAgent, - type LocalAgentClientInfo, - type LocalAgentConnectOptions, + SpawnAgent, + type SpawnAgentClientInfo, + type SpawnAgentConnectOptions, type FileSystemHandlers, type TerminalHandlers, -} from "./local-agent"; +} from "./spawn-agent"; export { connect, type ConnectOptions, type ConnectResult } from "./connect"; export { - createLocalAgentProvider, - createLocalAgentSession, - localAgent, - LocalAgentLanguageModel, - type LocalAgentLanguageModelConfig, - type LocalAgentProvider, - type LocalAgentProviderSettings, - type LocalAgentSession, - type LocalAgentSessionOptions, + createSpawnAgent, + createSpawnAgentSession, + spawnAgent, + SpawnAgentLanguageModel, + type SpawnAgentLanguageModelConfig, + type SpawnAgentProvider, + type SpawnAgentProviderSettings, + type SpawnAgentSession, + type SpawnAgentSessionOptions, } from "./ai-sdk"; export type { AgentAdapter, AdapterFactoryOptions, ResolvedAdapter } from "./adapter"; @@ -85,11 +85,11 @@ export { AgentUsageLimitError, CapabilityNotSupportedError, InvalidPromptContentError, - LocalAgentError, ProtocolVersionMismatchError, + SpawnAgentError, isAuthRequiredJsonRpcError, isMethodNotFoundJsonRpcError, - type LocalAgentErrorTag, + type SpawnAgentErrorTag, } from "./errors"; export * as adapters from "./adapters"; diff --git a/packages/use-local-agent/src/permission.ts b/packages/spawn-agent/src/permission.ts similarity index 100% rename from packages/use-local-agent/src/permission.ts rename to packages/spawn-agent/src/permission.ts diff --git a/packages/use-local-agent/src/local-agent.ts b/packages/spawn-agent/src/spawn-agent.ts similarity index 97% rename from packages/use-local-agent/src/local-agent.ts rename to packages/spawn-agent/src/spawn-agent.ts index 281c3a5..73500fa 100644 --- a/packages/use-local-agent/src/local-agent.ts +++ b/packages/spawn-agent/src/spawn-agent.ts @@ -40,7 +40,7 @@ import { AgentUsageLimitError, CapabilityNotSupportedError, InvalidPromptContentError, - LocalAgentError, + SpawnAgentError, ProtocolVersionMismatchError, isAuthRequiredJsonRpcError, isMethodNotFoundJsonRpcError, @@ -86,7 +86,7 @@ export interface ClientDispatcher { ) => Promise; } -export interface LocalAgentClientInfo { +export interface SpawnAgentClientInfo { readonly name: string; readonly title?: string; readonly version: string; @@ -109,7 +109,7 @@ export interface TerminalHandlers { killTerminal(params: schema.KillTerminalRequest): Promise; } -export interface LocalAgentConnectOptions { +export interface SpawnAgentConnectOptions { readonly cwd?: string; readonly env?: Readonly>; readonly mcpServers?: readonly McpServer[]; @@ -117,7 +117,7 @@ export interface LocalAgentConnectOptions { readonly inactivityTimeoutMs?: number; readonly initializeTimeoutMs?: number; readonly systemPrompt?: string; - readonly clientInfo?: LocalAgentClientInfo; + readonly clientInfo?: SpawnAgentClientInfo; readonly fileSystem?: FileSystemHandlers; readonly terminal?: TerminalHandlers; readonly traceContext?: () => Record; @@ -191,7 +191,7 @@ const formatSlashCommand = (command: SlashCommandInput): string => const PERMISSION_MODE_PATTERN = /invalid permissions\.defaultmode/i; -export class LocalAgent { +export class SpawnAgent { readonly id: string; readonly displayName: string; readonly protocolVersion: number; @@ -199,7 +199,7 @@ export class LocalAgent { readonly agentInfo?: Implementation; readonly authMethods: readonly AuthMethod[]; readonly clientCapabilities: schema.ClientCapabilities; - readonly clientInfo: LocalAgentClientInfo; + readonly clientInfo: SpawnAgentClientInfo; #connection: ConnectResult; #permissionResolver: (request: RequestPermissionRequest) => Promise; @@ -220,11 +220,11 @@ export class LocalAgent { private constructor( connectionResult: ConnectResult, - options: LocalAgentConnectOptions, + options: SpawnAgentConnectOptions, dispatcher: ClientDispatcher, initResponse: InitializeResponse, clientCapabilities: schema.ClientCapabilities, - clientInfo: LocalAgentClientInfo, + clientInfo: SpawnAgentClientInfo, ) { this.#connection = connectionResult; this.id = connectionResult.adapterId; @@ -246,15 +246,15 @@ export class LocalAgent { this.#clock = options.clock ?? realClock; const policy: PermissionPolicy = options.permission ?? "auto-allow"; this.#permissionStream = policy === "stream"; - this.#permissionResolver = LocalAgent.#buildPermissionResolver(policy); + this.#permissionResolver = SpawnAgent.#buildPermissionResolver(policy); dispatcher.onSessionUpdate = (notification) => this.#dispatchSessionUpdate(notification); dispatcher.onPermissionRequest = (request) => this.#handlePermissionRequest(request); } static async connect( adapterOrId: AgentAdapter | SupportedAgentId, - options: LocalAgentConnectOptions = {}, - ): Promise { + options: SpawnAgentConnectOptions = {}, + ): Promise { const adapter = typeof adapterOrId === "string" ? builtInAdapter(adapterOrId, { env: options.env }) @@ -267,7 +267,7 @@ export class LocalAgent { onFatalError, fatalErrorListeners, getFirstFatalError, - } = LocalAgent.#buildDispatcher(options); + } = SpawnAgent.#buildDispatcher(options); const stderrFatalEnabled = { value: false }; @@ -310,7 +310,7 @@ export class LocalAgent { }, }); - const localAgent = await LocalAgent.fromConnectResult(connectionResult, { + const agent = await SpawnAgent.fromConnectResult(connectionResult, { options, dispatcher, clientCapabilities, @@ -319,27 +319,27 @@ export class LocalAgent { getFirstFatalError, }); stderrFatalEnabled.value = true; - return localAgent; + return agent; } - static buildDispatcher(options: LocalAgentConnectOptions): { + static buildDispatcher(options: SpawnAgentConnectOptions): { dispatcher: ClientDispatcher; clientCapabilities: schema.ClientCapabilities; - clientInfo: LocalAgentClientInfo; + clientInfo: SpawnAgentClientInfo; onFatalError: (error: AgentUnauthenticatedError | AgentUsageLimitError) => void; fatalErrorListeners: Set<(error: AgentUnauthenticatedError | AgentUsageLimitError) => void>; getFirstFatalError: () => AgentUnauthenticatedError | AgentUsageLimitError | undefined; } { - return LocalAgent.#buildDispatcher(options); + return SpawnAgent.#buildDispatcher(options); } static async fromConnectResult( connectionResult: ConnectResult, init: { - readonly options: LocalAgentConnectOptions; + readonly options: SpawnAgentConnectOptions; readonly dispatcher: ClientDispatcher; readonly clientCapabilities: schema.ClientCapabilities; - readonly clientInfo: LocalAgentClientInfo; + readonly clientInfo: SpawnAgentClientInfo; readonly fatalErrorListeners: Set< (error: AgentUnauthenticatedError | AgentUsageLimitError) => void >; @@ -348,7 +348,7 @@ export class LocalAgent { | AgentUsageLimitError | undefined; }, - ): Promise { + ): Promise { const adapterId = connectionResult.adapterId; const initializeTimeoutMs = init.options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS; @@ -375,7 +375,7 @@ export class LocalAgent { const fatal = init.getFirstFatalError?.(); if (fatal) throw fatal; if (cause instanceof AgentInitTimeoutError) throw cause; - if (cause instanceof LocalAgentError) throw cause; + if (cause instanceof SpawnAgentError) throw cause; if (processExited !== undefined) { throw new AgentConnectionClosedError( adapterId, @@ -405,7 +405,7 @@ export class LocalAgent { ); } - const localAgent = new LocalAgent( + const agent = new SpawnAgent( connectionResult, init.options, init.dispatcher, @@ -414,19 +414,19 @@ export class LocalAgent { init.clientInfo, ); - init.fatalErrorListeners.add((error) => localAgent.#onFatalError(error)); + init.fatalErrorListeners.add((error) => agent.#onFatalError(error)); void connectionResult.closed.then((exit) => { - localAgent.#onSubprocessExit(exit.exitCode, exit.signal); + agent.#onSubprocessExit(exit.exitCode, exit.signal); }); - return localAgent; + return agent; } - static #buildDispatcher(options: LocalAgentConnectOptions): { + static #buildDispatcher(options: SpawnAgentConnectOptions): { dispatcher: ClientDispatcher; clientCapabilities: schema.ClientCapabilities; - clientInfo: LocalAgentClientInfo; + clientInfo: SpawnAgentClientInfo; onFatalError: (error: AgentUnauthenticatedError | AgentUsageLimitError) => void; fatalErrorListeners: Set<(error: AgentUnauthenticatedError | AgentUsageLimitError) => void>; getFirstFatalError: () => AgentUnauthenticatedError | AgentUsageLimitError | undefined; @@ -444,7 +444,7 @@ export class LocalAgent { : {}), ...(term ? { terminal: true } : {}), }; - const clientInfo: LocalAgentClientInfo = options.clientInfo ?? { + const clientInfo: SpawnAgentClientInfo = options.clientInfo ?? { name: PACKAGE_NAME, title: PACKAGE_TITLE, version: PACKAGE_VERSION, @@ -896,7 +896,7 @@ export class LocalAgent { } catch (cause) { const error = activeStream.cancelled ? new AgentCancelledError(this.id) - : cause instanceof LocalAgentError + : cause instanceof SpawnAgentError ? cause : isAuthRequiredJsonRpcError(cause) ? new AgentUnauthenticatedError( @@ -970,7 +970,7 @@ export class LocalAgent { #assertOpen(): void { if (this.#closed) { - throw new AgentStreamError(this.id, "LocalAgent is closed"); + throw new AgentStreamError(this.id, "SpawnAgent is closed"); } if (this.#fatalError) { throw this.#fatalError; @@ -1416,7 +1416,7 @@ const raceTimeout = ( interface InitializeRaceOptions { readonly protocolVersion: number; readonly clientCapabilities: schema.ClientCapabilities; - readonly clientInfo: LocalAgentClientInfo; + readonly clientInfo: SpawnAgentClientInfo; readonly timeoutMs: number; } diff --git a/packages/use-local-agent/src/testing/mock-agent.ts b/packages/spawn-agent/src/testing/mock-agent.ts similarity index 96% rename from packages/use-local-agent/src/testing/mock-agent.ts rename to packages/spawn-agent/src/testing/mock-agent.ts index fbd7872..3c019ed 100644 --- a/packages/use-local-agent/src/testing/mock-agent.ts +++ b/packages/spawn-agent/src/testing/mock-agent.ts @@ -34,7 +34,7 @@ import { ClientSideConnection as ClientSideConnectionCtor, ndJsonStream, } from "@agentclientprotocol/sdk"; -import { LocalAgent, type LocalAgentConnectOptions } from "../local-agent"; +import { SpawnAgent, type SpawnAgentConnectOptions } from "../spawn-agent"; import type { ConnectResult } from "../connect"; export interface MockAgentHandlers { @@ -76,13 +76,13 @@ export interface MockAgentHandlers { extMethod?: (request: ExtRequest) => ExtResponse | Promise; } -export interface ConnectMockAgentOptions extends LocalAgentConnectOptions { +export interface ConnectMockAgentOptions extends SpawnAgentConnectOptions { readonly adapterId?: string; readonly displayName?: string; } export interface MockAgentSession { - readonly agent: LocalAgent; + readonly agent: SpawnAgent; readonly mockConnection: AgentSideConnection; close(): Promise; } @@ -219,7 +219,7 @@ export const connectMockAgent = async ( ); const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, getFirstFatalError } = - LocalAgent.buildDispatcher(options); + SpawnAgent.buildDispatcher(options); const connection = new ClientSideConnectionCtor( () => ({ @@ -257,7 +257,7 @@ export const connectMockAgent = async ( }, }; - const localAgent = await LocalAgent.fromConnectResult(connectionResult, { + const agent = await SpawnAgent.fromConnectResult(connectionResult, { options, dispatcher, clientCapabilities, @@ -267,10 +267,10 @@ export const connectMockAgent = async ( }); return { - agent: localAgent, + agent, mockConnection, close: async () => { - await localAgent.close(); + await agent.close(); }, }; }; @@ -278,7 +278,7 @@ export const connectMockAgent = async ( /** * Returns an `AgentAdapter` whose `resolve()` always points at a binary that * does not exist, so spawning it raises ENOENT. Useful for asserting that - * `LocalAgent.connect` surfaces `AgentSpawnError` cleanly without leaking + * `SpawnAgent.connect` surfaces `AgentSpawnError` cleanly without leaking * unhandled rejections. */ export const withSpawnFailure = ( diff --git a/packages/use-local-agent/src/types.ts b/packages/spawn-agent/src/types.ts similarity index 100% rename from packages/use-local-agent/src/types.ts rename to packages/spawn-agent/src/types.ts diff --git a/packages/use-local-agent/src/utils/async-queue.ts b/packages/spawn-agent/src/utils/async-queue.ts similarity index 100% rename from packages/use-local-agent/src/utils/async-queue.ts rename to packages/spawn-agent/src/utils/async-queue.ts diff --git a/packages/use-local-agent/src/utils/cap-buffer.ts b/packages/spawn-agent/src/utils/cap-buffer.ts similarity index 100% rename from packages/use-local-agent/src/utils/cap-buffer.ts rename to packages/spawn-agent/src/utils/cap-buffer.ts diff --git a/packages/use-local-agent/src/utils/clock.ts b/packages/spawn-agent/src/utils/clock.ts similarity index 100% rename from packages/use-local-agent/src/utils/clock.ts rename to packages/spawn-agent/src/utils/clock.ts diff --git a/packages/use-local-agent/src/utils/extract-error-details.ts b/packages/spawn-agent/src/utils/extract-error-details.ts similarity index 100% rename from packages/use-local-agent/src/utils/extract-error-details.ts rename to packages/spawn-agent/src/utils/extract-error-details.ts diff --git a/packages/use-local-agent/src/utils/filter-stdout-noise.ts b/packages/spawn-agent/src/utils/filter-stdout-noise.ts similarity index 100% rename from packages/use-local-agent/src/utils/filter-stdout-noise.ts rename to packages/spawn-agent/src/utils/filter-stdout-noise.ts diff --git a/packages/use-local-agent/src/utils/has-string-message.ts b/packages/spawn-agent/src/utils/has-string-message.ts similarity index 100% rename from packages/use-local-agent/src/utils/has-string-message.ts rename to packages/spawn-agent/src/utils/has-string-message.ts diff --git a/packages/use-local-agent/src/utils/is-command-available.ts b/packages/spawn-agent/src/utils/is-command-available.ts similarity index 100% rename from packages/use-local-agent/src/utils/is-command-available.ts rename to packages/spawn-agent/src/utils/is-command-available.ts diff --git a/packages/use-local-agent/src/utils/is-command-timeout.ts b/packages/spawn-agent/src/utils/is-command-timeout.ts similarity index 100% rename from packages/use-local-agent/src/utils/is-command-timeout.ts rename to packages/spawn-agent/src/utils/is-command-timeout.ts diff --git a/packages/use-local-agent/src/utils/is-spawn-not-found.ts b/packages/spawn-agent/src/utils/is-spawn-not-found.ts similarity index 100% rename from packages/use-local-agent/src/utils/is-spawn-not-found.ts rename to packages/spawn-agent/src/utils/is-spawn-not-found.ts diff --git a/packages/use-local-agent/src/utils/json-rpc-error.ts b/packages/spawn-agent/src/utils/json-rpc-error.ts similarity index 100% rename from packages/use-local-agent/src/utils/json-rpc-error.ts rename to packages/spawn-agent/src/utils/json-rpc-error.ts diff --git a/packages/use-local-agent/src/utils/process-alive.ts b/packages/spawn-agent/src/utils/process-alive.ts similarity index 100% rename from packages/use-local-agent/src/utils/process-alive.ts rename to packages/spawn-agent/src/utils/process-alive.ts diff --git a/packages/use-local-agent/src/utils/resolve-package.ts b/packages/spawn-agent/src/utils/resolve-package.ts similarity index 100% rename from packages/use-local-agent/src/utils/resolve-package.ts rename to packages/spawn-agent/src/utils/resolve-package.ts diff --git a/packages/use-local-agent/src/utils/run-command.ts b/packages/spawn-agent/src/utils/run-command.ts similarity index 100% rename from packages/use-local-agent/src/utils/run-command.ts rename to packages/spawn-agent/src/utils/run-command.ts diff --git a/packages/use-local-agent/src/utils/swallow-rejection.ts b/packages/spawn-agent/src/utils/swallow-rejection.ts similarity index 100% rename from packages/use-local-agent/src/utils/swallow-rejection.ts rename to packages/spawn-agent/src/utils/swallow-rejection.ts diff --git a/packages/use-local-agent/tests/adapter-env-fallbacks.test.ts b/packages/spawn-agent/tests/adapter-env-fallbacks.test.ts similarity index 100% rename from packages/use-local-agent/tests/adapter-env-fallbacks.test.ts rename to packages/spawn-agent/tests/adapter-env-fallbacks.test.ts diff --git a/packages/use-local-agent/tests/additional-directories.test.ts b/packages/spawn-agent/tests/additional-directories.test.ts similarity index 100% rename from packages/use-local-agent/tests/additional-directories.test.ts rename to packages/spawn-agent/tests/additional-directories.test.ts diff --git a/packages/use-local-agent/tests/ai-sdk-provider.test.ts b/packages/spawn-agent/tests/ai-sdk-provider.test.ts similarity index 90% rename from packages/use-local-agent/tests/ai-sdk-provider.test.ts rename to packages/spawn-agent/tests/ai-sdk-provider.test.ts index 314a084..f694277 100644 --- a/packages/use-local-agent/tests/ai-sdk-provider.test.ts +++ b/packages/spawn-agent/tests/ai-sdk-provider.test.ts @@ -1,8 +1,8 @@ import type { LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"; import { NoSuchModelError } from "@ai-sdk/provider"; import { describe, expect, it } from "vite-plus/test"; -import { LocalAgentLanguageModel } from "../src/ai-sdk/local-agent-language-model"; -import { createLocalAgentProvider, localAgent } from "../src/ai-sdk/provider"; +import { SpawnAgentLanguageModel } from "../src/ai-sdk/spawn-agent-language-model"; +import { createSpawnAgent, spawnAgent } from "../src/ai-sdk/provider"; import { convertPromptToContentBlocks } from "../src/ai-sdk/convert-prompt"; import { connectMockAgent, type MockAgentHandlers } from "../src/testing/mock-agent"; @@ -29,7 +29,7 @@ const buildModelWithMock = async ( ) => { const session = await connectMockAgent(handlers); const sessionId = await session.agent.createSession(); - const model = new LocalAgentLanguageModel({ + const model = new SpawnAgentLanguageModel({ modelId: "mock", acquire: async () => ({ agent: session.agent, @@ -107,28 +107,28 @@ describe("convertPromptToContentBlocks", () => { }); }); -describe("createLocalAgentProvider", () => { +describe("createSpawnAgent", () => { it("exposes ProviderV3 shape with specificationVersion 'v3'", () => { - expect(localAgent.specificationVersion).toBe("v3"); - const model = localAgent("claude"); + expect(spawnAgent.specificationVersion).toBe("v3"); + const model = spawnAgent("claude"); expect(model.specificationVersion).toBe("v3"); expect(model.modelId).toBe("claude"); - expect(model.provider).toBe("use-local-agent"); + expect(model.provider).toBe("spawn-agent"); }); it("throws NoSuchModelError for unsupported model types", () => { - expect(() => localAgent.embeddingModel("any")).toThrow(NoSuchModelError); - expect(() => localAgent.imageModel("any")).toThrow(NoSuchModelError); + expect(() => spawnAgent.embeddingModel("any")).toThrow(NoSuchModelError); + expect(() => spawnAgent.imageModel("any")).toThrow(NoSuchModelError); }); it("languageModel and call form return equivalent models", () => { - const fromCall = localAgent("codex"); - const fromMethod = localAgent.languageModel("codex"); + const fromCall = spawnAgent("codex"); + const fromMethod = spawnAgent.languageModel("codex"); expect(fromCall.modelId).toBe(fromMethod.modelId); }); it("fromAdapter wires a custom adapter through to the model id", () => { - const provider = createLocalAgentProvider(); + const provider = createSpawnAgent(); const adapter = { id: "custom", displayName: "Custom", @@ -139,7 +139,7 @@ describe("createLocalAgentProvider", () => { }); }); -describe("LocalAgentLanguageModel.doGenerate", () => { +describe("SpawnAgentLanguageModel.doGenerate", () => { it("returns reasoning, text, and tool-call content from a single turn", async () => { const { model, session } = await buildModelWithMock({ newSession: () => ({ sessionId: "s1" }), @@ -231,7 +231,7 @@ describe("LocalAgentLanguageModel.doGenerate", () => { }); }); -describe("LocalAgentLanguageModel.doStream", () => { +describe("SpawnAgentLanguageModel.doStream", () => { it("emits text-start/-delta/-end and a finish stream part", async () => { const { model, session } = await buildModelWithMock({ newSession: () => ({ sessionId: "s1" }), @@ -264,9 +264,7 @@ describe("LocalAgentLanguageModel.doStream", () => { expect(types).toContain("text-end"); expect(types[types.length - 1]).toBe("finish"); const deltas = parts.filter((part) => part.type === "text-delta"); - expect(deltas.map((part) => (part as { delta: string }).delta).join("")).toBe( - "Hello world", - ); + expect(deltas.map((part) => (part as { delta: string }).delta).join("")).toBe("Hello world"); const finish = parts.find((part) => part.type === "finish"); expect(finish).toBeDefined(); if (finish && finish.type === "finish") { @@ -286,9 +284,7 @@ describe("session-bound model (isSessionBound)", () => { newSession: () => ({ sessionId: "s-bound" }), prompt: async (request) => { promptCalls.push( - request.prompt.flatMap((part) => - part.type === "text" ? [part.text] : [], - ), + request.prompt.flatMap((part) => (part.type === "text" ? [part.text] : [])), ); return { stopReason: "end_turn" }; }, @@ -310,25 +306,21 @@ describe("session-bound model (isSessionBound)", () => { }); expect(promptCalls).toHaveLength(1); expect(promptCalls[0]).toEqual(["follow up"]); - const compatibility = result.warnings.find( - (warning) => warning.type === "compatibility", - ); + const compatibility = result.warnings.find((warning) => warning.type === "compatibility"); expect(compatibility?.feature).toBe("session-history-replay"); } finally { await session.close(); } }); - it("forwards a slash command via providerOptions.useLocalAgent.command", async () => { + it("forwards a slash command via providerOptions.spawnAgent.command", async () => { const seenPrompts: string[][] = []; const { model, session } = await buildModelWithMock( { newSession: () => ({ sessionId: "s-cmd" }), prompt: async (request) => { seenPrompts.push( - request.prompt.flatMap((part) => - part.type === "text" ? [part.text] : [], - ), + request.prompt.flatMap((part) => (part.type === "text" ? [part.text] : [])), ); return { stopReason: "end_turn" }; }, @@ -345,7 +337,7 @@ describe("session-bound model (isSessionBound)", () => { }, ], providerOptions: { - useLocalAgent: { command: { name: "web", input: "agent client protocol" } }, + spawnAgent: { command: { name: "web", input: "agent client protocol" } }, }, }); expect(seenPrompts).toHaveLength(1); @@ -361,8 +353,7 @@ describe("session-bound model (isSessionBound)", () => { { newSession: () => ({ sessionId: "s-cmd2" }), prompt: async (request) => { - for (const part of request.prompt) - if (part.type === "text") seen.push(part.text); + for (const part of request.prompt) if (part.type === "text") seen.push(part.text); return { stopReason: "end_turn" }; }, }, @@ -371,7 +362,7 @@ describe("session-bound model (isSessionBound)", () => { try { await model.doGenerate({ prompt: [{ role: "user", content: [{ type: "text", text: "go" }] }], - providerOptions: { useLocalAgent: { command: "compact" } }, + providerOptions: { spawnAgent: { command: "compact" } }, }); expect(seen.some((line) => line.startsWith("/compact"))).toBe(true); } finally { @@ -441,7 +432,7 @@ describe("session-bound model (isSessionBound)", () => { }); }); -describe("LocalAgentLanguageModel.doStream cancellation", () => { +describe("SpawnAgentLanguageModel.doStream cancellation", () => { it("calls release exactly once when the prompt is aborted mid-stream", async () => { let releaseCount = 0; const { model, session } = await buildModelWithMock( @@ -460,7 +451,7 @@ describe("LocalAgentLanguageModel.doStream cancellation", () => { }, cancel: () => {}, }, - { releaseSpy: () => releaseCount += 1 }, + { releaseSpy: () => (releaseCount += 1) }, ); try { diff --git a/packages/use-local-agent/tests/auth-retry.test.ts b/packages/spawn-agent/tests/auth-retry.test.ts similarity index 100% rename from packages/use-local-agent/tests/auth-retry.test.ts rename to packages/spawn-agent/tests/auth-retry.test.ts diff --git a/packages/use-local-agent/tests/buffer-cap.test.ts b/packages/spawn-agent/tests/buffer-cap.test.ts similarity index 92% rename from packages/use-local-agent/tests/buffer-cap.test.ts rename to packages/spawn-agent/tests/buffer-cap.test.ts index c722b6e..d7439d3 100644 --- a/packages/use-local-agent/tests/buffer-cap.test.ts +++ b/packages/spawn-agent/tests/buffer-cap.test.ts @@ -31,7 +31,9 @@ describe("per-session buffered update cap", () => { const textDeltas = events.filter((event) => event.type === "text-delta"); expect(textDeltas.length).toBe(PENDING_UPDATE_BUFFER_LIMIT_PER_SESSION); if (textDeltas[0]?.type === "text-delta") { - expect(textDeltas[0].text).toBe(`chunk-${overflow - PENDING_UPDATE_BUFFER_LIMIT_PER_SESSION}`); + expect(textDeltas[0].text).toBe( + `chunk-${overflow - PENDING_UPDATE_BUFFER_LIMIT_PER_SESSION}`, + ); } await session.close(); }); diff --git a/packages/use-local-agent/tests/cancellation.test.ts b/packages/spawn-agent/tests/cancellation.test.ts similarity index 100% rename from packages/use-local-agent/tests/cancellation.test.ts rename to packages/spawn-agent/tests/cancellation.test.ts diff --git a/packages/use-local-agent/tests/concurrent-sessions.test.ts b/packages/spawn-agent/tests/concurrent-sessions.test.ts similarity index 100% rename from packages/use-local-agent/tests/concurrent-sessions.test.ts rename to packages/spawn-agent/tests/concurrent-sessions.test.ts diff --git a/packages/use-local-agent/tests/inactivity-watchdog.test.ts b/packages/spawn-agent/tests/inactivity-watchdog.test.ts similarity index 100% rename from packages/use-local-agent/tests/inactivity-watchdog.test.ts rename to packages/spawn-agent/tests/inactivity-watchdog.test.ts diff --git a/packages/use-local-agent/tests/initialize.test.ts b/packages/spawn-agent/tests/initialize.test.ts similarity index 98% rename from packages/use-local-agent/tests/initialize.test.ts rename to packages/spawn-agent/tests/initialize.test.ts index fdc84eb..eb9c8a9 100644 --- a/packages/use-local-agent/tests/initialize.test.ts +++ b/packages/spawn-agent/tests/initialize.test.ts @@ -21,7 +21,7 @@ describe("initialize", () => { }); expect(received?.protocolVersion).toBe(1); - expect(received?.clientInfo?.name).toBe("use-local-agent"); + expect(received?.clientInfo?.name).toBe("spawn-agent"); expect(received?.clientInfo?.version).toBeTypeOf("string"); expect(received?.clientCapabilities?.fs?.readTextFile).toBeFalsy(); expect(received?.clientCapabilities?.fs?.writeTextFile).toBeFalsy(); diff --git a/packages/use-local-agent/tests/late-updates.test.ts b/packages/spawn-agent/tests/late-updates.test.ts similarity index 100% rename from packages/use-local-agent/tests/late-updates.test.ts rename to packages/spawn-agent/tests/late-updates.test.ts diff --git a/packages/use-local-agent/tests/list-sessions-pagination.test.ts b/packages/spawn-agent/tests/list-sessions-pagination.test.ts similarity index 100% rename from packages/use-local-agent/tests/list-sessions-pagination.test.ts rename to packages/spawn-agent/tests/list-sessions-pagination.test.ts diff --git a/packages/use-local-agent/tests/load-session-replay.test.ts b/packages/spawn-agent/tests/load-session-replay.test.ts similarity index 100% rename from packages/use-local-agent/tests/load-session-replay.test.ts rename to packages/spawn-agent/tests/load-session-replay.test.ts diff --git a/packages/use-local-agent/tests/permissions.test.ts b/packages/spawn-agent/tests/permissions.test.ts similarity index 100% rename from packages/use-local-agent/tests/permissions.test.ts rename to packages/spawn-agent/tests/permissions.test.ts diff --git a/packages/use-local-agent/tests/prompt-capabilities.test.ts b/packages/spawn-agent/tests/prompt-capabilities.test.ts similarity index 100% rename from packages/use-local-agent/tests/prompt-capabilities.test.ts rename to packages/spawn-agent/tests/prompt-capabilities.test.ts diff --git a/packages/use-local-agent/tests/prompt-events.test.ts b/packages/spawn-agent/tests/prompt-events.test.ts similarity index 100% rename from packages/use-local-agent/tests/prompt-events.test.ts rename to packages/spawn-agent/tests/prompt-events.test.ts diff --git a/packages/use-local-agent/tests/reliability.test.ts b/packages/spawn-agent/tests/reliability.test.ts similarity index 97% rename from packages/use-local-agent/tests/reliability.test.ts rename to packages/spawn-agent/tests/reliability.test.ts index 540f931..d2d19b5 100644 --- a/packages/use-local-agent/tests/reliability.test.ts +++ b/packages/spawn-agent/tests/reliability.test.ts @@ -14,7 +14,7 @@ import { AgentUsageLimitError, InvalidPromptContentError, } from "../src/errors"; -import { LocalAgent } from "../src/local-agent"; +import { SpawnAgent } from "../src/spawn-agent"; import { connectMockAgent } from "../src/testing/mock-agent"; import type { AgentEvent, SessionId } from "../src/types"; import { appendCapped } from "../src/utils/cap-buffer"; @@ -365,13 +365,13 @@ describe("reliability: init races against subprocess exit", () => { stderrTail: "boom", }); const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, getFirstFatalError } = - LocalAgent.buildDispatcher({}); + SpawnAgent.buildDispatcher({}); setTimeout(() => connectionResult.resolveExit({ exitCode: 7, signal: null }), 25); const start = Date.now(); await expect( - LocalAgent.fromConnectResult(connectionResult, { + SpawnAgent.fromConnectResult(connectionResult, { options: { initializeTimeoutMs: 30_000 }, dispatcher, clientCapabilities, @@ -386,10 +386,10 @@ describe("reliability: init races against subprocess exit", () => { it("preserves AgentInitTimeoutError when no subprocess exit happens", async () => { const { connectionResult } = buildManualConnect({ initializeBehavior: "hang" }); const { dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, getFirstFatalError } = - LocalAgent.buildDispatcher({}); + SpawnAgent.buildDispatcher({}); await expect( - LocalAgent.fromConnectResult(connectionResult, { + SpawnAgent.fromConnectResult(connectionResult, { options: { initializeTimeoutMs: 50 }, dispatcher, clientCapabilities, @@ -411,12 +411,12 @@ describe("reliability: fatal stderr during init", () => { fatalErrorListeners, onFatalError, getFirstFatalError, - } = LocalAgent.buildDispatcher({}); + } = SpawnAgent.buildDispatcher({}); onFatalError(new AgentUnauthenticatedError("manual", "boot-time auth failure")); await expect( - LocalAgent.fromConnectResult(connectionResult, { + SpawnAgent.fromConnectResult(connectionResult, { options: { initializeTimeoutMs: 1_000 }, dispatcher, clientCapabilities, @@ -436,12 +436,12 @@ describe("reliability: fatal stderr during init", () => { fatalErrorListeners, onFatalError, getFirstFatalError, - } = LocalAgent.buildDispatcher({}); + } = SpawnAgent.buildDispatcher({}); onFatalError(new AgentUsageLimitError("manual", "limits exceeded for plan")); await expect( - LocalAgent.fromConnectResult(connectionResult, { + SpawnAgent.fromConnectResult(connectionResult, { options: { initializeTimeoutMs: 1_000 }, dispatcher, clientCapabilities, diff --git a/packages/use-local-agent/tests/resume-close.test.ts b/packages/spawn-agent/tests/resume-close.test.ts similarity index 100% rename from packages/use-local-agent/tests/resume-close.test.ts rename to packages/spawn-agent/tests/resume-close.test.ts diff --git a/packages/use-local-agent/tests/sdk-upgrade.test.ts b/packages/spawn-agent/tests/sdk-upgrade.test.ts similarity index 95% rename from packages/use-local-agent/tests/sdk-upgrade.test.ts rename to packages/spawn-agent/tests/sdk-upgrade.test.ts index 7cb8e8f..6abce6e 100644 --- a/packages/use-local-agent/tests/sdk-upgrade.test.ts +++ b/packages/spawn-agent/tests/sdk-upgrade.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { connectMockAgent } from "../src/testing/mock-agent"; import type { AgentEvent, SessionId } from "../src/types"; -describe("SDK 0.20 reliability fixes proxy through use-local-agent", () => { +describe("SDK 0.20 reliability fixes proxy through spawn-agent", () => { it("response and notification arriving together preserves logical ordering (PR #130)", async () => { const session = await connectMockAgent({ newSession: () => ({ sessionId: "s1" }), diff --git a/packages/use-local-agent/tests/slash-commands.test.ts b/packages/spawn-agent/tests/slash-commands.test.ts similarity index 100% rename from packages/use-local-agent/tests/slash-commands.test.ts rename to packages/spawn-agent/tests/slash-commands.test.ts diff --git a/packages/use-local-agent/tests/spawn-failures.test.ts b/packages/spawn-agent/tests/spawn-failures.test.ts similarity index 84% rename from packages/use-local-agent/tests/spawn-failures.test.ts rename to packages/spawn-agent/tests/spawn-failures.test.ts index c78aff7..7c6d711 100644 --- a/packages/use-local-agent/tests/spawn-failures.test.ts +++ b/packages/spawn-agent/tests/spawn-failures.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { AgentAdapter } from "../src/adapter"; -import { AgentConnectionClosedError, AgentSpawnError, LocalAgentError } from "../src/errors"; -import { LocalAgent } from "../src/local-agent"; +import { AgentConnectionClosedError, AgentSpawnError, SpawnAgentError } from "../src/errors"; +import { SpawnAgent } from "../src/spawn-agent"; import { withSpawnFailure } from "../src/testing/mock-agent"; const missingBinAdapter = (): AgentAdapter => withSpawnFailure(); @@ -25,7 +25,7 @@ describe("spawn / init fast-fail", () => { process.on("unhandledRejection", onUnhandled); try { await expect( - LocalAgent.connect(missingBinAdapter(), { + SpawnAgent.connect(missingBinAdapter(), { inactivityTimeoutMs: 0, initializeTimeoutMs: 30_000, }), @@ -40,11 +40,11 @@ describe("spawn / init fast-fail", () => { it("subprocess exit before initialize raises AgentConnectionClosedError quickly", async () => { const start = Date.now(); await expect( - LocalAgent.connect(exitImmediatelyAdapter(), { + SpawnAgent.connect(exitImmediatelyAdapter(), { inactivityTimeoutMs: 0, initializeTimeoutMs: 30_000, }), - ).rejects.toBeInstanceOf(LocalAgentError); + ).rejects.toBeInstanceOf(SpawnAgentError); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(2_000); }); @@ -52,7 +52,7 @@ describe("spawn / init fast-fail", () => { it("subprocess exit before initialize: error tagged ConnectionClosed", async () => { let thrown: unknown; try { - await LocalAgent.connect(exitImmediatelyAdapter(), { + await SpawnAgent.connect(exitImmediatelyAdapter(), { inactivityTimeoutMs: 0, initializeTimeoutMs: 30_000, }); diff --git a/packages/use-local-agent/tests/stderr-fatal-detection.test.ts b/packages/spawn-agent/tests/stderr-fatal-detection.test.ts similarity index 95% rename from packages/use-local-agent/tests/stderr-fatal-detection.test.ts rename to packages/spawn-agent/tests/stderr-fatal-detection.test.ts index 121b62f..514f908 100644 --- a/packages/use-local-agent/tests/stderr-fatal-detection.test.ts +++ b/packages/spawn-agent/tests/stderr-fatal-detection.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { AgentAdapter } from "../src/adapter"; import { AgentUnauthenticatedError } from "../src/errors"; -import { LocalAgent } from "../src/local-agent"; +import { SpawnAgent } from "../src/spawn-agent"; const echoStderrAdapter = (script: string): AgentAdapter => ({ id: "stderr-fixture", @@ -55,7 +55,7 @@ describe("stderr fatal detection gating", () => { const lines: string[] = []; const fatals: unknown[] = []; - const agent = await LocalAgent.connect( + const agent = await SpawnAgent.connect( echoStderrAdapter(initRespondingAgent("", "Authentication failed")), { inactivityTimeoutMs: 0, @@ -79,7 +79,7 @@ describe("stderr fatal detection gating", () => { it("pre-init stderr matches do not crash and surface only via onStderr", async () => { const lines: string[] = []; - const agent = await LocalAgent.connect( + const agent = await SpawnAgent.connect( echoStderrAdapter(initRespondingAgent("Authentication failed", "")), { inactivityTimeoutMs: 0, diff --git a/packages/use-local-agent/tests/system-prompt.test.ts b/packages/spawn-agent/tests/system-prompt.test.ts similarity index 100% rename from packages/use-local-agent/tests/system-prompt.test.ts rename to packages/spawn-agent/tests/system-prompt.test.ts diff --git a/packages/use-local-agent/tests/terminal.test.ts b/packages/spawn-agent/tests/terminal.test.ts similarity index 100% rename from packages/use-local-agent/tests/terminal.test.ts rename to packages/spawn-agent/tests/terminal.test.ts diff --git a/packages/use-local-agent/tests/trace-context.test.ts b/packages/spawn-agent/tests/trace-context.test.ts similarity index 100% rename from packages/use-local-agent/tests/trace-context.test.ts rename to packages/spawn-agent/tests/trace-context.test.ts diff --git a/packages/use-local-agent/tests/watchdog-permission.test.ts b/packages/spawn-agent/tests/watchdog-permission.test.ts similarity index 100% rename from packages/use-local-agent/tests/watchdog-permission.test.ts rename to packages/spawn-agent/tests/watchdog-permission.test.ts diff --git a/packages/use-local-agent/tests/wire-fuzz.test.ts b/packages/spawn-agent/tests/wire-fuzz.test.ts similarity index 100% rename from packages/use-local-agent/tests/wire-fuzz.test.ts rename to packages/spawn-agent/tests/wire-fuzz.test.ts diff --git a/packages/use-local-agent/tsconfig.json b/packages/spawn-agent/tsconfig.json similarity index 100% rename from packages/use-local-agent/tsconfig.json rename to packages/spawn-agent/tsconfig.json diff --git a/packages/use-local-agent/vite.config.ts b/packages/spawn-agent/vite.config.ts similarity index 100% rename from packages/use-local-agent/vite.config.ts rename to packages/spawn-agent/vite.config.ts diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md deleted file mode 100644 index 4323e00..0000000 --- a/packages/use-local-agent/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# use-local-agent - -[![version](https://img.shields.io/npm/v/use-local-agent?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/use-local-agent) -[![downloads](https://img.shields.io/npm/dt/use-local-agent.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/use-local-agent) - -An API for accessing any locally-installed coding agent - -A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams over the [Agent Client Protocol](https://agentclientprotocol.com). - -## Install - -```bash -npm install use-local-agent ai -``` - -The user also needs the agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code`). Claude Code and Codex additionally need a small ACP shim: - -```bash -npm install @agentclientprotocol/claude-agent-acp # Claude Code -npm install @zed-industries/codex-acp # Codex -``` - -## Usage - -```ts -import { streamText } from "ai"; -import { localAgent } from "use-local-agent"; - -const { textStream } = streamText({ - model: localAgent("claude"), - prompt: "Refactor src/auth.ts to use the new session API", -}); - -for await (const chunk of textStream) { - process.stdout.write(chunk); -} -``` - -Pass settings inline at the call site, or build a pre-configured provider with `createLocalAgentProvider`: - -```ts -import { generateText } from "ai"; -import { localAgent } from "use-local-agent"; - -const { text } = await generateText({ - model: localAgent("codex", { - cwd: "/Users/me/project", - permission: "auto-allow", - mcpServers: [ - { - type: "stdio", - name: "filesystem", - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], - }, - ], - }), - prompt: "Summarize README.md in three bullets", -}); -``` - -| Setting | Effect | -| --- | --- | -| `cwd` | Working directory the agent operates in. | -| `permission` | `"auto-allow"` (default) / `"auto-allow-once"` / `"auto-reject"` / `"stream"`. | -| `mcpServers` | MCP server configs the agent connects to for extra tools. | -| `additionalDirectories` | Extra absolute paths the agent can read/write. | -| `systemPrompt` | Prepended to user prompts. | -| `inactivityTimeoutMs` | Kill the turn if the agent goes silent (default 3 min). | - -## Supported agents - -| ID | Display name | Notes | -| ---------- | ------------------- | -------------------------------------------------- | -| `claude` | Claude Code | requires `@agentclientprotocol/claude-agent-acp` | -| `codex` | Codex | requires `@zed-industries/codex-acp` | -| `cursor` | Cursor Agent | native ACP | -| `copilot` | GitHub Copilot CLI | native ACP | -| `gemini` | Gemini CLI | native ACP | -| `opencode` | OpenCode | native ACP | -| `droid` | Factory Droid | native ACP | -| `pi` | Pi | native ACP | - -For a custom ACP-speaking subprocess, use `localAgent.fromAdapter(...)`. - -## Stateful sessions - -For multi-turn conversations on a single subprocess, use `createLocalAgentSession`. Each `streamText` call against `session.model` sends one `session/prompt` turn, so the agent's conversation memory is preserved across turns: - -```ts -import { streamText } from "ai"; -import { createLocalAgentSession } from "use-local-agent"; - -await using session = await createLocalAgentSession("codex"); - -await streamText({ model: session.model, prompt: "list TODOs" }); -await streamText({ model: session.model, prompt: "now fix the highest one" }); - -// slash commands via providerOptions -await streamText({ - model: session.model, - prompt: "agent client protocol", - providerOptions: { useLocalAgent: { command: "web" } }, -}); -``` - -For human-in-the-loop permission prompts, terminal handlers, and session resume, the `session.agent` field exposes the underlying `LocalAgent`. See [`src/index.ts`](https://github.com/millionco/use-local-agent/blob/main/packages/use-local-agent/src/index.ts) for the full API. - -## Contributing - -[Contributing Guide](https://github.com/millionco/use-local-agent/blob/main/CONTRIBUTING.md) ยท [Issues](https://github.com/millionco/use-local-agent/issues) - -### License - -MIT diff --git a/packages/use-local-agent/src/ai-sdk/index.ts b/packages/use-local-agent/src/ai-sdk/index.ts deleted file mode 100644 index 3a1d165..0000000 --- a/packages/use-local-agent/src/ai-sdk/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { - LocalAgentLanguageModel, - type AcquiredSession, - type LocalAgentLanguageModelConfig, -} from "./local-agent-language-model"; -export { - createLocalAgentProvider, - createLocalAgentSession, - localAgent, - type LocalAgentProvider, - type LocalAgentProviderSettings, - type LocalAgentSession, - type LocalAgentSessionOptions, -} from "./provider"; -export { - convertPromptToContentBlocks, - type PromptConversionOptions, - type PromptConversionResult, -} from "./convert-prompt"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79b25fa..f7ee7c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,9 +39,9 @@ importers: '@wterm/dom': specifier: ^0.1.9 version: 0.1.9 - use-local-agent: + spawn-agent: specifier: workspace:* - version: link:../../packages/use-local-agent + version: link:../../packages/spawn-agent ws: specifier: ^8.18.0 version: 8.20.0 @@ -62,7 +62,7 @@ importers: specifier: npm:@voidzero-dev/vite-plus-core version: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@22.19.17)(typescript@5.9.3)' - packages/use-local-agent: + packages/spawn-agent: dependencies: '@agentclientprotocol/claude-agent-acp': specifier: ^0.24.0 From d867b8ed2f3856c2cc845888bb223691804f17fe Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 22:44:53 -0700 Subject: [PATCH 23/24] chore: drop spawn-agent rename changeset --- .changeset/spawn-agent-rename.md | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .changeset/spawn-agent-rename.md diff --git a/.changeset/spawn-agent-rename.md b/.changeset/spawn-agent-rename.md deleted file mode 100644 index 7a36d2e..0000000 --- a/.changeset/spawn-agent-rename.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"spawn-agent": major ---- - -Rename package `use-local-agent` to `spawn-agent` and align the AI SDK provider surface with the Vercel AI SDK convention (factory `createSpawnAgent` drops the `Provider` suffix). - -### Migration - -| Before (`use-local-agent`) | After (`spawn-agent`) | -| ---------------------------------------------------------- | ---------------------------------------------------------- | -| `localAgent` | `spawnAgent` | -| `createLocalAgentProvider` | `createSpawnAgent` | -| `createLocalAgentSession` | `createSpawnAgentSession` | -| `LocalAgent` (class) | `SpawnAgent` | -| `LocalAgentProvider`, `LocalAgentProviderSettings` | `SpawnAgentProvider`, `SpawnAgentProviderSettings` | -| `LocalAgentLanguageModel`, `LocalAgentLanguageModelConfig` | `SpawnAgentLanguageModel`, `SpawnAgentLanguageModelConfig` | -| `LocalAgentSession`, `LocalAgentSessionOptions` | `SpawnAgentSession`, `SpawnAgentSessionOptions` | -| `LocalAgentConnectOptions`, `LocalAgentClientInfo` | `SpawnAgentConnectOptions`, `SpawnAgentClientInfo` | -| `LocalAgentError`, `LocalAgentErrorTag` | `SpawnAgentError`, `SpawnAgentErrorTag` | -| `providerOptions: { useLocalAgent: { command } }` | `providerOptions: { spawnAgent: { command } }` | - -`model.provider` is now `"spawn-agent"`. From 8b63b837aec52b978e4bf404ef870eb4725e9c89 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 25 Apr 2026 22:51:03 -0700 Subject: [PATCH 24/24] feat(spawn-agent): bundle ACP shims as direct dependencies `@agentclientprotocol/claude-agent-acp` and `@zed-industries/codex-acp` now ship as regular dependencies of `spawn-agent` instead of optional peer dependencies. Users no longer need to install them separately to drive Claude Code or Codex. `ai` remains a peer dependency. Updates README install instructions accordingly. --- README.md | 9 --------- packages/spawn-agent/README.md | 13 ++++++++++--- packages/spawn-agent/package.json | 14 +++----------- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 676111d..b6c5277 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,6 @@ [![version](https://img.shields.io/npm/v/spawn-agent?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/spawn-agent) [![downloads](https://img.shields.io/npm/dt/spawn-agent.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/spawn-agent) -Spawn any locally-installed coding agent as a Vercel AI SDK provider. - A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, or Pi as a subprocess and streams over the [Agent Client Protocol](https://agentclientprotocol.com). ## Install @@ -13,13 +11,6 @@ A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cur npm install spawn-agent ai ``` -The user also needs the agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code`). Claude Code and Codex additionally need a small ACP shim: - -```bash -npm install @agentclientprotocol/claude-agent-acp # Claude Code -npm install @zed-industries/codex-acp # Codex -``` - ## Usage ```ts diff --git a/packages/spawn-agent/README.md b/packages/spawn-agent/README.md index d2f87fa..10c1f87 100644 --- a/packages/spawn-agent/README.md +++ b/packages/spawn-agent/README.md @@ -13,11 +13,18 @@ A [Vercel AI SDK](https://ai-sdk.dev) provider that runs Claude Code, Codex, Cur npm install spawn-agent ai ``` -The user also needs the agent CLI installed locally (e.g. `npm install -g @anthropic-ai/claude-code`). Claude Code and Codex additionally need a small ACP shim: +Then install whichever agent CLIs you want to drive (the Claude Code and Codex ACP shims ship with `spawn-agent`): ```bash -npm install @agentclientprotocol/claude-agent-acp # Claude Code -npm install @zed-industries/codex-acp # Codex +npm install -g \ + @anthropic-ai/claude-code \ + @openai/codex \ + cursor-agent \ + @github/copilot \ + @google/gemini-cli \ + opencode-ai \ + droid \ + @mariozechner/pi-coding-agent ``` ## Usage diff --git a/packages/spawn-agent/package.json b/packages/spawn-agent/package.json index 79b0645..e3541aa 100644 --- a/packages/spawn-agent/package.json +++ b/packages/spawn-agent/package.json @@ -37,9 +37,11 @@ "check": "vp check" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.24.0", "@agentclientprotocol/sdk": "^0.20.0", "@ai-sdk/provider": "^3.0.8", - "@ai-sdk/provider-utils": "^4.0.23" + "@ai-sdk/provider-utils": "^4.0.23", + "@zed-industries/codex-acp": "^0.10.0" }, "devDependencies": { "@types/node": "^22.19.17", @@ -47,18 +49,8 @@ "typescript": "^5.7.0" }, "peerDependencies": { - "@agentclientprotocol/claude-agent-acp": "^0.24.0", - "@zed-industries/codex-acp": "^0.10.0", "ai": "^6.0.0" }, - "peerDependenciesMeta": { - "@agentclientprotocol/claude-agent-acp": { - "optional": true - }, - "@zed-industries/codex-acp": { - "optional": true - } - }, "engines": { "node": ">=22" }