@plaited/acp-harness 0.4.1 → 0.4.2
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 +60 -13
- 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
|
|
@@ -232,12 +251,14 @@ export const createSessionManager = (config: SessionManagerConfig) => {
|
|
|
232
251
|
args.push(schema.resume.flag, session.cliSessionId)
|
|
233
252
|
}
|
|
234
253
|
|
|
235
|
-
// Add prompt flag and text
|
|
236
|
-
if (schema.prompt.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
254
|
+
// Add prompt flag and text (skip if using stdin)
|
|
255
|
+
if (!schema.prompt.stdin) {
|
|
256
|
+
if (schema.prompt.flag) {
|
|
257
|
+
args.push(schema.prompt.flag, promptText)
|
|
258
|
+
} else {
|
|
259
|
+
// Positional argument (no flag)
|
|
260
|
+
args.push(promptText)
|
|
261
|
+
}
|
|
241
262
|
}
|
|
242
263
|
|
|
243
264
|
return args
|
|
@@ -299,6 +320,32 @@ const generateSessionId = (): string => {
|
|
|
299
320
|
return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
300
321
|
}
|
|
301
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Writes a prompt to a process stdin stream.
|
|
325
|
+
*
|
|
326
|
+
* @remarks
|
|
327
|
+
* Uses Bun's FileSink API to write text to the process stdin.
|
|
328
|
+
* The FileSink type provides `write()` and `flush()` methods for
|
|
329
|
+
* efficient stream writing without async overhead.
|
|
330
|
+
*
|
|
331
|
+
* Type guard ensures stdin is a FileSink (not a file descriptor number)
|
|
332
|
+
* before attempting to write. This handles Bun's subprocess stdin types:
|
|
333
|
+
* - `'pipe'` → FileSink with write/flush methods
|
|
334
|
+
* - `'ignore'` → null (not writable)
|
|
335
|
+
* - number → file descriptor (not a FileSink)
|
|
336
|
+
*
|
|
337
|
+
* @param process - Subprocess with stdin stream
|
|
338
|
+
* @param prompt - Prompt text to write
|
|
339
|
+
*
|
|
340
|
+
* @internal
|
|
341
|
+
*/
|
|
342
|
+
const writePromptToStdin = (process: Subprocess, prompt: string): void => {
|
|
343
|
+
if (process.stdin && typeof process.stdin !== 'number') {
|
|
344
|
+
process.stdin.write(`${prompt}\n`)
|
|
345
|
+
process.stdin.flush()
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
302
349
|
/**
|
|
303
350
|
* Collects output from a running process.
|
|
304
351
|
*
|
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,
|