Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/acp-reliability-overhaul.md
Original file line number Diff line number Diff line change
@@ -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 `/<name> <input>` 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<string,string>` (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.
107 changes: 107 additions & 0 deletions apps/playground/README.md
Original file line number Diff line number Diff line change
@@ -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
```
2 changes: 1 addition & 1 deletion apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions apps/playground/src/echo-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
65 changes: 63 additions & 2 deletions packages/use-local-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/use-local-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"check": "vp check"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1"
"@agentclientprotocol/sdk": "^0.20.0"
},
"devDependencies": {
"@types/node": "^22.19.17",
Expand Down
42 changes: 27 additions & 15 deletions packages/use-local-agent/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 23 additions & 13 deletions packages/use-local-agent/src/adapters/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ?? {} };
Expand Down
8 changes: 7 additions & 1 deletion packages/use-local-agent/src/adapters/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
if (hasEnvKey()) return true;
const home = homedir();
if (!home) return false;
const accountsPath = path.join(home, ".gemini", "google_accounts.json");
Expand Down Expand Up @@ -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",
);
}
Expand Down
Loading
Loading