@plaited/acp-harness 0.2.5

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 (45) hide show
  1. package/.claude/rules/accuracy.md +43 -0
  2. package/.claude/rules/bun-apis.md +80 -0
  3. package/.claude/rules/code-review.md +254 -0
  4. package/.claude/rules/git-workflow.md +37 -0
  5. package/.claude/rules/github.md +154 -0
  6. package/.claude/rules/testing.md +172 -0
  7. package/.claude/skills/acp-harness/SKILL.md +310 -0
  8. package/.claude/skills/acp-harness/assets/Dockerfile.acp +25 -0
  9. package/.claude/skills/acp-harness/assets/docker-compose.acp.yml +19 -0
  10. package/.claude/skills/acp-harness/references/downstream.md +288 -0
  11. package/.claude/skills/acp-harness/references/output-formats.md +221 -0
  12. package/.claude-plugin/marketplace.json +15 -0
  13. package/.claude-plugin/plugin.json +16 -0
  14. package/.github/CODEOWNERS +6 -0
  15. package/.github/workflows/ci.yml +63 -0
  16. package/.github/workflows/publish.yml +146 -0
  17. package/.mcp.json +20 -0
  18. package/CLAUDE.md +92 -0
  19. package/Dockerfile.test +23 -0
  20. package/LICENSE +15 -0
  21. package/README.md +94 -0
  22. package/bin/cli.ts +670 -0
  23. package/bin/tests/cli.spec.ts +362 -0
  24. package/biome.json +96 -0
  25. package/bun.lock +513 -0
  26. package/docker-compose.test.yml +21 -0
  27. package/package.json +57 -0
  28. package/scripts/bun-test-wrapper.sh +46 -0
  29. package/src/acp-client.ts +503 -0
  30. package/src/acp-helpers.ts +121 -0
  31. package/src/acp-transport.ts +455 -0
  32. package/src/acp-utils.ts +341 -0
  33. package/src/acp.constants.ts +56 -0
  34. package/src/acp.schemas.ts +161 -0
  35. package/src/acp.ts +27 -0
  36. package/src/acp.types.ts +28 -0
  37. package/src/tests/acp-client.spec.ts +205 -0
  38. package/src/tests/acp-helpers.spec.ts +105 -0
  39. package/src/tests/acp-integration.docker.ts +214 -0
  40. package/src/tests/acp-transport.spec.ts +153 -0
  41. package/src/tests/acp-utils.spec.ts +394 -0
  42. package/src/tests/fixtures/.claude/settings.local.json +8 -0
  43. package/src/tests/fixtures/.claude/skills/greeting/SKILL.md +17 -0
  44. package/src/tests/fixtures/calculator-mcp.ts +215 -0
  45. package/tsconfig.json +32 -0
