@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,251 @@
1
+ /**
2
+ * Generic output parser for headless CLI agents.
3
+ *
4
+ * @remarks
5
+ * Uses schema-defined mappings to convert CLI JSON output into ACP session updates.
6
+ * Supports JSONPath-like expressions for matching and extraction.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import type { HeadlessAdapterConfig, OutputEventMapping } from './headless.schemas.ts'
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ /** ACP session update types */
18
+ export type SessionUpdateType = 'thought' | 'tool_call' | 'message' | 'plan'
19
+
20
+ /** Parsed session update from CLI output */
21
+ export type ParsedUpdate = {
22
+ type: SessionUpdateType
23
+ content?: string
24
+ title?: string
25
+ status?: string
26
+ raw: unknown
27
+ }
28
+
29
+ /** Result extraction from CLI output */
30
+ export type ParsedResult = {
31
+ isResult: true
32
+ content: string
33
+ raw: unknown
34
+ }
35
+
36
+ /** Not a result */
37
+ export type NotResult = {
38
+ isResult: false
39
+ }
40
+
41
+ /** Parse result for final output */
42
+ export type ResultParseResult = ParsedResult | NotResult
43
+
44
+ // ============================================================================
45
+ // JSONPath Implementation
46
+ // ============================================================================
47
+
48
+ /**
49
+ * Extracts a value from an object using a simple JSONPath expression.
50
+ *
51
+ * @remarks
52
+ * Supports:
53
+ * - `$.field` - Root field access
54
+ * - `$.nested.field` - Nested field access
55
+ * - `$.array[0]` - Array index access
56
+ * - `$.array[0].field` - Combined array and field access
57
+ * - `'literal'` - Literal string values (single quotes)
58
+ *
59
+ * @param obj - Object to extract from
60
+ * @param path - JSONPath expression
61
+ * @returns Extracted value or undefined
62
+ */
63
+ export const jsonPath = (obj: unknown, path: string): unknown => {
64
+ // Handle literal strings (e.g., "'pending'")
65
+ if (path.startsWith("'") && path.endsWith("'")) {
66
+ return path.slice(1, -1)
67
+ }
68
+
69
+ // Handle JSONPath expressions (e.g., "$.type", "$.message.content[0].text")
70
+ if (!path.startsWith('$.')) {
71
+ return undefined
72
+ }
73
+
74
+ // Parse path into segments, handling both dot notation and array indices
75
+ // e.g., "message.content[0].text" -> ["message", "content", 0, "text"]
76
+ const segments: (string | number)[] = []
77
+ const pathBody = path.slice(2) // Remove "$."
78
+
79
+ // Split by dots first, then handle array indices within each part
80
+ for (const part of pathBody.split('.')) {
81
+ if (!part) continue
82
+
83
+ // Check for array index: "content[0]" or just "[0]"
84
+ const arrayMatch = part.match(/^([^[]*)\[(\d+)\]$/)
85
+ if (arrayMatch) {
86
+ const propName = arrayMatch[1]
87
+ const indexStr = arrayMatch[2]
88
+ if (propName) {
89
+ segments.push(propName)
90
+ }
91
+ if (indexStr) {
92
+ segments.push(parseInt(indexStr, 10))
93
+ }
94
+ } else {
95
+ segments.push(part)
96
+ }
97
+ }
98
+
99
+ let current: unknown = obj
100
+
101
+ for (const segment of segments) {
102
+ if (current === null || current === undefined) {
103
+ return undefined
104
+ }
105
+
106
+ if (typeof segment === 'number') {
107
+ // Array index access
108
+ if (!Array.isArray(current)) {
109
+ return undefined
110
+ }
111
+ current = current[segment]
112
+ } else {
113
+ // Property access
114
+ if (typeof current !== 'object') {
115
+ return undefined
116
+ }
117
+ current = (current as Record<string, unknown>)[segment]
118
+ }
119
+ }
120
+
121
+ return current
122
+ }
123
+
124
+ /**
125
+ * Extracts a string value from an object using JSONPath.
126
+ *
127
+ * @param obj - Object to extract from
128
+ * @param path - JSONPath expression
129
+ * @returns String value or undefined
130
+ */
131
+ export const jsonPathString = (obj: unknown, path: string): string | undefined => {
132
+ const value = jsonPath(obj, path)
133
+ if (value === undefined || value === null) {
134
+ return undefined
135
+ }
136
+ return String(value)
137
+ }
138
+
139
+ // ============================================================================
140
+ // Output Parser Factory
141
+ // ============================================================================
142
+
143
+ /**
144
+ * Creates an output parser from adapter configuration.
145
+ *
146
+ * @remarks
147
+ * The parser uses the schema's outputEvents mappings to:
148
+ * 1. Match incoming JSON lines against patterns
149
+ * 2. Extract content using JSONPath expressions
150
+ * 3. Emit ACP session update objects
151
+ *
152
+ * @param config - Headless adapter configuration
153
+ * @returns Parser function for individual lines
154
+ */
155
+ export const createOutputParser = (config: HeadlessAdapterConfig) => {
156
+ const { outputEvents, result } = config
157
+
158
+ /**
159
+ * Parses a single JSON line from CLI output.
160
+ *
161
+ * @param line - JSON string from CLI stdout
162
+ * @returns Parsed update or null if no mapping matches
163
+ */
164
+ const parseLine = (line: string): ParsedUpdate | null => {
165
+ let event: unknown
166
+ try {
167
+ event = JSON.parse(line)
168
+ } catch {
169
+ // Not valid JSON, skip
170
+ return null
171
+ }
172
+
173
+ // Try each mapping until one matches
174
+ for (const mapping of outputEvents) {
175
+ const matchValue = jsonPath(event, mapping.match.path)
176
+ // Support wildcard "*" to match any non-null value
177
+ if (mapping.match.value === '*') {
178
+ if (matchValue !== undefined && matchValue !== null) {
179
+ return createUpdate(event, mapping)
180
+ }
181
+ } else if (matchValue === mapping.match.value) {
182
+ return createUpdate(event, mapping)
183
+ }
184
+ }
185
+
186
+ return null
187
+ }
188
+
189
+ /**
190
+ * Creates a ParsedUpdate from a matched event.
191
+ */
192
+ const createUpdate = (event: unknown, mapping: OutputEventMapping): ParsedUpdate => {
193
+ const update: ParsedUpdate = {
194
+ type: mapping.emitAs,
195
+ raw: event,
196
+ }
197
+
198
+ if (mapping.extract) {
199
+ if (mapping.extract.content) {
200
+ update.content = jsonPathString(event, mapping.extract.content)
201
+ }
202
+ if (mapping.extract.title) {
203
+ update.title = jsonPathString(event, mapping.extract.title)
204
+ }
205
+ if (mapping.extract.status) {
206
+ update.status = jsonPathString(event, mapping.extract.status)
207
+ }
208
+ }
209
+
210
+ return update
211
+ }
212
+
213
+ /**
214
+ * Checks if a JSON line represents the final result.
215
+ *
216
+ * @param line - JSON string from CLI stdout
217
+ * @returns Result extraction or indication that it's not a result
218
+ */
219
+ const parseResult = (line: string): ResultParseResult => {
220
+ let event: unknown
221
+ try {
222
+ event = JSON.parse(line)
223
+ } catch {
224
+ return { isResult: false }
225
+ }
226
+
227
+ const matchValue = jsonPath(event, result.matchPath)
228
+ // Support wildcard "*" to match any non-null value
229
+ const matches =
230
+ result.matchValue === '*' ? matchValue !== undefined && matchValue !== null : matchValue === result.matchValue
231
+
232
+ if (matches) {
233
+ const content = jsonPathString(event, result.contentPath)
234
+ return {
235
+ isResult: true,
236
+ content: content ?? '',
237
+ raw: event,
238
+ }
239
+ }
240
+
241
+ return { isResult: false }
242
+ }
243
+
244
+ return {
245
+ parseLine,
246
+ parseResult,
247
+ }
248
+ }
249
+
250
+ /** Output parser type */
251
+ export type OutputParser = ReturnType<typeof createOutputParser>
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Session manager for headless CLI agents.
3
+ *
4
+ * @remarks
5
+ * Manages the lifecycle of CLI agent sessions including:
6
+ * - Process spawning and tracking
7
+ * - Stream mode (persistent process) vs iterative mode (new process per turn)
8
+ * - Output parsing and ACP update emission
9
+ * - Session state management
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import type { Subprocess } from 'bun'
15
+ import type { HeadlessAdapterConfig } from './headless.schemas.ts'
16
+ import { createHistoryBuilder, type HistoryBuilder } from './headless-history-builder.ts'
17
+ import { createOutputParser, type OutputParser, type ParsedUpdate } from './headless-output-parser.ts'
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ /** Session state */
24
+ export type Session = {
25
+ /** Unique session identifier */
26
+ id: string
27
+ /** Working directory for this session */
28
+ cwd: string
29
+ /** Subprocess (stream mode only) */
30
+ process?: Subprocess
31
+ /** History builder (iterative mode only) */
32
+ history?: HistoryBuilder
33
+ /** Session ID from CLI (for resume, stream mode) */
34
+ cliSessionId?: string
35
+ /** Whether the session is active */
36
+ active: boolean
37
+ /** Turn count for this session */
38
+ turnCount: number
39
+ }
40
+
41
+ /** Update callback for emitting ACP session updates */
42
+ export type UpdateCallback = (update: ParsedUpdate) => void
43
+
44
+ /** Prompt result with final output */
45
+ export type PromptResult = {
46
+ /** Final output content */
47
+ output: string
48
+ /** All updates collected during the prompt */
49
+ updates: ParsedUpdate[]
50
+ /** Session ID from CLI (if available) */
51
+ cliSessionId?: string
52
+ }
53
+
54
+ /** Session manager configuration */
55
+ export type SessionManagerConfig = {
56
+ /** Headless adapter configuration */
57
+ schema: HeadlessAdapterConfig
58
+ /** Default timeout for operations in ms */
59
+ timeout?: number
60
+ }
61
+
62
+ // ============================================================================
63
+ // Session Manager Factory
64
+ // ============================================================================
65
+
66
+ /**
67
+ * Creates a session manager for headless CLI agents.
68
+ *
69
+ * @remarks
70
+ * The session manager is the core orchestrator for CLI agent interaction:
71
+ *
72
+ * **Stream mode:**
73
+ * - Spawns one process per session
74
+ * - Keeps process alive across turns
75
+ * - Uses stdin/stdout for communication
76
+ * - Supports session resume via CLI flags
77
+ *
78
+ * **Iterative mode:**
79
+ * - Spawns a new process per turn
80
+ * - Accumulates history in prompts
81
+ * - No persistent process state
82
+ *
83
+ * @param config - Session manager configuration
84
+ * @returns Session manager with create, prompt, and cancel methods
85
+ */
86
+ export const createSessionManager = (config: SessionManagerConfig) => {
87
+ const { schema, timeout = 60000 } = config
88
+ const sessions = new Map<string, Session>()
89
+ const outputParser = createOutputParser(schema)
90
+
91
+ /**
92
+ * Creates a new session.
93
+ *
94
+ * @param cwd - Working directory for the session
95
+ * @returns Created session
96
+ */
97
+ const create = async (cwd: string): Promise<Session> => {
98
+ const id = generateSessionId()
99
+
100
+ const session: Session = {
101
+ id,
102
+ cwd,
103
+ active: true,
104
+ turnCount: 0,
105
+ }
106
+
107
+ // Initialize mode-specific state
108
+ if (schema.sessionMode === 'iterative') {
109
+ session.history = createHistoryBuilder({
110
+ template: schema.historyTemplate,
111
+ })
112
+ }
113
+
114
+ sessions.set(id, session)
115
+ return session
116
+ }
117
+
118
+ /**
119
+ * Sends a prompt to a session and collects the response.
120
+ *
121
+ * @param sessionId - Session ID
122
+ * @param promptText - Prompt text to send
123
+ * @param onUpdate - Callback for streaming updates
124
+ * @returns Prompt result with output and updates
125
+ */
126
+ const prompt = async (sessionId: string, promptText: string, onUpdate?: UpdateCallback): Promise<PromptResult> => {
127
+ const session = sessions.get(sessionId)
128
+ if (!session) {
129
+ throw new Error(`Session not found: ${sessionId}`)
130
+ }
131
+
132
+ if (!session.active) {
133
+ throw new Error(`Session is not active: ${sessionId}`)
134
+ }
135
+
136
+ session.turnCount++
137
+
138
+ if (schema.sessionMode === 'stream') {
139
+ return promptStream(session, promptText, onUpdate)
140
+ }
141
+
142
+ return promptIterative(session, promptText, onUpdate)
143
+ }
144
+
145
+ /**
146
+ * Stream mode: send prompt via stdin to persistent process.
147
+ */
148
+ const promptStream = async (
149
+ session: Session,
150
+ promptText: string,
151
+ onUpdate?: UpdateCallback,
152
+ ): Promise<PromptResult> => {
153
+ // Build command for first turn or if no process exists
154
+ if (!session.process || session.process.killed) {
155
+ const args = buildCommand(session, promptText)
156
+ // First turn: prompt is in command line, use 'ignore' for stdin
157
+ // Some CLIs (like Claude) hang when stdin is piped but not written to
158
+ session.process = Bun.spawn(args, {
159
+ cwd: session.cwd,
160
+ stdin: 'ignore',
161
+ stdout: 'pipe',
162
+ stderr: 'inherit',
163
+ })
164
+ } else {
165
+ // Subsequent turns: spawn new process with resume flag
166
+ // (stdin-based multi-turn not currently supported)
167
+ const args = buildCommand(session, promptText)
168
+ session.process = Bun.spawn(args, {
169
+ cwd: session.cwd,
170
+ stdin: 'ignore',
171
+ stdout: 'pipe',
172
+ stderr: 'inherit',
173
+ })
174
+ }
175
+
176
+ return collectOutput(session, outputParser, onUpdate, timeout)
177
+ }
178
+
179
+ /**
180
+ * Iterative mode: spawn new process per turn with history context.
181
+ */
182
+ const promptIterative = async (
183
+ session: Session,
184
+ promptText: string,
185
+ onUpdate?: UpdateCallback,
186
+ ): Promise<PromptResult> => {
187
+ // Build full prompt with history
188
+ const fullPrompt = session.history?.buildPrompt(promptText) ?? promptText
189
+
190
+ // Build and spawn command
191
+ // Use 'ignore' for stdin - prompt is passed via command line flag
192
+ const args = buildCommand(session, fullPrompt)
193
+ session.process = Bun.spawn(args, {
194
+ cwd: session.cwd,
195
+ stdin: 'ignore',
196
+ stdout: 'pipe',
197
+ stderr: 'inherit',
198
+ })
199
+
200
+ const result = await collectOutput(session, outputParser, onUpdate, timeout)
201
+
202
+ // Store in history for next turn
203
+ session.history?.addTurn(promptText, result.output)
204
+
205
+ // Clean up process
206
+ session.process = undefined
207
+
208
+ return result
209
+ }
210
+
211
+ /**
212
+ * Builds the command array for spawning the CLI.
213
+ */
214
+ const buildCommand = (session: Session, promptText: string): string[] => {
215
+ const args = [...schema.command]
216
+
217
+ // Add output format flags
218
+ args.push(schema.output.flag, schema.output.value)
219
+
220
+ // Add auto-approve flags
221
+ if (schema.autoApprove) {
222
+ args.push(...schema.autoApprove)
223
+ }
224
+
225
+ // Add cwd flag if specified
226
+ if (schema.cwdFlag) {
227
+ args.push(schema.cwdFlag, session.cwd)
228
+ }
229
+
230
+ // Add resume flag if available (stream mode, after first turn)
231
+ if (schema.sessionMode === 'stream' && schema.resume && session.cliSessionId) {
232
+ args.push(schema.resume.flag, session.cliSessionId)
233
+ }
234
+
235
+ // Add prompt flag and text
236
+ if (schema.prompt.flag) {
237
+ args.push(schema.prompt.flag, promptText)
238
+ } else {
239
+ // Positional argument (no flag)
240
+ args.push(promptText)
241
+ }
242
+
243
+ return args
244
+ }
245
+
246
+ /**
247
+ * Cancels an active session.
248
+ *
249
+ * @param sessionId - Session ID to cancel
250
+ */
251
+ const cancel = (sessionId: string): void => {
252
+ const session = sessions.get(sessionId)
253
+ if (!session) return
254
+
255
+ session.active = false
256
+
257
+ if (session.process && !session.process.killed) {
258
+ session.process.kill()
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Gets a session by ID.
264
+ *
265
+ * @param sessionId - Session ID
266
+ * @returns Session or undefined
267
+ */
268
+ const get = (sessionId: string): Session | undefined => {
269
+ return sessions.get(sessionId)
270
+ }
271
+
272
+ /**
273
+ * Deletes a session.
274
+ *
275
+ * @param sessionId - Session ID
276
+ */
277
+ const destroy = (sessionId: string): void => {
278
+ cancel(sessionId)
279
+ sessions.delete(sessionId)
280
+ }
281
+
282
+ return {
283
+ create,
284
+ prompt,
285
+ cancel,
286
+ get,
287
+ destroy,
288
+ }
289
+ }
290
+
291
+ // ============================================================================
292
+ // Helper Functions
293
+ // ============================================================================
294
+
295
+ /**
296
+ * Generates a unique session ID.
297
+ */
298
+ const generateSessionId = (): string => {
299
+ return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
300
+ }
301
+
302
+ /**
303
+ * Collects output from a running process.
304
+ *
305
+ * @param session - Active session
306
+ * @param parser - Output parser
307
+ * @param onUpdate - Update callback
308
+ * @param timeout - Timeout in ms
309
+ * @returns Collected output and updates
310
+ */
311
+ const collectOutput = async (
312
+ session: Session,
313
+ parser: OutputParser,
314
+ onUpdate: UpdateCallback | undefined,
315
+ timeout: number,
316
+ ): Promise<PromptResult> => {
317
+ const updates: ParsedUpdate[] = []
318
+ let output = ''
319
+ let cliSessionId: string | undefined
320
+
321
+ const stdout = session.process?.stdout
322
+ if (!stdout || typeof stdout === 'number') {
323
+ throw new Error('No stdout available')
324
+ }
325
+
326
+ const reader = stdout.getReader()
327
+ const decoder = new TextDecoder()
328
+ let buffer = ''
329
+
330
+ const timeoutPromise = new Promise<never>((_, reject) => {
331
+ setTimeout(() => reject(new Error(`Prompt timed out after ${timeout}ms`)), timeout)
332
+ })
333
+
334
+ try {
335
+ const readLoop = async () => {
336
+ readLines: while (true) {
337
+ const { done, value } = await reader.read()
338
+
339
+ if (done) break
340
+
341
+ buffer += decoder.decode(value, { stream: true })
342
+
343
+ // Process complete lines
344
+ const lines = buffer.split('\n')
345
+ buffer = lines.pop() ?? ''
346
+
347
+ for (const line of lines) {
348
+ if (!line.trim()) continue
349
+
350
+ // Parse as update first (so updates are emitted even for result lines)
351
+ const update = parser.parseLine(line)
352
+ if (update) {
353
+ updates.push(update)
354
+ onUpdate?.(update)
355
+
356
+ // Extract CLI session ID if available
357
+ if (!cliSessionId && update.raw && typeof update.raw === 'object') {
358
+ const raw = update.raw as Record<string, unknown>
359
+ if (typeof raw.session_id === 'string') {
360
+ cliSessionId = raw.session_id
361
+ session.cliSessionId = cliSessionId
362
+ }
363
+ }
364
+ }
365
+
366
+ // Check for final result (after emitting update)
367
+ const resultCheck = parser.parseResult(line)
368
+ if (resultCheck.isResult) {
369
+ output = resultCheck.content
370
+ break readLines // Exit both loops immediately on result
371
+ }
372
+ }
373
+ }
374
+ }
375
+
376
+ await Promise.race([readLoop(), timeoutPromise])
377
+ } finally {
378
+ reader.releaseLock()
379
+ }
380
+
381
+ return {
382
+ output,
383
+ updates,
384
+ cliSessionId,
385
+ }
386
+ }
387
+
388
+ /** Session manager type */
389
+ export type SessionManager = ReturnType<typeof createSessionManager>