@sagentlab/navarch-runtime 0.1.130 → 0.1.132

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 CHANGED
@@ -286,10 +286,70 @@ files. A supervisor also pins its starting identity into replacement workers,
286
286
  so an accidental later edit to `machine.json` cannot change that running
287
287
  instance during an automatic-update handoff.
288
288
 
289
+ ## Live steering
290
+
291
+ A person can send a message to a session that is already running, from the
292
+ task page, Slack or the user MCP, without stopping it. A project opts in with
293
+ `projects.steering_live_enabled`; while that is off, guidance behaves as
294
+ described under [Active-task guidance](#active-task-guidance). The design is in
295
+ [`docs/navarch/live-steering.md`](../docs/navarch/live-steering.md).
296
+
297
+ The runtime sends a **capability record** with every claim, and the control
298
+ plane resolves each message against it, so a message is never silently
299
+ downgraded. Every adapter except the retired Gemini tombstone says
300
+ `queue: boundary, interrupt: restart`. Claude Code on the host also says
301
+ `steer: native` once a startup probe finds that the installed CLI accepts
302
+ streaming input.
303
+
304
+ | Sent as | Claude Code (host) | Every other adapter, and Claude Code in Docker |
305
+ |---|---|---|
306
+ | `steer` | Written to the running agent, which reads it at its next step. The turn is not stopped. | Becomes `queue`, with the fallback reason `steer_unsupported`. |
307
+ | `queue` | Waits for the current turn to end, then the next turn resumes the conversation with it. | The same: the next turn carries it (`codex exec resume` for Codex; a fresh invocation with the full brief for the rest). |
308
+ | `interrupt` | The agent process is stopped and the next turn, in the same lease and worktree, starts with the message (`claude -p --resume`). | The same. In Docker the sandbox container is restarted first, because stopping the agent stops the container. |
309
+
310
+ How it works:
311
+
312
+ - A long-poll (`GET /api/dispatch/:leaseId/steering?wait=20`) delivers a message
313
+ within about a second. The lease heartbeat carries the same payload as a
314
+ fallback, so a lost long-poll does not lose a message, but the heartbeat is
315
+ `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` apart (five minutes by default) and the
316
+ control plane gives a `steer` or `interrupt` 90 s to be acknowledged, so such a
317
+ message then falls back to the next session. A control plane without the
318
+ endpoint answers 404 three times in a row and the runtime stops long-polling.
319
+ - Each message is **acknowledged** once the agent has it: when the agent process
320
+ first produces output after starting with the message in its prompt (or five
321
+ seconds after it started without failing), or, for a native steer, when Claude
322
+ Code echoes the message back (`--replay-user-messages`) as proof that it read
323
+ it. A message the runtime cannot deliver, including one whose agent never
324
+ started, is acknowledged `failed`, and the control plane hands it to the next
325
+ session; nothing is dropped silently.
326
+ - A message is introduced to the agent with a header that names the sender, the
327
+ time, a short id and the mode, for example
328
+ `[Message from Alex at 2026-10-21T12:03:00.000Z, id a1b2c3d4, sent as queue]`.
329
+ The id lets the agent ignore a repeat. After an interrupt the prompt also says
330
+ that the previous turn was stopped, so the agent inspects `git status`.
331
+ - A completion that races a message is refused with `409 steering_pending`; the
332
+ runtime runs another turn instead of finishing.
333
+ - A sender can cancel a queued message until it reaches the agent. The runtime
334
+ answers the cancel instead of delivering it.
335
+
336
+ Set `NAVARCH_LIVE_STEERING=off` to make a machine a legacy runtime: it sends no
337
+ capability record and only an explicit interrupt reaches it. Native steering
338
+ needs host mode, a `claude` that lists `--input-format` and
339
+ `--replay-user-messages` in `--help`, and no `--input-format` or
340
+ `--output-format` in `NAVARCH_CLAUDE_EXTRA_ARGS`; otherwise Claude Code keeps
341
+ the baseline above.
342
+
343
+ `NAVARCH_LIVE_STEERING_SMOKE=1 npx vitest run tests/steering-live-smoke.test.cts`
344
+ runs steer, queue and interrupt against the installed `claude` (add
345
+ `NAVARCH_LIVE_STEERING_SMOKE_CODEX=1` for Codex) and an in-process port of the
346
+ control-plane contract, and `NAVARCH_LIVE_SMOKE_REPORT=<file>` writes the
347
+ evidence. It spends a few cents of provider usage per scenario.
348
+
289
349
  ## Active-task guidance
290
350
 
291
- Guidance added to a task that is already running is delivered on that
292
- task's next lease heartbeat (every
351
+ Guidance added to a task that is already running, on a project without live
352
+ steering, is delivered on that task's next lease heartbeat (every
293
353
  `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS`, five minutes by default). The runtime
