@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
@@ -0,0 +1,675 @@
1
+ import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
2
+ import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
3
+ import { BaseTextAdapter } from '@tanstack/ai/adapters'
4
+ import {
5
+ SandboxCapability,
6
+ buildApprovalRequestedEvent,
7
+ createBridgeEventChannel,
8
+ getSandbox,
9
+ getToolBridgeProvisioner,
10
+ getWorkspaceProjection,
11
+ mergeChunkStreams,
12
+ nodeHttpBridgeProvisioner,
13
+ resolveHarnessCwd,
14
+ } from '@tanstack/ai-sandbox'
15
+ import { AsyncQueue } from '../stream/queue'
16
+ import { startAcpSession } from '../session/acp-client'
17
+ import { translateAcpStream } from '../stream/translate'
18
+ import { resolveInteractivePermission, resolvePermission } from '../permissions'
19
+ import { buildAcpPrompt } from '../messages/prompt'
20
+ import { projectAcpWorkspace, workspaceMcpServers } from './projection'
21
+ import type { AcpMcpServer } from './projection'
22
+ import type { HostToolBridge, SandboxHandle } from '@tanstack/ai-sandbox'
23
+ import type {
24
+ StructuredOutputOptions,
25
+ StructuredOutputResult,
26
+ } from '@tanstack/ai/adapters'
27
+ import type {
28
+ DefaultMessageMetadataByModality,
29
+ Modality,
30
+ ModelMessage,
31
+ StreamChunk,
32
+ TextOptions,
33
+ } from '@tanstack/ai'
34
+ import type { AcpSessionHandle } from '../session/acp-client'
35
+ import type { AcpStreamEvent } from '../stream/translate'
36
+ import type { AcpSessionTransport } from '../transport/types'
37
+ import type {
38
+ AcpPermissionMode,
39
+ AcpSessionUpdate,
40
+ PermissionHandler,
41
+ } from '../types/acp-types'
42
+ import type { BuiltAcpPrompt } from '../messages/prompt'
43
+
44
+ const DEFAULT_WORKDIR = '/workspace'
45
+
46
+ /**
47
+ * Everything a harness needs to know to launch its ACP server inside the
48
+ * sandbox. Passed to {@link AcpCompatibleConfig.command} /
49
+ * {@link AcpCompatibleConfig.openTransport}.
50
+ */
51
+ export interface AcpHarnessContext<
52
+ TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions,
53
+ > {
54
+ /** The sandbox the harness runs in (from `withSandbox(...)` middleware). */
55
+ sandbox: SandboxHandle
56
+ /** The selected model id. */
57
+ model: string
58
+ /** Virtual cwd for `sandbox.process.spawn` (the provider maps `/workspace`). */
59
+ cwd: string
60
+ /** Literal cwd for the harness's own `--cwd` flag / ACP `newSession`. */
61
+ harnessCwd: string
62
+ /** Extra env vars configured for the harness process. */
63
+ env: Record<string, string> | undefined
64
+ /**
65
+ * Per-call options from `chat({ modelOptions })` — the base ACP options plus
66
+ * whatever you declared via {@link AcpCompatibleConfig.modelOptions}. Read
67
+ * these to turn options into CLI flags / transport choices.
68
+ */
69
+ modelOptions: TModelOptions | undefined
70
+ /** Abort signal for the run, when one was provided. */
71
+ signal: AbortSignal | undefined
72
+ }
73
+
74
+ /** Union of selectable model names from a `models` tuple (any string if omitted). */
75
+ export type AcpModelNameOf<TModels extends ReadonlyArray<string>> =
76
+ TModels[number]
77
+
78
+ export interface AcpCompatibleConfig<
79
+ TModels extends ReadonlyArray<string> = ReadonlyArray<string>,
80
+ TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions,
81
+ > {
82
+ /**
83
+ * Harness name. Used as the provider label, the log prefix, and the CUSTOM
84
+ * session-id event name (`<name>.session-id`).
85
+ */
86
+ name: string
87
+ /**
88
+ * The models this harness accepts. Declaring them makes the returned factory
89
+ * type-safe — `harness('known-model')` is checked, unknown ids are rejected.
90
+ * Omit to accept any string.
91
+ */
92
+ models?: TModels
93
+ /**
94
+ * Type-only brand for the per-call options accepted via `chat({ modelOptions })`.
95
+ * Declare your harness's options here with `{} as { ... }` (the value is unused
96
+ * at runtime); they are merged with the base {@link AcpCompatibleProviderOptions}
97
+ * and exposed on {@link AcpHarnessContext.modelOptions} so `command` /
98
+ * `openTransport` can turn them into CLI flags.
99
+ *
100
+ * @example modelOptions: {} as { reasoningEffort?: 'low' | 'high' }
101
+ */
102
+ modelOptions?: TModelOptions
103
+ /**
104
+ * Build the shell command that launches the harness's ACP server over
105
+ * **stdio** inside the sandbox (e.g. `` `pi --acp -m ${model}` ``). Required
106
+ * unless {@link openTransport} is provided.
107
+ */
108
+ command?: (
109
+ ctx: AcpHarnessContext<AcpCompatibleProviderOptions & TModelOptions>,
110
+ ) => string
111
+ /**
112
+ * Full transport escape hatch — open any {@link AcpSessionTransport} yourself
113
+ * (e.g. boot a `serve` process and connect over WebSocket, as Grok Build
114
+ * does). Overrides {@link command}. Put ALL teardown in the returned
115
+ * transport's `dispose` (stream) / process (stdio); it is disposed when the
116
+ * session ends.
117
+ */
118
+ openTransport?: (
119
+ ctx: AcpHarnessContext<AcpCompatibleProviderOptions & TModelOptions>,
120
+ ) => Promise<AcpSessionTransport> | AcpSessionTransport
121
+ /** Working directory inside the sandbox. Defaults to `/workspace`. */
122
+ cwd?: string
123
+ /**
124
+ * The harness's skills directory, relative to the workspace root (e.g.
125
+ * `'.pi/skills'`) — its native convention for where it auto-discovers skills,
126
+ * the way Claude Code uses `.claude/skills`. When set, `withSandbox` workspace
127
+ * `gitSkill`s are linked here. MCP skills don't need this: they're passed to
128
+ * the agent over ACP natively. Omit and `gitSkill`s are left unlinked (warned).
129
+ */
130
+ skillsDir?: string
131
+ /** Extra environment variables for the harness process. */
132
+ env?: Record<string, string>
133
+ /**
134
+ * ACP auth method to select before the session starts, when the harness
135
+ * advertises one (e.g. `'pi-api-key'`). Overridable per call via
136
+ * `modelOptions.authMethodId`.
137
+ */
138
+ authMethodId?: string
139
+ /** ACP permission policy. Defaults to `'bypassPermissions'`. */
140
+ permissionMode?: AcpPermissionMode
141
+ /**
142
+ * Permission strategy:
143
+ * - `'headless'` (default) — auto-resolve via {@link permissionMode}; the
144
+ * sandbox is the boundary, so the agent runs without prompting.
145
+ * - `'interactive'` — same policy, but `ask`-style prompts emit an
146
+ * approval-requested event so a client can approve and re-run.
147
+ */
148
+ permissions?: 'headless' | 'interactive'
149
+ /** Custom permission handler; overrides {@link permissions}/{@link permissionMode}. */
150
+ onPermissionRequest?: PermissionHandler
151
+ /** Message used for `RUN_ERROR` when the harness refuses a request. */
152
+ refusalMessage?: string
153
+ /** Emit ACP `plan` updates as a CUSTOM event under this name (off by default). */
154
+ planEventName?: string
155
+ /**
156
+ * After the run, emit the `git diff` of the working dir as a `file.changed`
157
+ * CUSTOM event. Requires a git repo at `cwd`. Off by default.
158
+ */
159
+ emitDiff?: boolean
160
+ /**
161
+ * Harness-specific JSON-RPC notifications (vendor `_x/...` extensions). Must
162
+ * return without throwing — unknown extensions must not tear down the session.
163
+ */
164
+ onExtNotification?: (method: string, params: Record<string, unknown>) => void
165
+ /**
166
+ * Convert chat history into the harness prompt + resume inputs. Defaults to
167
+ * {@link buildAcpPrompt} (trailing user message + flattened transcript).
168
+ */
169
+ buildPrompt?: (
170
+ messages: Array<ModelMessage>,
171
+ sessionId: string | undefined,
172
+ ) => BuiltAcpPrompt
173
+ }
174
+
175
+ /** Per-call provider options, passed via `modelOptions` on `chat()`. */
176
+ export interface AcpCompatibleProviderOptions {
177
+ /**
178
+ * Resume an existing harness session. The adapter emits the session id of
179
+ * every run via a CUSTOM `<name>.session-id` event; thread it back here to
180
+ * continue (only the trailing user message is sent).
181
+ */
182
+ sessionId?: string
183
+ /** Per-call override of the harness working directory. */
184
+ cwd?: string
185
+ /** Per-call override of the ACP auth method. */
186
+ authMethodId?: string
187
+ /** Per-call override of the ACP permission policy. */
188
+ permissionMode?: AcpPermissionMode
189
+ }
190
+
191
+ /** Per-call options the adapter sees: the base ACP options + the harness's own. */
192
+ type ResolvedOptions<TModelOptions extends Record<string, any>> =
193
+ AcpCompatibleProviderOptions & TModelOptions
194
+
195
+ function q(value: string): string {
196
+ return `'${value.replace(/'/g, `'\\''`)}'`
197
+ }
198
+
199
+ async function disposeTransport(transport: AcpSessionTransport): Promise<void> {
200
+ if (transport.kind === 'stdio') {
201
+ await transport.process.kill()
202
+ return
203
+ }
204
+ await transport.dispose()
205
+ }
206
+
207
+ /**
208
+ * A generic ACP harness adapter built from {@link AcpCompatibleConfig}. Runs the
209
+ * configured coding-agent CLI inside the sandbox provided by `withSandbox(...)`
210
+ * and translates its ACP session into AG-UI `StreamChunk`s.
211
+ */
212
+ export class AcpCompatibleTextAdapter<
213
+ TModel extends string,
214
+ TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions,
215
+ > extends BaseTextAdapter<
216
+ TModel,
217
+ ResolvedOptions<TModelOptions>,
218
+ ReadonlyArray<Modality> & readonly ['text'],
219
+ DefaultMessageMetadataByModality,
220
+ ReadonlyArray<string>,
221
+ unknown,
222
+ never
223
+ > {
224
+ override readonly name: string
225
+
226
+ override readonly requires = [SandboxCapability] as const
227
+
228
+ private readonly harness: AcpCompatibleConfig<
229
+ ReadonlyArray<string>,
230
+ TModelOptions
231
+ >
232
+
233
+ constructor(
234
+ config: AcpCompatibleConfig<ReadonlyArray<string>, TModelOptions>,
235
+ model: TModel,
236
+ ) {
237
+ super({}, model)
238
+ if (config.command === undefined && config.openTransport === undefined) {
239
+ throw new Error(
240
+ `acpCompatible("${config.name}") needs either a "command" or an "openTransport".`,
241
+ )
242
+ }
243
+ this.harness = config
244
+ this.name = config.name
245
+ }
246
+
247
+ private sandboxFrom(
248
+ options: TextOptions<ResolvedOptions<TModelOptions>>,
249
+ ): SandboxHandle {
250
+ const ctx = options.capabilities
251
+ if (!ctx) {
252
+ throw new Error(
253
+ `Adapter "${this.name}" requires a sandbox. Add withSandbox(defineSandbox({ ... })) to chat() middleware.`,
254
+ )
255
+ }
256
+ return getSandbox(ctx)
257
+ }
258
+
259
+ private buildPrompt(
260
+ messages: Array<ModelMessage>,
261
+ sessionId: string | undefined,
262
+ ): BuiltAcpPrompt {
263
+ return this.harness.buildPrompt
264
+ ? this.harness.buildPrompt(messages, sessionId)
265
+ : buildAcpPrompt(messages, sessionId, this.name)
266
+ }
267
+
268
+ private applySystemPrompts(
269
+ systemPrompts: Array<string>,
270
+ prompt: string,
271
+ ): string {
272
+ if (systemPrompts.length === 0) return prompt
273
+ return `${systemPrompts.join('\n\n')}\n\n${prompt}`
274
+ }
275
+
276
+ private makePermissionHandler(input: {
277
+ mode: AcpPermissionMode
278
+ bridgedToolNames: ReadonlySet<string>
279
+ approvals: ReadonlyMap<string, boolean> | undefined
280
+ approvalRequests: Array<StreamChunk>
281
+ threadId: string
282
+ runId: string
283
+ }): PermissionHandler {
284
+ if (this.harness.onPermissionRequest)
285
+ return this.harness.onPermissionRequest
286
+
287
+ if (this.harness.permissions === 'interactive') {
288
+ return (request) => {
289
+ const result = resolveInteractivePermission(
290
+ request,
291
+ input.mode,
292
+ input.bridgedToolNames,
293
+ input.approvals,
294
+ this.name,
295
+ )
296
+ if (result.approvalId !== undefined) {
297
+ input.approvalRequests.push(
298
+ buildApprovalRequestedEvent({
299
+ approvalId: result.approvalId,
300
+ title:
301
+ result.title ??
302
+ request.toolCall.title ??
303
+ request.toolCall.toolCallId,
304
+ threadId: input.threadId,
305
+ runId: input.runId,
306
+ detail: { provider: this.name },
307
+ }),
308
+ )
309
+ }
310
+ return result.outcome
311
+ }
312
+ }
313
+
314
+ return (request) =>
315
+ resolvePermission(request, input.mode, input.bridgedToolNames)
316
+ }
317
+
318
+ async *chatStream(
319
+ options: TextOptions<ResolvedOptions<TModelOptions>>,
320
+ ): AsyncIterable<StreamChunk> {
321
+ const { logger } = options
322
+ let handle: AcpSessionHandle | undefined
323
+ let bridge: HostToolBridge | undefined
324
+ let transport: AcpSessionTransport | undefined
325
+ const externalSignal =
326
+ options.abortController?.signal ?? options.request?.signal ?? undefined
327
+ let onAbort: (() => void) | undefined
328
+
329
+ try {
330
+ const sandbox = this.sandboxFrom(options)
331
+ const modelOptions = options.modelOptions
332
+ const cwd = modelOptions?.cwd ?? this.harness.cwd ?? DEFAULT_WORKDIR
333
+ const harnessCwd = resolveHarnessCwd(sandbox, cwd)
334
+ const runId = options.runId ?? this.generateId()
335
+ const threadId = options.threadId ?? this.generateId()
336
+ const channel = createBridgeEventChannel({
337
+ model: this.model,
338
+ threadId,
339
+ runId,
340
+ })
341
+
342
+ const sessionId = modelOptions?.sessionId
343
+ const { prompt: resumePrompt } = this.buildPrompt(
344
+ options.messages,
345
+ sessionId,
346
+ )
347
+
348
+ // Bridge chat()-provided tools into the agent over MCP (ACP http server).
349
+ const bridgedToolNames = new Set(
350
+ (options.tools ?? []).map((tool) => tool.name),
351
+ )
352
+ if (options.tools && options.tools.length > 0) {
353
+ const provisioner =
354
+ (options.capabilities
355
+ ? getToolBridgeProvisioner(options.capabilities, { optional: true })
356
+ : undefined) ?? nodeHttpBridgeProvisioner
357
+ bridge = await provisioner.provision(options.tools, {
358
+ provider: sandbox.provider,
359
+ context: options.context,
360
+ emitCustomEvent: channel.emitCustomEvent,
361
+ ...(externalSignal ? { signal: externalSignal } : {}),
362
+ })
363
+ }
364
+
365
+ // Project workspace skills declared via withSandbox. MCP skills ride ACP's
366
+ // native `mcpServers` (below); gitSkills are linked into `skillsDir`.
367
+ let workspaceServers: Array<AcpMcpServer> = []
368
+ const projection = options.capabilities
369
+ ? getWorkspaceProjection(options.capabilities, { optional: true })
370
+ : undefined
371
+ if (projection !== undefined) {
372
+ await projectAcpWorkspace(sandbox, projection, {
373
+ ...(this.harness.skillsDir !== undefined && {
374
+ skillsDir: this.harness.skillsDir,
375
+ }),
376
+ harnessName: this.name,
377
+ })
378
+ workspaceServers = workspaceMcpServers(projection)
379
+ }
380
+
381
+ const ctx: AcpHarnessContext<ResolvedOptions<TModelOptions>> = {
382
+ sandbox,
383
+ model: this.model,
384
+ cwd,
385
+ harnessCwd,
386
+ env: this.harness.env,
387
+ modelOptions,
388
+ signal: externalSignal,
389
+ }
390
+ transport = this.harness.openTransport
391
+ ? await this.harness.openTransport(ctx)
392
+ : await this.openStdioTransport(ctx)
393
+
394
+ const mode =
395
+ modelOptions?.permissionMode ??
396
+ this.harness.permissionMode ??
397
+ 'bypassPermissions'
398
+ const authMethodId =
399
+ modelOptions?.authMethodId ?? this.harness.authMethodId
400
+
401
+ const approvalRequests: Array<StreamChunk> = []
402
+ const permissionHandler = this.makePermissionHandler({
403
+ mode,
404
+ bridgedToolNames,
405
+ approvals: options.approvals,
406
+ approvalRequests,
407
+ threadId,
408
+ runId,
409
+ })
410
+
411
+ const queue = new AsyncQueue<AcpStreamEvent>()
412
+
413
+ logger.request(
414
+ `activity=chat provider=${this.name} model=${this.model} sandbox=${sandbox.provider} messages=${options.messages.length} resume=${sessionId ?? 'none'}`,
415
+ { provider: this.name, model: this.model },
416
+ )
417
+
418
+ // The host tool-bridge (chat() tools) + workspace MCP skills, both over
419
+ // ACP's native MCP channel.
420
+ const mcpServers: Array<AcpMcpServer> = [
421
+ ...(bridge !== undefined
422
+ ? [
423
+ {
424
+ name: bridge.name,
425
+ url: bridge.url,
426
+ headers: [
427
+ { name: 'Authorization', value: `Bearer ${bridge.token}` },
428
+ ],
429
+ },
430
+ ]
431
+ : []),
432
+ ...workspaceServers,
433
+ ]
434
+
435
+ const onAcpUpdate = (update: AcpSessionUpdate) =>
436
+ queue.push({ kind: 'update', update })
437
+ handle = await startAcpSession({
438
+ transport,
439
+ cwd: harnessCwd,
440
+ ...(authMethodId !== undefined && { authMethodId }),
441
+ ...(sessionId !== undefined && { resumeSessionId: sessionId }),
442
+ ...(mcpServers.length > 0 && { mcpServers }),
443
+ onUpdate: onAcpUpdate,
444
+ ...(this.harness.onExtNotification && {
445
+ onExtNotification: this.harness.onExtNotification,
446
+ }),
447
+ onPermissionRequest: permissionHandler,
448
+ })
449
+ const session = handle
450
+
451
+ if (externalSignal !== undefined) {
452
+ onAbort = () => void session.cancel().catch(() => undefined)
453
+ if (externalSignal.aborted) onAbort()
454
+ else externalSignal.addEventListener('abort', onAbort, { once: true })
455
+ }
456
+
457
+ queue.push({ kind: 'session', sessionId: session.sessionId })
458
+
459
+ const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
460
+ .map((p) => p.content)
461
+ .filter((c) => c.trim() !== '')
462
+ const promptText = this.applySystemPrompts(
463
+ systemPrompts,
464
+ session.resumed || sessionId === undefined
465
+ ? resumePrompt
466
+ : this.buildPrompt(options.messages, undefined).prompt,
467
+ )
468
+
469
+ session
470
+ .prompt(promptText)
471
+ .then(({ stopReason, usage }) => {
472
+ queue.push({
473
+ kind: 'done',
474
+ stopReason,
475
+ ...(usage !== undefined && { usage }),
476
+ })
477
+ queue.end()
478
+ })
479
+ .catch((error: unknown) => queue.fail(error))
480
+
481
+ yield* mergeChunkStreams(
482
+ translateAcpStream(queue, {
483
+ model: this.model,
484
+ runId,
485
+ threadId,
486
+ ...(options.parentRunId !== undefined && {
487
+ parentRunId: options.parentRunId,
488
+ }),
489
+ genId: () => this.generateId(),
490
+ bridgedToolNames,
491
+ labels: {
492
+ sessionIdEvent: `${this.name}.session-id`,
493
+ // Surface non-text agent content (image/audio/resource) instead of
494
+ // dropping it — emitted as a CUSTOM `<name>.message-content` event.
495
+ contentEvent: `${this.name}.message-content`,
496
+ ...(this.harness.planEventName !== undefined && {
497
+ planEvent: this.harness.planEventName,
498
+ }),
499
+ ...(this.harness.refusalMessage !== undefined && {
500
+ refusalMessage: this.harness.refusalMessage,
501
+ }),
502
+ },
503
+ onAcpEvent: (event) =>
504
+ logger.provider(`provider=${this.name} kind=${event.kind}`, {
505
+ chunk: event,
506
+ }),
507
+ }),
508
+ channel.stream,
509
+ )
510
+
511
+ // Surface any pending approval requests (interactive ask-policy actions
512
+ // awaiting a client decision); the client approves and re-runs to continue.
513
+ for (const event of approvalRequests) yield event
514
+
515
+ if (this.harness.emitDiff) {
516
+ yield* this.emitDiffChunks(sandbox, cwd, threadId, runId)
517
+ }
518
+ } catch (error: unknown) {
519
+ const err = error as Error & { code?: string }
520
+ const rawEvent = toRunErrorRawEvent(error)
521
+ logger.errors(`${this.name}.chatStream fatal`, {
522
+ error,
523
+ source: `${this.name}.chatStream`,
524
+ })
525
+ yield {
526
+ type: EventType.RUN_ERROR,
527
+ model: options.model,
528
+ timestamp: Date.now(),
529
+ message: err.message || 'Unknown error occurred',
530
+ ...(err.code !== undefined && { code: err.code }),
531
+ ...(rawEvent !== undefined && { rawEvent }),
532
+ error: {
533
+ message: err.message || 'Unknown error occurred',
534
+ ...(err.code !== undefined && { code: err.code }),
535
+ },
536
+ }
537
+ } finally {
538
+ if (externalSignal !== undefined && onAbort !== undefined) {
539
+ externalSignal.removeEventListener('abort', onAbort)
540
+ }
541
+ // startAcpSession owns transport teardown once a handle exists (and tears
542
+ // it down itself on a failed init). Only dispose here if we opened a
543
+ // transport but never reached a session.
544
+ if (handle !== undefined) await handle.dispose()
545
+ else if (transport !== undefined) await disposeTransport(transport)
546
+ await bridge?.close()
547
+ }
548
+ }
549
+
550
+ private async openStdioTransport(
551
+ ctx: AcpHarnessContext<ResolvedOptions<TModelOptions>>,
552
+ ): Promise<AcpSessionTransport> {
553
+ const build = this.harness.command
554
+ if (build === undefined) {
555
+ // Unreachable — the constructor requires `command` or `openTransport`,
556
+ // and this path only runs when `openTransport` is absent.
557
+ throw new Error(
558
+ `acpCompatible("${this.name}") has no "command" to launch over stdio.`,
559
+ )
560
+ }
561
+ const command = build(ctx)
562
+ const proc = await ctx.sandbox.process.spawn(command, {
563
+ cwd: ctx.cwd,
564
+ ...(this.harness.env ? { env: this.harness.env } : {}),
565
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
566
+ })
567
+ return { kind: 'stdio', process: proc }
568
+ }
569
+
570
+ private async *emitDiffChunks(
571
+ sandbox: SandboxHandle,
572
+ cwd: string,
573
+ threadId: string,
574
+ runId: string,
575
+ ): AsyncIterable<StreamChunk> {
576
+ try {
577
+ const diff = await sandbox.process.exec(`git -C ${q(cwd)} diff`, { cwd })
578
+ if (diff.exitCode === 0 && diff.stdout.trim() !== '') {
579
+ yield {
580
+ type: EventType.CUSTOM,
581
+ name: 'file.changed',
582
+ value: { path: '.', diff: diff.stdout },
583
+ timestamp: Date.now(),
584
+ threadId,
585
+ runId,
586
+ }
587
+ }
588
+ } catch {
589
+ // ignore — diff is best-effort
590
+ }
591
+ }
592
+
593
+ structuredOutput(
594
+ _options: StructuredOutputOptions<ResolvedOptions<TModelOptions>>,
595
+ ): Promise<StructuredOutputResult<unknown>> {
596
+ return Promise.reject(
597
+ new Error(
598
+ `Structured output is not supported by the in-sandbox "${this.name}" ACP harness adapter. ` +
599
+ 'Use a model adapter for structured output, or omit outputSchema.',
600
+ ),
601
+ )
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Configure an ACP-compatible harness once, then select a model per call.
607
+ *
608
+ * Mirrors `openaiCompatible`: it lets you plug ANY Agent Client Protocol agent
609
+ * into a TanStack AI sandbox without a dedicated adapter package.
610
+ *
611
+ * @example
612
+ * ```ts
613
+ * import { acpCompatible } from '@tanstack/ai-acp'
614
+ * import { chat } from '@tanstack/ai'
615
+ * import { defineSandbox, withSandbox } from '@tanstack/ai-sandbox'
616
+ *
617
+ * const pi = acpCompatible({
618
+ * name: 'pi',
619
+ * // declaring `models` makes pi('…') type-safe; omit to accept any string
620
+ * models: ['pi-fast', 'pi-pro'],
621
+ * // declare per-call options; merged with the base ACP options and exposed
622
+ * // on ctx.modelOptions inside `command` / `openTransport`
623
+ * modelOptions: {} as { reasoningEffort?: 'low' | 'high' },
624
+ * command: ({ model, harnessCwd, modelOptions }) =>
625
+ * `pi --acp -m ${model} --cwd ${harnessCwd}` +
626
+ * (modelOptions?.reasoningEffort ? ` --effort ${modelOptions.reasoningEffort}` : ''),
627
+ * authMethodId: 'pi-api-key',
628
+ * })
629
+ *
630
+ * chat({
631
+ * adapter: pi('pi-pro'),
632
+ * modelOptions: { reasoningEffort: 'high' }, // typed
633
+ * messages,
634
+ * middleware: [withSandbox(defineSandbox({ /* provider, install pi *\/ }))],
635
+ * })
636
+ * ```
637
+ */
638
+ export function acpCompatible<
639
+ const TModels extends ReadonlyArray<string> = ReadonlyArray<string>,
640
+ TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions,
641
+ >(config: AcpCompatibleConfig<TModels, TModelOptions>) {
642
+ return <TModel extends AcpModelNameOf<TModels>>(
643
+ model: TModel,
644
+ overrides?: Partial<AcpCompatibleConfig<TModels, TModelOptions>>,
645
+ ): AcpCompatibleTextAdapter<TModel, TModelOptions> =>
646
+ new AcpCompatibleTextAdapter<TModel, TModelOptions>(
647
+ overrides ? { ...config, ...overrides } : config,
648
+ model,
649
+ )
650
+ }
651
+
652
+ /**
653
+ * One-shot helper: build a single-model ACP-compatible harness adapter inline.
654
+ *
655
+ * @example
656
+ * ```ts
657
+ * chat({
658
+ * adapter: acpCompatibleText('pi-fast', {
659
+ * name: 'pi',
660
+ * command: ({ model }) => `pi --acp -m ${model}`,
661
+ * }),
662
+ * messages,
663
+ * middleware: [withSandbox(defineSandbox({ ... }))],
664
+ * })
665
+ * ```
666
+ */
667
+ export function acpCompatibleText<
668
+ TModel extends string,
669
+ TModelOptions extends Record<string, any> = AcpCompatibleProviderOptions,
670
+ >(
671
+ model: TModel,
672
+ config: AcpCompatibleConfig<ReadonlyArray<string>, TModelOptions>,
673
+ ): AcpCompatibleTextAdapter<TModel, TModelOptions> {
674
+ return new AcpCompatibleTextAdapter<TModel, TModelOptions>(config, model)
675
+ }