@tanstack/ai-acp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +428 -0
- package/dist/esm/adapters/compatible.d.ts +206 -0
- package/dist/esm/adapters/compatible.js +313 -0
- package/dist/esm/adapters/compatible.js.map +1 -0
- package/dist/esm/adapters/projection.d.ts +29 -0
- package/dist/esm/adapters/projection.js +83 -0
- package/dist/esm/adapters/projection.js.map +1 -0
- package/dist/esm/index.d.ts +21 -0
- package/dist/esm/index.js +35 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/messages/prompt.d.ts +19 -0
- package/dist/esm/messages/prompt.js +37 -0
- package/dist/esm/messages/prompt.js.map +1 -0
- package/dist/esm/permissions.d.ts +8 -0
- package/dist/esm/permissions.js +44 -0
- package/dist/esm/permissions.js.map +1 -0
- package/dist/esm/session/acp-client.d.ts +37 -0
- package/dist/esm/session/acp-client.js +140 -0
- package/dist/esm/session/acp-client.js.map +1 -0
- package/dist/esm/session/sandbox-server.d.ts +46 -0
- package/dist/esm/session/sandbox-server.js +114 -0
- package/dist/esm/session/sandbox-server.js.map +1 -0
- package/dist/esm/stream/queue.d.ts +15 -0
- package/dist/esm/stream/queue.js +54 -0
- package/dist/esm/stream/queue.js.map +1 -0
- package/dist/esm/stream/translate.d.ts +38 -0
- package/dist/esm/stream/translate.js +320 -0
- package/dist/esm/stream/translate.js.map +1 -0
- package/dist/esm/transport/resolve.d.ts +7 -0
- package/dist/esm/transport/resolve.js +34 -0
- package/dist/esm/transport/resolve.js.map +1 -0
- package/dist/esm/transport/stdio.d.ts +3 -0
- package/dist/esm/transport/stdio.js +51 -0
- package/dist/esm/transport/stdio.js.map +1 -0
- package/dist/esm/transport/types.d.ts +29 -0
- package/dist/esm/transport/websocket.d.ts +17 -0
- package/dist/esm/transport/websocket.js +135 -0
- package/dist/esm/transport/websocket.js.map +1 -0
- package/dist/esm/types/acp-types.d.ts +75 -0
- package/package.json +61 -0
- package/src/adapters/compatible.ts +675 -0
- package/src/adapters/projection.ts +164 -0
- package/src/index.ts +85 -0
- package/src/messages/prompt.ts +71 -0
- package/src/permissions.ts +71 -0
- package/src/session/acp-client.ts +238 -0
- package/src/session/sandbox-server.ts +195 -0
- package/src/stream/queue.ts +61 -0
- package/src/stream/translate.ts +412 -0
- package/src/transport/resolve.ts +46 -0
- package/src/transport/stdio.ts +63 -0
- package/src/transport/types.ts +33 -0
- package/src/transport/websocket.ts +192 -0
- package/src/types/acp-types.ts +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# @tanstack/ai-acp
|
|
2
|
+
|
|
3
|
+
Shared [Agent Client Protocol](https://agentclientprotocol.com) (ACP) plumbing for TanStack AI **harness adapters** — the code that turns a coding-agent CLI (`grok`, `gemini --acp`, …) into a `chat()` backend inside a sandbox.
|
|
4
|
+
|
|
5
|
+
Most apps should use a harness package directly (`@tanstack/ai-grok-build`, …). Reach for `@tanstack/ai-acp` when:
|
|
6
|
+
|
|
7
|
+
- you want to plug an ACP agent that **has no dedicated adapter package** into a sandbox — use [`acpCompatible`](#plug-in-any-acp-agent-acpcompatible) (the `openaiCompatible` of harnesses), or
|
|
8
|
+
- you are **building or extending** a harness adapter and need the transport, session, permission, and stream-translation layers in one place.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Peer dependencies: `@tanstack/ai`, `@tanstack/ai-sandbox`. The package also depends on [`@agentclientprotocol/sdk`](https://www.npmjs.com/package/@agentclientprotocol/sdk) for the JSON-RPC client.
|
|
17
|
+
|
|
18
|
+
## What it does
|
|
19
|
+
|
|
20
|
+
Harness CLIs that support ACP expose a long-lived JSON-RPC session. They stream **session updates** (text chunks, tool calls, planning, permissions) while the orchestrator drives the turn with `prompt`. TanStack AI speaks **AG-UI `StreamChunk`s** (`RUN_STARTED`, `TEXT_MESSAGE_CONTENT`, `TOOL_CALL_*`, `RUN_FINISHED`, …).
|
|
21
|
+
|
|
22
|
+
`@tanstack/ai-acp` sits in the middle:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
┌─────────────────┐ ACP JSON-RPC ┌──────────────────┐
|
|
26
|
+
│ Harness CLI │ ◄──────────────────► │ startAcpSession │
|
|
27
|
+
│ (in sandbox) │ stdio or WebSocket │ (ClientSide) │
|
|
28
|
+
└─────────────────┘ └────────┬─────────┘
|
|
29
|
+
│ session updates
|
|
30
|
+
▼
|
|
31
|
+
┌──────────────────┐
|
|
32
|
+
│ AsyncQueue │
|
|
33
|
+
│ translateAcpStream │
|
|
34
|
+
└────────┬─────────┘
|
|
35
|
+
│ StreamChunk
|
|
36
|
+
▼
|
|
37
|
+
┌──────────────────┐
|
|
38
|
+
│ chat() stream │
|
|
39
|
+
└──────────────────┘
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Responsibilities split roughly as:
|
|
43
|
+
|
|
44
|
+
| Layer | Module | Role |
|
|
45
|
+
| ------------------ | ------------------------ | -------------------------------------------------------------------------- |
|
|
46
|
+
| **Transport** | `transport/*` | Bytes ↔ JSON-RPC: stdio (NDJSON) or WebSocket |
|
|
47
|
+
| **Session** | `session/acp-client` | `initialize` → `authenticate` → `newSession` / `loadSession` → `prompt` |
|
|
48
|
+
| **Sandbox server** | `session/sandbox-server` | Boot an in-sandbox `serve` process and connect over an exposed port |
|
|
49
|
+
| **Translation** | `stream/translate` | ACP `sessionUpdate` events → TanStack `StreamChunk`s |
|
|
50
|
+
| **Permissions** | `permissions` | Map harness permission prompts to allow/reject (and optional approval ids) |
|
|
51
|
+
|
|
52
|
+
## Plug in any ACP agent (`acpCompatible`)
|
|
53
|
+
|
|
54
|
+
`acpCompatible` is the **easy path**: it builds a `chat()` text adapter for any
|
|
55
|
+
ACP-compliant agent CLI without a dedicated package — the harness equivalent of
|
|
56
|
+
`openaiCompatible`. Configure the harness once, select a model per call, pass it
|
|
57
|
+
into a sandbox.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { acpCompatible } from '@tanstack/ai-acp'
|
|
61
|
+
import { chat } from '@tanstack/ai'
|
|
62
|
+
import { defineSandbox, withSandbox } from '@tanstack/ai-sandbox'
|
|
63
|
+
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
|
|
64
|
+
|
|
65
|
+
// Configure the "pi" agent harness once (it speaks ACP over stdio):
|
|
66
|
+
const pi = acpCompatible({
|
|
67
|
+
name: 'pi',
|
|
68
|
+
models: ['pi-fast', 'pi-pro'], // optional — makes pi('…') type-safe
|
|
69
|
+
command: ({ model, harnessCwd }) =>
|
|
70
|
+
`pi --acp -m ${model} --cwd ${harnessCwd}`,
|
|
71
|
+
authMethodId: 'pi-api-key', // when the harness advertises it
|
|
72
|
+
refusalMessage: 'Pi refused the request.',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Then drive it like any other adapter, inside a sandbox:
|
|
76
|
+
const stream = chat({
|
|
77
|
+
adapter: pi('pi-fast'),
|
|
78
|
+
messages: [
|
|
79
|
+
{ role: 'user', content: 'Add a health check route and run the tests.' },
|
|
80
|
+
],
|
|
81
|
+
middleware: [
|
|
82
|
+
withSandbox(
|
|
83
|
+
defineSandbox({
|
|
84
|
+
id: 'pi-demo',
|
|
85
|
+
provider: dockerSandbox({ image: 'node:22' }),
|
|
86
|
+
// …workspace: clone source, install the `pi` CLI, inject its API key
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
],
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
You get the full ACP flow for free: sandbox resolution, `chat()`-tool → MCP
|
|
94
|
+
bridging, session resume (via `modelOptions.sessionId`), permission modes,
|
|
95
|
+
abort, and AG-UI translation.
|
|
96
|
+
|
|
97
|
+
### Configuration
|
|
98
|
+
|
|
99
|
+
| Field | Purpose |
|
|
100
|
+
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
101
|
+
| `name` (required) | Provider label, log prefix, and the `<name>.session-id` CUSTOM event name. |
|
|
102
|
+
| `models` | Model ids the harness accepts — declaring them makes `harness('id')` type-safe. Omit to accept any string. |
|
|
103
|
+
| `modelOptions` | Type-only brand (`{} as { … }`) for the per-call options of `chat({ modelOptions })`; merged with the base options and exposed on `ctx.modelOptions`. |
|
|
104
|
+
| `command` | Build the **stdio** launch command (`({ model, cwd, harnessCwd, sandbox, env, modelOptions, signal }) => string`). Required unless `openTransport` is given. |
|
|
105
|
+
| `openTransport` | Full transport escape hatch — open any `AcpSessionTransport` yourself (e.g. boot a `serve` process and connect over WebSocket). Overrides `command`. |
|
|
106
|
+
| `cwd` | Working directory inside the sandbox (default `/workspace`). |
|
|
107
|
+
| `skillsDir` | The harness's skills dir relative to the workspace root (e.g. `'.pi/skills'`) — `withSandbox` workspace `gitSkill`s are linked here. MCP skills ride ACP natively, so they need no path. |
|
|
108
|
+
| `env` | Extra environment variables for the harness process. |
|
|
109
|
+
| `authMethodId` | ACP auth method to select before the session starts. |
|
|
110
|
+
| `permissionMode` | `'default'` \| `'acceptEdits'` \| `'bypassPermissions'` (default). |
|
|
111
|
+
| `permissions` | `'headless'` (auto-resolve, default) or `'interactive'` (emit approval-requested events for `ask` prompts). |
|
|
112
|
+
| `onPermissionRequest` | Custom `PermissionHandler`; overrides `permissions`/`permissionMode`. |
|
|
113
|
+
| `refusalMessage` | `RUN_ERROR` message when the harness refuses. |
|
|
114
|
+
| `planEventName` | Emit ACP `plan` updates as a CUSTOM event under this name. |
|
|
115
|
+
| `emitDiff` | Emit the post-run `git diff` of `cwd` as a `file.changed` CUSTOM event (off by default). |
|
|
116
|
+
| `onExtNotification` | Handle vendor `_x/…` JSON-RPC notifications. |
|
|
117
|
+
| `buildPrompt` | Override how chat history maps to the harness prompt (defaults to `buildAcpPrompt`). |
|
|
118
|
+
|
|
119
|
+
For WebSocket/`serve` harnesses, return your own transport from `openTransport`
|
|
120
|
+
(see how `@tanstack/ai-grok-build` boots `grok agent serve` with
|
|
121
|
+
`startAcpServerInSandbox` + `connectAcpWebSocket`). Use `acpCompatibleText(model,
|
|
122
|
+
config)` for a one-shot single-model adapter.
|
|
123
|
+
|
|
124
|
+
### Protocol coverage
|
|
125
|
+
|
|
126
|
+
This is a compliant **minimal client** for the orchestration role — it drives a
|
|
127
|
+
full prompt turn, not the entire spec surface. Everything it omits is either
|
|
128
|
+
capability-gated (advertising non-support _is_ the spec-defined behavior) or a
|
|
129
|
+
rendering choice, not a violation.
|
|
130
|
+
|
|
131
|
+
- **Covered:** `initialize` (with `clientInfo` + version negotiation),
|
|
132
|
+
`authenticate`, `session/new`, `session/load`, `session/prompt`,
|
|
133
|
+
`session/cancel`, `session/request_permission` (all four option kinds), the
|
|
134
|
+
turn-output updates (`agent_message_chunk`, `agent_thought_chunk`, `tool_call`,
|
|
135
|
+
`tool_call_update`, `plan`), and all five stop reasons.
|
|
136
|
+
- **Surfaced as `CUSTOM` events** (the AG-UI chat-event protocol has no
|
|
137
|
+
first-class event for non-text assistant _output_): `<name>.session-id`, the
|
|
138
|
+
plan event, and `<name>.message-content` for non-text agent content (image /
|
|
139
|
+
audio / resource blocks). Non-text **tool** content is preserved inside the
|
|
140
|
+
`TOOL_CALL_RESULT` payload.
|
|
141
|
+
- **Workspace projection:** MCP skills → ACP `mcpServers` natively;
|
|
142
|
+
`gitSkill`s → linked into `skillsDir`; `fileSkill`/`instructions`/`secrets` →
|
|
143
|
+
handled by bootstrap. `agentSkill`/`plugins` are warned-and-skipped.
|
|
144
|
+
- **Not implemented (by design):** `fs/read_text_file`, `fs/write_text_file`,
|
|
145
|
+
`terminal/*` (advertised unsupported — the agent has direct sandbox FS/shell
|
|
146
|
+
access); sending multimodal _prompts_ (text only); incremental `usage_update`
|
|
147
|
+
(final usage is reported); `available_commands_update` / `current_mode_update`;
|
|
148
|
+
and experimental features (elicitation, NES, providers, session modes).
|
|
149
|
+
|
|
150
|
+
## Quick start (building a harness adapter)
|
|
151
|
+
|
|
152
|
+
If `acpCompatible` doesn't fit (you need a typed provider-options surface, custom
|
|
153
|
+
structured output, vendor projections, …), build the adapter by hand. The
|
|
154
|
+
pattern every ACP harness adapter follows:
|
|
155
|
+
|
|
156
|
+
1. Spawn the CLI inside a sandbox (`withSandbox` middleware).
|
|
157
|
+
2. Open an ACP transport (stdio or WebSocket).
|
|
158
|
+
3. Call `startAcpSession` and wire `onUpdate` / `onPermissionRequest`.
|
|
159
|
+
4. Push events into an `AsyncQueue`, call `session.prompt`, then `translateAcpStream`.
|
|
160
|
+
5. Emit a **CUSTOM** session-id chunk so follow-up runs can `loadSession`.
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { chat } from '@tanstack/ai'
|
|
164
|
+
import {
|
|
165
|
+
AsyncQueue,
|
|
166
|
+
resolveInteractivePermission,
|
|
167
|
+
spawnHandleToAcpTransport,
|
|
168
|
+
startAcpSession,
|
|
169
|
+
translateAcpStream,
|
|
170
|
+
} from '@tanstack/ai-acp'
|
|
171
|
+
import { withSandbox } from '@tanstack/ai-sandbox'
|
|
172
|
+
|
|
173
|
+
// Inside your adapter's chatStream():
|
|
174
|
+
const proc = await sandbox.process.spawn('my-cli --acp -m auto', {
|
|
175
|
+
cwd: '/workspace',
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const queue = new AsyncQueue()
|
|
179
|
+
const session = await startAcpSession({
|
|
180
|
+
transport: { kind: 'stdio', process: proc },
|
|
181
|
+
cwd: '/workspace',
|
|
182
|
+
authMethodId: 'gemini-api-key', // when the harness advertises it
|
|
183
|
+
resumeSessionId: options.modelOptions?.sessionId,
|
|
184
|
+
mcpServers: bridge
|
|
185
|
+
? [
|
|
186
|
+
{
|
|
187
|
+
name: bridge.name,
|
|
188
|
+
url: bridge.url,
|
|
189
|
+
headers: [{ name: 'Authorization', value: `Bearer ${bridge.token}` }],
|
|
190
|
+
},
|
|
191
|
+
]
|
|
192
|
+
: undefined,
|
|
193
|
+
onUpdate: (update) => queue.push({ kind: 'update', update }),
|
|
194
|
+
onPermissionRequest: (request) =>
|
|
195
|
+
resolveInteractivePermission(
|
|
196
|
+
request,
|
|
197
|
+
'acceptEdits',
|
|
198
|
+
bridgedToolNames,
|
|
199
|
+
options.approvals,
|
|
200
|
+
'my-harness',
|
|
201
|
+
).outcome,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
queue.push({ kind: 'session', sessionId: session.sessionId })
|
|
205
|
+
|
|
206
|
+
session
|
|
207
|
+
.prompt(userText)
|
|
208
|
+
.then(({ stopReason, usage }) => {
|
|
209
|
+
queue.push({
|
|
210
|
+
kind: 'done',
|
|
211
|
+
stopReason,
|
|
212
|
+
...(usage !== undefined && { usage }),
|
|
213
|
+
})
|
|
214
|
+
queue.end()
|
|
215
|
+
})
|
|
216
|
+
.catch((error) => queue.fail(error))
|
|
217
|
+
|
|
218
|
+
yield *
|
|
219
|
+
translateAcpStream(queue, {
|
|
220
|
+
model: 'auto',
|
|
221
|
+
runId,
|
|
222
|
+
threadId,
|
|
223
|
+
genId: () => crypto.randomUUID(),
|
|
224
|
+
labels: {
|
|
225
|
+
sessionIdEvent: 'my-harness.session-id',
|
|
226
|
+
planEvent: 'my-harness.plan',
|
|
227
|
+
refusalMessage: 'Harness refused the request.',
|
|
228
|
+
},
|
|
229
|
+
bridgedToolNames,
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Wire the adapter into `chat()` with `withSandbox(...)` like any other harness package.
|
|
234
|
+
|
|
235
|
+
## Transports
|
|
236
|
+
|
|
237
|
+
ACP can run over **stdio** (newline-delimited JSON-RPC on the child process pipes) or **WebSocket** (harness runs a `serve` subcommand inside the sandbox; the orchestrator connects through an exposed port).
|
|
238
|
+
|
|
239
|
+
### Stdio
|
|
240
|
+
|
|
241
|
+
Use when the sandbox can write to process stdin (`capabilities.writableStdin === true`):
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
import { spawnHandleToAcpTransport } from '@tanstack/ai-acp'
|
|
245
|
+
|
|
246
|
+
const proc = await sandbox.process.spawn('grok agent --acp -m auto', { cwd })
|
|
247
|
+
// spawnHandleToAcpTransport adapts SpawnHandle stdout/stdin for ndJsonStream
|
|
248
|
+
await startAcpSession({ transport: { kind: 'stdio', process: proc }, ... })
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
On providers without writable stdin (e.g. Cloudflare Containers), adapters feed the prompt via a shell redirect instead of a host stdin write — that workaround lives in each harness adapter, not in `@tanstack/ai-acp`.
|
|
252
|
+
|
|
253
|
+
### WebSocket
|
|
254
|
+
|
|
255
|
+
Use when stdin is not writable but the sandbox supports background processes and port exposure:
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
import {
|
|
259
|
+
buildGrokServeWebSocketUrl,
|
|
260
|
+
resolveAcpTransportMode,
|
|
261
|
+
startAcpServerInSandbox,
|
|
262
|
+
} from '@tanstack/ai-acp'
|
|
263
|
+
|
|
264
|
+
const mode = resolveAcpTransportMode(sandbox, 'auto') // 'stdio' | 'websocket'
|
|
265
|
+
|
|
266
|
+
const server = await startAcpServerInSandbox(sandbox, {
|
|
267
|
+
port: 2419,
|
|
268
|
+
cwd: '/workspace',
|
|
269
|
+
command: 'grok agent -m composer-2.5 --always-approve serve --bind 0.0.0.0:2419 --secret …',
|
|
270
|
+
buildWsUrl: ({ channel }) => buildGrokServeWebSocketUrl(channel.url, secret),
|
|
271
|
+
readyMarker: 'WebSocket URL:',
|
|
272
|
+
framing: 'frame',
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const { stream, close } = await server.connect(signal)
|
|
276
|
+
await startAcpSession({
|
|
277
|
+
transport: { kind: 'stream', stream, dispose: async () => { close(); await server.dispose() } },
|
|
278
|
+
...
|
|
279
|
+
})
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`resolveAcpTransportMode(sandbox, preference)` implements the selection table:
|
|
283
|
+
|
|
284
|
+
| Preference | Behavior |
|
|
285
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
286
|
+
| `'stdio'` | Requires `writableStdin`; throws if unavailable |
|
|
287
|
+
| `'websocket'` | Requires `ports` + `backgroundProcesses` |
|
|
288
|
+
| `'auto'` (default) | Prefer stdio when writable; else WebSocket when ports are available; else throw with a actionable error |
|
|
289
|
+
|
|
290
|
+
Helpers: `connectAcpWebSocket`, `httpChannelUrlToWsBase`, `webSocketFrameToAcpStream`, `parseWebSocketUrlFromServeOutput`.
|
|
291
|
+
|
|
292
|
+
## Session lifecycle
|
|
293
|
+
|
|
294
|
+
`startAcpSession` wraps `@agentclientprotocol/sdk`'s `ClientSideConnection`:
|
|
295
|
+
|
|
296
|
+
1. **`initialize`** — negotiate protocol version and read agent capabilities.
|
|
297
|
+
2. **`authenticate`** (optional) — when `authMethodId` is set and the harness advertises that method (e.g. `gemini-api-key`, `oauth-personal`).
|
|
298
|
+
3. **`loadSession` or `newSession`** — resume when `resumeSessionId` is provided and the agent supports `loadSession`; otherwise start fresh. MCP server descriptors (bridged TanStack tools) are attached here.
|
|
299
|
+
4. **`prompt`** — send the user turn; the harness streams updates via `sessionUpdate` callbacks until it returns `stopReason` + optional `usage`.
|
|
300
|
+
5. **`cancel` / `dispose`** — abort an in-flight turn or tear down the transport.
|
|
301
|
+
|
|
302
|
+
The returned `AcpSessionHandle` exposes `{ sessionId, resumed, prompt, cancel, dispose }`.
|
|
303
|
+
|
|
304
|
+
### Stateful sessions
|
|
305
|
+
|
|
306
|
+
On the first run, `translateAcpStream` emits a CUSTOM chunk:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
{ type: 'CUSTOM', name: 'my-harness.session-id', value: { sessionId: '…' } }
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Thread that `sessionId` through `modelOptions.sessionId` on the next `chat()` call so `startAcpSession` can `loadSession` and only send the trailing user message.
|
|
313
|
+
|
|
314
|
+
## Stream translation
|
|
315
|
+
|
|
316
|
+
`translateAcpStream(events, ctx)` is a pure async generator. Feed it `AcpStreamEvent` values:
|
|
317
|
+
|
|
318
|
+
| Event | When |
|
|
319
|
+
| -------------------------------------- | ----------------------------------------- |
|
|
320
|
+
| `{ kind: 'session', sessionId }` | Right after `newSession` / `loadSession` |
|
|
321
|
+
| `{ kind: 'update', update }` | Each `onUpdate` callback from the harness |
|
|
322
|
+
| `{ kind: 'done', stopReason, usage? }` | After `prompt` resolves |
|
|
323
|
+
|
|
324
|
+
ACP → AG-UI mapping (high level):
|
|
325
|
+
|
|
326
|
+
| ACP `sessionUpdate` | StreamChunk(s) |
|
|
327
|
+
| ---------------------------------- | ----------------------------------------- |
|
|
328
|
+
| `agent_message_chunk` | `TEXT_MESSAGE_*` |
|
|
329
|
+
| `agent_thought_chunk` | `REASONING_*` |
|
|
330
|
+
| `tool_call` / `tool_call_update` | `TOOL_CALL_*` + `TOOL_CALL_RESULT` |
|
|
331
|
+
| `plan` | `CUSTOM` (when `labels.planEvent` is set) |
|
|
332
|
+
| (terminal) `stopReason: 'refusal'` | `RUN_ERROR` |
|
|
333
|
+
| (terminal) other stop reasons | `RUN_FINISHED` + usage |
|
|
334
|
+
|
|
335
|
+
`matchBridgedToolName` rewrites tool titles from the harness MCP namespace back to TanStack tool names when host tools are bridged in. `BRIDGED_MCP_SERVER_NAME` (`'tanstack'`) is the conventional MCP server name adapters use for the bridge.
|
|
336
|
+
|
|
337
|
+
`AsyncQueue` bridges callback-style `onUpdate` notifications into the async-iterable world `translateAcpStream` consumes.
|
|
338
|
+
|
|
339
|
+
## Permissions
|
|
340
|
+
|
|
341
|
+
Harnesses can pause mid-turn and ask the client to approve a tool call. Wire `onPermissionRequest` on `startAcpSession`:
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
import {
|
|
345
|
+
resolvePermission,
|
|
346
|
+
resolveInteractivePermission,
|
|
347
|
+
} from '@tanstack/ai-acp'
|
|
348
|
+
|
|
349
|
+
// Headless / sandboxed: auto-approve bridged tools + edits, reject everything else
|
|
350
|
+
onPermissionRequest: (request) =>
|
|
351
|
+
resolvePermission(request, permissionMode, bridgedToolNames)
|
|
352
|
+
|
|
353
|
+
// Interactive: same policy, but emit approval-requested events for 'ask' actions
|
|
354
|
+
const { outcome, approvalId } = resolveInteractivePermission(
|
|
355
|
+
request,
|
|
356
|
+
permissionMode,
|
|
357
|
+
bridgedToolNames,
|
|
358
|
+
options.approvals,
|
|
359
|
+
'my-harness',
|
|
360
|
+
)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`AcpPermissionMode`:
|
|
364
|
+
|
|
365
|
+
| Mode | Behavior |
|
|
366
|
+
| --------------------- | --------------------------------------------------------------- |
|
|
367
|
+
| `'default'` | Approve TanStack-bridged tools; reject other permission prompts |
|
|
368
|
+
| `'acceptEdits'` | Also auto-approve file mutations (`edit`, `move`, `delete`) |
|
|
369
|
+
| `'bypassPermissions'` | Approve everything |
|
|
370
|
+
|
|
371
|
+
Pass a custom `PermissionHandler` to override the policy entirely.
|
|
372
|
+
|
|
373
|
+
## Public API
|
|
374
|
+
|
|
375
|
+
### Harness adapter
|
|
376
|
+
|
|
377
|
+
- `acpCompatible(config)` → `(model, overrides?) => AcpCompatibleTextAdapter`
|
|
378
|
+
- `acpCompatibleText(model, config)` → `AcpCompatibleTextAdapter`
|
|
379
|
+
- `buildAcpPrompt(messages, sessionId, harnessName?)` → `{ prompt, resume? }`
|
|
380
|
+
- `AcpCompatibleConfig`, `AcpCompatibleProviderOptions`, `AcpHarnessContext`, `BuiltAcpPrompt`
|
|
381
|
+
|
|
382
|
+
### Session
|
|
383
|
+
|
|
384
|
+
- `startAcpSession(options)` → `AcpSessionHandle`
|
|
385
|
+
- `StartAcpSessionOptions`, `AcpSessionHandle`
|
|
386
|
+
|
|
387
|
+
### Translation
|
|
388
|
+
|
|
389
|
+
- `translateAcpStream(events, ctx)` → `AsyncIterable<StreamChunk>`
|
|
390
|
+
- `AsyncQueue<T>`
|
|
391
|
+
- `matchBridgedToolName`, `BRIDGED_MCP_SERVER_NAME`
|
|
392
|
+
- `AcpStreamEvent`, `TranslateContext`, `AcpTranslateLabels`
|
|
393
|
+
|
|
394
|
+
### Transport
|
|
395
|
+
|
|
396
|
+
- `spawnHandleToAcpTransport(handle)` — stdio byte streams from a `SpawnHandle`
|
|
397
|
+
- `resolveAcpTransportMode(sandbox, preference?)`
|
|
398
|
+
- `connectAcpWebSocket(url, options?)`, `httpChannelUrlToWsBase`
|
|
399
|
+
- `webSocketFrameToAcpStream(ws)`
|
|
400
|
+
|
|
401
|
+
### In-sandbox server
|
|
402
|
+
|
|
403
|
+
- `startAcpServerInSandbox(sandbox, options)` → `AcpSandboxServer`
|
|
404
|
+
- `buildGrokServeWebSocketUrl(channelUrl, secret)`
|
|
405
|
+
- `parseWebSocketUrlFromServeOutput(stdout)`
|
|
406
|
+
|
|
407
|
+
### Permissions
|
|
408
|
+
|
|
409
|
+
- `resolvePermission`, `resolveInteractivePermission`
|
|
410
|
+
- `AcpPermissionMode`, `PermissionHandler`, `AcpPermissionRequest`, `AcpPermissionOutcome`
|
|
411
|
+
|
|
412
|
+
### Types
|
|
413
|
+
|
|
414
|
+
Structural subsets of ACP shapes (`AcpSessionUpdate`, `AcpToolCallUpdate`, `AcpUsage`, …) live in `types/acp-types.ts` so the translator stays fixture-testable without pulling the full SDK surface into every consumer.
|
|
415
|
+
|
|
416
|
+
## Consumers in this repo
|
|
417
|
+
|
|
418
|
+
| Package | How it uses `@tanstack/ai-acp` |
|
|
419
|
+
| ------------------------- | ------------------------------------------------------------------------- |
|
|
420
|
+
| `@tanstack/ai-grok-build` | Stdio + WebSocket (`grok agent serve`); vendor `extNotification` handling |
|
|
421
|
+
|
|
422
|
+
Harness adapters re-export commonly needed symbols (`startAcpSession`, `translateAcpStream`, permission helpers) from their own entry points so app code rarely imports `@tanstack/ai-acp` directly.
|
|
423
|
+
|
|
424
|
+
## Further reading
|
|
425
|
+
|
|
426
|
+
- [Agent Client Protocol](https://agentclientprotocol.com)
|
|
427
|
+
- TanStack harness adapters: `@tanstack/ai-grok-build`
|
|
428
|
+
- Sandbox layer: `@tanstack/ai-sandbox` ([README](../ai-sandbox/README.md))
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { BaseTextAdapter, StructuredOutputOptions, StructuredOutputResult } from '@tanstack/ai/adapters';
|
|
2
|
+
import { SandboxHandle } from '@tanstack/ai-sandbox';
|
|
3
|
+
import { DefaultMessageMetadataByModality, Modality, ModelMessage, StreamChunk, TextOptions } from '@tanstack/ai';
|
|
4
|
+
import { AcpSessionTransport } from '../transport/types.js';
|
|
5
|
+
import { AcpPermissionMode, PermissionHandler } from '../types/acp-types.js';
|
|
6
|
+
import { BuiltAcpPrompt } from '../messages/prompt.js';
|
|
7
|
+
/**
|
|
8
|
+
* Everything a harness needs to know to launch its ACP server inside the
|
|
9
|
+
* sandbox. Passed to {@link AcpCompatibleConfig.command} /
|
|
10
|
+
* {@link AcpCompatibleConfig.openTransport}.
|
|
11
|
+
*/
|
|
12
|
+
export interface AcpHarnessContext<TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions> {
|
|
13
|
+
/** The sandbox the harness runs in (from `withSandbox(...)` middleware). */
|
|
14
|
+
sandbox: SandboxHandle;
|
|
15
|
+
/** The selected model id. */
|
|
16
|
+
model: string;
|
|
17
|
+
/** Virtual cwd for `sandbox.process.spawn` (the provider maps `/workspace`). */
|
|
18
|
+
cwd: string;
|
|
19
|
+
/** Literal cwd for the harness's own `--cwd` flag / ACP `newSession`. */
|
|
20
|
+
harnessCwd: string;
|
|
21
|
+
/** Extra env vars configured for the harness process. */
|
|
22
|
+
env: Record<string, string> | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Per-call options from `chat({ modelOptions })` — the base ACP options plus
|
|
25
|
+
* whatever you declared via {@link AcpCompatibleConfig.modelOptions}. Read
|
|
26
|
+
* these to turn options into CLI flags / transport choices.
|
|
27
|
+
*/
|
|
28
|
+
modelOptions: TModelOptions | undefined;
|
|
29
|
+
/** Abort signal for the run, when one was provided. */
|
|
30
|
+
signal: AbortSignal | undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Union of selectable model names from a `models` tuple (any string if omitted). */
|
|
33
|
+
export type AcpModelNameOf<TModels extends ReadonlyArray<string>> = TModels[number];
|
|
34
|
+
export interface AcpCompatibleConfig<TModels extends ReadonlyArray<string> = ReadonlyArray<string>, TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions> {
|
|
35
|
+
/**
|
|
36
|
+
* Harness name. Used as the provider label, the log prefix, and the CUSTOM
|
|
37
|
+
* session-id event name (`<name>.session-id`).
|
|
38
|
+
*/
|
|
39
|
+
name: string;
|
|
40
|
+
/**
|
|
41
|
+
* The models this harness accepts. Declaring them makes the returned factory
|
|
42
|
+
* type-safe — `harness('known-model')` is checked, unknown ids are rejected.
|
|
43
|
+
* Omit to accept any string.
|
|
44
|
+
*/
|
|
45
|
+
models?: TModels;
|
|
46
|
+
/**
|
|
47
|
+
* Type-only brand for the per-call options accepted via `chat({ modelOptions })`.
|
|
48
|
+
* Declare your harness's options here with `{} as { ... }` (the value is unused
|
|
49
|
+
* at runtime); they are merged with the base {@link AcpCompatibleProviderOptions}
|
|
50
|
+
* and exposed on {@link AcpHarnessContext.modelOptions} so `command` /
|
|
51
|
+
* `openTransport` can turn them into CLI flags.
|
|
52
|
+
*
|
|
53
|
+
* @example modelOptions: {} as { reasoningEffort?: 'low' | 'high' }
|
|
54
|
+
*/
|
|
55
|
+
modelOptions?: TModelOptions;
|
|
56
|
+
/**
|
|
57
|
+
* Build the shell command that launches the harness's ACP server over
|
|
58
|
+
* **stdio** inside the sandbox (e.g. `` `pi --acp -m ${model}` ``). Required
|
|
59
|
+
* unless {@link openTransport} is provided.
|
|
60
|
+
*/
|
|
61
|
+
command?: (ctx: AcpHarnessContext<AcpCompatibleProviderOptions & TModelOptions>) => string;
|
|
62
|
+
/**
|
|
63
|
+
* Full transport escape hatch — open any {@link AcpSessionTransport} yourself
|
|
64
|
+
* (e.g. boot a `serve` process and connect over WebSocket, as Grok Build
|
|
65
|
+
* does). Overrides {@link command}. Put ALL teardown in the returned
|
|
66
|
+
* transport's `dispose` (stream) / process (stdio); it is disposed when the
|
|
67
|
+
* session ends.
|
|
68
|
+
*/
|
|
69
|
+
openTransport?: (ctx: AcpHarnessContext<AcpCompatibleProviderOptions & TModelOptions>) => Promise<AcpSessionTransport> | AcpSessionTransport;
|
|
70
|
+
/** Working directory inside the sandbox. Defaults to `/workspace`. */
|
|
71
|
+
cwd?: string;
|
|
72
|
+
/**
|
|
73
|
+
* The harness's skills directory, relative to the workspace root (e.g.
|
|
74
|
+
* `'.pi/skills'`) — its native convention for where it auto-discovers skills,
|
|
75
|
+
* the way Claude Code uses `.claude/skills`. When set, `withSandbox` workspace
|
|
76
|
+
* `gitSkill`s are linked here. MCP skills don't need this: they're passed to
|
|
77
|
+
* the agent over ACP natively. Omit and `gitSkill`s are left unlinked (warned).
|
|
78
|
+
*/
|
|
79
|
+
skillsDir?: string;
|
|
80
|
+
/** Extra environment variables for the harness process. */
|
|
81
|
+
env?: Record<string, string>;
|
|
82
|
+
/**
|
|
83
|
+
* ACP auth method to select before the session starts, when the harness
|
|
84
|
+
* advertises one (e.g. `'pi-api-key'`). Overridable per call via
|
|
85
|
+
* `modelOptions.authMethodId`.
|
|
86
|
+
*/
|
|
87
|
+
authMethodId?: string;
|
|
88
|
+
/** ACP permission policy. Defaults to `'bypassPermissions'`. */
|
|
89
|
+
permissionMode?: AcpPermissionMode;
|
|
90
|
+
/**
|
|
91
|
+
* Permission strategy:
|
|
92
|
+
* - `'headless'` (default) — auto-resolve via {@link permissionMode}; the
|
|
93
|
+
* sandbox is the boundary, so the agent runs without prompting.
|
|
94
|
+
* - `'interactive'` — same policy, but `ask`-style prompts emit an
|
|
95
|
+
* approval-requested event so a client can approve and re-run.
|
|
96
|
+
*/
|
|
97
|
+
permissions?: 'headless' | 'interactive';
|
|
98
|
+
/** Custom permission handler; overrides {@link permissions}/{@link permissionMode}. */
|
|
99
|
+
onPermissionRequest?: PermissionHandler;
|
|
100
|
+
/** Message used for `RUN_ERROR` when the harness refuses a request. */
|
|
101
|
+
refusalMessage?: string;
|
|
102
|
+
/** Emit ACP `plan` updates as a CUSTOM event under this name (off by default). */
|
|
103
|
+
planEventName?: string;
|
|
104
|
+
/**
|
|
105
|
+
* After the run, emit the `git diff` of the working dir as a `file.changed`
|
|
106
|
+
* CUSTOM event. Requires a git repo at `cwd`. Off by default.
|
|
107
|
+
*/
|
|
108
|
+
emitDiff?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Harness-specific JSON-RPC notifications (vendor `_x/...` extensions). Must
|
|
111
|
+
* return without throwing — unknown extensions must not tear down the session.
|
|
112
|
+
*/
|
|
113
|
+
onExtNotification?: (method: string, params: Record<string, unknown>) => void;
|
|
114
|
+
/**
|
|
115
|
+
* Convert chat history into the harness prompt + resume inputs. Defaults to
|
|
116
|
+
* {@link buildAcpPrompt} (trailing user message + flattened transcript).
|
|
117
|
+
*/
|
|
118
|
+
buildPrompt?: (messages: Array<ModelMessage>, sessionId: string | undefined) => BuiltAcpPrompt;
|
|
119
|
+
}
|
|
120
|
+
/** Per-call provider options, passed via `modelOptions` on `chat()`. */
|
|
121
|
+
export interface AcpCompatibleProviderOptions {
|
|
122
|
+
/**
|
|
123
|
+
* Resume an existing harness session. The adapter emits the session id of
|
|
124
|
+
* every run via a CUSTOM `<name>.session-id` event; thread it back here to
|
|
125
|
+
* continue (only the trailing user message is sent).
|
|
126
|
+
*/
|
|
127
|
+
sessionId?: string;
|
|
128
|
+
/** Per-call override of the harness working directory. */
|
|
129
|
+
cwd?: string;
|
|
130
|
+
/** Per-call override of the ACP auth method. */
|
|
131
|
+
authMethodId?: string;
|
|
132
|
+
/** Per-call override of the ACP permission policy. */
|
|
133
|
+
permissionMode?: AcpPermissionMode;
|
|
134
|
+
}
|
|
135
|
+
/** Per-call options the adapter sees: the base ACP options + the harness's own. */
|
|
136
|
+
type ResolvedOptions<TModelOptions extends Record<string, any>> = AcpCompatibleProviderOptions & TModelOptions;
|
|
137
|
+
/**
|
|
138
|
+
* A generic ACP harness adapter built from {@link AcpCompatibleConfig}. Runs the
|
|
139
|
+
* configured coding-agent CLI inside the sandbox provided by `withSandbox(...)`
|
|
140
|
+
* and translates its ACP session into AG-UI `StreamChunk`s.
|
|
141
|
+
*/
|
|
142
|
+
export declare class AcpCompatibleTextAdapter<TModel extends string, TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions> extends BaseTextAdapter<TModel, ResolvedOptions<TModelOptions>, ReadonlyArray<Modality> & readonly ['text'], DefaultMessageMetadataByModality, ReadonlyArray<string>, unknown, never> {
|
|
143
|
+
readonly name: string;
|
|
144
|
+
readonly requires: readonly [import('@tanstack/ai').Capability<SandboxHandle, "sandbox">];
|
|
145
|
+
private readonly harness;
|
|
146
|
+
constructor(config: AcpCompatibleConfig<ReadonlyArray<string>, TModelOptions>, model: TModel);
|
|
147
|
+
private sandboxFrom;
|
|
148
|
+
private buildPrompt;
|
|
149
|
+
private applySystemPrompts;
|
|
150
|
+
private makePermissionHandler;
|
|
151
|
+
chatStream(options: TextOptions<ResolvedOptions<TModelOptions>>): AsyncIterable<StreamChunk>;
|
|
152
|
+
private openStdioTransport;
|
|
153
|
+
private emitDiffChunks;
|
|
154
|
+
structuredOutput(_options: StructuredOutputOptions<ResolvedOptions<TModelOptions>>): Promise<StructuredOutputResult<unknown>>;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Configure an ACP-compatible harness once, then select a model per call.
|
|
158
|
+
*
|
|
159
|
+
* Mirrors `openaiCompatible`: it lets you plug ANY Agent Client Protocol agent
|
|
160
|
+
* into a TanStack AI sandbox without a dedicated adapter package.
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```ts
|
|
164
|
+
* import { acpCompatible } from '@tanstack/ai-acp'
|
|
165
|
+
* import { chat } from '@tanstack/ai'
|
|
166
|
+
* import { defineSandbox, withSandbox } from '@tanstack/ai-sandbox'
|
|
167
|
+
*
|
|
168
|
+
* const pi = acpCompatible({
|
|
169
|
+
* name: 'pi',
|
|
170
|
+
* // declaring `models` makes pi('…') type-safe; omit to accept any string
|
|
171
|
+
* models: ['pi-fast', 'pi-pro'],
|
|
172
|
+
* // declare per-call options; merged with the base ACP options and exposed
|
|
173
|
+
* // on ctx.modelOptions inside `command` / `openTransport`
|
|
174
|
+
* modelOptions: {} as { reasoningEffort?: 'low' | 'high' },
|
|
175
|
+
* command: ({ model, harnessCwd, modelOptions }) =>
|
|
176
|
+
* `pi --acp -m ${model} --cwd ${harnessCwd}` +
|
|
177
|
+
* (modelOptions?.reasoningEffort ? ` --effort ${modelOptions.reasoningEffort}` : ''),
|
|
178
|
+
* authMethodId: 'pi-api-key',
|
|
179
|
+
* })
|
|
180
|
+
*
|
|
181
|
+
* chat({
|
|
182
|
+
* adapter: pi('pi-pro'),
|
|
183
|
+
* modelOptions: { reasoningEffort: 'high' }, // typed
|
|
184
|
+
* messages,
|
|
185
|
+
* middleware: [withSandbox(defineSandbox({ /* provider, install pi *\/ }))],
|
|
186
|
+
* })
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
export declare function acpCompatible<const TModels extends ReadonlyArray<string> = ReadonlyArray<string>, TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions>(config: AcpCompatibleConfig<TModels, TModelOptions>): <TModel extends AcpModelNameOf<TModels>>(model: TModel, overrides?: Partial<AcpCompatibleConfig<TModels, TModelOptions>>) => AcpCompatibleTextAdapter<TModel, TModelOptions>;
|
|
190
|
+
/**
|
|
191
|
+
* One-shot helper: build a single-model ACP-compatible harness adapter inline.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```ts
|
|
195
|
+
* chat({
|
|
196
|
+
* adapter: acpCompatibleText('pi-fast', {
|
|
197
|
+
* name: 'pi',
|
|
198
|
+
* command: ({ model }) => `pi --acp -m ${model}`,
|
|
199
|
+
* }),
|
|
200
|
+
* messages,
|
|
201
|
+
* middleware: [withSandbox(defineSandbox({ ... }))],
|
|
202
|
+
* })
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
export declare function acpCompatibleText<TModel extends string, TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions>(model: TModel, config: AcpCompatibleConfig<ReadonlyArray<string>, TModelOptions>): AcpCompatibleTextAdapter<TModel, TModelOptions>;
|
|
206
|
+
export {};
|