@plaited/acp 0.0.1

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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * High-level helper utilities for ACP prompt building and response analysis.
3
+ *
4
+ * @remarks
5
+ * Provides convenience functions for common ACP workflows:
6
+ * - Building prompts with text, files, and images
7
+ * - Summarizing agent responses for evaluation
8
+ *
9
+ * For low-level content manipulation, see internal utilities in acp-utils.ts.
10
+ */
11
+
12
+ import type { ContentBlock, PlanEntry, SessionNotification, ToolCall } from '@agentclientprotocol/sdk'
13
+ import {
14
+ createImageContent,
15
+ createTextContent,
16
+ createTextResource,
17
+ extractLatestToolCalls,
18
+ extractPlan,
19
+ extractTextFromUpdates,
20
+ filterToolCallsByStatus,
21
+ getPlanProgress,
22
+ hasToolCallErrors,
23
+ } from './acp-utils.ts'
24
+
25
+ // ============================================================================
26
+ // Prompt Building Utilities
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Creates a simple text prompt.
31
+ *
32
+ * @param text - The prompt text
33
+ * @returns Array with single text content block
34
+ */
35
+ export const createPrompt = (text: string): ContentBlock[] => {
36
+ return [createTextContent(text)]
37
+ }
38
+
39
+ /**
40
+ * Creates a prompt with text and file context.
41
+ *
42
+ * @param text - The prompt text
43
+ * @param files - Array of file paths and contents to include
44
+ * @returns Array of content blocks
45
+ */
46
+ export const createPromptWithFiles = (
47
+ text: string,
48
+ files: Array<{ path: string; content: string }>,
49
+ ): ContentBlock[] => {
50
+ const blocks: ContentBlock[] = [createTextContent(text)]
51
+
52
+ for (const file of files) {
53
+ blocks.push(createTextResource({ uri: `file://${file.path}`, text: file.content, mimeType: 'text/plain' }))
54
+ }
55
+
56
+ return blocks
57
+ }
58
+
59
+ /** Parameters for creating a prompt with image */
60
+ export type CreatePromptWithImageParams = {
61
+ /** The prompt text */
62
+ text: string
63
+ /** Base64-encoded image data */
64
+ imageData: string
65
+ /** Image MIME type */
66
+ mimeType: string
67
+ }
68
+
69
+ /**
70
+ * Creates a prompt with text and image.
71
+ *
72
+ * @param params - Prompt with image parameters
73
+ * @returns Array of content blocks
74
+ */
75
+ export const createPromptWithImage = ({ text, imageData, mimeType }: CreatePromptWithImageParams): ContentBlock[] => {
76
+ return [createTextContent(text), createImageContent(imageData, mimeType)]
77
+ }
78
+
79
+ // ============================================================================
80
+ // Response Analysis Utilities
81
+ // ============================================================================
82
+
83
+ /** Summary of a prompt response for evaluation */
84
+ export type PromptResponseSummary = {
85
+ /** Concatenated text output */
86
+ text: string
87
+ /** Number of tool calls made */
88
+ toolCallCount: number
89
+ /** Tool calls that completed */
90
+ completedToolCalls: ToolCall[]
91
+ /** Tool calls that failed */
92
+ failedToolCalls: ToolCall[]
93
+ /** Final plan state */
94
+ plan?: PlanEntry[]
95
+ /** Plan completion percentage */
96
+ planProgress?: number
97
+ /** Whether any errors occurred */
98
+ hasErrors: boolean
99
+ }
100
+
101
+ /**
102
+ * Creates a summary of a prompt response for evaluation.
103
+ *
104
+ * @param notifications - Session notifications from the prompt
105
+ * @returns Response summary
106
+ */
107
+ export const summarizeResponse = (notifications: SessionNotification[]): PromptResponseSummary => {
108
+ const text = extractTextFromUpdates(notifications)
109
+ const toolCalls = [...extractLatestToolCalls(notifications).values()]
110
+ const plan = extractPlan(notifications)
111
+
112
+ return {
113
+ text,
114
+ toolCallCount: toolCalls.length,
115
+ completedToolCalls: filterToolCallsByStatus(toolCalls, 'completed'),
116
+ failedToolCalls: filterToolCallsByStatus(toolCalls, 'failed'),
117
+ plan,
118
+ planProgress: plan ? getPlanProgress(plan) : undefined,
119
+ hasErrors: hasToolCallErrors(toolCalls),
120
+ }
121
+ }
@@ -0,0 +1,448 @@
1
+ /**
2
+ * ACP stdio transport for subprocess communication.
3
+ *
4
+ * @remarks
5
+ * Manages bidirectional JSON-RPC 2.0 communication with ACP agents over
6
+ * stdin/stdout. Handles message framing, request/response correlation,
7
+ * and notification streaming.
8
+ *
9
+ * The transport spawns the agent as a subprocess and communicates using
10
+ * newline-delimited JSON messages with Zod runtime validation.
11
+ */
12
+
13
+ import { JSON_RPC_ERRORS } from './acp.constants.ts'
14
+ import type {
15
+ JsonRpcError,
16
+ JsonRpcErrorResponse,
17
+ JsonRpcMessage,
18
+ JsonRpcNotification,
19
+ JsonRpcRequest,
20
+ JsonRpcResponse,
21
+ JsonRpcSuccessResponse,
22
+ } from './acp.schemas.ts'
23
+ import { JsonRpcMessageSchema } from './acp.schemas.ts'
24
+
25
+ // ============================================================================
26
+ // Types
27
+ // ============================================================================
28
+
29
+ /** Configuration for the ACP transport */
30
+ export type ACPTransportConfig = {
31
+ /** Command to spawn agent (e.g., ['claude', 'code', '--print-acp-config']) */
32
+ command: string[]
33
+ /** Working directory for agent process */
34
+ cwd?: string
35
+ /** Environment variables for agent process */
36
+ env?: Record<string, string>
37
+ /** Timeout for requests in milliseconds (default: 30000) */
38
+ timeout?: number
39
+ /** Callback for incoming notifications */
40
+ onNotification?: (method: string, params: unknown) => void
41
+ /** Callback for incoming requests (agent → client) */
42
+ onRequest?: (method: string, params: unknown) => Promise<unknown>
43
+ /** Callback for transport errors */
44
+ onError?: (error: Error) => void
45
+ /** Callback when transport closes */
46
+ onClose?: (code: number | null) => void
47
+ }
48
+
49
+ /** Pending request tracker */
50
+ type PendingRequest = {
51
+ resolve: (result: unknown) => void
52
+ reject: (error: Error) => void
53
+ timer: Timer
54
+ }
55
+
56
+ /** Bun FileSink for subprocess stdin */
57
+ type FileSink = {
58
+ write: (data: string | ArrayBufferView | ArrayBuffer) => number
59
+ flush: () => void
60
+ end: () => void
61
+ }
62
+
63
+ /** Subprocess type with piped stdio (Bun.spawn return type) */
64
+ type PipedSubprocess = {
65
+ stdin: FileSink
66
+ stdout: ReadableStream<Uint8Array>
67
+ stderr: ReadableStream<Uint8Array>
68
+ exited: Promise<number>
69
+ kill: (signal?: number) => void
70
+ pid: number
71
+ }
72
+
73
+ /** Custom error for ACP transport failures */
74
+ class ACPTransportError extends Error {
75
+ constructor(
76
+ message: string,
77
+ public readonly code?: number,
78
+ public readonly data?: unknown,
79
+ ) {
80
+ super(message)
81
+ this.name = 'ACPTransportError'
82
+ }
83
+
84
+ /** Create from JSON-RPC error */
85
+ static fromJsonRpcError(error: JsonRpcError): ACPTransportError {
86
+ return new ACPTransportError(error.message, error.code, error.data)
87
+ }
88
+ }
89
+
90
+ // ============================================================================
91
+ // Transport Implementation
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Creates an ACP transport for subprocess communication.
96
+ *
97
+ * @param config - Transport configuration
98
+ * @returns Transport object with send/close methods
99
+ *
100
+ * @remarks
101
+ * The transport handles:
102
+ * - Spawning the agent subprocess
103
+ * - JSON-RPC message framing over stdio
104
+ * - Request/response correlation with timeouts
105
+ * - Notification and request routing
106
+ * - Graceful shutdown
107
+ * - Runtime validation of incoming messages via Zod
108
+ */
109
+ export const createACPTransport = (config: ACPTransportConfig) => {
110
+ const { command, cwd, env, timeout = 30000, onNotification, onRequest, onError, onClose } = config
111
+
112
+ let subprocess: PipedSubprocess | undefined
113
+ let nextId = 1
114
+ const pendingRequests = new Map<string | number, PendingRequest>()
115
+ let buffer = ''
116
+ let isClosing = false
117
+
118
+ // Stream readers for explicit cleanup
119
+ let stdoutReader: ReadableStreamDefaultReader<Uint8Array> | undefined
120
+ let stderrReader: ReadableStreamDefaultReader<Uint8Array> | undefined
121
+
122
+ // --------------------------------------------------------------------------
123
+ // Message Parsing (with Zod validation)
124
+ // --------------------------------------------------------------------------
125
+
126
+ const parseMessages = (data: string): JsonRpcMessage[] => {
127
+ buffer += data
128
+ const messages: JsonRpcMessage[] = []
129
+ const lines = buffer.split('\n')
130
+
131
+ // Keep incomplete last line in buffer
132
+ buffer = lines.pop() ?? ''
133
+
134
+ for (const line of lines) {
135
+ const trimmed = line.trim()
136
+ if (!trimmed) continue
137
+
138
+ // Skip lines that don't look like JSON objects (debug output from adapters)
139
+ if (!trimmed.startsWith('{')) continue
140
+
141
+ try {
142
+ const json = JSON.parse(trimmed)
143
+ const result = JsonRpcMessageSchema.safeParse(json)
144
+
145
+ if (!result.success) {
146
+ // Only log if it looked like valid JSON but failed schema validation
147
+ onError?.(new Error(`Invalid JSON-RPC message: ${result.error.message}`))
148
+ continue
149
+ }
150
+
151
+ messages.push(result.data as JsonRpcMessage)
152
+ } catch {
153
+ // Silently skip non-JSON lines (common with debug output)
154
+ }
155
+ }
156
+
157
+ return messages
158
+ }
159
+
160
+ // --------------------------------------------------------------------------
161
+ // Message Handling
162
+ // --------------------------------------------------------------------------
163
+
164
+ const handleMessage = async (message: JsonRpcMessage) => {
165
+ // Response to our request
166
+ if (
167
+ 'id' in message &&
168
+ message.id !== undefined &&
169
+ message.id !== null &&
170
+ ('result' in message || 'error' in message)
171
+ ) {
172
+ const response = message as JsonRpcResponse
173
+ const id = response.id as string | number
174
+ const pending = pendingRequests.get(id)
175
+ if (pending) {
176
+ pendingRequests.delete(id)
177
+ clearTimeout(pending.timer)
178
+
179
+ if ('error' in response) {
180
+ pending.reject(ACPTransportError.fromJsonRpcError(response.error))
181
+ } else {
182
+ pending.resolve(response.result)
183
+ }
184
+ }
185
+ return
186
+ }
187
+
188
+ // Request from agent (e.g., permission request, file read)
189
+ if ('id' in message && message.id !== undefined && message.id !== null && 'method' in message) {
190
+ const request = message as JsonRpcRequest
191
+ const id = request.id as string | number
192
+ if (onRequest) {
193
+ try {
194
+ const result = await onRequest(request.method, request.params)
195
+ await sendResponse(id, result)
196
+ } catch (err) {
197
+ const error = err instanceof Error ? err : new Error(String(err))
198
+ await sendErrorResponse(id, JSON_RPC_ERRORS.INTERNAL_ERROR, error.message)
199
+ }
200
+ } else {
201
+ // No handler, respond with method not found
202
+ await sendErrorResponse(id, JSON_RPC_ERRORS.METHOD_NOT_FOUND, `No handler for ${request.method}`)
203
+ }
204
+ return
205
+ }
206
+
207
+ // Notification from agent
208
+ if ('method' in message && !('id' in message)) {
209
+ const notification = message as JsonRpcNotification
210
+ onNotification?.(notification.method, notification.params)
211
+ }
212
+ }
213
+
214
+ // --------------------------------------------------------------------------
215
+ // Sending Messages
216
+ // --------------------------------------------------------------------------
217
+
218
+ const sendRaw = (message: JsonRpcMessage): void => {
219
+ if (!subprocess || isClosing) {
220
+ throw new ACPTransportError('Transport is not connected')
221
+ }
222
+
223
+ const json = `${JSON.stringify(message)}\n`
224
+ subprocess.stdin.write(json)
225
+ subprocess.stdin.flush()
226
+ }
227
+
228
+ const sendResponse = async (id: string | number, result: unknown): Promise<void> => {
229
+ const response: JsonRpcSuccessResponse = {
230
+ jsonrpc: '2.0',
231
+ id,
232
+ result,
233
+ }
234
+ sendRaw(response)
235
+ }
236
+
237
+ const sendErrorResponse = async (id: string | number, code: number, message: string): Promise<void> => {
238
+ const response: JsonRpcErrorResponse = {
239
+ jsonrpc: '2.0',
240
+ id,
241
+ error: { code, message },
242
+ }
243
+ sendRaw(response)
244
+ }
245
+
246
+ // --------------------------------------------------------------------------
247
+ // Public API
248
+ // --------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Starts the transport by spawning the agent subprocess.
252
+ *
253
+ * @throws {ACPTransportError} If the subprocess fails to start
254
+ */
255
+ const start = async (): Promise<void> => {
256
+ if (subprocess) {
257
+ throw new ACPTransportError('Transport already started')
258
+ }
259
+
260
+ if (command.length === 0) {
261
+ throw new ACPTransportError('Command array is empty')
262
+ }
263
+
264
+ const proc = Bun.spawn(command, {
265
+ cwd,
266
+ env: { ...Bun.env, ...env },
267
+ stdin: 'pipe',
268
+ stdout: 'pipe',
269
+ stderr: 'pipe',
270
+ })
271
+
272
+ // Cast to our expected type - Bun.spawn with 'pipe' options returns streams
273
+ subprocess = proc as unknown as PipedSubprocess
274
+
275
+ // Read stdout for JSON-RPC messages
276
+ const readStdout = async () => {
277
+ stdoutReader = subprocess!.stdout.getReader()
278
+ const decoder = new TextDecoder()
279
+
280
+ try {
281
+ while (true) {
282
+ const { done, value } = await stdoutReader.read()
283
+ if (done) break
284
+
285
+ const text = decoder.decode(value, { stream: true })
286
+ const messages = parseMessages(text)
287
+ for (const message of messages) {
288
+ await handleMessage(message)
289
+ }
290
+ }
291
+ } catch (err) {
292
+ if (!isClosing) {
293
+ onError?.(err instanceof Error ? err : new Error(String(err)))
294
+ }
295
+ } finally {
296
+ stdoutReader = undefined
297
+ }
298
+ }
299
+
300
+ // Read stderr for debugging
301
+ const readStderr = async () => {
302
+ stderrReader = subprocess!.stderr.getReader()
303
+ const decoder = new TextDecoder()
304
+
305
+ try {
306
+ while (true) {
307
+ const { done, value } = await stderrReader.read()
308
+ if (done) break
309
+ // Log stderr for debugging but don't treat as error
310
+ const text = decoder.decode(value, { stream: true })
311
+ if (text.trim()) {
312
+ console.error('[ACP Agent stderr]:', text.trim())
313
+ }
314
+ }
315
+ } catch {
316
+ // Ignore stderr read errors
317
+ } finally {
318
+ stderrReader = undefined
319
+ }
320
+ }
321
+
322
+ // Start reading streams
323
+ readStdout()
324
+ readStderr()
325
+
326
+ // Monitor process exit
327
+ subprocess.exited.then((code) => {
328
+ if (!isClosing) {
329
+ // Reject all pending requests
330
+ for (const [id, pending] of pendingRequests) {
331
+ clearTimeout(pending.timer)
332
+ pending.reject(new ACPTransportError(`Process exited with code ${code}`))
333
+ pendingRequests.delete(id)
334
+ }
335
+ onClose?.(code)
336
+ }
337
+ })
338
+ }
339
+
340
+ /**
341
+ * Sends a JSON-RPC request and waits for response.
342
+ *
343
+ * @param method - The RPC method name
344
+ * @param params - Optional parameters
345
+ * @returns The result from the response
346
+ * @throws {ACPTransportError} On timeout, transport error, or RPC error
347
+ */
348
+ const request = async <T>(method: string, params?: unknown): Promise<T> => {
349
+ const id = nextId++
350
+
351
+ const rpcRequest: JsonRpcRequest = {
352
+ jsonrpc: '2.0',
353
+ id,
354
+ method,
355
+ ...(params !== undefined && { params }),
356
+ }
357
+
358
+ const { promise, resolve, reject } = Promise.withResolvers<unknown>()
359
+
360
+ const timer = setTimeout(() => {
361
+ pendingRequests.delete(id)
362
+ reject(new ACPTransportError(`Request timed out after ${timeout}ms`, JSON_RPC_ERRORS.INTERNAL_ERROR))
363
+ }, timeout)
364
+
365
+ pendingRequests.set(id, { resolve, reject, timer })
366
+
367
+ try {
368
+ sendRaw(rpcRequest)
369
+ } catch (err) {
370
+ pendingRequests.delete(id)
371
+ clearTimeout(timer)
372
+ throw err
373
+ }
374
+
375
+ return promise as Promise<T>
376
+ }
377
+
378
+ /**
379
+ * Sends a JSON-RPC notification (no response expected).
380
+ *
381
+ * @param method - The notification method name
382
+ * @param params - Optional parameters
383
+ */
384
+ const notify = async (method: string, params?: unknown): Promise<void> => {
385
+ const notification: JsonRpcNotification = {
386
+ jsonrpc: '2.0',
387
+ method,
388
+ ...(params !== undefined && { params }),
389
+ }
390
+ sendRaw(notification)
391
+ }
392
+
393
+ /**
394
+ * Cancels a pending request using the ACP cancel notification.
395
+ *
396
+ * @param requestId - The ID of the request to cancel
397
+ */
398
+ const cancelRequest = async (requestId: string | number): Promise<void> => {
399
+ // Use SDK's CancelRequestNotification format
400
+ await notify('$/cancel_request', { requestId })
401
+ }
402
+
403
+ /**
404
+ * Closes the transport and terminates the subprocess.
405
+ *
406
+ * @param graceful - If true, sends shutdown request first (default: true)
407
+ */
408
+ const close = async (graceful = true): Promise<void> => {
409
+ if (!subprocess || isClosing) return
410
+ isClosing = true
411
+
412
+ // Cancel all pending requests
413
+ for (const [id, pending] of pendingRequests) {
414
+ clearTimeout(pending.timer)
415
+ pending.reject(new ACPTransportError('Transport closed'))
416
+ pendingRequests.delete(id)
417
+ }
418
+
419
+ try {
420
+ if (graceful) {
421
+ // Try graceful shutdown - not in SDK, use string literal
422
+ await request('shutdown').catch(() => {})
423
+ }
424
+ } finally {
425
+ // Release stream readers to allow clean subprocess termination
426
+ await Promise.all([stdoutReader?.cancel().catch(() => {}), stderrReader?.cancel().catch(() => {})])
427
+
428
+ subprocess.kill()
429
+ subprocess = undefined
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Checks if the transport is connected.
435
+ */
436
+ const isConnected = (): boolean => {
437
+ return subprocess !== undefined && !isClosing
438
+ }
439
+
440
+ return {
441
+ start,
442
+ request,
443
+ notify,
444
+ cancelRequest,
445
+ close,
446
+ isConnected,
447
+ }
448
+ }