@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.
- package/README.md +53 -31
- package/bin/cli.ts +15 -0
- package/package.json +5 -7
- package/src/acp-client.ts +7 -4
- package/src/adapter-check.ts +0 -1
- package/src/adapter-scaffold.ts +16 -15
- package/src/calibrate.ts +28 -8
- package/src/capture.ts +114 -33
- package/src/grader-loader.ts +3 -3
- package/src/harness.ts +4 -0
- package/src/headless-cli.ts +433 -0
- package/src/headless-history-builder.ts +141 -0
- package/src/headless-output-parser.ts +251 -0
- package/src/headless-session-manager.ts +389 -0
- package/src/headless.schemas.ts +241 -0
- package/src/headless.ts +71 -0
- package/src/headless.types.ts +19 -0
- package/src/integration_tests/acp-claude.spec.ts +170 -0
- package/src/integration_tests/acp-gemini.spec.ts +174 -0
- package/src/schemas.ts +88 -36
- package/src/summarize.ts +4 -8
- package/src/tests/acp-client.spec.ts +1 -1
- package/src/tests/capture-cli.spec.ts +188 -0
- package/src/tests/capture-helpers.spec.ts +229 -67
- package/src/tests/constants.spec.ts +121 -0
- package/src/tests/fixtures/grader-exec.py +3 -3
- package/src/tests/fixtures/grader-module.ts +2 -2
- package/src/tests/grader-loader.spec.ts +5 -5
- package/src/tests/headless.spec.ts +460 -0
- package/src/tests/schemas-cli.spec.ts +142 -0
- package/src/tests/schemas.spec.ts +657 -0
- package/src/tests/summarize-helpers.spec.ts +3 -3
- package/src/tests/trials-cli.spec.ts +145 -0
- package/src/trials.ts +6 -19
- package/src/validate-refs.ts +1 -1
- package/src/tests/acp-integration.docker.ts +0 -214
|
@@ -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>
|