Skip to content

Commit 4a6b715

Browse files
PythonLuvrclaude
andcommitted
v1.2.0: SquireOptions.keepStdinOpen for follow-up stdin input
The .send() API has existed since v1.0 but was unusable in practice: start() unconditionally calls child.stdin.end() right after writing the initial prompt, so any later send() throws ADAPTER_UNSUPPORTED_FEATURE because stdin is already closed. That defeats the point of the API for CLIs that DO accept follow-up input — Claude Code's `--input-format stream-json` mode being the motivating case (a host needs to deliver tool_result for AskUserQuestion mid-turn). Adds an opt-in `keepStdinOpen` flag on SquireOptions. When set, start() writes the initial prompt but leaves stdin open, so the host can call .send(followup) until the child exits. Off by default — every existing one-shot caller is unchanged. Includes a test that drives the new path end-to-end: spawn a child that reads stdin lines, start() with the first line, send() two more lines, sentinel exits the child, assert assembled output matches what was delivered across all three writes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 28ad4b7 commit 4a6b715

5 files changed

Lines changed: 65 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ The v1.0 public API surface (everything exported from `src/index.ts`) is frozen.
88

99
## [Unreleased]
1010

11+
## [1.2.0] - 2026-05-23
12+
13+
### Added
14+
- `SquireOptions.keepStdinOpen`: when set, `start()` writes the initial prompt but does NOT close the child's stdin afterwards, so the host can deliver follow-up messages via `.send()` (e.g. a `tool_result` for a CLI that asked the user a question mid-turn). Off by default — existing one-shot callers are unchanged. Pairs with `--input-format stream-json` on the Claude Code CLI; other binaries' contracts vary.
15+
- Test coverage for the new option: `keepStdinOpen: send() works after start() to deliver follow-up input`.
16+
1117
## [1.1.0] - 2026-05-19
1218

1319
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pythonluvr/squire",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "General-purpose runtime for spawning CLI AI agents (Claude Code, Codex, Gemini CLI) as subprocesses with structured event streaming, MCP tool forwarding, and permission auto-setup.",
55
"license": "MIT",
66
"type": "module",

src/options.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,15 @@ export interface SquireOptions {
8787
* own per-CLI output parsing.
8888
*/
8989
adapter?: string;
90+
/**
91+
* When true, write the initial prompt to the child's stdin but do NOT
92+
* close stdin afterwards. Lets the host deliver follow-up messages via
93+
* `.send()` (e.g. a `tool_result` for a CLI that exposed a question to
94+
* the user mid-turn). Off by default — most one-shot CLIs expect stdin
95+
* to close so they can flush and exit.
96+
*
97+
* The target CLI must accept follow-up input. For Claude Code that is
98+
* `--input-format stream-json`; for other binaries see their own docs.
99+
*/
100+
keepStdinOpen?: boolean;
90101
}

src/squire.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,16 @@ export class Squire extends EventEmitter {
133133
else opts.signal.addEventListener("abort", onAbort, { once: true });
134134
}
135135

136-
// Pipe stdin
136+
// Pipe stdin. When `keepStdinOpen` is set, write the initial prompt
137+
// but DON'T close stdin: the host plans to deliver follow-up messages
138+
// via `.send()` (e.g. a `tool_result` for a CLI that asked the user a
139+
// question mid-turn). Default is one-shot: write, end, let the CLI
140+
// flush and exit.
137141
try {
138142
child.stdin?.write(prompt);
139-
child.stdin?.end();
143+
if (!this.options.keepStdinOpen) {
144+
child.stdin?.end();
145+
}
140146
} catch {
141147
// EPIPE if child died before consuming stdin. Surfaces via exit handler.
142148
}

tests/squire.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,45 @@ test("prompt passes through stdin to the child", async () => {
222222
}
223223
});
224224

225+
test("keepStdinOpen: send() works after start() to deliver follow-up input", async () => {
226+
// Child reads stdin lines, echoes each prefixed, exits on a sentinel line.
227+
// Without keepStdinOpen the child would see EOF after the initial prompt
228+
// and the .send() below would throw.
229+
const code = [
230+
"process.stdin.setEncoding('utf8');",
231+
"let buf='';",
232+
"process.stdin.on('data', c => {",
233+
" buf += c;",
234+
" let i;",
235+
" while ((i = buf.indexOf('\\n')) !== -1) {",
236+
" const line = buf.slice(0, i); buf = buf.slice(i + 1);",
237+
" if (line === 'BYE') { process.stdout.write('done'); process.exit(0); }",
238+
" process.stdout.write('got:' + line + '|');",
239+
" }",
240+
"});",
241+
].join("");
242+
const squire = new Squire({
243+
binary: process.execPath,
244+
args: ["-e", code],
245+
keepStdinOpen: true,
246+
});
247+
const events: SquireEvent[] = [];
248+
squire.on("event", (e: SquireEvent) => events.push(e));
249+
// Start with the initial line; do NOT await yet, the child stays alive
250+
// waiting for more stdin.
251+
const done = squire.start("hello\n");
252+
// Give the child a tick to register the data handler before we send more.
253+
await new Promise((r) => setTimeout(r, 50));
254+
await squire.send("world\n");
255+
await squire.send("BYE\n");
256+
await done;
257+
const stop = events.find((e) => e.type === "message_stop");
258+
assert.ok(stop);
259+
if (stop?.type === "message_stop") {
260+
assert.equal(stop.assembled, "got:hello|got:world|done");
261+
}
262+
});
263+
225264
test("autoSetup: writeSettings=false skips the merge", async () => {
226265
// No setttings file is written because writeSettings is explicitly off.
227266
const squire = new Squire({

0 commit comments

Comments
 (0)