@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.
Files changed (100) hide show
  1. package/README.md +213 -189
  2. package/dist/agent/agent-context.d.ts +3 -3
  3. package/dist/agent/agent-loop.d.ts +6 -5
  4. package/dist/agent/perf-sampler.d.ts +27 -2
  5. package/dist/agent/run-agent.d.ts +1 -1
  6. package/dist/client.d.ts +119 -54
  7. package/dist/directives.d.ts +3 -3
  8. package/dist/display.d.ts +7 -0
  9. package/dist/errors.d.ts +1 -1
  10. package/dist/generated/agentc-commands.d.ts +34 -0
  11. package/dist/index.d.ts +12 -12
  12. package/dist/index.js +771 -204
  13. package/dist/request-context/request-context.d.ts +1 -1
  14. package/dist/runtimes/_cli-agent.d.ts +185 -68
  15. package/dist/runtimes/_reported-model.d.ts +16 -0
  16. package/dist/runtimes/claude-code.d.ts +60 -1
  17. package/dist/runtimes/claude.d.ts +1 -1
  18. package/dist/runtimes/codex.d.ts +94 -6
  19. package/dist/runtimes/codex.mid-turn-hook.test.d.ts +10 -0
  20. package/dist/runtimes/model-report.test.d.ts +14 -0
  21. package/dist/runtimes/openai-desktop.js +741 -200
  22. package/dist/runtimes/opencode.d.ts +48 -11
  23. package/dist/runtimes/opencode.test.d.ts +14 -0
  24. package/dist/sandbox/baked-clis.d.ts +75 -0
  25. package/dist/sandbox/exec-stream.d.ts +1 -2
  26. package/dist/sandbox/network-policy.d.ts +23 -5
  27. package/dist/sandbox.d.ts +4 -2
  28. package/dist/step-invocation/protocol.d.ts +3 -4
  29. package/dist/step-invocation/server.d.ts +2 -2
  30. package/dist/step-invocation/types.d.ts +1 -1
  31. package/dist/types/api-conversations.d.ts +442 -29
  32. package/dist/types/api-factory.d.ts +99 -10
  33. package/dist/types/api-projects.d.ts +521 -0
  34. package/dist/types/api-runs.d.ts +83 -0
  35. package/dist/types/api-scopes.d.ts +32 -3
  36. package/dist/types/conversation-stream.d.ts +5 -0
  37. package/dist/types/execution-context.d.ts +1 -1
  38. package/dist/types/protocol.d.ts +86 -2
  39. package/dist/types/runtime.d.ts +9 -2
  40. package/dist/types/workflow-metadata.d.ts +2 -4
  41. package/dist/types/workflow-plan.d.ts +1 -3
  42. package/dist/utils/bundler.d.ts +23 -0
  43. package/dist/workflow-steps/observability.d.ts +2 -3
  44. package/dist/workflow-steps/runner.d.ts +5 -8
  45. package/dist/workflow-steps/types.d.ts +8 -10
  46. package/dist/workflow-steps/workflow.d.ts +2 -1
  47. package/dist/workflows/engine.d.ts +3 -5
  48. package/dist/workflows/invoke-child.d.ts +2 -2
  49. package/package.json +2 -2
  50. package/src/agent/agent-context.ts +168 -125
  51. package/src/agent/agent-loop.ts +7 -6
  52. package/src/agent/perf-sampler.ts +54 -3
  53. package/src/agent/run-agent.ts +1 -1
  54. package/src/client.ts +226 -71
  55. package/src/directives.ts +3 -3
  56. package/src/display.ts +12 -0
  57. package/src/errors.ts +1 -0
  58. package/src/generated/agentc-commands.ts +571 -0
  59. package/src/index.ts +57 -21
  60. package/src/pause/pause-core.ts +2 -1
  61. package/src/request-context/request-context.ts +1 -1
  62. package/src/runtimes/_cli-agent.ts +318 -122
  63. package/src/runtimes/_reported-model.ts +24 -0
  64. package/src/runtimes/claude-code.ts +195 -12
  65. package/src/runtimes/claude.ts +9 -2
  66. package/src/runtimes/codex.ts +188 -19
  67. package/src/runtimes/opencode.ts +195 -26
  68. package/src/sandbox/baked-clis.ts +86 -0
  69. package/src/sandbox/exec-stream.ts +1 -2
  70. package/src/sandbox/network-policy.ts +51 -7
  71. package/src/sandbox/providers/e2b.ts +3 -3
  72. package/src/sandbox/providers/vercel.ts +6 -6
  73. package/src/sandbox.ts +8 -2
  74. package/src/step-invocation/invoker.ts +2 -6
  75. package/src/step-invocation/protocol.ts +3 -4
  76. package/src/step-invocation/server.ts +2 -2
  77. package/src/types/api-conversations.ts +366 -23
  78. package/src/types/api-factory.ts +95 -10
  79. package/src/types/api-projects.ts +477 -0
  80. package/src/types/api-runs.ts +73 -0
  81. package/src/types/api-scopes.ts +32 -3
  82. package/src/types/conversation-stream.ts +5 -0
  83. package/src/types/execution-context.ts +1 -1
  84. package/src/types/protocol.ts +91 -2
  85. package/src/types/runtime.ts +8 -2
  86. package/src/types/sandbox-environment.ts +1 -2
  87. package/src/types/workflow-metadata.ts +2 -4
  88. package/src/types/workflow-plan.ts +1 -3
  89. package/src/utils/bundler.ts +88 -19
  90. package/src/workflow-steps/observability.ts +2 -3
  91. package/src/workflow-steps/runner.ts +5 -8
  92. package/src/workflow-steps/types.ts +8 -10
  93. package/src/workflow-steps/workflow.ts +2 -1
  94. package/src/workflows/engine.ts +3 -5
  95. package/src/workflows/invoke-child.ts +2 -2
  96. package/dist/generated/verb-synopsis.d.ts +0 -34
  97. package/dist/pause/__tests__/errors.test.d.ts +0 -1
  98. package/dist/pause/__tests__/wrappers.test.d.ts +0 -1
  99. package/dist/step-invocation/__tests__/protocol.test.d.ts +0 -1
  100. 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 `vercelRuntime` drives the Vercel AI SDK,
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 claude CLI's stderr complaint when `--resume <id>` names a thread
245
- * that does not exist on this machine (the 2026-08-18 split-brain
246
- * forensic). When a nonzero exit's stderr carries it, the transport
247
- * surfaces the stderr as the turn's LAST error event even though a bare
248
- * result error already streamed — the consumer's classifier needs the
249
- * specific complaint, not the generic `error_during_execution` token. */
250
- export const RESUME_TARGET_MISSING_STDERR = /no conversation found with session id/i;
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 stream input (the delivery half of injectUserMessage) ──────────
340
- // The durable transport's stdin, when the spec declares `streamInput`, is a
341
- // FIFO fed by an in-guest subshell: the prompt line first, then any complete
342
- // lines appended to the turn's durable INBOX file. A steering-capable CLI
343
- // (claude -p --input-format stream-json) folds a message that arrives while
344
- // the turn runs into the RUNNING turn at its next tool boundary — verified
345
- // live against claude 2.1.233. The feeder checks for the CLI's terminal
346
- // `result` line BEFORE each delivery (a message that races the result stays
347
- // owed for the follow-up turn, never half-consumed) and stops on it; closing
348
- // the FIFO is what EOFs the CLI's stdin and lets it exit. The DELIVERED
349
- // counter file is the ack the server trusts: it advances only after the
350
- // feeder forwarded those inbox lines into the CLI's stdin.
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 | grep -q '"type":"result"'; then break; fi; `
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)}; ac_fed=$ac_lines; `
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
- return "The user sent a new message while you were working:\n\n"
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 = delivered into the CLI's stdin pre-result; 4 = the
407
- * runner died first; 5 = not delivered within the ack window (feeder
408
- * stopped on the result, or the guest is crawling) — both non-zero exits
409
- * mean "leave the message owed". Exported for tests. */
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
- // +1 per adoption; the server mirrors it durably
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 (anchored grep — hint-grade; the
474
- // server-side stream-json parse is authoritative).
475
- // idle ⇔ results ≥ served AND the inbox is fully fed. Mid-turn injects ride
476
- // the same inbox WITHOUT bumping served, and fold into the running turn
477
- // producing no result of their own — the parity holds.
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
- /** Anchored prefix of the CLI's terminal result event line. Anchoring keeps
486
- * the guest-side counts honest against tool output that merely CONTAINS
487
- * the substring; a serialization-order change fails SAFE (parity check
488
- * refuses adoption → cold launch — a latency cost, never correctness). */
489
- export const RESIDENT_RESULT_LINE_PREFIX = '{"type":"result"';
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} | grep -c ${q(`^${RESIDENT_RESULT_LINE_PREFIX}`)}); echo "\${ac_res:-0}"`;
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 — the ONLY guest-contract change the
504
- * resident shape needs (verified live by the spec's probe: two messages,
505
- * one process, one session file, graceful exit on `.end`). */
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
- + `sed -n "$((ac_fed+1)),$((ac_lines))p" ${q(paths.inboxPath)}; ac_fed=$ac_lines; `
518
- + `echo "$ac_fed" > ${q(paths.deliveredPath)}; fi; `
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
- * and the inbox is fully fed). Embedded by the watchdog and the resident
524
- * heartbeat gate — one idle predicate, stated once. */
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; inboxPath: string; deliveredPath: string; servedPath: string;
719
+ outPath: string; servedPath: string;
527
720
  }): string {
528
721
  const q = shellQuote;
529
- return `ac_res=$(grep -c ${q(`^${RESIDENT_RESULT_LINE_PREFIX}`)} ${q(paths.outPath)} 2>/dev/null); ac_res=\${ac_res:-0}; `
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
- + `ac_in=$(wc -l < ${q(paths.inboxPath)} 2>/dev/null); ac_in=\${ac_in:-0}; `
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; inboxPath: string; deliveredPath: string; servedPath: 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
- async function recoverLaunchPid(read: (path: string) => Promise<string>, pidPath: string): Promise<number> {
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 (see `CliAgentSpec.streamInput`): `promptPath` is
829
- * then the FIFO the feeder writes, and the CLI must be invoked in its
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 STREAM-INPUT support (the delivery half of
862
- * `injectUserMessage`). When present AND the durable detached transport
863
- * is in play, the CLI's stdin is fed from a FIFO by an in-guest feeder:
864
- * the prompt line first, then any lines appended to the turn's durable
865
- * inbox file — so a steering-capable CLI (claude: queued user input is
866
- * folded into the RUNNING turn at the next tool boundary; verified live
867
- * against claude 2.1.233) receives user messages while the turn runs.
868
- * The feeder stops at the CLI's terminal result line (a message that
869
- * races the result is NOT forwarded — it stays owed) and closes the
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) reports
1005
- * "delivered". Guest exit 5 (ack window exhausted with the runner still
1006
- * alive — a CLI that is not draining stdin mid-step) reports "pending":
1007
- * the appended line may still be read when the current step finishes,
1008
- * but it was NOT seen yet. Never throws. */
1009
- async injectUserMessage(text: string): Promise<"delivered" | "pending" | "closed" | "unsupported"> {
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 streamInput = this.spec.streamInput;
1012
- if (!inject || !streamInput || this.guestDetached) return "unsupported";
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, streamInput.messageLine(wrapMidTurnUserMessage(text)));
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.streamInput?.interruptLine;
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 may be fed when the CLI next drains stdin (the mid-step wall).
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 stream input engages only where BOTH halves exist: a spec
1511
- // that can serialise stream-input lines AND the durable detached
1512
- // transport (the feeder, inbox, and result-watch live in the guest;
1513
- // single-exec transports keep the plain prompt-file stdin).
1514
- const streamInput = this.spec.streamInput !== undefined
1515
- && this.sandbox.commands.runBackground !== undefined;
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, streamInput
1518
- ? `${this.spec.streamInput!.promptLine(opts.prompt)}\n`
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: streamInput ? fifoPath : 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
- ...(streamInput ? { streamInput: true } : {}),
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 stream input: the feeder subshell (see
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 = streamInput
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 below.
1751
- if (streamInput) {
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