@agent-compose/sdk 0.8.5 → 0.8.7
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 +213 -189
- package/dist/agent/agent-context.d.ts +3 -3
- package/dist/agent/agent-loop.d.ts +6 -5
- package/dist/agent/perf-sampler.d.ts +27 -2
- package/dist/agent/run-agent.d.ts +1 -1
- package/dist/client.d.ts +119 -54
- package/dist/directives.d.ts +3 -3
- package/dist/display.d.ts +7 -0
- package/dist/errors.d.ts +1 -1
- package/dist/generated/agentc-commands.d.ts +34 -0
- package/dist/index.d.ts +12 -12
- package/dist/index.js +771 -204
- package/dist/request-context/request-context.d.ts +1 -1
- package/dist/runtimes/_cli-agent.d.ts +185 -68
- package/dist/runtimes/_reported-model.d.ts +16 -0
- package/dist/runtimes/claude-code.d.ts +60 -1
- package/dist/runtimes/claude.d.ts +1 -1
- package/dist/runtimes/codex.d.ts +94 -6
- package/dist/runtimes/codex.mid-turn-hook.test.d.ts +10 -0
- package/dist/runtimes/model-report.test.d.ts +14 -0
- package/dist/runtimes/openai-desktop.js +741 -200
- package/dist/runtimes/opencode.d.ts +48 -11
- package/dist/runtimes/opencode.test.d.ts +14 -0
- package/dist/sandbox/baked-clis.d.ts +75 -0
- package/dist/sandbox/exec-stream.d.ts +1 -2
- package/dist/sandbox/network-policy.d.ts +23 -5
- package/dist/sandbox.d.ts +4 -2
- package/dist/step-invocation/protocol.d.ts +3 -4
- package/dist/step-invocation/server.d.ts +2 -2
- package/dist/step-invocation/types.d.ts +1 -1
- package/dist/types/api-conversations.d.ts +442 -29
- package/dist/types/api-factory.d.ts +99 -10
- package/dist/types/api-projects.d.ts +521 -0
- package/dist/types/api-runs.d.ts +83 -0
- package/dist/types/api-scopes.d.ts +32 -3
- package/dist/types/conversation-stream.d.ts +5 -0
- package/dist/types/execution-context.d.ts +1 -1
- package/dist/types/protocol.d.ts +86 -2
- package/dist/types/runtime.d.ts +9 -2
- package/dist/types/workflow-metadata.d.ts +2 -4
- package/dist/types/workflow-plan.d.ts +1 -3
- package/dist/utils/bundler.d.ts +23 -0
- package/dist/workflow-steps/observability.d.ts +2 -3
- package/dist/workflow-steps/runner.d.ts +5 -8
- package/dist/workflow-steps/types.d.ts +8 -10
- package/dist/workflow-steps/workflow.d.ts +2 -1
- package/dist/workflows/engine.d.ts +3 -5
- package/dist/workflows/invoke-child.d.ts +2 -2
- package/package.json +2 -2
- package/src/agent/agent-context.ts +168 -125
- package/src/agent/agent-loop.ts +7 -6
- package/src/agent/perf-sampler.ts +54 -3
- package/src/agent/run-agent.ts +1 -1
- package/src/client.ts +226 -71
- package/src/directives.ts +3 -3
- package/src/display.ts +12 -0
- package/src/errors.ts +1 -0
- package/src/generated/agentc-commands.ts +571 -0
- package/src/index.ts +57 -21
- package/src/pause/pause-core.ts +2 -1
- package/src/request-context/request-context.ts +1 -1
- package/src/runtimes/_cli-agent.ts +318 -122
- package/src/runtimes/_reported-model.ts +24 -0
- package/src/runtimes/claude-code.ts +195 -12
- package/src/runtimes/claude.ts +9 -2
- package/src/runtimes/codex.ts +188 -19
- package/src/runtimes/opencode.ts +195 -26
- package/src/sandbox/baked-clis.ts +86 -0
- package/src/sandbox/exec-stream.ts +1 -2
- package/src/sandbox/network-policy.ts +51 -7
- package/src/sandbox/providers/e2b.ts +3 -3
- package/src/sandbox/providers/vercel.ts +6 -6
- package/src/sandbox.ts +8 -2
- package/src/step-invocation/invoker.ts +2 -6
- package/src/step-invocation/protocol.ts +3 -4
- package/src/step-invocation/server.ts +2 -2
- package/src/types/api-conversations.ts +366 -23
- package/src/types/api-factory.ts +95 -10
- package/src/types/api-projects.ts +477 -0
- package/src/types/api-runs.ts +73 -0
- package/src/types/api-scopes.ts +32 -3
- package/src/types/conversation-stream.ts +5 -0
- package/src/types/execution-context.ts +1 -1
- package/src/types/protocol.ts +91 -2
- package/src/types/runtime.ts +8 -2
- package/src/types/sandbox-environment.ts +1 -2
- package/src/types/workflow-metadata.ts +2 -4
- package/src/types/workflow-plan.ts +1 -3
- package/src/utils/bundler.ts +88 -19
- package/src/workflow-steps/observability.ts +2 -3
- package/src/workflow-steps/runner.ts +5 -8
- package/src/workflow-steps/types.ts +8 -10
- package/src/workflow-steps/workflow.ts +2 -1
- package/src/workflows/engine.ts +3 -5
- package/src/workflows/invoke-child.ts +2 -2
- package/dist/generated/verb-synopsis.d.ts +0 -34
- package/dist/pause/__tests__/errors.test.d.ts +0 -1
- package/dist/pause/__tests__/wrappers.test.d.ts +0 -1
- package/dist/step-invocation/__tests__/protocol.test.d.ts +0 -1
- package/src/generated/verb-synopsis.ts +0 -544
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* contract.
|
|
5
5
|
*
|
|
6
6
|
* This is a different *mechanism* from the other runtimes: `claudeRuntime`
|
|
7
|
-
* drives the Anthropic Agent SDK and `
|
|
7
|
+
* drives the Anthropic Agent SDK and `createVercelRuntime` drives the Vercel AI SDK,
|
|
8
8
|
* but a CLI-agent runtime spawns the provider's own CLI (`codex exec --json`,
|
|
9
9
|
* `amp -x --stream-json`) — the CLI brings its own agent loop + tools, and we
|
|
10
10
|
* only stream-parse the events it prints. Codex and Amp are the first two; this
|
|
@@ -241,13 +241,16 @@ export function probeDeathVerdict(
|
|
|
241
241
|
return null;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
/** The
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
|
|
244
|
+
/** The CLI's stderr complaint when the thread it was told to resume does
|
|
245
|
+
* not exist on this machine: claude's `--resume <id>` "No conversation
|
|
246
|
+
* found with session ID" (the 2026-08-18 split-brain forensic) and
|
|
247
|
+
* opencode's `--session <id>` "Error: Session not found" (in the CLI's
|
|
248
|
+
* red-bold ANSI dressing; 1.18.34). When a nonzero exit's stderr carries
|
|
249
|
+
* it, the transport surfaces the stderr as the turn's LAST error event
|
|
250
|
+
* even though a bare result error already streamed — the consumer's
|
|
251
|
+
* classifier needs the specific complaint, not the generic
|
|
252
|
+
* `error_during_execution` token. */
|
|
253
|
+
export const RESUME_TARGET_MISSING_STDERR = /no conversation found with session id|error:\s*(?:\x1b\[[0-9;]*m)*\s*session not found/i;
|
|
251
254
|
|
|
252
255
|
/** One durable-trio reading, parsed from the probe exec's single line. */
|
|
253
256
|
export interface TurnProbeReading {
|
|
@@ -336,18 +339,24 @@ export function livenessVerdictFromReading(reading: TurnProbeReading | null): Ru
|
|
|
336
339
|
return "dead";
|
|
337
340
|
}
|
|
338
341
|
|
|
339
|
-
// ── Mid-turn
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
342
|
+
// ── Mid-turn input (the delivery half of injectUserMessage) ─────────────────
|
|
343
|
+
// A message for a RUNNING turn is appended as one line to the turn's durable
|
|
344
|
+
// INBOX file; the DELIVERED counter file beside it is the ack the server
|
|
345
|
+
// trusts, advancing only once the line was handed to the CLI. Two transports
|
|
346
|
+
// hand it over (CliAgentSpec.midTurnInput):
|
|
347
|
+
// - "stdin-stream": the durable transport's stdin is a FIFO fed by an
|
|
348
|
+
// in-guest subshell — the prompt line first, then any complete inbox
|
|
349
|
+
// lines. A steering-capable CLI (claude -p --input-format stream-json)
|
|
350
|
+
// folds a message that arrives while the turn runs into the RUNNING turn
|
|
351
|
+
// at its next tool boundary — verified live against claude 2.1.233. The
|
|
352
|
+
// feeder checks for the CLI's terminal `result` line BEFORE each delivery
|
|
353
|
+
// (a message that races the result stays owed for the follow-up turn,
|
|
354
|
+
// never half-consumed) and stops on it; closing the FIFO is what EOFs the
|
|
355
|
+
// CLI's stdin and lets it exit.
|
|
356
|
+
// - "tool-hook": the CLI runs a platform hook after every tool call it
|
|
357
|
+
// completes (codex: a PostToolUse command hook); the hook forwards the
|
|
358
|
+
// unfed inbox lines to the model as additional context and advances the
|
|
359
|
+
// counter. The CLI's stdin stays the plain prompt file.
|
|
351
360
|
|
|
352
361
|
/** Feeder poll cadence (seconds — it is a shell `sleep`; GNU sleep accepts
|
|
353
362
|
* fractions and the durable transport only runs on the GNU guest image). */
|
|
@@ -364,10 +373,40 @@ export const INJECT_ACK_POLL_ATTEMPTS = 30;
|
|
|
364
373
|
export const INJECT_ACK_POLL_SECONDS = "0.5";
|
|
365
374
|
export const INJECT_ACK_EXEC_TIMEOUT_MS = 25_000;
|
|
366
375
|
|
|
376
|
+
/** How the guest recognises the CLI's terminal `result` event in the durable
|
|
377
|
+
* .out: a line that carries the `"type":"result"` pair and does not open
|
|
378
|
+
* with another event's type, as an awk condition over `$0` (POSIX awk, so
|
|
379
|
+
* dash, busybox and the macOS test hosts read it alike). The CLI does not
|
|
380
|
+
* keep its key order: 2.1.212 wrote `{"type":"result",…` but 2.1.278 and
|
|
381
|
+
* later write the pair near the END of the object
|
|
382
|
+
* (`{"duration_api_ms":…,"result":"…","type":"result","duration_ms":…}`), so
|
|
383
|
+
* the anchored prefix the resident contract used matched nothing on the
|
|
384
|
+
* pinned 2.1.283. Every other event the CLI writes opens with its own type
|
|
385
|
+
* (`{"type":"assistant",…`, `{"type":"stream_event",…`), which keeps a tool
|
|
386
|
+
* call whose input holds the same pair from counting; tool output and model
|
|
387
|
+
* text ride inside JSON strings, where the quotes are escaped. */
|
|
388
|
+
export const CLI_RESULT_LINE_AWK =
|
|
389
|
+
'index($0, "\\"type\\":\\"result\\"") > 0 && '
|
|
390
|
+
+ '(index($0, "{\\"type\\":\\"") != 1 || index($0, "{\\"type\\":\\"result\\"") == 1)';
|
|
391
|
+
|
|
392
|
+
/** An awk program over the lines on stdin (or the files after it) that
|
|
393
|
+
* applies `action` to each result line and runs `end` at the end. */
|
|
394
|
+
function resultLineAwk(action: string, end: string): string {
|
|
395
|
+
return `awk ${shellQuote(`${CLI_RESULT_LINE_AWK} { ${action} } END { ${end} }`)}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
367
398
|
/** The in-guest feeder fragment, prepended to the detached wrapper script.
|
|
368
399
|
* Runs inside the setsid session (group-kill reaps it) and keys its loop to
|
|
369
400
|
* the wrapper's own pid (`$$`), like the heartbeat subshell. Exported for
|
|
370
|
-
* tests.
|
|
401
|
+
* tests.
|
|
402
|
+
*
|
|
403
|
+
* The forward is GUARDED: the fed and delivered counters advance only
|
|
404
|
+
* when `sed` wrote the lines. The loop keys on the wrapper, which outlives
|
|
405
|
+
* a CLI that died without a result line by the sentinel write and the
|
|
406
|
+
* exit push; in that window the FIFO has no reader, `sed` dies of SIGPIPE,
|
|
407
|
+
* and an unguarded counter still advanced, so the ack exited 0 for a line
|
|
408
|
+
* nobody could read and the settle sealed it (review C1). Guarded, the ack
|
|
409
|
+
* sees no advance and exits 4 or 5, and the message stays owed. */
|
|
371
410
|
export function streamInputFeederFragment(paths: {
|
|
372
411
|
promptPath: string; fifoPath: string; inboxPath: string;
|
|
373
412
|
deliveredPath: string; outPath: string;
|
|
@@ -376,11 +415,11 @@ export function streamInputFeederFragment(paths: {
|
|
|
376
415
|
return `rm -f ${q(paths.fifoPath)}; mkfifo ${q(paths.fifoPath)}; `
|
|
377
416
|
+ `: > ${q(paths.inboxPath)}; echo 0 > ${q(paths.deliveredPath)}; `
|
|
378
417
|
+ `( cat ${q(paths.promptPath)}; ac_fed=0; while kill -0 "$$" 2>/dev/null; do `
|
|
379
|
-
+ `if tail -c ${INJECT_FEEDER_RESULT_TAIL_BYTES} ${q(paths.outPath)} 2>/dev/null |
|
|
418
|
+
+ `if tail -c ${INJECT_FEEDER_RESULT_TAIL_BYTES} ${q(paths.outPath)} 2>/dev/null | ${resultLineAwk("f = 1", "exit f ? 0 : 1")}; then break; fi; `
|
|
380
419
|
+ `ac_lines=$(wc -l < ${q(paths.inboxPath)} 2>/dev/null || echo 0); ac_lines=\${ac_lines:-0}; `
|
|
381
420
|
+ `if [ "$ac_lines" -gt "$ac_fed" ]; then `
|
|
382
|
-
+ `sed -n "$((ac_fed+1)),$((ac_lines))p" ${q(paths.inboxPath)}
|
|
383
|
-
+ `echo "$ac_fed" > ${q(paths.deliveredPath)}; fi; `
|
|
421
|
+
+ `sed -n "$((ac_fed+1)),$((ac_lines))p" ${q(paths.inboxPath)} && { ac_fed=$ac_lines; `
|
|
422
|
+
+ `echo "$ac_fed" > ${q(paths.deliveredPath)}; }; fi; `
|
|
384
423
|
+ `sleep ${INJECT_FEEDER_POLL_SECONDS}; done ) > ${q(paths.fifoPath)} 2>/dev/null </dev/null & `;
|
|
385
424
|
}
|
|
386
425
|
|
|
@@ -395,18 +434,56 @@ export function streamInputFeederFragment(paths: {
|
|
|
395
434
|
* activity) MUST wrap through here — the wrapper rides only the wire to
|
|
396
435
|
* the model; the stored conversation row keeps the user's raw text.
|
|
397
436
|
* The text is pinned by tests: change it deliberately or not at all. */
|
|
398
|
-
export function wrapMidTurnUserMessage(text: string): string {
|
|
399
|
-
|
|
437
|
+
export function wrapMidTurnUserMessage(text: string, opts?: MidTurnEnvelopeOptions): string {
|
|
438
|
+
const relayedFrom = opts?.relayedFrom?.trim();
|
|
439
|
+
// A person's own words RELAYED by the agent that owns the worker's thread
|
|
440
|
+
// (review D2, 2026-09-25): the envelope names who said them, because they
|
|
441
|
+
// are not the session user's words and "The user sent" would misattribute
|
|
442
|
+
// them. The relayed row's own text carries the SAID marker and its frame.
|
|
443
|
+
// The thread agent that owns this worker, in its own words (owner decision
|
|
444
|
+
// 2026-09-27): not the session user either. The row's own text closes with
|
|
445
|
+
// its trust frame.
|
|
446
|
+
// A PLATFORM notice posted to wake this conversation (a credential
|
|
447
|
+
// delivered while the turn ran, prod 2026-10-05): nobody's message, so
|
|
448
|
+
// neither "the user" nor an agent is named, and the close asks for the
|
|
449
|
+
// act the notice calls for rather than an answer to a person.
|
|
450
|
+
if (opts?.platformNotice) {
|
|
451
|
+
return "The platform posted a notice in this conversation while you were working:\n\n"
|
|
452
|
+
+ text
|
|
453
|
+
+ "\n\nAct on it as you continue this turn.";
|
|
454
|
+
}
|
|
455
|
+
const head = relayedFrom
|
|
456
|
+
? `A message relaying ${relayedFrom}'s own words arrived while you were working:\n\n`
|
|
457
|
+
: opts?.fromOwnerAgent
|
|
458
|
+
? "The agent that owns your thread sent a message while you were working:\n\n"
|
|
459
|
+
: "The user sent a new message while you were working:\n\n";
|
|
460
|
+
return head
|
|
400
461
|
+ text
|
|
401
462
|
+ "\n\nAddress the message above as you continue this turn.";
|
|
402
463
|
}
|
|
403
464
|
|
|
465
|
+
/** Who a mid-turn message speaks for, when it is not the session user.
|
|
466
|
+
* `relayedFrom` = the display name of the person whose own words an agent
|
|
467
|
+
* relayed verbatim; `fromOwnerAgent` = the thread agent that owns this
|
|
468
|
+
* worker, in its own words; `platformNotice` = a system notice the
|
|
469
|
+
* platform posted to wake the conversation (nobody's words); all absent =
|
|
470
|
+
* the session user's own message. */
|
|
471
|
+
export interface MidTurnEnvelopeOptions {
|
|
472
|
+
relayedFrom?: string | null;
|
|
473
|
+
fromOwnerAgent?: boolean;
|
|
474
|
+
platformNotice?: boolean;
|
|
475
|
+
}
|
|
476
|
+
|
|
404
477
|
/** The one delivery-ack exec `injectUserMessage` runs: append the message
|
|
405
478
|
* line to the durable inbox, then wait for the feeder's delivered counter
|
|
406
|
-
* to cover it. Exit 0 =
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
479
|
+
* to cover it. Exit 0 = written into the CLI's stdin pipe pre-result,
|
|
480
|
+
* which is NOT "the model saw it": the CLI surfaces the line at its next
|
|
481
|
+
* tool boundary, so a kill before then loses it, and the settle's kill
|
|
482
|
+
* terminals hold the anchor at the pre-inject dispatch head for that
|
|
483
|
+
* reason (review D8). 4 = the runner died first; 5 = not forwarded within
|
|
484
|
+
* the ack window (the feeder is not forwarding: it stopped on the result,
|
|
485
|
+
* the resident turn gate is closed, or the guest is crawling). Both
|
|
486
|
+
* non-zero exits mean "leave the message owed". Exported for tests. */
|
|
410
487
|
export function injectAppendAndAckCommand(args: {
|
|
411
488
|
line: string; target: number; pid: number;
|
|
412
489
|
inboxPath: string; deliveredPath: string;
|
|
@@ -420,6 +497,71 @@ export function injectAppendAndAckCommand(args: {
|
|
|
420
497
|
+ `sleep ${INJECT_ACK_POLL_SECONDS}; i=$((i+1)); done; exit 5`;
|
|
421
498
|
}
|
|
422
499
|
|
|
500
|
+
// ── Mid-turn input transports (CliAgentSpec.midTurnInput) ───────────────────
|
|
501
|
+
|
|
502
|
+
/** stdin is a live message stream: the FIFO feeder above delivers the prompt
|
|
503
|
+
* line first and inbox lines after (claude: `--input-format stream-json`). */
|
|
504
|
+
export interface StdinStreamInputSpec {
|
|
505
|
+
transport: "stdin-stream";
|
|
506
|
+
/** Serialise the opening prompt into ONE stream-input stdin line
|
|
507
|
+
* (claude: a stream-json user message). No trailing newline — the
|
|
508
|
+
* transport owns line framing. */
|
|
509
|
+
promptLine(prompt: string): string;
|
|
510
|
+
/** Serialise one mid-turn user message into ONE stdin line. No
|
|
511
|
+
* trailing newline. */
|
|
512
|
+
messageLine(text: string): string;
|
|
513
|
+
/** Serialise ONE in-band interrupt control line (claude: a stream-json
|
|
514
|
+
* `control_request` with subtype "interrupt") — the ESC equivalent.
|
|
515
|
+
* The CLI's control layer handles these immediately, MID-STEP
|
|
516
|
+
* included: the running tool call aborts (its tool_result records the
|
|
517
|
+
* harness's own rejection text), the run ends with an
|
|
518
|
+
* `error_during_execution` result within ~100ms, and the session file
|
|
519
|
+
* stays `--resume`-able with the whole turn context. Verified live
|
|
520
|
+
* against claude 2.1.236. Absent → the runtime has no in-band
|
|
521
|
+
* interrupt; callers fall back to kill semantics. */
|
|
522
|
+
interruptLine?(requestId: string): string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The CLI hands the inbox to the model itself, through a hook it runs after
|
|
526
|
+
* each tool call it completes (codex: the platform's PostToolUse command
|
|
527
|
+
* hook, CODEX_MID_TURN_HOOK_COMMAND in codex.ts). The launch creates the
|
|
528
|
+
* inbox and exports its paths into the CLI's environment
|
|
529
|
+
* (toolHookInboxFragment); the hook reads them back, prints the unfed lines
|
|
530
|
+
* as its additional context for the model and advances the delivered
|
|
531
|
+
* counter. stdin stays the plain prompt file, so there is no in-band
|
|
532
|
+
* interrupt on this transport. */
|
|
533
|
+
export interface ToolHookInputSpec {
|
|
534
|
+
transport: "tool-hook";
|
|
535
|
+
/** Serialise one mid-turn user message into ONE inbox line the hook can
|
|
536
|
+
* splice into its JSON output without decoding: a JSON string literal. */
|
|
537
|
+
messageLine(text: string): string;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export type MidTurnInputSpec = StdinStreamInputSpec | ToolHookInputSpec;
|
|
541
|
+
|
|
542
|
+
/** The stdin-stream transport of a spec, or undefined. The FIFO feeder, the
|
|
543
|
+
* resident harness, the prewarm lane and the in-band interrupt exist only
|
|
544
|
+
* for this transport; the inject lane itself reads `midTurnInput`. */
|
|
545
|
+
export function stdinStreamInputOf(spec: Pick<CliAgentSpec, "midTurnInput">): StdinStreamInputSpec | undefined {
|
|
546
|
+
return spec.midTurnInput?.transport === "stdin-stream" ? spec.midTurnInput : undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The environment the tool-hook lane hands the CLI, and so the hook: codex
|
|
550
|
+
* runs a command hook with the CLI process's own environment (codex-rs
|
|
551
|
+
* hooks/src/registry.rs Hooks::new, engine/command_runner.rs at
|
|
552
|
+
* rust-v0.160.0). The turn's inbox and delivered-counter paths. */
|
|
553
|
+
export const MID_TURN_INBOX_ENV = "AC_MID_TURN_INBOX";
|
|
554
|
+
export const MID_TURN_DELIVERED_ENV = "AC_MID_TURN_DELIVERED";
|
|
555
|
+
|
|
556
|
+
/** The tool-hook lane's launch fragment, prepended to the detached wrapper
|
|
557
|
+
* script (the shell the CLI starts in): a fresh inbox, the counter at 0,
|
|
558
|
+
* and both paths exported for the hook. Exported for tests. */
|
|
559
|
+
export function toolHookInboxFragment(paths: { inboxPath: string; deliveredPath: string }): string {
|
|
560
|
+
const q = shellQuote;
|
|
561
|
+
return `: > ${q(paths.inboxPath)}; echo 0 > ${q(paths.deliveredPath)}; `
|
|
562
|
+
+ `export ${MID_TURN_INBOX_ENV}=${q(paths.inboxPath)} ${MID_TURN_DELIVERED_ENV}=${q(paths.deliveredPath)}; `;
|
|
563
|
+
}
|
|
564
|
+
|
|
423
565
|
// ── Reap = kill AND CONFIRM (2026-08-18 incident: codex writer lock) ────────
|
|
424
566
|
// A reaped runner must be CONFIRMED dead before the turn unwinds. codex holds
|
|
425
567
|
// a kernel flock on its thread store (`~/.codex/thread-writer-locks/<id>.lock`)
|
|
@@ -467,14 +609,19 @@ export function reapKillAndConfirmCommand(pid: number): string {
|
|
|
467
609
|
// clean sentinel — the probe measured 625ms), and a guest idle watchdog
|
|
468
610
|
// reaps the group if the server's durable record is ever lost. Turn
|
|
469
611
|
// accounting rides two guest numbers next to the inbox:
|
|
470
|
-
// `<prompt>.served` — turns DELIVERED into this process (1 at launch
|
|
471
|
-
//
|
|
612
|
+
// `<prompt>.served` — turns DELIVERED into this process (1 at launch;
|
|
613
|
+
// each adoption rebases it to the result lines it
|
|
614
|
+
// counted + 1, so a result the CLI wrote on its
|
|
615
|
+
// own, such as a background task's wake turn,
|
|
616
|
+
// never desyncs it; the server mirrors it durably
|
|
472
617
|
// as the runner stamp's `turnsServed`);
|
|
473
|
-
// result lines in .out — turns FINISHED (
|
|
474
|
-
// server-side stream-json parse is
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
618
|
+
// result lines in .out — turns FINISHED (CLI_RESULT_LINE_AWK — hint-
|
|
619
|
+
// grade; the server-side stream-json parse is
|
|
620
|
+
// authoritative).
|
|
621
|
+
// idle ⇔ results ≥ served. Mid-turn injects ride the same inbox WITHOUT
|
|
622
|
+
// bumping served, and fold into the running turn producing no result of
|
|
623
|
+
// their own. The feeder forwards inbox lines only while results < served,
|
|
624
|
+
// so a line that raced the result is never fed into an idle CLI.
|
|
478
625
|
|
|
479
626
|
/** Guest idle TTL for a resident with no new inbox line: hot window (180s)
|
|
480
627
|
* + margin, so the park gate's graceful `.end` normally wins and the
|
|
@@ -482,11 +629,14 @@ export function reapKillAndConfirmCommand(pid: number): string {
|
|
|
482
629
|
export const RESIDENT_IDLE_TTL_S = 240;
|
|
483
630
|
/** Watchdog poll cadence (seconds). */
|
|
484
631
|
export const RESIDENT_WATCHDOG_POLL_S = 15;
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
* the
|
|
488
|
-
*
|
|
489
|
-
|
|
632
|
+
|
|
633
|
+
/** Shell command printing how many result lines the durable .out holds —
|
|
634
|
+
* the guest's turn accounting (feeder gate, idle check, adoption parity).
|
|
635
|
+
* Prints nothing when the file is missing: callers default an empty count
|
|
636
|
+
* to 0. */
|
|
637
|
+
export function cliResultCountCommand(outPath: string): string {
|
|
638
|
+
return `${resultLineAwk("n++", "print n + 0")} ${shellQuote(outPath)} 2>/dev/null`;
|
|
639
|
+
}
|
|
490
640
|
|
|
491
641
|
/** One bounded exec printing the count of result lines at or past
|
|
492
642
|
* `fromByte` (0-based) in the durable .out. Always exits 0; stdout is the
|
|
@@ -496,41 +646,82 @@ export function residentResultCountCommand(outPath: string, fromByte = 0): strin
|
|
|
496
646
|
const src = fromByte > 0
|
|
497
647
|
? `tail -c +${fromByte + 1} ${q(outPath)} 2>/dev/null`
|
|
498
648
|
: `cat ${q(outPath)} 2>/dev/null`;
|
|
499
|
-
return `ac_res=$(${src} |
|
|
649
|
+
return `ac_res=$(${src} | ${resultLineAwk("n++", "print n + 0")}); echo "\${ac_res:-0}"`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** One bounded exec reporting the LAST result line in the durable .out
|
|
653
|
+
* window [`fromByte`, `toByte`): prints `none` (no result line), `error`
|
|
654
|
+
* (its `is_error` is true) or `ok`. Always exits 0 and never prints the
|
|
655
|
+
* line itself. The harvest's confirm for a resident result the tailer never
|
|
656
|
+
* parsed (review C2): a count alone cannot say whether the turn failed. The
|
|
657
|
+
* whole line is searched for `"is_error":true`, wherever the CLI put it
|
|
658
|
+
* (key order is not fixed). `toByte` bounds the window to bytes a drain
|
|
659
|
+
* already walked past, so a result the drain never reached (or a line still
|
|
660
|
+
* being written past its line-aligned offset) is not confirmed; omitted,
|
|
661
|
+
* the window runs to the end of the file. A window with `toByte <= fromByte`
|
|
662
|
+
* is empty. */
|
|
663
|
+
export function residentLastResultCommand(outPath: string, fromByte = 0, toByte?: number): string {
|
|
664
|
+
const q = shellQuote;
|
|
665
|
+
const read = fromByte > 0
|
|
666
|
+
? `tail -c +${fromByte + 1} ${q(outPath)} 2>/dev/null`
|
|
667
|
+
: `cat ${q(outPath)} 2>/dev/null`;
|
|
668
|
+
const src = toByte === undefined ? read : `${read} | head -c ${Math.max(0, toByte - fromByte)}`;
|
|
669
|
+
return `${src} | ${resultLineAwk("last = $0",
|
|
670
|
+
'if (last == "") print "none"; else if (index(last, "\\"is_error\\":true") > 0) print "error"; else print "ok"')}`;
|
|
500
671
|
}
|
|
501
672
|
|
|
502
673
|
/** The resident feeder: `streamInputFeederFragment` with the result-break
|
|
503
|
-
* REPLACED by the end-file break
|
|
504
|
-
*
|
|
505
|
-
*
|
|
674
|
+
* REPLACED by the end-file break (verified live by the spec's probe: two
|
|
675
|
+
* messages, one process, one session file, graceful exit on `.end`), plus
|
|
676
|
+
* the TURN GATE: inbox lines are forwarded only while a delivered turn is
|
|
677
|
+
* still unanswered (the result count in .out is below `.served`),
|
|
678
|
+
* the same two numbers the idle predicate reads. Without the gate, a line
|
|
679
|
+
* appended after the turn's result line but before the server settled the
|
|
680
|
+
* turn was fed into the idle CLI, started a turn nobody tails, and was
|
|
681
|
+
* acked as delivered: the message was never answered, and the extra result
|
|
682
|
+
* line broke the next adoption's parity. With the gate that line stays
|
|
683
|
+
* unfed, the inject ack exits 5, and the message stays owed for the
|
|
684
|
+
* follow-up turn. Adoption bumps `.served` BEFORE it appends, so the next
|
|
685
|
+
* turn's prompt still flows. The count is a full-file grep, re-run
|
|
686
|
+
* only on a poll that has unfed lines AND a changed (.out size, .served)
|
|
687
|
+
* pair, so an idle resident holding a gated line never rescans. The
|
|
688
|
+
* forward is guarded like the plain feeder's: the counters advance only
|
|
689
|
+
* when `sed` wrote the lines into the FIFO. */
|
|
506
690
|
export function residentFeederFragment(paths: {
|
|
507
691
|
promptPath: string; fifoPath: string; inboxPath: string;
|
|
508
|
-
deliveredPath: string; endPath: string;
|
|
692
|
+
deliveredPath: string; endPath: string; outPath: string; servedPath: string;
|
|
509
693
|
}): string {
|
|
510
694
|
const q = shellQuote;
|
|
511
695
|
return `rm -f ${q(paths.fifoPath)}; mkfifo ${q(paths.fifoPath)}; `
|
|
512
696
|
+ `: > ${q(paths.inboxPath)}; echo 0 > ${q(paths.deliveredPath)}; rm -f ${q(paths.endPath)}; `
|
|
513
|
-
+ `( cat ${q(paths.promptPath)}; ac_fed=0; while kill -0 "$$" 2>/dev/null; do `
|
|
697
|
+
+ `( cat ${q(paths.promptPath)}; ac_fed=0; ac_gk=; ac_gr=0; while kill -0 "$$" 2>/dev/null; do `
|
|
514
698
|
+ `if [ -f ${q(paths.endPath)} ]; then break; fi; `
|
|
515
699
|
+ `ac_lines=$(wc -l < ${q(paths.inboxPath)} 2>/dev/null || echo 0); ac_lines=\${ac_lines:-0}; `
|
|
516
700
|
+ `if [ "$ac_lines" -gt "$ac_fed" ]; then `
|
|
517
|
-
+ `
|
|
518
|
-
+ `
|
|
701
|
+
+ `ac_gs=$(wc -c < ${q(paths.outPath)} 2>/dev/null); ac_gv=$(cat ${q(paths.servedPath)} 2>/dev/null); ac_gv=\${ac_gv:-1}; `
|
|
702
|
+
+ `if [ "$ac_gs:$ac_gv" != "$ac_gk" ]; then ac_gk="$ac_gs:$ac_gv"; `
|
|
703
|
+
+ `ac_gr=$(${cliResultCountCommand(paths.outPath)}); ac_gr=\${ac_gr:-0}; fi; `
|
|
704
|
+
+ `if [ "$ac_gr" -lt "$ac_gv" ]; then `
|
|
705
|
+
+ `sed -n "$((ac_fed+1)),$((ac_lines))p" ${q(paths.inboxPath)} && { ac_fed=$ac_lines; `
|
|
706
|
+
+ `echo "$ac_fed" > ${q(paths.deliveredPath)}; }; fi; fi; `
|
|
519
707
|
+ `sleep ${INJECT_FEEDER_POLL_SECONDS}; done ) > ${q(paths.fifoPath)} 2>/dev/null </dev/null & `;
|
|
520
708
|
}
|
|
521
709
|
|
|
522
|
-
/** Sets `ac_idle` (1 = between turns: every delivered turn has its result
|
|
523
|
-
*
|
|
524
|
-
*
|
|
710
|
+
/** Sets `ac_idle` (1 = between turns: every delivered turn has its result).
|
|
711
|
+
* Embedded by the watchdog and the resident heartbeat gate: one idle
|
|
712
|
+
* predicate, stated once. Unfed inbox lines do not make a resident busy:
|
|
713
|
+
* the feeder's turn gate forwards a line only while results < served, so
|
|
714
|
+
* a line still unfed once results >= served is held until the next
|
|
715
|
+
* adoption bumps `.served` (a message that raced the result, owed on the
|
|
716
|
+
* server). Reading it as work would keep the heartbeat fresh and the
|
|
717
|
+
* watchdog quiet for as long as the line sat there. */
|
|
525
718
|
export function residentIdleCheckFragment(paths: {
|
|
526
|
-
outPath: string;
|
|
719
|
+
outPath: string; servedPath: string;
|
|
527
720
|
}): string {
|
|
528
721
|
const q = shellQuote;
|
|
529
|
-
return `ac_res=$(
|
|
722
|
+
return `ac_res=$(${cliResultCountCommand(paths.outPath)}); ac_res=\${ac_res:-0}; `
|
|
530
723
|
+ `ac_srv=$(cat ${q(paths.servedPath)} 2>/dev/null); ac_srv=\${ac_srv:-1}; `
|
|
531
|
-
+ `
|
|
532
|
-
+ `ac_del=$(cat ${q(paths.deliveredPath)} 2>/dev/null); ac_del=\${ac_del:-0}; `
|
|
533
|
-
+ `if [ "$ac_res" -ge "$ac_srv" ] && [ "$ac_in" -eq "$ac_del" ]; then ac_idle=1; else ac_idle=0; fi; `;
|
|
724
|
+
+ `if [ "$ac_res" -ge "$ac_srv" ]; then ac_idle=1; else ac_idle=0; fi; `;
|
|
534
725
|
}
|
|
535
726
|
|
|
536
727
|
/** Guest idle watchdog: reap the whole process group after
|
|
@@ -554,7 +745,7 @@ export function residentIdleCheckFragment(paths: {
|
|
|
554
745
|
* guest rule about not killing, and journal advance is required, not mere
|
|
555
746
|
* process existence). */
|
|
556
747
|
export function residentIdleWatchdogFragment(paths: {
|
|
557
|
-
outPath: string;
|
|
748
|
+
outPath: string; servedPath: string;
|
|
558
749
|
}): string {
|
|
559
750
|
return `( ac_idle_s=0; while kill -0 "$$" 2>/dev/null; do `
|
|
560
751
|
+ residentIdleCheckFragment(paths)
|
|
@@ -621,8 +812,10 @@ function parsePid(raw: string): number {
|
|
|
621
812
|
/** Recover the detached runner's pid from its durable pidfile after the
|
|
622
813
|
* launch exec's STREAM died (deadline_exceeded, canceled context, any wire
|
|
623
814
|
* fault). Reads over the file transport with a short retry so the race
|
|
624
|
-
* where the launch shell is still writing the pidfile is absorbed.
|
|
625
|
-
|
|
815
|
+
* where the launch shell is still writing the pidfile is absorbed.
|
|
816
|
+
* Exported for the server's workflow-lane launch (turn-workflow/tailer.ts
|
|
817
|
+
* launchDetachedRunner), which recovers the same way. */
|
|
818
|
+
export async function recoverLaunchPid(read: (path: string) => Promise<string>, pidPath: string): Promise<number> {
|
|
626
819
|
for (let attempt = 0; attempt < LAUNCH_PID_RECOVERY_ATTEMPTS; attempt++) {
|
|
627
820
|
try {
|
|
628
821
|
const pid = parsePid(await read(pidPath));
|
|
@@ -662,6 +855,23 @@ export function sessionEnvSourceFragment(relPath: string | undefined): string {
|
|
|
662
855
|
return `[ -f "$HOME/${relPath}" ] && . "$HOME/${relPath}" >/dev/null 2>&1 || true; `;
|
|
663
856
|
}
|
|
664
857
|
|
|
858
|
+
/** Shell command printing a fingerprint of the session env file's current
|
|
859
|
+
* bytes (POSIX `cksum`), or `absent` when there is no file. A resident
|
|
860
|
+
* records it next to its prompt immediately BEFORE sourcing the file, and
|
|
861
|
+
* an adoption compares the file's fingerprint at that moment with it: a
|
|
862
|
+
* fresh launch would source the file as it is now, the resident only ever
|
|
863
|
+
* saw the one it started with. Recording before the source keeps the
|
|
864
|
+
* comparison conservative — a write that lands between the two reads as a
|
|
865
|
+
* change. The guest file is the one truth every replica writes to, so the
|
|
866
|
+
* verdict never depends on which server process wrote it. */
|
|
867
|
+
export function sessionEnvFingerprintCommand(relPath: string): string {
|
|
868
|
+
if (!SESSION_ENV_FILE_SAFE.test(relPath) || relPath.includes("..")) {
|
|
869
|
+
throw new Error(`unsafe session env path: ${relPath}`);
|
|
870
|
+
}
|
|
871
|
+
// One group, so a redirect after it (`> <prompt>.envsum`) takes either arm.
|
|
872
|
+
return `{ { cksum < "$HOME/${relPath}"; } 2>/dev/null || echo absent; }`;
|
|
873
|
+
}
|
|
874
|
+
|
|
665
875
|
/** The per-turn model-credential source fragment (`RuntimeOptions.
|
|
666
876
|
* credEnvFile` — an ABSOLUTE guest path), or "" when none is configured.
|
|
667
877
|
* Sourced AFTER the session env file so the turn actor's credential wins.
|
|
@@ -825,9 +1035,9 @@ export interface CliAgentSpec {
|
|
|
825
1035
|
* `effort` is present only when the caller configured a reasoning effort
|
|
826
1036
|
* AND the spec has a real knob for it (see `CliReasoningEffort`).
|
|
827
1037
|
* `streamInput` is true when the durable transport is feeding stdin as a
|
|
828
|
-
* live message stream (
|
|
829
|
-
* then the FIFO the feeder writes, and the CLI must be
|
|
830
|
-
* stream-input mode (claude: `--input-format stream-json`). */
|
|
1038
|
+
* live message stream (the "stdin-stream" `midTurnInput` transport):
|
|
1039
|
+
* `promptPath` is then the FIFO the feeder writes, and the CLI must be
|
|
1040
|
+
* invoked in its stream-input mode (claude: `--input-format stream-json`). */
|
|
831
1041
|
buildCommand(args: { promptPath: string; sessionId?: string; model?: string; cwd?: string; effort?: CliReasoningEffort; streamInput?: boolean }): string;
|
|
832
1042
|
/** Map one parsed JSONL stdout event to `AgentMessage`s. The base emits
|
|
833
1043
|
* `init`/`done`/`error` lifecycle itself, so a spec maps only content +
|
|
@@ -858,37 +1068,15 @@ export interface CliAgentSpec {
|
|
|
858
1068
|
* `extractSessionId`/`mapEvent` members are used only as the version-mismatch
|
|
859
1069
|
* fallback. Absent → the spec is JSONL-only (legacy path always). */
|
|
860
1070
|
acp?: { command: string; args: string[]; env?: Record<string, string> };
|
|
861
|
-
/** Mid-turn
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
|
|
870
|
-
* FIFO, which is what ends the CLI process (stream-input CLIs exit on
|
|
871
|
-
* stdin EOF, not after a result). Absent → the prompt file is the whole
|
|
872
|
-
* stdin, exactly as before. */
|
|
873
|
-
streamInput?: {
|
|
874
|
-
/** Serialise the opening prompt into ONE stream-input stdin line
|
|
875
|
-
* (claude: a stream-json user message). No trailing newline — the
|
|
876
|
-
* transport owns line framing. */
|
|
877
|
-
promptLine(prompt: string): string;
|
|
878
|
-
/** Serialise one mid-turn user message into ONE stdin line. No
|
|
879
|
-
* trailing newline. */
|
|
880
|
-
messageLine(text: string): string;
|
|
881
|
-
/** Serialise ONE in-band interrupt control line (claude: a stream-json
|
|
882
|
-
* `control_request` with subtype "interrupt") — the ESC equivalent.
|
|
883
|
-
* The CLI's control layer handles these immediately, MID-STEP
|
|
884
|
-
* included: the running tool call aborts (its tool_result records the
|
|
885
|
-
* harness's own rejection text), the run ends with an
|
|
886
|
-
* `error_during_execution` result within ~100ms, and the session file
|
|
887
|
-
* stays `--resume`-able with the whole turn context. Verified live
|
|
888
|
-
* against claude 2.1.236. Absent → the runtime has no in-band
|
|
889
|
-
* interrupt; callers fall back to kill semantics. */
|
|
890
|
-
interruptLine?(requestId: string): string;
|
|
891
|
-
};
|
|
1071
|
+
/** Mid-turn INPUT support (the delivery half of `injectUserMessage`): how
|
|
1072
|
+
* a line appended to the turn's durable inbox reaches the CLI while its
|
|
1073
|
+
* turn runs — see the transports above. Engages only on the durable
|
|
1074
|
+
* detached transport (the inbox, the delivered counter and the feeder or
|
|
1075
|
+
* hook live in the guest; single-exec transports keep the plain
|
|
1076
|
+
* prompt-file stdin). Absent → the runtime cannot take a message
|
|
1077
|
+
* mid-turn: every inject answers "unsupported" and the message waits for
|
|
1078
|
+
* the turn boundary. */
|
|
1079
|
+
midTurnInput?: MidTurnInputSpec;
|
|
892
1080
|
}
|
|
893
1081
|
|
|
894
1082
|
/** The CURRENT turn's mid-turn injection state (durable stream-input
|
|
@@ -1001,19 +1189,20 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
1001
1189
|
/** Deliver one user message INTO the live turn — see the contract doc
|
|
1002
1190
|
* (types/runtime.ts). Appends a stream-input line to the turn's durable
|
|
1003
1191
|
* inbox and waits for the in-guest feeder's delivered-counter ack; only
|
|
1004
|
-
* an acked forward (pre-result, into the CLI's stdin)
|
|
1005
|
-
* "delivered"
|
|
1006
|
-
*
|
|
1007
|
-
* the
|
|
1008
|
-
*
|
|
1009
|
-
|
|
1192
|
+
* an acked forward (pre-result, written into the CLI's stdin pipe)
|
|
1193
|
+
* reports "delivered", and even then the model reads the line only at
|
|
1194
|
+
* its next tool boundary (review D8). Guest exit 5 (ack window exhausted
|
|
1195
|
+
* with the runner still alive: the feeder is not forwarding) reports
|
|
1196
|
+
* "pending": the appended line may still be forwarded later, but it was
|
|
1197
|
+
* NOT seen yet. Never throws. */
|
|
1198
|
+
async injectUserMessage(text: string, opts?: MidTurnEnvelopeOptions): Promise<"delivered" | "pending" | "closed" | "unsupported"> {
|
|
1010
1199
|
const inject = this.turnInject;
|
|
1011
|
-
const
|
|
1012
|
-
if (!inject || !
|
|
1200
|
+
const input = this.spec.midTurnInput;
|
|
1201
|
+
if (!inject || !input || this.guestDetached) return "unsupported";
|
|
1013
1202
|
// The address-this envelope (wrapMidTurnUserMessage): a raw injected
|
|
1014
1203
|
// line carries no framing, and the model continues its narration
|
|
1015
1204
|
// without acknowledging the message.
|
|
1016
|
-
return this.appendTurnStreamLine(inject,
|
|
1205
|
+
return this.appendTurnStreamLine(inject, input.messageLine(wrapMidTurnUserMessage(text, opts)));
|
|
1017
1206
|
}
|
|
1018
1207
|
|
|
1019
1208
|
/** Request an in-band step interrupt of the LIVE turn — the ESC
|
|
@@ -1027,7 +1216,7 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
1027
1216
|
* throws. */
|
|
1028
1217
|
async interruptTurn(): Promise<"delivered" | "pending" | "closed" | "unsupported"> {
|
|
1029
1218
|
const inject = this.turnInject;
|
|
1030
|
-
const interruptLine = this.spec
|
|
1219
|
+
const interruptLine = stdinStreamInputOf(this.spec)?.interruptLine;
|
|
1031
1220
|
if (!inject || !interruptLine || this.guestDetached) return "unsupported";
|
|
1032
1221
|
return this.appendTurnStreamLine(inject, interruptLine(`itr-${Date.now().toString(36)}`));
|
|
1033
1222
|
}
|
|
@@ -1055,7 +1244,7 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
1055
1244
|
{ timeoutMs: INJECT_ACK_EXEC_TIMEOUT_MS });
|
|
1056
1245
|
if (res.exitCode === 0) return "delivered";
|
|
1057
1246
|
// 5 = ack window exhausted, runner alive: the line is in the inbox
|
|
1058
|
-
// and
|
|
1247
|
+
// and the feeder is not forwarding it (stopped, or gated).
|
|
1059
1248
|
// 4 = the runner died first; anything else is a fault — both closed.
|
|
1060
1249
|
return res.exitCode === 5 ? "pending" : "closed";
|
|
1061
1250
|
} catch { return "closed"; }
|
|
@@ -1507,25 +1696,27 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
1507
1696
|
this.turnPendingLineEnd = null;
|
|
1508
1697
|
this.nudgePending = false;
|
|
1509
1698
|
this.wakeTailWatchdog = null;
|
|
1510
|
-
// Mid-turn
|
|
1511
|
-
//
|
|
1512
|
-
// transport (the
|
|
1513
|
-
// single-exec transports keep the plain
|
|
1514
|
-
|
|
1515
|
-
|
|
1699
|
+
// Mid-turn input engages only where BOTH halves exist: a spec that
|
|
1700
|
+
// declares a mid-turn input transport AND the durable detached
|
|
1701
|
+
// transport (the inbox, the delivered counter and the feeder or hook
|
|
1702
|
+
// live in the guest; single-exec transports keep the plain
|
|
1703
|
+
// prompt-file stdin).
|
|
1704
|
+
const durable = this.sandbox.commands.runBackground !== undefined;
|
|
1705
|
+
const stdinStream = durable ? stdinStreamInputOf(this.spec) : undefined;
|
|
1706
|
+
const toolHook = durable && this.spec.midTurnInput?.transport === "tool-hook";
|
|
1516
1707
|
const fifoPath = `${promptPath}.fifo`;
|
|
1517
|
-
const promptWrite = this.sandbox.files.write(promptPath,
|
|
1518
|
-
? `${
|
|
1708
|
+
const promptWrite = this.sandbox.files.write(promptPath, stdinStream
|
|
1709
|
+
? `${stdinStream.promptLine(opts.prompt)}\n`
|
|
1519
1710
|
: this.spec.promptPayload(opts.prompt));
|
|
1520
1711
|
const cmd = this.spec.buildCommand({
|
|
1521
1712
|
// Stream-input turns read stdin from the feeder's FIFO; the prompt
|
|
1522
1713
|
// file is the feeder's first delivery, not the CLI's stdin.
|
|
1523
|
-
promptPath:
|
|
1714
|
+
promptPath: stdinStream ? fifoPath : promptPath,
|
|
1524
1715
|
sessionId: opts.sessionId,
|
|
1525
1716
|
model: this.model,
|
|
1526
1717
|
cwd: this.options.cwd,
|
|
1527
1718
|
effort: this.configEffort,
|
|
1528
|
-
...(
|
|
1719
|
+
...(stdinStream ? { streamInput: true } : {}),
|
|
1529
1720
|
});
|
|
1530
1721
|
|
|
1531
1722
|
// Frame guard (see _jsonl-guard.ts): the CLI's stdout is piped through
|
|
@@ -1643,14 +1834,18 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
1643
1834
|
// supersede group-kill still reaps this subshell with the tree.
|
|
1644
1835
|
const busyPath = `${promptPath}.busy`;
|
|
1645
1836
|
const tasksPath = `${promptPath}.tasks`;
|
|
1646
|
-
// Mid-turn
|
|
1837
|
+
// Mid-turn input: the stdin-stream feeder subshell (see
|
|
1647
1838
|
// streamInputFeederFragment) rides at the FRONT of the detached
|
|
1648
|
-
// script so the FIFO exists before the CLI opens it as stdin
|
|
1839
|
+
// script so the FIFO exists before the CLI opens it as stdin; the
|
|
1840
|
+
// tool-hook lane creates the inbox here and exports its paths into
|
|
1841
|
+
// the CLI's environment, for the hook the CLI runs at each tool step.
|
|
1649
1842
|
const inboxPath = `${promptPath}.inbox`;
|
|
1650
1843
|
const deliveredPath = `${promptPath}.delivered`;
|
|
1651
|
-
const feeder =
|
|
1844
|
+
const feeder = stdinStream
|
|
1652
1845
|
? streamInputFeederFragment({ promptPath, fifoPath, inboxPath, deliveredPath, outPath })
|
|
1653
|
-
:
|
|
1846
|
+
: toolHook
|
|
1847
|
+
? toolHookInboxFragment({ inboxPath, deliveredPath })
|
|
1848
|
+
: "";
|
|
1654
1849
|
// Perf sampling rides the same beat: every 6th beat (~60s) the
|
|
1655
1850
|
// subshell burst-reads /proc and rewrites the `.perf` token the
|
|
1656
1851
|
// durable-trio probe carries as its trailing field (perf-sampler.ts).
|
|
@@ -1747,8 +1942,9 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
1747
1942
|
// durable files without this process's memory.
|
|
1748
1943
|
this.options.onDetachedLaunch?.({ promptPath, sentinel, pid });
|
|
1749
1944
|
// Arm mid-turn injection now that the detached runner (and its
|
|
1750
|
-
// feeder) exist; cleared with the turn in the finally
|
|
1751
|
-
|
|
1945
|
+
// feeder or hook inbox) exist; cleared with the turn in the finally
|
|
1946
|
+
// below.
|
|
1947
|
+
if (stdinStream !== undefined || toolHook) {
|
|
1752
1948
|
this.turnInject = { inboxPath, deliveredPath, pid, appended: 0, chain: Promise.resolve() };
|
|
1753
1949
|
}
|
|
1754
1950
|
|