294
354
  stops the current agent process and starts a **new agent turn**. This is a
295
355
  turn restart, not a new dispatch:
@@ -471,6 +531,7 @@ unchanged across the deployment.
471
531
  | `NAVARCH_POLL_INTERVAL_MS` | `30000` | Claim-loop poll interval. |
472
532
  | `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
473
533
  | `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 60-minute lease TTL (schema-design.md §4). |
534
+ | `NAVARCH_LIVE_STEERING` | on | Tell the control plane this runtime takes live-steering messages (see [Live steering](#live-steering)). `off` makes it a legacy runtime. |
474
535
  | `NAVARCH_SESSION_TIMEOUT_MS` | `0` (disabled) | Optional absolute runtime budget across agent turns in a claimed session. |
475
536
  | `NAVARCH_SESSION_IDLE_TIMEOUT_MS` | `2700000` (45 min) | Stop after a continuously observed window with no stdout/stderr or process-tree CPU activity. `0` disables idle detection. |
476
537
  | `NAVARCH_OPENCODE_STARTUP_TIMEOUT_MS` | `120000` (2 min) | Stop OpenCode when it produces no stdout/stderr during startup. This catches provider auth/quota failures that leave its CLI process alive. `0` disables the startup deadline. |
@@ -6,6 +6,7 @@ const node_child_process_1 = require("node:child_process");
6
6
  const node_fs_1 = require("node:fs");
7
7
  const node_stream_1 = require("node:stream");
8
8
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
9
+ const types_cjs_1 = require("./types.cjs");
9
10
  /**
10
11
  * How long a cancelled agent may take to observe `session/cancel` and settle
11
12
  * the in-flight `session/prompt` before the subprocess is terminated. The
@@ -276,4 +277,5 @@ function describeError(error) {
276
277
  exports.acpAdapter = {
277
278
  agentType: "acp",
278
279
  run: runAcpAdapter,
280
+ steering: types_cjs_1.TURN_RESTART_STEERING,
279
281
  };
@@ -12,6 +12,7 @@ const node_string_decoder_1 = require("node:string_decoder");
12
12
  const config_cjs_1 = require("../config.cjs");
13
13
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
14
14
  const antigravity_mcp_cjs_1 = require("./antigravity-mcp.cjs");
15
+ const types_cjs_1 = require("./types.cjs");
15
16
  /** Documented agy 1.3.1 contract, not Gemini CLI's similarly named format.
16
17
  * https://antigravity.google/docs/cli/headless/
17
18
  * https://antigravity.google/docs/changelog (1.1.27, 1.1.28, 1.2.6, 1.2.10)
@@ -345,4 +346,4 @@ async function runDocker(options, args, input) {
345
346
  }
346
347
  }
347
348
  function quote(value) { return `'${value.replace(/'/g, `'\\''`)}'`; }
348
- exports.antigravityAdapter = { agentType: "antigravity", run: runAntigravityAdapter };
349
+ exports.antigravityAdapter = { agentType: "antigravity", run: runAntigravityAdapter, steering: types_cjs_1.TURN_RESTART_STEERING };
@@ -2,9 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.claudeCodeAdapter = void 0;
4
4
  exports.runClaudeCodeAdapter = runClaudeCodeAdapter;
5
+ exports.probeClaudeSteering = probeClaudeSteering;
5
6
  const node_child_process_1 = require("node:child_process");
7
+ const node_crypto_1 = require("node:crypto");
6
8
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
7
9
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
10
+ const types_cjs_1 = require("./types.cjs");
8
11
  const claude_conversation_cjs_1 = require("./claude-conversation.cjs");
9
12
  const adapter_capacity_cjs_1 = require("../adapter-capacity.cjs");
10
13
  const DEFAULT_CLAUDE_MODEL = "claude-opus-5-5";
@@ -163,6 +166,16 @@ async function runClaudeAttempt(options, automaticFallback) {
163
166
  if (!hasFlag(options.extraArgs, "--verbose"))
164
167
  args.push("--verbose");
165
168
  }
169
+ // Native steering (docs/navarch/live-steering.md section 5.3). The prompt then
170
+ // travels as the first stream-json user message and stdin stays open, so a
171
+ // later message reaches the agent at its next step. Only on the host path and
172
+ // only when the operator has not chosen the stream formats themselves.
173
+ const liveInput = options.liveInput && !options.dockerExec && !hasFlag(options.extraArgs, "--input-format") &&
174
+ !hasFlag(options.extraArgs, "--output-format")
175
+ ? options.liveInput
176
+ : undefined;
177
+ if (liveInput)
178
+ args.push("--input-format", "stream-json", "--replay-user-messages");
166
179
  args.push(...options.extraArgs);
167
180
  if (options.resumeSessionId)
168
181
  args.push("--resume", options.resumeSessionId);
@@ -180,7 +193,11 @@ async function runClaudeAttempt(options, automaticFallback) {
180
193
  env: { ...options.env, CLAUDE_CODE_EFFORT_LEVEL: options.reasoningEffort },
181
194
  }
182
195
  : options;
183
- const raw = runOptions.dockerExec ? await runViaDocker(runOptions, args) : await runOnHost(runOptions, args);
196
+ const raw = runOptions.dockerExec
197
+ ? await runViaDocker(runOptions, args)
198
+ : liveInput
199
+ ? await runOnHostStreaming(runOptions, args, liveInput)
200
+ : await runOnHost(runOptions, args);
184
201
  return attachSession(attachUsage(raw), options);
185
202
  }
186
203
  /**
@@ -230,12 +247,25 @@ function attachSession(result, options) {
230
247
  ...(rejected ? { resumeRejected: true } : {}),
231
248
  };
232
249
  }
233
- /** Parses the final `claude -p --output-format stream-json` result record for usage and folds it onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd unset when parsing fails). */
250
+ /**
251
+ * Parses the `claude -p --output-format stream-json` result records for usage
252
+ * and folds them onto the raw result (best-effort; leaves
253
+ * tokensIn/tokensOut/costUsd unset when parsing fails). A streaming launch runs
254
+ * one turn per interrupt or late message, each ending in its own `result`:
255
+ * `usage` covers that turn alone, so the tokens are summed, while
256
+ * `total_cost_usd` is already the process's running total, so the last one counts.
257
+ */
234
258
  function attachUsage(result) {
235
259
  const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout);
236
260
  if (!parsed)
237
261
  return result;
238
262
  const usage = (0, exit_conditions_cjs_1.extractUsageFromClaudeJson)(parsed);
263
+ const turns = streamResultRecords(result.stdout).map(exit_conditions_cjs_1.extractUsageFromClaudeJson);
264
+ if (turns.length > 1) {
265
+ usage.tokensIn = turns.reduce((sum, turn) => sum + turn.tokensIn, 0);
266
+ usage.tokensOut = turns.reduce((sum, turn) => sum + turn.tokensOut, 0);
267
+ usage.cacheHitTokensIn = turns.reduce((sum, turn) => sum + turn.cacheHitTokensIn, 0);
268
+ }
239
269
  return {
240
270
  ...result,
241
271
  tokensIn: usage.tokensIn,
@@ -244,6 +274,26 @@ function attachUsage(result) {
244
274
  costUsd: usage.costUsd,
245
275
  };
246
276
  }
277
+ /** Every stream-json `type: "result"` record, one per turn the process ran. */
278
+ function streamResultRecords(stdout) {
279
+ const records = [];
280
+ for (const line of stdout.split("\n")) {
281
+ const trimmed = line.trim();
282
+ if (!trimmed.startsWith("{"))
283
+ continue;
284
+ try {
285
+ const record = JSON.parse(trimmed);
286
+ if (typeof record === "object" && record !== null && !Array.isArray(record) &&
287
+ record.type === "result") {
288
+ records.push(record);
289
+ }
290
+ }
291
+ catch {
292
+ continue;
293
+ }
294
+ }
295
+ return records;
296
+ }
247
297
  async function runOnHost(options, args) {
248
298
  return new Promise((resolve) => {
249
299
  let stdout = "";
@@ -297,6 +347,333 @@ async function runOnHost(options, args) {
297
347
  });
298
348
  });
299
349
  }
350
+ /** How long the process may stay open for a written message the agent has not echoed after its turn ended. */
351
+ const UNECHOED_INPUT_GRACE_MS = 30_000;
352
+ /**
353
+ * How long an interrupt waits for the stopped turn's `result` before its input
354
+ * is written anyway. Spike S1 saw the result at once; a CLI that ignores the
355
+ * request then reads the message at its next step, which the ack reports as a steer.
356
+ */
357
+ const INTERRUPT_RESULT_TIMEOUT_MS = 15_000;
358
+ /**
359
+ * `claude -p --input-format stream-json --replay-user-messages`: the prompt is
360
+ * the first user message, and stdin stays open so a steering message written
361
+ * during the turn is injected at the agent's next step (spike S1 in
362
+ * docs/navarch/live-steering.md, verified against claude 2.1.304).
363
+ *
364
+ * `--replay-user-messages` makes Claude Code echo each user message back, with
365
+ * its uuid, when it takes the message into the conversation. That echo is the
366
+ * only proof of consumption, so the process is closed to input only once every
367
+ * written message has been echoed: Claude Code reads a message that is already
368
+ * in the pipe even after stdin ends and runs it as a further turn, but a
369
+ * message written after the process has finished is silently dropped, which is
370
+ * why `send` refuses once input is closed.
371
+ *
372
+ * Native interrupt (spike S1, claude 2.1.284 and 2.1.304): the control request
373
+ * `{"type":"control_request","request":{"subtype":"interrupt"}}` stops the
374
+ * running tool and ends the turn with a `result` of subtype
375
+ * `error_during_execution`, and the process and conversation stay. The
376
+ * interrupt's message is written only after that `result`, so it starts the
377
+ * next turn instead of joining the stopped one, and every message sent while
378
+ * the interrupt is pending waits behind it, in order (as the hosted relay does).
379
+ */
380
+ async function runOnHostStreaming(options, args, live) {
381
+ return new Promise((resolve) => {
382
+ let stdout = "";
383
+ let stderr = "";
384
+ let timedOut = false;
385
+ let killedByLeaseLoss = false;
386
+ let inputOpen = true;
387
+ let lineBuffer = "";
388
+ let graceTimer;
389
+ let interruptTimer;
390
+ let interruptRequests = 0;
391
+ // A turn is running: the prompt starts one, and a `result` ends it.
392
+ let turnRunning = true;
393
+ // Written steering messages the agent has not echoed yet.
394
+ const pending = new Map();
395
+ // Inputs waiting for the turn an interrupt is stopping, in the order they were sent.
396
+ const held = [];
397
+ const child = (0, node_child_process_1.spawn)(options.bin, args, {
398
+ detached: process.platform !== "win32",
399
+ cwd: options.cwd,
400
+ env: { ...process.env, ...options.env },
401
+ });
402
+ let attached = false;
403
+ const detach = () => {
404
+ if (!attached)
405
+ return;
406
+ attached = false;
407
+ live.attach(null);
408
+ };
409
+ child.stdin?.on("error", () => {
410
+ // The child exited before reading; `close` reports it, and the session
411
+ // takes back every message it never echoed.
412
+ inputOpen = false;
413
+ detach();
414
+ });
415
+ const writeUser = (id, text) => {
416
+ child.stdin?.write(`${JSON.stringify({
417
+ type: "user",
418
+ uuid: id,
419
+ message: { role: "user", content: [{ type: "text", text }] },
420
+ })}\n`);
421
+ };
422
+ const closeInput = () => {
423
+ if (!inputOpen)
424
+ return;
425
+ inputOpen = false;
426
+ clearTimeout(graceTimer);
427
+ detach();
428
+ child.stdin?.end();
429
+ };
430
+ const canWrite = () => inputOpen && child.exitCode === null && !child.killed;
431
+ /** Writes one steering input. A message written while no turn runs starts the next one. */
432
+ const writeInput = (input, state) => {
433
+ try {
434
+ writeUser(input.id, input.text);
435
+ }
436
+ catch {
437
+ return false;
438
+ }
439
+ pending.set(input.id, state);
440
+ return true;
441
+ };
442
+ /**
443
+ * Writes what waited for the interrupted turn. `stopped` is null when the
444
+ * turn has not ended (no `result` came in time), so the inputs join it.
445
+ */
446
+ const releaseHeld = (stopped) => {
447
+ clearTimeout(interruptTimer);
448
+ interruptTimer = undefined;
449
+ // Input closed meanwhile: nothing is written, and the session takes them back.
450
+ if (!canWrite()) {
451
+ held.length = 0;
452
+ return;
453
+ }
454
+ // The first input (the interrupt that sent the request) starts the next
455
+ // turn and owns the stop; the rest join that turn.
456
+ let first = true;
457
+ for (const { input } of held.splice(0)) {
458
+ writeInput(input, {
459
+ afterTurnEnd: first && stopped !== null,
460
+ stoppedTurn: first && stopped === true,
461
+ });
462
+ first = false;
463
+ }
464
+ turnRunning = true;
465
+ };
466
+ const promptId = (0, node_crypto_1.randomUUID)();
467
+ writeUser(promptId, options.prompt);
468
+ attached = true;
469
+ live.attach({
470
+ send: (input) => {
471
+ if (!canWrite())
472
+ return false;
473
+ // Behind a pending interrupt, so the interrupt stays the next input.
474
+ if (held.length > 0) {
475
+ held.push({ input, interrupt: false });
476
+ return true;
477
+ }
478
+ return writeInput(input, { afterTurnEnd: !turnRunning, stoppedTurn: false });
479
+ },
480
+ interrupt: (input) => {
481
+ if (!canWrite())
482
+ return false;
483
+ // Nothing to stop: the message starts the next turn, a boundary delivery.
484
+ if (!turnRunning)
485
+ return writeInput(input, { afterTurnEnd: true, stoppedTurn: false });
486
+ held.push({ input, interrupt: true });
487
+ if (held.length > 1)
488
+ return true;
489
+ try {
490
+ interruptRequests += 1;
491
+ child.stdin?.write(`${JSON.stringify({
492
+ type: "control_request",
493
+ request_id: `navarch-interrupt-${interruptRequests}`,
494
+ request: { subtype: "interrupt" },
495
+ })}\n`);
496
+ }
497
+ catch {
498
+ held.length = 0;
499
+ return false;
500
+ }
501
+ interruptTimer = setTimeout(() => releaseHeld(null), INTERRUPT_RESULT_TIMEOUT_MS);
502
+ return true;
503
+ },
504
+ });
505
+ options.onProcessStarted?.(child.pid);
506
+ /** Reads one stdout line. Returns false for the line that must stay out of the transcript. */
507
+ const handleRecord = (line) => {
508
+ if (!line.startsWith("{"))
509
+ return true;
510
+ let record;
511
+ try {
512
+ record = JSON.parse(line);
513
+ }
514
+ catch {
515
+ return true;
516
+ }
517
+ if (typeof record !== "object" || record === null || Array.isArray(record))
518
+ return true;
519
+ const { type, subtype, uuid, isReplay } = record;
520
+ if (type === "user" && isReplay === true && typeof uuid === "string") {
521
+ // The echo of the task brief would copy the whole prompt into every
522
+ // transcript; a one-shot launch never prints it. Steering messages are
523
+ // kept: the echo shows where in the turn the agent took them in.
524
+ if (uuid === promptId)
525
+ return false;
526
+ const entry = pending.get(uuid);
527
+ if (!entry)
528
+ return true;
529
+ pending.delete(uuid);
530
+ // The agent took it in, so a turn is running again; its `result`
531
+ // decides whether input can close.
532
+ turnRunning = true;
533
+ clearTimeout(graceTimer);
534
+ live.consumed(uuid, entry.afterTurnEnd, entry.stoppedTurn);
535
+ }
536
+ else if (type === "control_response" && held.length > 0) {
537
+ // A CLI that refuses the interrupt stops nothing: the waiting inputs
538
+ // join the running turn now instead of after the 15 s fallback.
539
+ const response = record.response;
540
+ if (response?.subtype === "error" && response.request_id === `navarch-interrupt-${interruptRequests}`) {
541
+ releaseHeld(null);
542
+ }
543
+ }
544
+ else if (type === "result") {
545
+ // A turn ended. A message written during it that was not echoed will
546
+ // start the next turn, so it now counts as consumed after the boundary,
547
+ // and that next turn counts as running before its echo arrives: an
548
+ // interrupt in that gap must still stop it.
549
+ turnRunning = pending.size > 0;
550
+ for (const entry of pending.values())
551
+ entry.afterTurnEnd = true;
552
+ // An interrupt was waiting for this turn to end. Only a turn the request
553
+ // cut short ends in `error_during_execution`; one that finished first
554
+ // was not stopped, and its message is a boundary delivery.
555
+ if (held.length > 0)
556
+ releaseHeld(subtype === "error_during_execution");
557
+ if (pending.size === 0)
558
+ closeInput();
559
+ else {
560
+ clearTimeout(graceTimer);
561
+ graceTimer = setTimeout(closeInput, UNECHOED_INPUT_GRACE_MS);
562
+ }
563
+ }
564
+ return true;
565
+ };
566
+ /** Passes on a stdout line to the result and the live transcript, unless it is withheld. */
567
+ const forwardLine = (line, terminator) => (handleRecord(line.trim()) ? line + terminator : "");
568
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
569
+ timedOut = true;
570
+ (0, kill_agent_cjs_1.killAgent)(child);
571
+ }, options.timeoutMs) : undefined;
572
+ const onAbort = () => {
573
+ killedByLeaseLoss = true;
574
+ (0, kill_agent_cjs_1.killAgent)(child);
575
+ };
576
+ options.signal?.addEventListener("abort", onAbort, { once: true });
577
+ if (options.signal?.aborted)
578
+ onAbort();
579
+ child.stdout.on("data", (d) => {
580
+ options.onActivity?.();
581
+ lineBuffer += d.toString();
582
+ let forwarded = "";
583
+ let newline = lineBuffer.indexOf("\n");
584
+ while (newline >= 0) {
585
+ forwarded += forwardLine(lineBuffer.slice(0, newline), "\n");
586
+ lineBuffer = lineBuffer.slice(newline + 1);
587
+ newline = lineBuffer.indexOf("\n");
588
+ }
589
+ if (forwarded) {
590
+ stdout += forwarded;
591
+ options.onOutput?.("stdout", forwarded);
592
+ }
593
+ });
594
+ child.stderr.on("data", (d) => {
595
+ options.onActivity?.();
596
+ const text = d.toString();
597
+ stderr += text;
598
+ options.onOutput?.("stderr", text);
599
+ });
600
+ const finish = (exitCode) => {
601
+ clearTimeout(timer);
602
+ // An input still waiting for an interrupted turn was never written: the
603
+ // session takes it back with every message the agent did not echo.
604
+ held.length = 0;
605
+ inputOpen = false;
606
+ detach();
607
+ if (lineBuffer) {
608
+ // A final line with no newline.
609
+ const rest = forwardLine(lineBuffer, "");
610
+ lineBuffer = "";
611
+ if (rest) {
612
+ stdout += rest;
613
+ options.onOutput?.("stdout", rest);
614
+ }
615
+ }
616
+ // After the last line: a trailing `result` there can arm the grace timer.
617
+ clearTimeout(graceTimer);
618
+ clearTimeout(interruptTimer);
619
+ options.signal?.removeEventListener("abort", onAbort);
620
+ resolve({ exitCode, timedOut, killedByLeaseLoss, stdout, stderr });
621
+ };
622
+ child.on("error", (err) => {
623
+ stderr += `\n${String(err)}`;
624
+ finish(null);
625
+ });
626
+ child.on("close", (code) => finish(code));
627
+ });
628
+ }
629
+ /** `claude --help`, cached per binary: it lists the flags this version accepts. */
630
+ const claudeHelpCache = new Map();
631
+ function readClaudeHelp(bin, env) {
632
+ const cached = claudeHelpCache.get(bin);
633
+ if (cached)
634
+ return cached;
635
+ const help = new Promise((resolve) => {
636
+ let output = "";
637
+ let settled = false;
638
+ const done = (text) => {
639
+ if (settled)
640
+ return;
641
+ settled = true;
642
+ clearTimeout(timer);
643
+ resolve(text);
644
+ };
645
+ const child = (0, node_child_process_1.spawn)(bin, ["--help"], { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "ignore"] });
646
+ const timer = setTimeout(() => {
647
+ child.kill("SIGKILL");
648
+ done("");
649
+ }, 10_000);
650
+ child.stdout?.on("data", (chunk) => { output += chunk.toString(); });
651
+ child.on("error", () => done(""));
652
+ child.on("close", (code) => done(code === 0 ? output : ""));
653
+ });
654
+ // A failed probe is not remembered: the CLI may be installed or fixed later.
655
+ void help.then((text) => { if (!text)
656
+ claudeHelpCache.delete(bin); });
657
+ claudeHelpCache.set(bin, help);
658
+ return help;
659
+ }
660
+ /**
661
+ * Native steering needs `--input-format stream-json` and
662
+ * `--replay-user-messages`, the host path (Docker `exec -i` has no streaming
663
+ * runner here), and the stream formats left to Navarch. The same streaming
664
+ * process takes the interrupt control request (spike S1), so native steering
665
+ * also brings native interrupt; without it, interrupt stays `restart`.
666
+ */
667
+ async function probeClaudeSteering(context) {
668
+ if (context.sandboxMode !== "host")
669
+ return null;
670
+ if (hasFlag(context.extraArgs, "--input-format") || hasFlag(context.extraArgs, "--output-format"))
671
+ return null;
672
+ const help = await readClaudeHelp(context.bin, context.env);
673
+ if (!/--input-format\b/.test(help) || !/--replay-user-messages\b/.test(help))
674
+ return null;
675
+ return { steer: "native", queue: "boundary", interrupt: "native" };
676
+ }
300
677
  async function runViaDocker(options, args) {
301
678
  const { containerName, runner } = options.dockerExec;
302
679
  const quoted = [options.bin, ...args].map(shellQuote).join(" ");
@@ -351,6 +728,8 @@ exports.claudeCodeAdapter = {
351
728
  agentType: "claude-code",
352
729
  run: runClaudeCodeAdapter,
353
730
  resumesTurns: true,
731
+ steering: types_cjs_1.TURN_RESTART_STEERING,
732
+ probeSteering: probeClaudeSteering,
354
733
  // `usage` is per turn; `total_cost_usd` covers the whole resumed conversation.
355
734
  cumulativeResumeUsage: ["costUsd"],
356
735
  conversation: claude_conversation_cjs_1.claudeConversationRunner,
@@ -8,6 +8,7 @@ const node_fs_1 = require("node:fs");
8
8
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
9
9
  const codex_pricing_cjs_1 = require("../codex-pricing.cjs");
10
10
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
11
+ const types_cjs_1 = require("./types.cjs");
11
12
  /**
12
13
  * Headless OpenAI Codex CLI adapter — the Codex sibling of claude.cts's
13
14
  * `runClaudeCodeAdapter`, implementing the same AgentAdapter interface
@@ -364,6 +365,7 @@ exports.codexAdapter = {
364
365
  agentType: "codex",
365
366
  run: runCodexAdapter,
366
367
  resumesTurns: true,
368
+ steering: types_cjs_1.TURN_RESTART_STEERING,
367
369
  // `exec resume` reports the thread's total_token_usage, not the turn's.
368
370
  cumulativeResumeUsage: ["tokensIn", "tokensOut", "cacheHitTokensIn", "costUsd"],
369
371
  };
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.geminiAdapter = void 0;
4
4
  exports.runGeminiAdapter = runGeminiAdapter;
5
+ const types_cjs_1 = require("./types.cjs");
5
6
  /** Tombstone for stale claims. Provider IDs and credentials are never aliased. */
6
7
  async function runGeminiAdapter(options) {
7
8
  return {
@@ -12,4 +13,4 @@ async function runGeminiAdapter(options) {
12
13
  stderr: "Gemini CLI is retired. Explicitly install and authenticate Antigravity, select antigravity, and update the task's runtime pin. No Gemini credentials or conversation IDs are transferred.",
13
14
  };
14
15
  }
15
- exports.geminiAdapter = { agentType: "gemini", run: runGeminiAdapter };
16
+ exports.geminiAdapter = { agentType: "gemini", run: runGeminiAdapter, steering: types_cjs_1.NO_STEERING };
@@ -12,6 +12,7 @@ const node_path_1 = __importDefault(require("node:path"));
12
12
  const node_crypto_1 = require("node:crypto");
13
13
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
14
14
  const version_cjs_1 = require("../version.cjs");
15
+ const types_cjs_1 = require("./types.cjs");
15
16
  const CANCEL_GRACE_MS = 2_000;
16
17
  const SHUTDOWN_GRACE_MS = 30_000;
17
18
  const TERMINATE_GRACE_MS = 2_000;
@@ -430,4 +431,4 @@ function spawnMuse(options) {
430
431
  }
431
432
  // Read-only Navi conversations are deliberately absent: an approval mode is
432
433
  // not a filesystem read boundary, and the SDK cannot yet prove that contract.
433
- exports.museAdapter = { agentType: "muse", run: runMuseAdapter };
434
+ exports.museAdapter = { agentType: "muse", run: runMuseAdapter, steering: types_cjs_1.TURN_RESTART_STEERING };
@@ -13,6 +13,7 @@ const node_path_1 = __importDefault(require("node:path"));
13
13
  const config_cjs_1 = require("../config.cjs");
14
14
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
15
15
  const opencode_providers_cjs_1 = require("./opencode-providers.cjs");
16
+ const types_cjs_1 = require("./types.cjs");
16
17
  /**
17
18
  * Headless OpenCode adapter, fixture-pinned to the OpenCode JSON event
18
19
  * contract shared by V1 and V2 (verified against 1.19.1, 2.0.6 and 2.0.17):
@@ -541,4 +542,5 @@ function shellQuote(value) {
541
542
  exports.openCodeAdapter = {
542
543
  agentType: "opencode",
543
544
  run: runOpenCodeAdapter,
545
+ steering: types_cjs_1.TURN_RESTART_STEERING,
544
546
  };
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.localPiAdapter = void 0;
4
4
  exports.runLocalPiAdapter = runLocalPiAdapter;
5
+ const types_cjs_1 = require("./types.cjs");
5
6
  const pi_cjs_1 = require("./pi.cjs");
6
7
  /** BYO Pi: native MCP, operator provider catalog, and a local OpenAI-compatible default. */
7
8
  async function runLocalPiAdapter(options) {
@@ -18,4 +19,4 @@ async function runLocalPiAdapter(options) {
18
19
  args.push("-p");
19
20
  return (0, pi_cjs_1.attachPiOutput)(await (0, pi_cjs_1.runOnHost)(options, args, true));
20
21
  }
21
- exports.localPiAdapter = { agentType: "pi-local", run: runLocalPiAdapter };
22
+ exports.localPiAdapter = { agentType: "pi-local", run: runLocalPiAdapter, steering: types_cjs_1.TURN_RESTART_STEERING };
@@ -16,6 +16,7 @@ const node_fs_1 = require("node:fs");
16
16
  const node_os_1 = __importDefault(require("node:os"));
17
17
  const node_path_1 = __importDefault(require("node:path"));
18
18
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
19
+ const types_cjs_1 = require("./types.cjs");
19
20
  /**
20
21
  * Headless Pi coding-agent adapter (#885) — the process-side twin of the
21
22
  * Cloudflare hosted executor (worker/hosted-pi/lifecycle.ts). It invokes
@@ -444,4 +445,5 @@ function shellQuote(value) {
444
445
  exports.piAdapter = {
445
446
  agentType: "pi",
446
447
  run: runPiAdapter,
448
+ steering: types_cjs_1.TURN_RESTART_STEERING,
447
449
  };
@@ -5,6 +5,7 @@ exports.runQoderAdapter = runQoderAdapter;
5
5
  const node_child_process_1 = require("node:child_process");
6
6
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
7
7
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
8
+ const types_cjs_1 = require("./types.cjs");
8
9
  function hasFlag(args, flag) {
9
10
  return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
10
11
  }
@@ -175,4 +176,5 @@ function shellQuote(value) {
175
176
  exports.qoderAdapter = {
176
177
  agentType: "qoder",
177
178
  run: runQoderAdapter,
179
+ steering: types_cjs_1.TURN_RESTART_STEERING,
178
180
  };