@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,63 @@
1
+ /**
2
+ * Adapt a sandbox {@link SpawnHandle} into byte streams for ACP stdio
3
+ * (newline-delimited JSON-RPC).
4
+ */
5
+ import type { SpawnHandle } from '@tanstack/ai-sandbox'
6
+ import type { AcpByteTransport } from './types'
7
+
8
+ export function spawnHandleToAcpTransport(
9
+ handle: SpawnHandle,
10
+ ): AcpByteTransport {
11
+ const encoder = new TextEncoder()
12
+ const decoder = new TextDecoder()
13
+
14
+ const readable = new ReadableStream<Uint8Array>({
15
+ async start(controller) {
16
+ try {
17
+ for await (const chunk of handle.stdout) {
18
+ controller.enqueue(encoder.encode(chunk))
19
+ }
20
+ controller.close()
21
+ } catch (error) {
22
+ controller.error(error)
23
+ }
24
+ },
25
+ })
26
+
27
+ const writable = new WritableStream<Uint8Array>({
28
+ write(chunk) {
29
+ return handle.stdin.write(decoder.decode(chunk))
30
+ },
31
+ close() {
32
+ return handle.stdin.end()
33
+ },
34
+ })
35
+
36
+ let tail = ''
37
+ void (async () => {
38
+ try {
39
+ for await (const chunk of handle.stderr) {
40
+ tail = (tail + chunk).slice(-4096)
41
+ }
42
+ } catch {
43
+ // stderr closed
44
+ }
45
+ })()
46
+
47
+ const exited: Promise<never> = handle.wait().then((code) => {
48
+ throw new Error(
49
+ `ACP harness process exited unexpectedly (code ${code}).${
50
+ tail.trim() !== '' ? `\nstderr: ${tail.trim()}` : ''
51
+ }`,
52
+ )
53
+ })
54
+ void exited.catch(() => undefined)
55
+
56
+ return {
57
+ writable,
58
+ readable,
59
+ exited,
60
+ stderrTail: () => tail,
61
+ kill: () => handle.kill(),
62
+ }
63
+ }
@@ -0,0 +1,33 @@
1
+ import type { ndJsonStream } from '@agentclientprotocol/sdk'
2
+ import type { SpawnHandle } from '@tanstack/ai-sandbox'
3
+
4
+ /** How JSON-RPC messages are framed on the wire. */
5
+ export type AcpMessageFraming = 'ndjson' | 'frame'
6
+
7
+ /**
8
+ * Byte-level duplex transport consumed by {@link ndJsonStream} for stdio
9
+ * harnesses.
10
+ */
11
+ export interface AcpByteTransport {
12
+ writable: WritableStream<Uint8Array>
13
+ readable: ReadableStream<Uint8Array>
14
+ /** Resolves (throws) when the underlying connection exits unexpectedly. */
15
+ exited: Promise<never>
16
+ /** Last bytes of stderr, for error messages. */
17
+ stderrTail: () => string
18
+ kill: () => Promise<void>
19
+ }
20
+
21
+ /** Parsed JSON-RPC object streams passed to {@link ClientSideConnection}. */
22
+ export type AcpJsonRpcStream = ReturnType<typeof ndJsonStream>
23
+
24
+ export type AcpSessionTransport =
25
+ | { kind: 'stdio'; process: SpawnHandle }
26
+ | {
27
+ kind: 'stream'
28
+ stream: AcpJsonRpcStream
29
+ dispose: () => Promise<void>
30
+ stderrTail?: () => string
31
+ }
32
+
33
+ export type AcpTransportPreference = 'auto' | 'stdio' | 'websocket'
@@ -0,0 +1,192 @@
1
+ import { ndJsonStream } from '@agentclientprotocol/sdk'
2
+ import type { AcpJsonRpcStream, AcpMessageFraming } from './types'
3
+
4
+ function waitForWebSocketOpen(
5
+ ws: WebSocket,
6
+ signal?: AbortSignal,
7
+ ): Promise<void> {
8
+ if (ws.readyState === WebSocket.OPEN) return Promise.resolve()
9
+ return new Promise((resolve, reject) => {
10
+ const onOpen = (): void => {
11
+ cleanup()
12
+ resolve()
13
+ }
14
+ const onError = (): void => {
15
+ cleanup()
16
+ reject(new Error('WebSocket connection failed'))
17
+ }
18
+ const onAbort = (): void => {
19
+ cleanup()
20
+ ws.close()
21
+ reject(signal?.reason ?? new Error('WebSocket connection aborted'))
22
+ }
23
+ const cleanup = (): void => {
24
+ ws.removeEventListener('open', onOpen)
25
+ ws.removeEventListener('error', onError)
26
+ signal?.removeEventListener('abort', onAbort)
27
+ }
28
+ ws.addEventListener('open', onOpen)
29
+ ws.addEventListener('error', onError)
30
+ signal?.addEventListener('abort', onAbort, { once: true })
31
+ })
32
+ }
33
+
34
+ /**
35
+ * Wrap a stream controller so its terminal calls are idempotent. A WebSocket can
36
+ * fire `close` after `error` (or after the reader cancels), and closing/erroring
37
+ * an already-settled controller throws `ERR_INVALID_STATE` out of the listener.
38
+ */
39
+ function idempotentController<T>(
40
+ controller: ReadableStreamDefaultController<T>,
41
+ ): {
42
+ enqueue: (chunk: T) => void
43
+ close: () => void
44
+ error: (reason: unknown) => void
45
+ } {
46
+ let settled = false
47
+ return {
48
+ enqueue: (chunk) => {
49
+ if (!settled) controller.enqueue(chunk)
50
+ },
51
+ close: () => {
52
+ if (settled) return
53
+ settled = true
54
+ controller.close()
55
+ },
56
+ error: (reason) => {
57
+ if (settled) return
58
+ settled = true
59
+ controller.error(reason)
60
+ },
61
+ }
62
+ }
63
+
64
+ /**
65
+ * One JSON-RPC object per WebSocket text frame (e.g. `grok agent serve`).
66
+ */
67
+ export function webSocketFrameToAcpStream(ws: WebSocket): AcpJsonRpcStream {
68
+ const decoder = new TextDecoder()
69
+
70
+ const readable = new ReadableStream<unknown>({
71
+ start(rawController) {
72
+ const controller = idempotentController(rawController)
73
+ ws.addEventListener('message', (event) => {
74
+ const text =
75
+ typeof event.data === 'string'
76
+ ? event.data
77
+ : decoder.decode(event.data as ArrayBuffer)
78
+ const trimmed = text.trim()
79
+ if (trimmed === '') return
80
+ try {
81
+ controller.enqueue(JSON.parse(trimmed))
82
+ } catch (error) {
83
+ controller.error(
84
+ error instanceof Error ? error : new Error(String(error)),
85
+ )
86
+ }
87
+ })
88
+ ws.addEventListener('close', () => controller.close())
89
+ ws.addEventListener('error', () =>
90
+ controller.error(new Error('WebSocket connection error')),
91
+ )
92
+ },
93
+ cancel() {
94
+ ws.close()
95
+ },
96
+ })
97
+
98
+ const writable = new WritableStream({
99
+ write(message) {
100
+ ws.send(JSON.stringify(message))
101
+ },
102
+ close() {
103
+ ws.close()
104
+ },
105
+ })
106
+
107
+ return { readable, writable } as AcpJsonRpcStream
108
+ }
109
+
110
+ function webSocketNdjsonToAcpStream(ws: WebSocket): AcpJsonRpcStream {
111
+ const encoder = new TextEncoder()
112
+ const decoder = new TextDecoder()
113
+
114
+ const readable = new ReadableStream<Uint8Array>({
115
+ start(rawController) {
116
+ const controller = idempotentController(rawController)
117
+ ws.addEventListener('message', (event) => {
118
+ const text =
119
+ typeof event.data === 'string'
120
+ ? event.data
121
+ : decoder.decode(event.data as ArrayBuffer)
122
+ controller.enqueue(encoder.encode(text))
123
+ })
124
+ ws.addEventListener('close', () => controller.close())
125
+ ws.addEventListener('error', () =>
126
+ controller.error(new Error('WebSocket connection error')),
127
+ )
128
+ },
129
+ cancel() {
130
+ ws.close()
131
+ },
132
+ })
133
+
134
+ const writable = new WritableStream<Uint8Array>({
135
+ write(chunk) {
136
+ ws.send(chunk)
137
+ },
138
+ close() {
139
+ ws.close()
140
+ },
141
+ })
142
+
143
+ return ndJsonStream(writable, readable)
144
+ }
145
+
146
+ export interface ConnectAcpWebSocketOptions {
147
+ headers?: Record<string, string>
148
+ signal?: AbortSignal
149
+ framing?: AcpMessageFraming
150
+ }
151
+
152
+ export interface AcpWebSocketConnection {
153
+ stream: AcpJsonRpcStream
154
+ close: () => void
155
+ }
156
+
157
+ /**
158
+ * Open a WebSocket to an in-sandbox ACP server and adapt it for
159
+ * {@link ClientSideConnection}.
160
+ */
161
+ function openWebSocket(
162
+ url: string,
163
+ headers?: Record<string, string>,
164
+ ): WebSocket {
165
+ if (headers === undefined) return new WebSocket(url)
166
+ // Node/ws accepts `{ headers }`; DOM lib constructor types omit this overload.
167
+ return Reflect.construct(WebSocket, [url, { headers }]) as WebSocket
168
+ }
169
+
170
+ export async function connectAcpWebSocket(
171
+ url: string,
172
+ options: ConnectAcpWebSocketOptions = {},
173
+ ): Promise<AcpWebSocketConnection> {
174
+ const ws = openWebSocket(url, options.headers)
175
+ await waitForWebSocketOpen(ws, options.signal)
176
+
177
+ const framing = options.framing ?? 'frame'
178
+ const stream =
179
+ framing === 'ndjson'
180
+ ? webSocketNdjsonToAcpStream(ws)
181
+ : webSocketFrameToAcpStream(ws)
182
+
183
+ return {
184
+ stream,
185
+ close: () => ws.close(),
186
+ }
187
+ }
188
+
189
+ /** Convert an HTTP sandbox channel URL to a WebSocket base URL. */
190
+ export function httpChannelUrlToWsBase(channelUrl: string): string {
191
+ return channelUrl.replace(/^http/i, 'ws').replace(/\/$/, '')
192
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Structural subset of the Agent Client Protocol (ACP) types that harness
3
+ * adapters consume.
4
+ *
5
+ * Defined structurally (rather than imported from `@agentclientprotocol/sdk`)
6
+ * so the stream translator stays a pure, fixture-testable state machine.
7
+ */
8
+
9
+ export type AcpContentBlock =
10
+ | { type: 'text'; text: string }
11
+ | { type: string; [key: string]: unknown }
12
+
13
+ export type AcpToolCallStatus =
14
+ | 'pending'
15
+ | 'in_progress'
16
+ | 'completed'
17
+ | 'failed'
18
+
19
+ export interface AcpToolCallUpdate {
20
+ toolCallId: string
21
+ title?: string | null
22
+ kind?: string | null
23
+ status?: AcpToolCallStatus | null
24
+ rawInput?: unknown
25
+ rawOutput?: unknown
26
+ content?: Array<{
27
+ type: string
28
+ content?: AcpContentBlock
29
+ [key: string]: unknown
30
+ }> | null
31
+ }
32
+
33
+ export type AcpSessionUpdate =
34
+ | { sessionUpdate: 'agent_message_chunk'; content: AcpContentBlock }
35
+ | { sessionUpdate: 'agent_thought_chunk'; content: AcpContentBlock }
36
+ | ({ sessionUpdate: 'tool_call' } & AcpToolCallUpdate)
37
+ | ({ sessionUpdate: 'tool_call_update' } & AcpToolCallUpdate)
38
+ | { sessionUpdate: 'plan'; entries: Array<unknown> }
39
+ | { sessionUpdate: 'available_commands_update' }
40
+ | { sessionUpdate: 'current_mode_update' }
41
+ | { sessionUpdate: 'user_message_chunk'; content: AcpContentBlock }
42
+
43
+ export type AcpStopReason =
44
+ | 'end_turn'
45
+ | 'max_tokens'
46
+ | 'max_turn_requests'
47
+ | 'refusal'
48
+ | 'cancelled'
49
+ | (string & {})
50
+
51
+ export interface AcpUsage {
52
+ inputTokens?: number | null
53
+ outputTokens?: number | null
54
+ totalTokens?: number | null
55
+ cachedReadTokens?: number | null
56
+ thoughtTokens?: number | null
57
+ }
58
+
59
+ export interface AcpPermissionOption {
60
+ optionId: string
61
+ name: string
62
+ kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'
63
+ }
64
+
65
+ export interface AcpPermissionRequest {
66
+ sessionId: string
67
+ toolCall: AcpToolCallUpdate
68
+ options: Array<AcpPermissionOption>
69
+ }
70
+
71
+ export type AcpPermissionOutcome =
72
+ | { outcome: 'cancelled' }
73
+ | { outcome: 'selected'; optionId: string }
74
+
75
+ export type AcpPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'
76
+
77
+ export type PermissionHandler = (
78
+ request: AcpPermissionRequest,
79
+ ) => Promise<AcpPermissionOutcome> | AcpPermissionOutcome