Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
cdd19f2
feat(use-local-agent): bump @agentclientprotocol/sdk to ^0.20.0
cursoragent Apr 26, 2026
3b59ec1
feat(use-local-agent): transport reliability hardening (Phase 2)
cursoragent Apr 26, 2026
cd721a4
feat(use-local-agent): turn-lifecycle correctness (Phase 3)
cursoragent Apr 26, 2026
83f5811
feat(use-local-agent): spec coverage expansion (Phase 4)
cursoragent Apr 26, 2026
7d77d73
feat(use-local-agent): adapter robustness (Phase 5)
cursoragent Apr 26, 2026
b24ee3c
docs(use-local-agent): README and changeset for ACP reliability overh…
cursoragent Apr 26, 2026
5e3d6f7
feat(use-local-agent): add reliability helpers, error type, and const…
cursoragent Apr 26, 2026
a5c4fc6
test(use-local-agent): mock harness helpers + wire fuzz (Phase 6 polish)
cursoragent Apr 26, 2026
80b637f
docs(playground): add README covering terminal opt-in and additionalD…
cursoragent Apr 26, 2026
e3fe5a7
fix(use-local-agent): SIGKILL escalation, init-vs-exit race, fatal-bu…
cursoragent Apr 26, 2026
1b1c38b
test(use-local-agent): add reliability test suite (14 tests)
cursoragent Apr 26, 2026
a20c0e8
chore: format + add reliability hardening changeset
cursoragent Apr 26, 2026
92f14eb
merge: PR #1 - ACP reliability + spec coverage overhaul
aidenybai Apr 26, 2026
f301ccf
merge: consolidate PR #1 (overhaul) and PR #2 (reliability hardening)
aidenybai Apr 26, 2026
3dcfb97
docs(use-local-agent): align README with consolidated reliability fea…
aidenybai Apr 26, 2026
ba90b51
feat(use-local-agent): become a Vercel AI SDK provider; drop streamAg…
aidenybai Apr 26, 2026
b97e46d
docs: polish README - add MCP, multi-modal, configuration table; tigh…
aidenybai Apr 26, 2026
f59d021
feat(use-local-agent): bundle ai; re-export streamText/generateText/s…
aidenybai Apr 26, 2026
2244e1a
revert(use-local-agent): require `ai` as a peer dep instead of bundling
aidenybai Apr 26, 2026
6f8075b
docs: remove em-dashes from READMEs and test/playground docs
aidenybai Apr 26, 2026
f518585
docs: cut README from 242 to 108 lines; remove implementation-detail …
aidenybai Apr 26, 2026
32111fb
feat(use-local-agent): add createLocalAgentSession for stateful strea…
aidenybai Apr 26, 2026
71a238d
fix(use-local-agent): address AGENTS.md review findings on AI SDK pro…
aidenybai Apr 26, 2026
460e577
feat(spawn-agent)!: rename package use-local-agent -> spawn-agent
aidenybai Apr 26, 2026
d867b8e
chore: drop spawn-agent rename changeset
aidenybai Apr 26, 2026
8b63b83
feat(spawn-agent): bundle ACP shims as direct dependencies
aidenybai Apr 26, 2026
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
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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):
Expand Down
105 changes: 96 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,106 @@
# use-local-agent
# spawn-agent

A pnpm monorepo scaffold.
[![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)

## Quick start
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
pnpm install
pnpm dev
npm install spawn-agent ai
```

## Structure
## 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);
}
```
apps/ # Playgrounds, sites, extensions
packages/ # Libraries, tools

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",
});
```

See [CONTRIBUTING.md](./CONTRIBUTING.md) for development guidelines and [AGENTS.md](./AGENTS.md) for code style rules.
| 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 [`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/spawn-agent/blob/main/CONTRIBUTING.md) · [Issues](https://github.com/millionco/spawn-agent/issues)

### License

MIT
107 changes: 107 additions & 0 deletions apps/playground/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# `@spawn-agent/playground`

Local web playground for the `spawn-agent` library.

```bash
pnpm install
pnpm --filter @spawn-agent/playground dev # vite dev server
pnpm --filter @spawn-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 SpawnAgent.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 `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 SpawnAgent.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 @spawn-agent/playground exec playwright test --project=node
pnpm --filter @spawn-agent/playground exec playwright test --project=chromium
```
4 changes: 2 additions & 2 deletions apps/playground/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>use-local-agent playground</title>
<title>spawn-agent playground</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<header>
<h1>use-local-agent playground</h1>
<h1>spawn-agent playground</h1>
<span id="status" data-testid="status">disconnected</span>
</header>
<main>
Expand Down
6 changes: 3 additions & 3 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@use-local-agent/playground",
"name": "@spawn-agent/playground",
"version": "0.0.0",
"private": true,
"type": "module",
Expand All @@ -12,9 +12,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"@agentclientprotocol/sdk": "^0.20.0",
"@wterm/dom": "^0.1.9",
"use-local-agent": "workspace:*",
"spawn-agent": "workspace:*",
"ws": "^8.18.0"
},
"devDependencies": {
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
6 changes: 3 additions & 3 deletions apps/playground/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -68,7 +68,7 @@ const send = (socket: WebSocket, payload: OutgoingMessageJson): void => {
};

const handleConnection = async (socket: WebSocket): Promise<void> => {
let agent: LocalAgent | undefined;
let agent: SpawnAgent | undefined;
let sessionId: SessionId | undefined;
let activeStreamCancel: (() => Promise<void>) | undefined;

Expand All @@ -78,7 +78,7 @@ const handleConnection = async (socket: WebSocket): Promise<void> => {
};

try {
agent = await LocalAgent.connect(echoAdapter, {
agent = await SpawnAgent.connect(echoAdapter, {
cwd: process.cwd(),
permission: "auto-allow",
inactivityTimeoutMs: 0,
Expand Down
22 changes: 11 additions & 11 deletions apps/playground/tests/spawn.node.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -24,7 +24,7 @@ const collect = async (events: AsyncIterable<AgentEvent>): Promise<readonly Agen

test.describe("e2e: real subprocess", () => {
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,
});
Expand All @@ -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,
});
Expand All @@ -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,
});
Expand All @@ -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,
});
Expand All @@ -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,
});
Expand All @@ -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,
});
Expand All @@ -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,
});
Expand All @@ -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,
});
Expand Down
Loading
Loading