@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plaited/acp-harness",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "CLI tool for capturing agent trajectories from ACP-compatible agents",
5
5
  "license": "ISC",
6
6
  "engines": {
@@ -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
- // 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
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: 'ignore',
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: 'ignore',
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: 'ignore',
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.flag) {
237
- args.push(schema.prompt.flag, promptText)
238
- } else {
239
- // Positional argument (no flag)
240
- args.push(promptText)
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
  *
@@ -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.object({
84
- /** Flag to pass prompt (e.g., "-p", "--prompt"). Omit for stdin. */
85
- flag: z.string().optional(),
86
- /** Format for stdin input in stream mode */
87
- stdinFormat: z.enum(['text', 'json']).optional(),
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,