@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,195 @@
1
+ import { connectAcpWebSocket } from '../transport/websocket'
2
+ import type {
3
+ SandboxChannel,
4
+ SandboxHandle,
5
+ SpawnHandle,
6
+ } from '@tanstack/ai-sandbox'
7
+ import type { AcpMessageFraming } from '../transport/types'
8
+ import type { AcpWebSocketConnection } from '../transport/websocket'
9
+
10
+ export interface AcpSandboxServer {
11
+ /** WebSocket URL the orchestrator uses to reach the in-sandbox ACP server. */
12
+ wsUrl: string
13
+ /** Sandbox channel used to build {@link wsUrl} (auth headers, when issued). */
14
+ channel: SandboxChannel
15
+ /** Open the ACP JSON-RPC stream over WebSocket. */
16
+ connect: (signal?: AbortSignal) => Promise<AcpWebSocketConnection>
17
+ /** Stop the in-sandbox server process. */
18
+ dispose: () => Promise<void>
19
+ }
20
+
21
+ export interface StartAcpServerOptions {
22
+ port: number
23
+ cwd: string
24
+ /** Full shell command that starts the harness ACP server inside the sandbox. */
25
+ command: string
26
+ /** Build the WebSocket URL once the server is ready and the port is exposed. */
27
+ buildWsUrl: (input: {
28
+ channel: SandboxChannel
29
+ port: number
30
+ stdout: string
31
+ }) => string
32
+ /**
33
+ * Return true once {@link buildWsUrl} can be called. Receives accumulated
34
+ * stdout from the server process.
35
+ */
36
+ isReady?: (stdout: string) => boolean
37
+ /** Substring/regex marker used when {@link isReady} is omitted. */
38
+ readyMarker?: string
39
+ env?: Record<string, string>
40
+ timeoutMs?: number
41
+ signal?: AbortSignal
42
+ framing?: AcpMessageFraming
43
+ hostname?: string
44
+ }
45
+
46
+ const DEFAULT_READY_MARKER = 'WebSocket URL:'
47
+
48
+ function waitForReady(
49
+ proc: SpawnHandle,
50
+ options: Pick<StartAcpServerOptions, 'isReady' | 'readyMarker' | 'timeoutMs'>,
51
+ ): Promise<{ stdout: string; stderr: string }> {
52
+ const readyMarker = options.readyMarker ?? DEFAULT_READY_MARKER
53
+ const isReady =
54
+ options.isReady ?? ((output: string) => output.includes(readyMarker))
55
+
56
+ return new Promise((resolve, reject) => {
57
+ let stdout = ''
58
+ let stderr = ''
59
+ const state = { settled: false }
60
+ const settle = (fn: () => void): void => {
61
+ if (state.settled) return
62
+ state.settled = true
63
+ clearTimeout(timer)
64
+ fn()
65
+ }
66
+ const diagnostics = (): string =>
67
+ [stdout, stderr]
68
+ .map((s) => s.trim())
69
+ .filter(Boolean)
70
+ .join('\n')
71
+ .slice(-500)
72
+ const combined = (): string => stdout + stderr
73
+ let stdoutDone = false
74
+ let stderrDone = false
75
+ const tryFinish = (): void => {
76
+ if (state.settled) return
77
+ if (isReady(combined())) {
78
+ settle(() => resolve({ stdout, stderr }))
79
+ return
80
+ }
81
+ if (stdoutDone && stderrDone) {
82
+ settle(() =>
83
+ reject(
84
+ new Error(
85
+ `ACP server exited before becoming ready${
86
+ diagnostics() ? `: ${diagnostics()}` : ' (no output)'
87
+ }`,
88
+ ),
89
+ ),
90
+ )
91
+ }
92
+ }
93
+
94
+ const timer = setTimeout(
95
+ () =>
96
+ settle(() =>
97
+ reject(
98
+ new Error(
99
+ `ACP server did not become ready within ${options.timeoutMs ?? 30_000}ms${
100
+ diagnostics() ? `: ${diagnostics()}` : ''
101
+ }`,
102
+ ),
103
+ ),
104
+ ),
105
+ options.timeoutMs ?? 30_000,
106
+ )
107
+
108
+ void (async () => {
109
+ try {
110
+ for await (const chunk of proc.stderr) {
111
+ stderr += chunk
112
+ tryFinish()
113
+ if (state.settled) return
114
+ }
115
+ } catch {
116
+ // non-fatal
117
+ } finally {
118
+ stderrDone = true
119
+ tryFinish()
120
+ }
121
+ })()
122
+
123
+ void (async () => {
124
+ try {
125
+ for await (const chunk of proc.stdout) {
126
+ stdout += chunk
127
+ tryFinish()
128
+ if (state.settled) return
129
+ }
130
+ } catch (error) {
131
+ settle(() => reject(error))
132
+ return
133
+ } finally {
134
+ stdoutDone = true
135
+ tryFinish()
136
+ }
137
+ })()
138
+ })
139
+ }
140
+
141
+ /**
142
+ * Boot a harness ACP WebSocket server inside a sandbox and expose its port via
143
+ * {@link SandboxHandle.ports.connect}. Mirrors the `opencode serve` pattern.
144
+ */
145
+ export async function startAcpServerInSandbox(
146
+ sandbox: SandboxHandle,
147
+ options: StartAcpServerOptions,
148
+ ): Promise<AcpSandboxServer> {
149
+ const proc = await sandbox.process.spawn(options.command, {
150
+ cwd: options.cwd,
151
+ ...(options.env ? { env: options.env } : {}),
152
+ ...(options.signal ? { signal: options.signal } : {}),
153
+ })
154
+
155
+ const { stdout } = await waitForReady(proc, options)
156
+ const channel = await sandbox.ports.connect(options.port)
157
+ const wsUrl = options.buildWsUrl({
158
+ channel,
159
+ port: options.port,
160
+ stdout,
161
+ })
162
+
163
+ const headers =
164
+ channel.headers ??
165
+ (channel.token ? { Authorization: `Bearer ${channel.token}` } : undefined)
166
+
167
+ return {
168
+ wsUrl,
169
+ channel,
170
+ connect: (signal?: AbortSignal) =>
171
+ connectAcpWebSocket(wsUrl, {
172
+ ...(headers ? { headers } : {}),
173
+ ...(signal ? { signal } : {}),
174
+ framing: options.framing ?? 'frame',
175
+ }),
176
+ dispose: () => proc.kill(),
177
+ }
178
+ }
179
+
180
+ /** Parse a `WebSocket URL: ws://…` line printed by `grok agent serve`. */
181
+ export function parseWebSocketUrlFromServeOutput(
182
+ output: string,
183
+ ): string | undefined {
184
+ const match = output.match(/WebSocket URL:\s*(ws\S+)/i)
185
+ return match?.[1]
186
+ }
187
+
188
+ /** Build a Grok `agent serve` WebSocket URL from a sandbox channel + secret. */
189
+ export function buildGrokServeWebSocketUrl(
190
+ channelUrl: string,
191
+ secret: string,
192
+ ): string {
193
+ const wsBase = channelUrl.replace(/^http/i, 'ws').replace(/\/$/, '')
194
+ return `${wsBase}/ws?server-key=${encodeURIComponent(secret)}`
195
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Minimal promise-based async queue bridging callback-style ACP notifications
3
+ * into the async-iterable world the stream translator consumes.
4
+ */
5
+ export class AsyncQueue<T> implements AsyncIterable<T> {
6
+ private readonly values: Array<T> = []
7
+ private readonly waiters: Array<{
8
+ resolve: (result: IteratorResult<T>) => void
9
+ reject: (error: unknown) => void
10
+ }> = []
11
+ private ended = false
12
+ private error: unknown = undefined
13
+ private failed = false
14
+
15
+ push(value: T): void {
16
+ if (this.ended || this.failed) return
17
+ const waiter = this.waiters.shift()
18
+ if (waiter) {
19
+ waiter.resolve({ value, done: false })
20
+ } else {
21
+ this.values.push(value)
22
+ }
23
+ }
24
+
25
+ end(): void {
26
+ if (this.ended || this.failed) return
27
+ this.ended = true
28
+ for (const waiter of this.waiters.splice(0)) {
29
+ waiter.resolve({ value: undefined, done: true })
30
+ }
31
+ }
32
+
33
+ fail(error: unknown): void {
34
+ if (this.ended || this.failed) return
35
+ this.failed = true
36
+ this.error = error
37
+ for (const waiter of this.waiters.splice(0)) {
38
+ waiter.reject(error)
39
+ }
40
+ }
41
+
42
+ [Symbol.asyncIterator](): AsyncIterator<T> {
43
+ return {
44
+ next: (): Promise<IteratorResult<T>> => {
45
+ if (this.values.length > 0) {
46
+ return Promise.resolve({
47
+ value: this.values.shift() as T,
48
+ done: false,
49
+ })
50
+ }
51
+ if (this.failed) return Promise.reject(this.error)
52
+ if (this.ended) {
53
+ return Promise.resolve({ value: undefined, done: true })
54
+ }
55
+ return new Promise((resolve, reject) => {
56
+ this.waiters.push({ resolve, reject })
57
+ })
58
+ },
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,412 @@
1
+ import { EventType, buildBaseUsage } from '@tanstack/ai'
2
+ import type { StreamChunk, TokenUsage } from '@tanstack/ai'
3
+ import type {
4
+ AcpSessionUpdate,
5
+ AcpStopReason,
6
+ AcpToolCallUpdate,
7
+ AcpUsage,
8
+ } from '../types/acp-types'
9
+
10
+ export const BRIDGED_MCP_SERVER_NAME = 'tanstack'
11
+
12
+ export type AcpStreamEvent =
13
+ | { kind: 'session'; sessionId: string }
14
+ | { kind: 'update'; update: AcpSessionUpdate }
15
+ | { kind: 'done'; stopReason: AcpStopReason; usage?: AcpUsage }
16
+
17
+ export interface AcpTranslateLabels {
18
+ sessionIdEvent: string
19
+ planEvent?: string
20
+ refusalMessage?: string
21
+ /**
22
+ * When set, non-text agent message content (image / audio / resource /
23
+ * resource_link blocks) is surfaced as a CUSTOM event under this name instead
24
+ * of being dropped. Each event's `value` is `{ content: <ACP content block> }`.
25
+ * Omit to keep the text-only behavior.
26
+ */
27
+ contentEvent?: string
28
+ }
29
+
30
+ export interface TranslateContext {
31
+ model: string
32
+ runId: string
33
+ threadId: string
34
+ parentRunId?: string
35
+ genId: () => string
36
+ labels: AcpTranslateLabels
37
+ bridgedToolNames?: ReadonlySet<string>
38
+ onAcpEvent?: (event: AcpStreamEvent) => void
39
+ }
40
+
41
+ export function matchBridgedToolName(
42
+ title: string | null | undefined,
43
+ bridgedToolNames: ReadonlySet<string> | undefined,
44
+ ): string | undefined {
45
+ if (!title || !bridgedToolNames) return undefined
46
+ if (bridgedToolNames.has(title)) return title
47
+ for (const name of bridgedToolNames) {
48
+ if (title.startsWith(`${name} (`)) return name
49
+ }
50
+ return undefined
51
+ }
52
+
53
+ function resolveToolName(
54
+ update: AcpToolCallUpdate,
55
+ bridgedToolNames: ReadonlySet<string> | undefined,
56
+ ): string {
57
+ return (
58
+ matchBridgedToolName(update.title, bridgedToolNames) ??
59
+ update.kind ??
60
+ 'tool'
61
+ )
62
+ }
63
+
64
+ function stringifyToolOutput(update: AcpToolCallUpdate): string {
65
+ if (update.rawOutput !== undefined) {
66
+ return typeof update.rawOutput === 'string'
67
+ ? update.rawOutput
68
+ : JSON.stringify(update.rawOutput)
69
+ }
70
+ const blocks = update.content ?? []
71
+ const text = blocks
72
+ .map((block) =>
73
+ block.content && typeof block.content.text === 'string'
74
+ ? block.content.text
75
+ : '',
76
+ )
77
+ .join('')
78
+ // Preserve non-text tool content (diff / terminal / image / resource blocks)
79
+ // by serializing the structured array, rather than collapsing to a stub.
80
+ const hasNonText = blocks.some(
81
+ (block) =>
82
+ block.type !== 'content' ||
83
+ (block.content !== undefined && block.content.type !== 'text'),
84
+ )
85
+ if (hasNonText) return JSON.stringify(blocks)
86
+ if (text !== '') return text
87
+ return JSON.stringify({ status: update.status ?? 'completed' })
88
+ }
89
+
90
+ function buildUsage(usage: AcpUsage | undefined): TokenUsage | undefined {
91
+ if (!usage) return undefined
92
+ const promptTokens = usage.inputTokens ?? 0
93
+ const completionTokens = usage.outputTokens ?? 0
94
+ const result = buildBaseUsage({
95
+ promptTokens,
96
+ completionTokens,
97
+ totalTokens: usage.totalTokens ?? promptTokens + completionTokens,
98
+ })
99
+ if (usage.cachedReadTokens) {
100
+ result.promptTokensDetails = { cachedTokens: usage.cachedReadTokens }
101
+ }
102
+ if (usage.thoughtTokens) {
103
+ result.completionTokensDetails = { reasoningTokens: usage.thoughtTokens }
104
+ }
105
+ return result
106
+ }
107
+
108
+ export async function* translateAcpStream(
109
+ events: AsyncIterable<AcpStreamEvent>,
110
+ ctx: TranslateContext,
111
+ ): AsyncIterable<StreamChunk> {
112
+ const { model, runId, threadId, genId, labels } = ctx
113
+ const now = () => Date.now()
114
+
115
+ let runStarted = false
116
+ const unresolvedToolCalls = new Set<string>()
117
+ const knownToolCalls = new Set<string>()
118
+
119
+ let textMessageId: string | null = null
120
+ let textContent = ''
121
+ let reasoningId: string | null = null
122
+
123
+ function* startRun(): Generator<StreamChunk> {
124
+ if (runStarted) return
125
+ runStarted = true
126
+ yield {
127
+ type: EventType.RUN_STARTED,
128
+ runId,
129
+ threadId,
130
+ model,
131
+ timestamp: now(),
132
+ ...(ctx.parentRunId !== undefined && { parentRunId: ctx.parentRunId }),
133
+ }
134
+ }
135
+
136
+ function* closeText(): Generator<StreamChunk> {
137
+ if (textMessageId !== null) {
138
+ yield {
139
+ type: EventType.TEXT_MESSAGE_END,
140
+ messageId: textMessageId,
141
+ model,
142
+ timestamp: now(),
143
+ }
144
+ }
145
+ textMessageId = null
146
+ textContent = ''
147
+ }
148
+
149
+ function* closeReasoning(): Generator<StreamChunk> {
150
+ if (reasoningId !== null) {
151
+ yield {
152
+ type: EventType.REASONING_MESSAGE_END,
153
+ messageId: reasoningId,
154
+ model,
155
+ timestamp: now(),
156
+ }
157
+ yield {
158
+ type: EventType.REASONING_END,
159
+ messageId: reasoningId,
160
+ model,
161
+ timestamp: now(),
162
+ }
163
+ }
164
+ reasoningId = null
165
+ }
166
+
167
+ function* synthesizeUnresolvedResults(): Generator<StreamChunk> {
168
+ for (const toolCallId of unresolvedToolCalls) {
169
+ yield {
170
+ type: EventType.TOOL_CALL_RESULT,
171
+ toolCallId,
172
+ messageId: genId(),
173
+ model,
174
+ timestamp: now(),
175
+ content: JSON.stringify({ status: 'interrupted' }),
176
+ }
177
+ }
178
+ unresolvedToolCalls.clear()
179
+ }
180
+
181
+ function* openToolCall(update: AcpToolCallUpdate): Generator<StreamChunk> {
182
+ if (knownToolCalls.has(update.toolCallId)) return
183
+ knownToolCalls.add(update.toolCallId)
184
+ const toolCallName = resolveToolName(update, ctx.bridgedToolNames)
185
+ const input = {
186
+ ...(update.title != null && { title: update.title }),
187
+ ...(update.rawInput !== undefined && update.rawInput !== null
188
+ ? typeof update.rawInput === 'object'
189
+ ? (update.rawInput as Record<string, unknown>)
190
+ : { input: update.rawInput }
191
+ : {}),
192
+ }
193
+ const args = JSON.stringify(input)
194
+ yield {
195
+ type: EventType.TOOL_CALL_START,
196
+ toolCallId: update.toolCallId,
197
+ toolCallName,
198
+ toolName: toolCallName,
199
+ model,
200
+ timestamp: now(),
201
+ }
202
+ yield {
203
+ type: EventType.TOOL_CALL_ARGS,
204
+ toolCallId: update.toolCallId,
205
+ model,
206
+ timestamp: now(),
207
+ delta: args,
208
+ args,
209
+ }
210
+ yield {
211
+ type: EventType.TOOL_CALL_END,
212
+ toolCallId: update.toolCallId,
213
+ toolCallName,
214
+ toolName: toolCallName,
215
+ model,
216
+ timestamp: now(),
217
+ input,
218
+ }
219
+ unresolvedToolCalls.add(update.toolCallId)
220
+ }
221
+
222
+ function* resolveToolCall(update: AcpToolCallUpdate): Generator<StreamChunk> {
223
+ yield* openToolCall(update)
224
+ unresolvedToolCalls.delete(update.toolCallId)
225
+ yield {
226
+ type: EventType.TOOL_CALL_RESULT,
227
+ toolCallId: update.toolCallId,
228
+ messageId: genId(),
229
+ model,
230
+ timestamp: now(),
231
+ content: stringifyToolOutput(update),
232
+ ...(update.status === 'failed' && { state: 'output-error' as const }),
233
+ }
234
+ }
235
+
236
+ function* handleUpdate(update: AcpSessionUpdate): Generator<StreamChunk> {
237
+ if (update.sessionUpdate === 'agent_message_chunk') {
238
+ yield* closeReasoning()
239
+ // Non-text content (image / audio / resource / resource_link): surface it
240
+ // as a CUSTOM event when the harness opted in, instead of dropping it.
241
+ if (update.content.type !== 'text') {
242
+ if (labels.contentEvent !== undefined) {
243
+ yield* closeText()
244
+ yield {
245
+ type: EventType.CUSTOM,
246
+ model,
247
+ timestamp: now(),
248
+ name: labels.contentEvent,
249
+ value: { content: update.content },
250
+ }
251
+ }
252
+ return
253
+ }
254
+ const text =
255
+ typeof update.content.text === 'string' ? update.content.text : ''
256
+ if (text === '') return
257
+ if (textMessageId === null) {
258
+ textMessageId = genId()
259
+ yield {
260
+ type: EventType.TEXT_MESSAGE_START,
261
+ messageId: textMessageId,
262
+ model,
263
+ timestamp: now(),
264
+ role: 'assistant',
265
+ }
266
+ }
267
+ textContent += text
268
+ yield {
269
+ type: EventType.TEXT_MESSAGE_CONTENT,
270
+ messageId: textMessageId,
271
+ model,
272
+ timestamp: now(),
273
+ delta: text,
274
+ content: textContent,
275
+ }
276
+ } else if (update.sessionUpdate === 'agent_thought_chunk') {
277
+ yield* closeText()
278
+ const thought =
279
+ typeof update.content.text === 'string' ? update.content.text : ''
280
+ if (thought === '') return
281
+ if (reasoningId === null) {
282
+ reasoningId = genId()
283
+ yield {
284
+ type: EventType.REASONING_START,
285
+ messageId: reasoningId,
286
+ model,
287
+ timestamp: now(),
288
+ }
289
+ yield {
290
+ type: EventType.REASONING_MESSAGE_START,
291
+ messageId: reasoningId,
292
+ role: 'reasoning' as const,
293
+ model,
294
+ timestamp: now(),
295
+ }
296
+ }
297
+ yield {
298
+ type: EventType.REASONING_MESSAGE_CONTENT,
299
+ messageId: reasoningId,
300
+ delta: thought,
301
+ model,
302
+ timestamp: now(),
303
+ }
304
+ } else if (update.sessionUpdate === 'tool_call') {
305
+ yield* closeText()
306
+ yield* closeReasoning()
307
+ yield* openToolCall(update)
308
+ if (update.status === 'completed' || update.status === 'failed') {
309
+ yield* resolveToolCall(update)
310
+ }
311
+ } else if (update.sessionUpdate === 'tool_call_update') {
312
+ if (update.status === 'completed' || update.status === 'failed') {
313
+ yield* resolveToolCall(update)
314
+ } else if (
315
+ update.status === 'in_progress' &&
316
+ update.rawInput !== undefined
317
+ ) {
318
+ yield* closeText()
319
+ yield* closeReasoning()
320
+ if (!knownToolCalls.has(update.toolCallId)) {
321
+ yield* openToolCall(update)
322
+ } else {
323
+ const input = {
324
+ ...(update.title != null && { title: update.title }),
325
+ ...(typeof update.rawInput === 'object' && update.rawInput !== null
326
+ ? (update.rawInput as Record<string, unknown>)
327
+ : { input: update.rawInput }),
328
+ }
329
+ const args = JSON.stringify(input)
330
+ yield {
331
+ type: EventType.TOOL_CALL_ARGS,
332
+ toolCallId: update.toolCallId,
333
+ model,
334
+ timestamp: now(),
335
+ delta: args,
336
+ args,
337
+ }
338
+ }
339
+ }
340
+ } else if (
341
+ update.sessionUpdate === 'plan' &&
342
+ labels.planEvent !== undefined
343
+ ) {
344
+ yield {
345
+ type: EventType.CUSTOM,
346
+ model,
347
+ timestamp: now(),
348
+ name: labels.planEvent,
349
+ value: { entries: update.entries },
350
+ }
351
+ }
352
+ }
353
+
354
+ try {
355
+ for await (const event of events) {
356
+ ctx.onAcpEvent?.(event)
357
+
358
+ if (event.kind === 'session') {
359
+ yield* startRun()
360
+ yield {
361
+ type: EventType.CUSTOM,
362
+ model,
363
+ timestamp: now(),
364
+ name: labels.sessionIdEvent,
365
+ value: { sessionId: event.sessionId },
366
+ }
367
+ } else if (event.kind === 'update') {
368
+ yield* startRun()
369
+ yield* handleUpdate(event.update)
370
+ } else {
371
+ yield* startRun()
372
+ yield* closeText()
373
+ yield* closeReasoning()
374
+ yield* synthesizeUnresolvedResults()
375
+
376
+ if (event.stopReason === 'refusal') {
377
+ const message =
378
+ labels.refusalMessage ?? 'The harness refused the request.'
379
+ yield {
380
+ type: EventType.RUN_ERROR,
381
+ model,
382
+ timestamp: now(),
383
+ message,
384
+ code: 'refusal',
385
+ error: { message, code: 'refusal' },
386
+ }
387
+ } else {
388
+ const usage = buildUsage(event.usage)
389
+ const finishReason =
390
+ event.stopReason === 'max_tokens' ||
391
+ event.stopReason === 'max_turn_requests'
392
+ ? ('length' as const)
393
+ : ('stop' as const)
394
+ yield {
395
+ type: EventType.RUN_FINISHED,
396
+ runId,
397
+ threadId,
398
+ model,
399
+ timestamp: now(),
400
+ finishReason,
401
+ ...(usage !== undefined && { usage }),
402
+ }
403
+ }
404
+ }
405
+ }
406
+ } catch (error) {
407
+ yield* closeText()
408
+ yield* closeReasoning()
409
+ yield* synthesizeUnresolvedResults()
410
+ throw error
411
+ }
412
+ }
@@ -0,0 +1,46 @@
1
+ import type { SandboxHandle } from '@tanstack/ai-sandbox'
2
+ import type { AcpTransportPreference } from './types'
3
+
4
+ /**
5
+ * Pick stdio vs WebSocket for ACP based on sandbox capabilities and caller
6
+ * preference.
7
+ */
8
+ export function resolveAcpTransportMode(
9
+ sandbox: SandboxHandle,
10
+ preference: AcpTransportPreference = 'auto',
11
+ ): 'stdio' | 'websocket' {
12
+ if (preference === 'stdio') {
13
+ if (sandbox.capabilities.writableStdin !== true) {
14
+ throw new Error(
15
+ `${sandbox.provider}: ACP stdio transport requires capabilities.writableStdin, but this sandbox cannot write to process stdin. Use transport: 'websocket' or 'auto'.`,
16
+ )
17
+ }
18
+ return 'stdio'
19
+ }
20
+
21
+ if (preference === 'websocket') {
22
+ if (sandbox.capabilities.ports !== true) {
23
+ throw new Error(
24
+ `${sandbox.provider}: ACP WebSocket transport requires capabilities.ports so the harness server can be reached from the orchestrator.`,
25
+ )
26
+ }
27
+ if (sandbox.capabilities.backgroundProcesses !== true) {
28
+ throw new Error(
29
+ `${sandbox.provider}: ACP WebSocket transport requires capabilities.backgroundProcesses to run the in-sandbox harness server.`,
30
+ )
31
+ }
32
+ return 'websocket'
33
+ }
34
+
35
+ if (sandbox.capabilities.writableStdin === true) return 'stdio'
36
+ if (
37
+ sandbox.capabilities.ports === true &&
38
+ sandbox.capabilities.backgroundProcesses === true
39
+ ) {
40
+ return 'websocket'
41
+ }
42
+
43
+ throw new Error(
44
+ `${sandbox.provider}: cannot run ACP — no writable stdin and no port-exposed background-process support. Use a Docker/local-process sandbox or enable ports on the provider.`,
45
+ )
46
+ }