@plaited/acp-harness 0.3.2 → 0.4.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.
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Headless ACP adapter factory CLI entry point.
4
+ *
5
+ * @remarks
6
+ * This module implements a schema-driven ACP adapter that can interact with
7
+ * ANY headless CLI agent. The adapter:
8
+ *
9
+ * 1. Reads a JSON schema defining how to interact with the CLI
10
+ * 2. Spawns the CLI process per schema's command + flags
11
+ * 3. Parses stdout using schema's outputEvents mappings
12
+ * 4. Emits ACP session/update notifications
13
+ * 5. Manages session state for multi-turn (stream or iterative mode)
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+
18
+ import { createInterface } from 'node:readline'
19
+ import { parseArgs } from 'node:util'
20
+ import { ACP_PROTOCOL_VERSION } from './constants.ts'
21
+ import { type HeadlessAdapterConfig, parseHeadlessConfig } from './headless.schemas.ts'
22
+ import { createSessionManager, type SessionManager } from './headless-session-manager.ts'
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ /** JSON-RPC 2.0 request */
29
+ type JsonRpcRequest = {
30
+ jsonrpc: '2.0'
31
+ id: string | number
32
+ method: string
33
+ params?: unknown
34
+ }
35
+
36
+ /** JSON-RPC 2.0 notification */
37
+ type JsonRpcNotification = {
38
+ jsonrpc: '2.0'
39
+ method: string
40
+ params?: unknown
41
+ }
42
+
43
+ /** JSON-RPC 2.0 success response */
44
+ type JsonRpcSuccessResponse = {
45
+ jsonrpc: '2.0'
46
+ id: string | number
47
+ result: unknown
48
+ }
49
+
50
+ /** JSON-RPC 2.0 error response */
51
+ type JsonRpcErrorResponse = {
52
+ jsonrpc: '2.0'
53
+ id: string | number | null
54
+ error: {
55
+ code: number
56
+ message: string
57
+ data?: unknown
58
+ }
59
+ }
60
+
61
+ /** JSON-RPC 2.0 response */
62
+ type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse
63
+
64
+ /** Content block for prompts */
65
+ type ContentBlock = { type: 'text'; text: string } | { type: 'image'; source: unknown }
66
+
67
+ // ============================================================================
68
+ // Message Sending
69
+ // ============================================================================
70
+
71
+ /**
72
+ * Sends a JSON-RPC message to stdout.
73
+ */
74
+ const sendMessage = (message: JsonRpcResponse | JsonRpcNotification): void => {
75
+ // biome-ignore lint/suspicious/noConsole: Protocol output
76
+ console.log(JSON.stringify(message))
77
+ }
78
+
79
+ /**
80
+ * Sends a session update notification.
81
+ */
82
+ const sendSessionUpdate = (sessionId: string, update: unknown): void => {
83
+ sendMessage({
84
+ jsonrpc: '2.0',
85
+ method: 'session/update',
86
+ params: { sessionId, update },
87
+ })
88
+ }
89
+
90
+ // ============================================================================
91
+ // Request Handlers
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Creates request handlers for the headless adapter.
96
+ *
97
+ * @param schema - Headless adapter configuration
98
+ * @param sessions - Session manager instance
99
+ */
100
+ const createHandlers = (schema: HeadlessAdapterConfig, sessions: SessionManager) => {
101
+ /**
102
+ * Handle initialize request.
103
+ */
104
+ const handleInitialize = async (params: unknown): Promise<unknown> => {
105
+ const { protocolVersion } = params as { protocolVersion: number }
106
+
107
+ if (protocolVersion !== ACP_PROTOCOL_VERSION) {
108
+ throw new Error(`Unsupported protocol version: ${protocolVersion}`)
109
+ }
110
+
111
+ return {
112
+ protocolVersion: ACP_PROTOCOL_VERSION,
113
+ agentInfo: {
114
+ name: schema.name,
115
+ version: '1.0.0',
116
+ },
117
+ agentCapabilities: {
118
+ loadSession: !!schema.resume,
119
+ promptCapabilities: {
120
+ image: false,
121
+ },
122
+ },
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Handle session/new request.
128
+ */
129
+ const handleSessionNew = async (params: unknown): Promise<unknown> => {
130
+ const { cwd } = params as { cwd: string }
131
+ const session = await sessions.create(cwd)
132
+ return { sessionId: session.id }
133
+ }
134
+
135
+ /**
136
+ * Handle session/load request.
137
+ */
138
+ const handleSessionLoad = async (params: unknown): Promise<unknown> => {
139
+ const { sessionId } = params as { sessionId: string }
140
+ const session = sessions.get(sessionId)
141
+
142
+ if (!session) {
143
+ throw new Error(`Session not found: ${sessionId}`)
144
+ }
145
+
146
+ return { sessionId }
147
+ }
148
+
149
+ /**
150
+ * Handle session/prompt request.
151
+ */
152
+ const handleSessionPrompt = async (params: unknown): Promise<unknown> => {
153
+ const { sessionId, prompt } = params as { sessionId: string; prompt: ContentBlock[] }
154
+
155
+ // Extract text from content blocks
156
+ const promptText = prompt
157
+ .filter((block): block is ContentBlock & { type: 'text' } => block.type === 'text')
158
+ .map((block) => block.text)
159
+ .join('\n')
160
+
161
+ // Execute prompt and stream updates
162
+ const result = await sessions.prompt(sessionId, promptText, (update) => {
163
+ // Map parsed update to ACP session update format
164
+ const acpUpdate = mapToACPUpdate(update)
165
+ sendSessionUpdate(sessionId, acpUpdate)
166
+ })
167
+
168
+ return {
169
+ content: [{ type: 'text', text: result.output }],
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Handle session/cancel notification.
175
+ */
176
+ const handleSessionCancel = async (params: unknown): Promise<void> => {
177
+ const { sessionId } = params as { sessionId: string }
178
+ sessions.cancel(sessionId)
179
+ }
180
+
181
+ return {
182
+ handleInitialize,
183
+ handleSessionNew,
184
+ handleSessionLoad,
185
+ handleSessionPrompt,
186
+ handleSessionCancel,
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Maps a parsed update to ACP session update format.
192
+ */
193
+ const mapToACPUpdate = (update: { type: string; content?: string; title?: string; status?: string }): unknown => {
194
+ switch (update.type) {
195
+ case 'thought':
196
+ return {
197
+ sessionUpdate: 'agent_thought_chunk',
198
+ content: { type: 'text', text: update.content ?? '' },
199
+ }
200
+
201
+ case 'message':
202
+ return {
203
+ sessionUpdate: 'agent_message_chunk',
204
+ content: { type: 'text', text: update.content ?? '' },
205
+ }
206
+
207
+ case 'tool_call':
208
+ return {
209
+ sessionUpdate: 'agent_tool_call',
210
+ toolCall: {
211
+ name: update.title ?? 'unknown',
212
+ status: update.status ?? 'pending',
213
+ },
214
+ }
215
+
216
+ case 'plan':
217
+ return {
218
+ sessionUpdate: 'agent_plan',
219
+ content: { type: 'text', text: update.content ?? '' },
220
+ }
221
+
222
+ default:
223
+ return {
224
+ sessionUpdate: 'agent_message_chunk',
225
+ content: { type: 'text', text: update.content ?? '' },
226
+ }
227
+ }
228
+ }
229
+
230
+ // ============================================================================
231
+ // Main Loop
232
+ // ============================================================================
233
+
234
+ /**
235
+ * Runs the headless adapter main loop.
236
+ *
237
+ * @param schema - Headless adapter configuration
238
+ */
239
+ const runAdapter = async (schema: HeadlessAdapterConfig): Promise<void> => {
240
+ const sessions = createSessionManager({ schema })
241
+ const handlers = createHandlers(schema, sessions)
242
+
243
+ // Method handlers (requests expect responses)
244
+ const methodHandlers: Record<string, (params: unknown) => Promise<unknown>> = {
245
+ initialize: handlers.handleInitialize,
246
+ 'session/new': handlers.handleSessionNew,
247
+ 'session/load': handlers.handleSessionLoad,
248
+ 'session/prompt': handlers.handleSessionPrompt,
249
+ }
250
+
251
+ // Notification handlers (no response expected)
252
+ const notificationHandlers: Record<string, (params: unknown) => Promise<void>> = {
253
+ 'session/cancel': handlers.handleSessionCancel,
254
+ }
255
+
256
+ /**
257
+ * Process incoming JSON-RPC message.
258
+ */
259
+ const processMessage = async (line: string): Promise<void> => {
260
+ let request: JsonRpcRequest | JsonRpcNotification
261
+
262
+ try {
263
+ request = JSON.parse(line)
264
+ } catch {
265
+ sendMessage({
266
+ jsonrpc: '2.0',
267
+ id: null,
268
+ error: { code: -32700, message: 'Parse error' },
269
+ })
270
+ return
271
+ }
272
+
273
+ // Check if it's a notification (no id)
274
+ const isNotification = !('id' in request)
275
+
276
+ if (isNotification) {
277
+ const handler = notificationHandlers[request.method]
278
+ if (handler) {
279
+ await handler(request.params)
280
+ }
281
+ // No response for notifications
282
+ return
283
+ }
284
+
285
+ // It's a request - send response
286
+ const reqWithId = request as JsonRpcRequest
287
+ const handler = methodHandlers[reqWithId.method]
288
+
289
+ if (!handler) {
290
+ sendMessage({
291
+ jsonrpc: '2.0',
292
+ id: reqWithId.id,
293
+ error: { code: -32601, message: `Method not found: ${reqWithId.method}` },
294
+ })
295
+ return
296
+ }
297
+
298
+ try {
299
+ const result = await handler(reqWithId.params)
300
+ sendMessage({
301
+ jsonrpc: '2.0',
302
+ id: reqWithId.id,
303
+ result,
304
+ })
305
+ } catch (error) {
306
+ sendMessage({
307
+ jsonrpc: '2.0',
308
+ id: reqWithId.id,
309
+ error: {
310
+ code: -32603,
311
+ message: error instanceof Error ? error.message : 'Internal error',
312
+ },
313
+ })
314
+ }
315
+ }
316
+
317
+ // Main loop: read lines from stdin
318
+ const rl = createInterface({
319
+ input: process.stdin,
320
+ output: process.stdout,
321
+ terminal: false,
322
+ })
323
+
324
+ rl.on('line', processMessage)
325
+
326
+ // Handle clean shutdown
327
+ process.on('SIGTERM', () => {
328
+ rl.close()
329
+ process.exit(0)
330
+ })
331
+
332
+ process.on('SIGINT', () => {
333
+ rl.close()
334
+ process.exit(0)
335
+ })
336
+ }
337
+
338
+ // ============================================================================
339
+ // CLI Entry Point
340
+ // ============================================================================
341
+
342
+ /**
343
+ * Headless adapter CLI entry point.
344
+ *
345
+ * @param args - Command line arguments
346
+ */
347
+ export const headless = async (args: string[]): Promise<void> => {
348
+ const { values } = parseArgs({
349
+ args,
350
+ options: {
351
+ schema: { type: 'string', short: 's' },
352
+ help: { type: 'boolean', short: 'h' },
353
+ },
354
+ allowPositionals: false,
355
+ })
356
+
357
+ if (values.help) {
358
+ // biome-ignore lint/suspicious/noConsole: CLI help output
359
+ console.log(`
360
+ Usage: acp-harness headless --schema <path>
361
+
362
+ Arguments:
363
+ -s, --schema Path to headless adapter schema (JSON)
364
+ -h, --help Show this help message
365
+
366
+ Description:
367
+ Schema-driven ACP adapter for ANY headless CLI agent. The adapter reads
368
+ a JSON schema defining how to interact with the CLI and translates between
369
+ ACP protocol and CLI stdio.
370
+
371
+ Schema Format:
372
+ {
373
+ "version": 1,
374
+ "name": "my-agent",
375
+ "command": ["my-agent-cli"],
376
+ "sessionMode": "stream" | "iterative",
377
+ "prompt": { "flag": "-p" },
378
+ "output": { "flag": "--output-format", "value": "stream-json" },
379
+ "outputEvents": [...],
380
+ "result": { "matchPath": "$.type", "matchValue": "result", "contentPath": "$.content" }
381
+ }
382
+
383
+ Examples:
384
+ # Run with Claude headless schema
385
+ acp-harness headless --schema ./claude-headless.json
386
+
387
+ # Use in capture pipeline
388
+ acp-harness capture prompts.jsonl \\
389
+ acp-harness headless --schema ./claude-headless.json \\
390
+ -o results.jsonl
391
+
392
+ # Validate adapter compliance
393
+ acp-harness adapter:check \\
394
+ acp-harness headless --schema ./gemini-headless.json
395
+ `)
396
+ return
397
+ }
398
+
399
+ if (!values.schema) {
400
+ console.error('Error: --schema is required')
401
+ console.error('Example: acp-harness headless --schema ./my-agent.json')
402
+ process.exit(1)
403
+ }
404
+
405
+ // Load and validate schema
406
+ const schemaPath = values.schema
407
+ const schemaFile = Bun.file(schemaPath)
408
+
409
+ if (!(await schemaFile.exists())) {
410
+ console.error(`Error: schema file not found: ${schemaPath}`)
411
+ process.exit(1)
412
+ }
413
+
414
+ let schema: HeadlessAdapterConfig
415
+ try {
416
+ const rawSchema = await schemaFile.json()
417
+ schema = parseHeadlessConfig(rawSchema)
418
+ } catch (error) {
419
+ console.error(`Error: invalid schema: ${error instanceof Error ? error.message : String(error)}`)
420
+ process.exit(1)
421
+ }
422
+
423
+ // Run the adapter
424
+ await runAdapter(schema)
425
+ }
426
+
427
+ // Allow direct execution
428
+ if (import.meta.main) {
429
+ headless(Bun.argv.slice(2)).catch((error) => {
430
+ console.error('Error:', error instanceof Error ? error.message : error)
431
+ process.exit(1)
432
+ })
433
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * History builder for iterative mode sessions.
3
+ *
4
+ * @remarks
5
+ * In iterative mode, each prompt spawns a new process. The history builder
6
+ * accumulates conversation context and formats it using the schema's
7
+ * historyTemplate for inclusion in subsequent prompts.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ // ============================================================================
13
+ // Types
14
+ // ============================================================================
15
+
16
+ /** A single turn in conversation history */
17
+ export type HistoryTurn = {
18
+ /** User input */
19
+ input: string
20
+ /** Agent output */
21
+ output: string
22
+ }
23
+
24
+ /** History builder configuration */
25
+ export type HistoryBuilderConfig = {
26
+ /** Template for formatting history (e.g., "User: {{input}}\nAssistant: {{output}}") */
27
+ template?: string
28
+ }
29
+
30
+ // ============================================================================
31
+ // Default Template
32
+ // ============================================================================
33
+
34
+ const DEFAULT_TEMPLATE = 'User: {{input}}\nAssistant: {{output}}'
35
+
36
+ // ============================================================================
37
+ // History Builder Factory
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Creates a history builder for iterative mode sessions.
42
+ *
43
+ * @remarks
44
+ * The history builder:
45
+ * 1. Stores conversation turns
46
+ * 2. Formats history using the template
47
+ * 3. Builds complete prompts with context
48
+ *
49
+ * @param config - History builder configuration
50
+ * @returns History builder with add, format, and build methods
51
+ */
52
+ export const createHistoryBuilder = (config: HistoryBuilderConfig = {}) => {
53
+ const template = config.template ?? DEFAULT_TEMPLATE
54
+ const history: HistoryTurn[] = []
55
+
56
+ /**
57
+ * Adds a turn to history.
58
+ *
59
+ * @param input - User input
60
+ * @param output - Agent output
61
+ */
62
+ const addTurn = (input: string, output: string): void => {
63
+ history.push({ input, output })
64
+ }
65
+
66
+ /**
67
+ * Formats the current history as a string.
68
+ *
69
+ * @returns Formatted history string
70
+ */
71
+ const formatHistory = (): string => {
72
+ return history.map((turn) => formatTurn(turn, template)).join('\n\n')
73
+ }
74
+
75
+ /**
76
+ * Builds a prompt with history context.
77
+ *
78
+ * @remarks
79
+ * For the first turn, returns just the input.
80
+ * For subsequent turns, prepends formatted history.
81
+ *
82
+ * @param newInput - The new user input
83
+ * @returns Full prompt including history context
84
+ */
85
+ const buildPrompt = (newInput: string): string => {
86
+ if (history.length === 0) {
87
+ return newInput
88
+ }
89
+
90
+ const formattedHistory = formatHistory()
91
+ return `${formattedHistory}\n\nUser: ${newInput}`
92
+ }
93
+
94
+ /**
95
+ * Gets the number of turns in history.
96
+ */
97
+ const getLength = (): number => {
98
+ return history.length
99
+ }
100
+
101
+ /**
102
+ * Clears all history.
103
+ */
104
+ const clear = (): void => {
105
+ history.length = 0
106
+ }
107
+
108
+ /**
109
+ * Gets a copy of the history.
110
+ */
111
+ const getHistory = (): HistoryTurn[] => {
112
+ return [...history]
113
+ }
114
+
115
+ return {
116
+ addTurn,
117
+ formatHistory,
118
+ buildPrompt,
119
+ getLength,
120
+ clear,
121
+ getHistory,
122
+ }
123
+ }
124
+
125
+ // ============================================================================
126
+ // Helper Functions
127
+ // ============================================================================
128
+
129
+ /**
130
+ * Formats a single turn using the template.
131
+ *
132
+ * @param turn - History turn
133
+ * @param template - Template string with {{input}} and {{output}} placeholders
134
+ * @returns Formatted turn string
135
+ */
136
+ const formatTurn = (turn: HistoryTurn, template: string): string => {
137
+ return template.replace('{{input}}', turn.input).replace('{{output}}', turn.output)
138
+ }
139
+
140
+ /** History builder type */
141
+ export type HistoryBuilder = ReturnType<typeof createHistoryBuilder>