@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.
Files changed (54) hide show
  1. package/README.md +428 -0
  2. package/dist/esm/adapters/compatible.d.ts +206 -0
  3. package/dist/esm/adapters/compatible.js +313 -0
  4. package/dist/esm/adapters/compatible.js.map +1 -0
  5. package/dist/esm/adapters/projection.d.ts +29 -0
  6. package/dist/esm/adapters/projection.js +83 -0
  7. package/dist/esm/adapters/projection.js.map +1 -0
  8. package/dist/esm/index.d.ts +21 -0
  9. package/dist/esm/index.js +35 -0
  10. package/dist/esm/index.js.map +1 -0
  11. package/dist/esm/messages/prompt.d.ts +19 -0
  12. package/dist/esm/messages/prompt.js +37 -0
  13. package/dist/esm/messages/prompt.js.map +1 -0
  14. package/dist/esm/permissions.d.ts +8 -0
  15. package/dist/esm/permissions.js +44 -0
  16. package/dist/esm/permissions.js.map +1 -0
  17. package/dist/esm/session/acp-client.d.ts +37 -0
  18. package/dist/esm/session/acp-client.js +140 -0
  19. package/dist/esm/session/acp-client.js.map +1 -0
  20. package/dist/esm/session/sandbox-server.d.ts +46 -0
  21. package/dist/esm/session/sandbox-server.js +114 -0
  22. package/dist/esm/session/sandbox-server.js.map +1 -0
  23. package/dist/esm/stream/queue.d.ts +15 -0
  24. package/dist/esm/stream/queue.js +54 -0
  25. package/dist/esm/stream/queue.js.map +1 -0
  26. package/dist/esm/stream/translate.d.ts +38 -0
  27. package/dist/esm/stream/translate.js +320 -0
  28. package/dist/esm/stream/translate.js.map +1 -0
  29. package/dist/esm/transport/resolve.d.ts +7 -0
  30. package/dist/esm/transport/resolve.js +34 -0
  31. package/dist/esm/transport/resolve.js.map +1 -0
  32. package/dist/esm/transport/stdio.d.ts +3 -0
  33. package/dist/esm/transport/stdio.js +51 -0
  34. package/dist/esm/transport/stdio.js.map +1 -0
  35. package/dist/esm/transport/types.d.ts +29 -0
  36. package/dist/esm/transport/websocket.d.ts +17 -0
  37. package/dist/esm/transport/websocket.js +135 -0
  38. package/dist/esm/transport/websocket.js.map +1 -0
  39. package/dist/esm/types/acp-types.d.ts +75 -0
  40. package/package.json +61 -0
  41. package/src/adapters/compatible.ts +675 -0
  42. package/src/adapters/projection.ts +164 -0
  43. package/src/index.ts +85 -0
  44. package/src/messages/prompt.ts +71 -0
  45. package/src/permissions.ts +71 -0
  46. package/src/session/acp-client.ts +238 -0
  47. package/src/session/sandbox-server.ts +195 -0
  48. package/src/stream/queue.ts +61 -0
  49. package/src/stream/translate.ts +412 -0
  50. package/src/transport/resolve.ts +46 -0
  51. package/src/transport/stdio.ts +63 -0
  52. package/src/transport/types.ts +33 -0
  53. package/src/transport/websocket.ts +192 -0
  54. 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 {};