@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.
- package/.claude/rules/accuracy.md +43 -0
- package/.claude/rules/bun-apis.md +80 -0
- package/.claude/rules/code-review.md +254 -0
- package/.claude/rules/git-workflow.md +37 -0
- package/.claude/rules/github.md +154 -0
- package/.claude/rules/testing.md +172 -0
- package/.claude/skills/acp-harness/SKILL.md +310 -0
- package/.claude/skills/acp-harness/assets/Dockerfile.acp +25 -0
- package/.claude/skills/acp-harness/assets/docker-compose.acp.yml +19 -0
- package/.claude/skills/acp-harness/references/downstream.md +288 -0
- package/.claude/skills/acp-harness/references/output-formats.md +221 -0
- package/.claude-plugin/marketplace.json +15 -0
- package/.claude-plugin/plugin.json +16 -0
- package/.github/CODEOWNERS +6 -0
- package/.github/workflows/ci.yml +63 -0
- package/.github/workflows/publish.yml +146 -0
- package/.mcp.json +20 -0
- package/CLAUDE.md +92 -0
- package/Dockerfile.test +23 -0
- package/LICENSE +15 -0
- package/README.md +94 -0
- package/bin/cli.ts +670 -0
- package/bin/tests/cli.spec.ts +362 -0
- package/biome.json +96 -0
- package/bun.lock +513 -0
- package/docker-compose.test.yml +21 -0
- package/package.json +57 -0
- package/scripts/bun-test-wrapper.sh +46 -0
- package/src/acp-client.ts +503 -0
- package/src/acp-helpers.ts +121 -0
- package/src/acp-transport.ts +455 -0
- package/src/acp-utils.ts +341 -0
- package/src/acp.constants.ts +56 -0
- package/src/acp.schemas.ts +161 -0
- package/src/acp.ts +27 -0
- package/src/acp.types.ts +28 -0
- package/src/tests/acp-client.spec.ts +205 -0
- package/src/tests/acp-helpers.spec.ts +105 -0
- package/src/tests/acp-integration.docker.ts +214 -0
- package/src/tests/acp-transport.spec.ts +153 -0
- package/src/tests/acp-utils.spec.ts +394 -0
- package/src/tests/fixtures/.claude/settings.local.json +8 -0
- package/src/tests/fixtures/.claude/skills/greeting/SKILL.md +17 -0
- package/src/tests/fixtures/calculator-mcp.ts +215 -0
- 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
|
+
}
|