@@ -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,455 @@
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
+ // Use global ReadableStreamDefaultReader type (Bun's type includes readMany)
120
+ let stdoutReader: globalThis.ReadableStreamDefaultReader<Uint8Array> | undefined
121
+ let stderrReader: globalThis.ReadableStreamDefaultReader<Uint8Array> | undefined
122
+
123
+ // --------------------------------------------------------------------------
124
+ // Message Parsing (with Zod validation)
125
+ // --------------------------------------------------------------------------
126
+
127
+ const parseMessages = (data: string): JsonRpcMessage[] => {
128
+ buffer += data
129
+ const messages: JsonRpcMessage[] = []
130
+ const lines = buffer.split('\n')
131
+
132
+ // Keep incomplete last line in buffer
133
+ buffer = lines.pop() ?? ''
134
+
135
+ for (const line of lines) {
136
+ const trimmed = line.trim()
137
+ if (!trimmed) continue
138
+
139
+ // Skip lines that don't look like JSON objects (debug output from adapters)
140
+ if (!trimmed.startsWith('{')) continue
141
+
142
+ try {
143
+ const json = JSON.parse(trimmed)
144
+ const result = JsonRpcMessageSchema.safeParse(json)
145
+
146
+ if (!result.success) {
147
+ // Only log if it looked like valid JSON but failed schema validation
148
+ onError?.(new Error(`Invalid JSON-RPC message: ${result.error.message}`))
149
+ continue
150
+ }
151
+
152
+ messages.push(result.data as JsonRpcMessage)
153
+ } catch {
154
+ // Silently skip non-JSON lines (common with debug output)
155
+ }
156
+ }
157
+
158
+ return messages
159
+ }
160
+
161
+ // --------------------------------------------------------------------------
162
+ // Message Handling
163
+ // --------------------------------------------------------------------------
164
+
165
+ const handleMessage = async (message: JsonRpcMessage) => {
166
+ // Response to our request
167
+ if (
168
+ 'id' in message &&
169
+ message.id !== undefined &&
170
+ message.id !== null &&
171
+ ('result' in message || 'error' in message)
172
+ ) {
173
+ const response = message as JsonRpcResponse
174
+ const id = response.id as string | number
175
+ const pending = pendingRequests.get(id)
176
+ if (pending) {
177
+ pendingRequests.delete(id)
178
+ clearTimeout(pending.timer)
179
+
180
+ if ('error' in response) {
181
+ pending.reject(ACPTransportError.fromJsonRpcError(response.error))
182
+ } else {
183
+ pending.resolve(response.result)
184
+ }
185
+ }
186
+ return
187
+ }
188
+
189
+ // Request from agent (e.g., permission request, file read)
190
+ if ('id' in message && message.id !== undefined && message.id !== null && 'method' in message) {
191
+ const request = message as JsonRpcRequest
192
+ const id = request.id as string | number
193
+ if (onRequest) {
194
+ try {
195
+ const result = await onRequest(request.method, request.params)
196
+ await sendResponse(id, result)
197
+ } catch (err) {
198
+ const error = err instanceof Error ? err : new Error(String(err))
199
+ await sendErrorResponse(id, JSON_RPC_ERRORS.INTERNAL_ERROR, error.message)
200
+ }
201
+ } else {
202
+ // No handler, respond with method not found
203
+ await sendErrorResponse(id, JSON_RPC_ERRORS.METHOD_NOT_FOUND, `No handler for ${request.method}`)
204
+ }
205
+ return
206
+ }
207
+
208
+ // Notification from agent
209
+ if ('method' in message && !('id' in message)) {
210
+ const notification = message as JsonRpcNotification
211
+ onNotification?.(notification.method, notification.params)
212
+ }
213
+ }
214
+
215
+ // --------------------------------------------------------------------------
216
+ // Sending Messages
217
+ // --------------------------------------------------------------------------
218
+
219
+ const sendRaw = (message: JsonRpcMessage): void => {
220
+ if (!subprocess || isClosing) {
221
+ throw new ACPTransportError('Transport is not connected')
222
+ }
223
+
224
+ const json = `${JSON.stringify(message)}\n`
225
+ subprocess.stdin.write(json)
226
+ subprocess.stdin.flush()
227
+ }
228
+
229
+ const sendResponse = async (id: string | number, result: unknown): Promise<void> => {
230
+ const response: JsonRpcSuccessResponse = {
231
+ jsonrpc: '2.0',
232
+ id,
233
+ result,
234
+ }
235
+ sendRaw(response)
236
+ }
237
+
238
+ const sendErrorResponse = async (id: string | number, code: number, message: string): Promise<void> => {
239
+ const response: JsonRpcErrorResponse = {
240
+ jsonrpc: '2.0',
241
+ id,
242
+ error: { code, message },
243
+ }
244
+ sendRaw(response)
245
+ }
246
+
247
+ // --------------------------------------------------------------------------
248
+ // Public API
249
+ // --------------------------------------------------------------------------
250
+
251
+ /**
252
+ * Starts the transport by spawning the agent subprocess.
253
+ *
254
+ * @throws {ACPTransportError} If the subprocess fails to start
255
+ */
256
+ const start = async (): Promise<void> => {
257
+ if (subprocess) {
258
+ throw new ACPTransportError('Transport already started')
259
+ }
260
+
261
+ if (command.length === 0) {
262
+ throw new ACPTransportError('Command array is empty')
263
+ }
264
+
265
+ const proc = Bun.spawn(command, {
266
+ cwd,
267
+ env: { ...Bun.env, ...env },
268
+ stdin: 'pipe',
269
+ stdout: 'pipe',
270
+ stderr: 'pipe',
271
+ })
272
+
273
+ // Cast to our expected type - Bun.spawn with 'pipe' options returns streams
274
+ subprocess = proc as unknown as PipedSubprocess
275
+
276
+ // Read stdout for JSON-RPC messages
277
+ const readStdout = async () => {
278
+ // Type assertion needed: Bun's ReadableStreamDefaultReader includes readMany
279
+ // but node:stream/web reader returned by getReader() doesn't have it
280
+ const reader = subprocess!.stdout.getReader() as globalThis.ReadableStreamDefaultReader<Uint8Array>
281
+ stdoutReader = reader
282
+ const decoder = new TextDecoder()
283
+
284
+ try {
285
+ while (true) {
286
+ const { done, value } = await reader.read()
287
+ if (done) break
288
+
289
+ const text = decoder.decode(value, { stream: true })
290
+ const messages = parseMessages(text)
291
+ for (const message of messages) {
292
+ await handleMessage(message)
293
+ }
294
+ }
295
+ } catch (err) {
296
+ if (!isClosing) {
297
+ onError?.(err instanceof Error ? err : new Error(String(err)))
298
+ }
299
+ } finally {
300
+ stdoutReader = undefined
301
+ }
302
+ }
303
+
304
+ // Read stderr for debugging
305
+ const readStderr = async () => {
306
+ // Type assertion needed: Bun's ReadableStreamDefaultReader includes readMany
307
+ // but node:stream/web reader returned by getReader() doesn't have it
308
+ const reader = subprocess!.stderr.getReader() as globalThis.ReadableStreamDefaultReader<Uint8Array>
309
+ stderrReader = reader
310
+ const decoder = new TextDecoder()
311
+
312
+ try {
313
+ while (true) {
314
+ const { done, value } = await reader.read()
315
+ if (done) break
316
+ // Log stderr for debugging but don't treat as error
317
+ const text = decoder.decode(value, { stream: true })
318
+ if (text.trim()) {
319
+ console.error('[ACP Agent stderr]:', text.trim())
320
+ }
321
+ }
322
+ } catch {
323
+ // Ignore stderr read errors
324
+ } finally {
325
+ stderrReader = undefined
326
+ }
327
+ }
328
+
329
+ // Start reading streams
330
+ readStdout()
331
+ readStderr()
332
+
333
+ // Monitor process exit
334
+ subprocess.exited.then((code) => {
335
+ if (!isClosing) {
336
+ // Reject all pending requests
337
+ for (const [id, pending] of pendingRequests) {
338
+ clearTimeout(pending.timer)
339
+ pending.reject(new ACPTransportError(`Process exited with code ${code}`))
340
+ pendingRequests.delete(id)
341
+ }
342
+ onClose?.(code)
343
+ }
344
+ })
345
+ }
346
+
347
+ /**
348
+ * Sends a JSON-RPC request and waits for response.
349
+ *
350
+ * @param method - The RPC method name
351
+ * @param params - Optional parameters
352
+ * @returns The result from the response
353
+ * @throws {ACPTransportError} On timeout, transport error, or RPC error
354
+ */
355
+ const request = async <T>(method: string, params?: unknown): Promise<T> => {
356
+ const id = nextId++
357
+
358
+ const rpcRequest: JsonRpcRequest = {
359
+ jsonrpc: '2.0',
360
+ id,
361
+ method,
362
+ ...(params !== undefined && { params }),
363
+ }
364
+
365
+ const { promise, resolve, reject } = Promise.withResolvers<unknown>()
366
+
367
+ const timer = setTimeout(() => {
368
+ pendingRequests.delete(id)
369
+ reject(new ACPTransportError(`Request timed out after ${timeout}ms`, JSON_RPC_ERRORS.INTERNAL_ERROR))
370
+ }, timeout)
371
+
372
+ pendingRequests.set(id, { resolve, reject, timer })
373
+
374
+ try {
375
+ sendRaw(rpcRequest)
376
+ } catch (err) {
377
+ pendingRequests.delete(id)
378
+ clearTimeout(timer)
379
+ throw err
380
+ }
381
+
382
+ return promise as Promise<T>
383
+ }
384
+
385
+ /**
386
+ * Sends a JSON-RPC notification (no response expected).
387
+ *
388
+ * @param method - The notification method name
389
+ * @param params - Optional parameters
390
+ */
391
+ const notify = async (method: string, params?: unknown): Promise<void> => {
392
+ const notification: JsonRpcNotification = {
393
+ jsonrpc: '2.0',
394
+ method,
395
+ ...(params !== undefined && { params }),
396
+ }
397
+ sendRaw(notification)
398
+ }
399
+
400
+ /**
401
+ * Cancels a pending request using the ACP cancel notification.
402
+ *
403
+ * @param requestId - The ID of the request to cancel
404
+ */
405
+ const cancelRequest = async (requestId: string | number): Promise<void> => {
406
+ // Use SDK's CancelRequestNotification format
407
+ await notify('$/cancel_request', { requestId })
408
+ }
409
+
410
+ /**
411
+ * Closes the transport and terminates the subprocess.
412
+ *
413
+ * @param graceful - If true, sends shutdown request first (default: true)
414
+ */
415
+ const close = async (graceful = true): Promise<void> => {
416
+ if (!subprocess || isClosing) return
417
+ isClosing = true
418
+
419
+ // Cancel all pending requests
420
+ for (const [id, pending] of pendingRequests) {
421
+ clearTimeout(pending.timer)
422
+ pending.reject(new ACPTransportError('Transport closed'))
423
+ pendingRequests.delete(id)
424
+ }
425
+
426
+ try {
427
+ if (graceful) {
428
+ // Try graceful shutdown - not in SDK, use string literal
429
+ await request('shutdown').catch(() => {})
430
+ }
431
+ } finally {
432
+ // Release stream readers to allow clean subprocess termination
433
+ await Promise.all([stdoutReader?.cancel().catch(() => {}), stderrReader?.cancel().catch(() => {})])
434
+
435
+ subprocess.kill()
436
+ subprocess = undefined
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Checks if the transport is connected.
442
+ */
443
+ const isConnected = (): boolean => {
444
+ return subprocess !== undefined && !isClosing
445
+ }
446
+
447
+ return {
448
+ start,
449
+ request,
450
+ notify,
451
+ cancelRequest,
452
+ close,
453
+ isConnected,
454
+ }
455
+ }