@plaited/acp-harness 0.4.1 → 0.4.3
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/package.json +1 -1
- package/src/headless-session-manager.ts +64 -15
- package/src/headless.schemas.ts +18 -6
- package/src/tests/headless.spec.ts +69 -0
package/package.json
CHANGED
|
@@ -153,24 +153,37 @@ export const createSessionManager = (config: SessionManagerConfig) => {
|
|
|
153
153
|
// Build command for first turn or if no process exists
|
|
154
154
|
if (!session.process || session.process.killed) {
|
|
155
155
|
const args = buildCommand(session, promptText)
|
|
156
|
-
|
|
157
|
-
//
|
|
156
|
+
|
|
157
|
+
// Choose stdin mode based on schema configuration
|
|
158
|
+
const stdinMode = schema.prompt.stdin ? 'pipe' : 'ignore'
|
|
159
|
+
|
|
158
160
|
session.process = Bun.spawn(args, {
|
|
159
161
|
cwd: session.cwd,
|
|
160
|
-
stdin:
|
|
162
|
+
stdin: stdinMode,
|
|
161
163
|
stdout: 'pipe',
|
|
162
164
|
stderr: 'inherit',
|
|
163
165
|
})
|
|
166
|
+
|
|
167
|
+
// If using stdin, write the prompt
|
|
168
|
+
if (schema.prompt.stdin && session.process) {
|
|
169
|
+
writePromptToStdin(session.process, promptText)
|
|
170
|
+
}
|
|
164
171
|
} else {
|
|
165
172
|
// Subsequent turns: spawn new process with resume flag
|
|
166
|
-
// (stdin-based multi-turn not currently supported)
|
|
167
173
|
const args = buildCommand(session, promptText)
|
|
174
|
+
const stdinMode = schema.prompt.stdin ? 'pipe' : 'ignore'
|
|
175
|
+
|
|
168
176
|
session.process = Bun.spawn(args, {
|
|
169
177
|
cwd: session.cwd,
|
|
170
|
-
stdin:
|
|
178
|
+
stdin: stdinMode,
|
|
171
179
|
stdout: 'pipe',
|
|
172
180
|
stderr: 'inherit',
|
|
173
181
|
})
|
|
182
|
+
|
|
183
|
+
// If using stdin, write the prompt
|
|
184
|
+
if (schema.prompt.stdin && session.process) {
|
|
185
|
+
writePromptToStdin(session.process, promptText)
|
|
186
|
+
}
|
|
174
187
|
}
|
|
175
188
|
|
|
176
189
|
return collectOutput(session, outputParser, onUpdate, timeout)
|
|
@@ -188,15 +201,21 @@ export const createSessionManager = (config: SessionManagerConfig) => {
|
|
|
188
201
|
const fullPrompt = session.history?.buildPrompt(promptText) ?? promptText
|
|
189
202
|
|
|
190
203
|
// Build and spawn command
|
|
191
|
-
// Use 'ignore' for stdin - prompt is passed via command line flag
|
|
192
204
|
const args = buildCommand(session, fullPrompt)
|
|
205
|
+
const stdinMode = schema.prompt.stdin ? 'pipe' : 'ignore'
|
|
206
|
+
|
|
193
207
|
session.process = Bun.spawn(args, {
|
|
194
208
|
cwd: session.cwd,
|
|
195
|
-
stdin:
|
|
209
|
+
stdin: stdinMode,
|
|
196
210
|
stdout: 'pipe',
|
|
197
211
|
stderr: 'inherit',
|
|
198
212
|
})
|
|
199
213
|
|
|
214
|
+
// If using stdin, write the prompt
|
|
215
|
+
if (schema.prompt.stdin && session.process) {
|
|
216
|
+
writePromptToStdin(session.process, fullPrompt)
|
|
217
|
+
}
|
|
218
|
+
|
|
200
219
|
const result = await collectOutput(session, outputParser, onUpdate, timeout)
|
|
201
220
|
|
|
202
221
|
// Store in history for next turn
|
|
@@ -214,8 +233,10 @@ export const createSessionManager = (config: SessionManagerConfig) => {
|
|
|
214
233
|
const buildCommand = (session: Session, promptText: string): string[] => {
|
|
215
234
|
const args = [...schema.command]
|
|
216
235
|
|
|
217
|
-
// Add output format flags
|
|
218
|
-
|
|
236
|
+
// Add output format flags (only if non-empty)
|
|
237
|
+
if (schema.output.flag) {
|
|
238
|
+
args.push(schema.output.flag, schema.output.value)
|
|
239
|
+
}
|
|
219
240
|
|
|
220
241
|
// Add auto-approve flags
|
|
221
242
|
if (schema.autoApprove) {
|
|
@@ -232,12 +253,14 @@ export const createSessionManager = (config: SessionManagerConfig) => {
|
|
|
232
253
|
args.push(schema.resume.flag, session.cliSessionId)
|
|
233
254
|
}
|
|
234
255
|
|
|
235
|
-
// Add prompt flag and text
|
|
236
|
-
if (schema.prompt.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
256
|
+
// Add prompt flag and text (skip if using stdin)
|
|
257
|
+
if (!schema.prompt.stdin) {
|
|
258
|
+
if (schema.prompt.flag) {
|
|
259
|
+
args.push(schema.prompt.flag, promptText)
|
|
260
|
+
} else {
|
|
261
|
+
// Positional argument (no flag)
|
|
262
|
+
args.push(promptText)
|
|
263
|
+
}
|
|
241
264
|
}
|
|
242
265
|
|
|
243
266
|
return args
|
|
@@ -299,6 +322,32 @@ const generateSessionId = (): string => {
|
|
|
299
322
|
return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
300
323
|
}
|
|
301
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Writes a prompt to a process stdin stream.
|
|
327
|
+
*
|
|
328
|
+
* @remarks
|
|
329
|
+
* Uses Bun's FileSink API to write text to the process stdin.
|
|
330
|
+
* The FileSink type provides `write()` and `flush()` methods for
|
|
331
|
+
* efficient stream writing without async overhead.
|
|
332
|
+
*
|
|
333
|
+
* Type guard ensures stdin is a FileSink (not a file descriptor number)
|
|
334
|
+
* before attempting to write. This handles Bun's subprocess stdin types:
|
|
335
|
+
* - `'pipe'` → FileSink with write/flush methods
|
|
336
|
+
* - `'ignore'` → null (not writable)
|
|
337
|
+
* - number → file descriptor (not a FileSink)
|
|
338
|
+
*
|
|
339
|
+
* @param process - Subprocess with stdin stream
|
|
340
|
+
* @param prompt - Prompt text to write
|
|
341
|
+
*
|
|
342
|
+
* @internal
|
|
343
|
+
*/
|
|
344
|
+
const writePromptToStdin = (process: Subprocess, prompt: string): void => {
|
|
345
|
+
if (process.stdin && typeof process.stdin !== 'number') {
|
|
346
|
+
process.stdin.write(`${prompt}\n`)
|
|
347
|
+
process.stdin.flush()
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
302
351
|
/**
|
|
303
352
|
* Collects output from a running process.
|
|
304
353
|
*
|
package/src/headless.schemas.ts
CHANGED
|
@@ -79,13 +79,25 @@ export type OutputEventMapping = z.infer<typeof OutputEventMappingSchema>
|
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
81
|
* Schema for how to pass prompts to the CLI.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* Three modes are supported:
|
|
85
|
+
* 1. **Flag-based**: `flag: "-p"` - Pass prompt via command-line flag
|
|
86
|
+
* 2. **Positional**: `flag: ""` - Pass prompt as positional argument
|
|
87
|
+
* 3. **Stdin**: `stdin: true` - Write prompt to stdin (command should include `-` or equivalent)
|
|
82
88
|
*/
|
|
83
|
-
export const PromptConfigSchema = z
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
export const PromptConfigSchema = z
|
|
90
|
+
.object({
|
|
91
|
+
/** Flag to pass prompt (e.g., "-p", "--prompt"). Empty string for positional. */
|
|
92
|
+
flag: z.string().optional(),
|
|
93
|
+
/** Use stdin to pass prompt instead of command args */
|
|
94
|
+
stdin: z.boolean().optional(),
|
|
95
|
+
/** Format for stdin input in stream mode */
|
|
96
|
+
stdinFormat: z.enum(['text', 'json']).optional(),
|
|
97
|
+
})
|
|
98
|
+
.refine((data) => !(data.flag && data.stdin), {
|
|
99
|
+
message: "Cannot specify both 'flag' and 'stdin' modes - use either flag-based or stdin mode, not both",
|
|
100
|
+
})
|
|
89
101
|
|
|
90
102
|
/** Prompt configuration type */
|
|
91
103
|
export type PromptConfig = z.infer<typeof PromptConfigSchema>
|
|
@@ -118,6 +118,59 @@ describe('HeadlessAdapterSchema', () => {
|
|
|
118
118
|
})
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
+
describe('stdin mode configuration', () => {
|
|
122
|
+
test('validates schema with stdin: true', () => {
|
|
123
|
+
const stdinSchema = {
|
|
124
|
+
version: 1,
|
|
125
|
+
name: 'stdin-agent',
|
|
126
|
+
command: ['agent', 'exec', '-'],
|
|
127
|
+
sessionMode: 'stream',
|
|
128
|
+
prompt: { stdin: true },
|
|
129
|
+
output: { flag: '--format', value: 'json' },
|
|
130
|
+
outputEvents: [],
|
|
131
|
+
result: { matchPath: '$.type', matchValue: 'done', contentPath: '$.text' },
|
|
132
|
+
}
|
|
133
|
+
const result = HeadlessAdapterSchema.safeParse(stdinSchema)
|
|
134
|
+
expect(result.success).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('validates schema with stdin: false', () => {
|
|
138
|
+
const stdinSchema = {
|
|
139
|
+
version: 1,
|
|
140
|
+
name: 'stdin-agent',
|
|
141
|
+
command: ['agent'],
|
|
142
|
+
sessionMode: 'stream',
|
|
143
|
+
prompt: { stdin: false, flag: '-p' },
|
|
144
|
+
output: { flag: '--format', value: 'json' },
|
|
145
|
+
outputEvents: [],
|
|
146
|
+
result: { matchPath: '$.type', matchValue: 'done', contentPath: '$.text' },
|
|
147
|
+
}
|
|
148
|
+
const result = HeadlessAdapterSchema.safeParse(stdinSchema)
|
|
149
|
+
expect(result.success).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('validates schema with positional prompt and - in command', () => {
|
|
153
|
+
const stdinSchema = {
|
|
154
|
+
version: 1,
|
|
155
|
+
name: 'codex-like',
|
|
156
|
+
command: ['codex', 'exec', '--json', '-'],
|
|
157
|
+
sessionMode: 'iterative',
|
|
158
|
+
prompt: { stdin: true },
|
|
159
|
+
output: { flag: '', value: '' },
|
|
160
|
+
outputEvents: [
|
|
161
|
+
{
|
|
162
|
+
match: { path: '$.item.type', value: 'agent_message' },
|
|
163
|
+
emitAs: 'message',
|
|
164
|
+
extract: { content: '$.item.text' },
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
result: { matchPath: '$.type', matchValue: 'turn.completed', contentPath: '$.usage.output_tokens' },
|
|
168
|
+
}
|
|
169
|
+
const result = HeadlessAdapterSchema.safeParse(stdinSchema)
|
|
170
|
+
expect(result.success).toBe(true)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
121
174
|
describe('invalid schemas', () => {
|
|
122
175
|
test('rejects missing version', () => {
|
|
123
176
|
const invalid = { ...validClaudeSchema, version: undefined }
|
|
@@ -143,6 +196,22 @@ describe('HeadlessAdapterSchema', () => {
|
|
|
143
196
|
expect(result.success).toBe(false)
|
|
144
197
|
})
|
|
145
198
|
|
|
199
|
+
test('rejects both flag and stdin specified', () => {
|
|
200
|
+
const invalid = {
|
|
201
|
+
...validClaudeSchema,
|
|
202
|
+
prompt: {
|
|
203
|
+
flag: '-p',
|
|
204
|
+
stdin: true,
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
const result = HeadlessAdapterSchema.safeParse(invalid)
|
|
208
|
+
expect(result.success).toBe(false)
|
|
209
|
+
// Type assertion after checking success is false
|
|
210
|
+
const error = (result as { success: false; error: { issues: Array<{ message: string }> } }).error
|
|
211
|
+
expect(error.issues.length).toBeGreaterThan(0)
|
|
212
|
+
expect(error.issues[0]!.message).toContain("Cannot specify both 'flag' and 'stdin' modes")
|
|
213
|
+
})
|
|
214
|
+
|
|
146
215
|
test('rejects invalid emitAs type', () => {
|
|
147
216
|
const invalid = {
|
|
148
217
|
...validClaudeSchema,
|