@sagentlab/navarch-runtime 0.1.129 → 0.1.131

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,12 +531,13 @@ 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. |
477
538
  | `NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS` | `3600000` (1 hour) | How often the runtime scans for session worktrees abandoned by a hard exit or host restart. |
478
539
  | `NAVARCH_WORKTREE_STALE_AFTER_MS` | `86400000` (24 hours) | Minimum inactivity age before an abandoned session worktree is removed. Active sessions are always protected. |
479
- | `NAVARCH_GIT_AUTHOR_NAME` / `NAVARCH_GIT_AUTHOR_EMAIL` | `sagentlab` / `z@sagentlab.com` | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. |
540
+ | `NAVARCH_GIT_AUTHOR_NAME` / `NAVARCH_GIT_AUTHOR_EMAIL` | `sagentlab` / `z@sagentlab.com` | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. For a project whose git proxy mode is not `off`, the control plane's commit identity (the GitHub App bot, or the consenting requester per the project's `git_attribution_mode`) replaces it, and a shared `prepare-commit-msg` hook adds `Navarch-Task` and any `Co-authored-by` trailer once. |
480
541
  | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
481
542
  | `NAVARCH_SANDBOX_PROFILE` | `trusted-development` | Named security profile for Docker sessions (`src/sandbox-profile.cts`): `trusted-development` (image-default user, uncapped, open egress), `untrusted-code` (non-root, 2 CPU / 4g / 512 PIDs, deny-by-default egress, read-only shared git), `elevated-verification` (non-root, 4 CPU / 8g / 2048 PIDs, egress limited to GitHub plus package registries). The default is the exact pre-profile flag set. |
482
543
  | `NAVARCH_SANDBOX_EGRESS_NETWORK` | _(unset)_ | Docker network that enforces a profile's egress allowlist. Docker cannot filter by domain itself, so an allowlist profile without this fails closed to `--network=none` and records the denial. |
@@ -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
  /**
@@ -297,6 +314,228 @@ async function runOnHost(options, args) {
297
314
  });
298
315
  });
299
316
  }
317
+ /** How long the process may stay open for a written message the agent has not echoed after its turn ended. */
318
+ const UNECHOED_INPUT_GRACE_MS = 30_000;
319
+ /**
320
+ * `claude -p --input-format stream-json --replay-user-messages`: the prompt is
321
+ * the first user message, and stdin stays open so a steering message written
322
+ * during the turn is injected at the agent's next step (spike S1 in
323
+ * docs/navarch/live-steering.md, verified against claude 2.1.304).
324
+ *
325
+ * `--replay-user-messages` makes Claude Code echo each user message back, with
326
+ * its uuid, when it takes the message into the conversation. That echo is the
327
+ * only proof of consumption, so the process is closed to input only once every
328
+ * written message has been echoed: Claude Code reads a message that is already
329
+ * in the pipe even after stdin ends and runs it as a further turn, but a
330
+ * message written after the process has finished is silently dropped, which is
331
+ * why `send` refuses once input is closed.
332
+ */
333
+ async function runOnHostStreaming(options, args, live) {
334
+ return new Promise((resolve) => {
335
+ let stdout = "";
336
+ let stderr = "";
337
+ let timedOut = false;
338
+ let killedByLeaseLoss = false;
339
+ let inputOpen = true;
340
+ let lineBuffer = "";
341
+ let graceTimer;
342
+ // Written steering messages the agent has not echoed yet.
343
+ const pending = new Map();
344
+ const child = (0, node_child_process_1.spawn)(options.bin, args, {
345
+ detached: process.platform !== "win32",
346
+ cwd: options.cwd,
347
+ env: { ...process.env, ...options.env },
348
+ });
349
+ let attached = false;
350
+ const detach = () => {
351
+ if (!attached)
352
+ return;
353
+ attached = false;
354
+ live.attach(null);
355
+ };
356
+ child.stdin?.on("error", () => {
357
+ // The child exited before reading; `close` reports it, and the session
358
+ // takes back every message it never echoed.
359
+ inputOpen = false;
360
+ detach();
361
+ });
362
+ const writeUser = (id, text) => {
363
+ child.stdin?.write(`${JSON.stringify({
364
+ type: "user",
365
+ uuid: id,
366
+ message: { role: "user", content: [{ type: "text", text }] },
367
+ })}\n`);
368
+ };
369
+ const closeInput = () => {
370
+ if (!inputOpen)
371
+ return;
372
+ inputOpen = false;
373
+ clearTimeout(graceTimer);
374
+ detach();
375
+ child.stdin?.end();
376
+ };
377
+ const promptId = (0, node_crypto_1.randomUUID)();
378
+ writeUser(promptId, options.prompt);
379
+ attached = true;
380
+ live.attach({
381
+ send: (input) => {
382
+ if (!inputOpen || child.exitCode !== null || child.killed)
383
+ return false;
384
+ try {
385
+ writeUser(input.id, input.text);
386
+ }
387
+ catch {
388
+ return false;
389
+ }
390
+ pending.set(input.id, { afterTurnEnd: false });
391
+ return true;
392
+ },
393
+ });
394
+ options.onProcessStarted?.(child.pid);
395
+ /** Reads one stdout line. Returns false for the line that must stay out of the transcript. */
396
+ const handleRecord = (line) => {
397
+ if (!line.startsWith("{"))
398
+ return true;
399
+ let record;
400
+ try {
401
+ record = JSON.parse(line);
402
+ }
403
+ catch {
404
+ return true;
405
+ }
406
+ if (typeof record !== "object" || record === null || Array.isArray(record))
407
+ return true;
408
+ const { type, uuid, isReplay } = record;
409
+ if (type === "user" && isReplay === true && typeof uuid === "string") {
410
+ // The echo of the task brief would copy the whole prompt into every
411
+ // transcript; a one-shot launch never prints it. Steering messages are
412
+ // kept: the echo shows where in the turn the agent took them in.
413
+ if (uuid === promptId)
414
+ return false;
415
+ const entry = pending.get(uuid);
416
+ if (!entry)
417
+ return true;
418
+ pending.delete(uuid);
419
+ live.consumed(uuid, entry.afterTurnEnd);
420
+ }
421
+ else if (type === "result") {
422
+ // A turn ended. A message written during it that was not echoed will
423
+ // start the next turn, so it now counts as consumed after the boundary.
424
+ for (const entry of pending.values())
425
+ entry.afterTurnEnd = true;
426
+ if (pending.size === 0)
427
+ closeInput();
428
+ else {
429
+ clearTimeout(graceTimer);
430
+ graceTimer = setTimeout(closeInput, UNECHOED_INPUT_GRACE_MS);
431
+ }
432
+ }
433
+ return true;
434
+ };
435
+ /** Passes on a stdout line to the result and the live transcript, unless it is withheld. */
436
+ const forwardLine = (line, terminator) => (handleRecord(line.trim()) ? line + terminator : "");
437
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
438
+ timedOut = true;
439
+ (0, kill_agent_cjs_1.killAgent)(child);
440
+ }, options.timeoutMs) : undefined;
441
+ const onAbort = () => {
442
+ killedByLeaseLoss = true;
443
+ (0, kill_agent_cjs_1.killAgent)(child);
444
+ };
445
+ options.signal?.addEventListener("abort", onAbort, { once: true });
446
+ if (options.signal?.aborted)
447
+ onAbort();
448
+ child.stdout.on("data", (d) => {
449
+ options.onActivity?.();
450
+ lineBuffer += d.toString();
451
+ let forwarded = "";
452
+ let newline = lineBuffer.indexOf("\n");
453
+ while (newline >= 0) {
454
+ forwarded += forwardLine(lineBuffer.slice(0, newline), "\n");
455
+ lineBuffer = lineBuffer.slice(newline + 1);
456
+ newline = lineBuffer.indexOf("\n");
457
+ }
458
+ if (forwarded) {
459
+ stdout += forwarded;
460
+ options.onOutput?.("stdout", forwarded);
461
+ }
462
+ });
463
+ child.stderr.on("data", (d) => {
464
+ options.onActivity?.();
465
+ const text = d.toString();
466
+ stderr += text;
467
+ options.onOutput?.("stderr", text);
468
+ });
469
+ const finish = (exitCode) => {
470
+ clearTimeout(timer);
471
+ clearTimeout(graceTimer);
472
+ inputOpen = false;
473
+ detach();
474
+ if (lineBuffer) {
475
+ // A final line with no newline.
476
+ const rest = forwardLine(lineBuffer, "");
477
+ lineBuffer = "";
478
+ if (rest) {
479
+ stdout += rest;
480
+ options.onOutput?.("stdout", rest);
481
+ }
482
+ }
483
+ options.signal?.removeEventListener("abort", onAbort);
484
+ resolve({ exitCode, timedOut, killedByLeaseLoss, stdout, stderr });
485
+ };
486
+ child.on("error", (err) => {
487
+ stderr += `\n${String(err)}`;
488
+ finish(null);
489
+ });
490
+ child.on("close", (code) => finish(code));
491
+ });
492
+ }
493
+ /** `claude --help`, cached per binary: it lists the flags this version accepts. */
494
+ const claudeHelpCache = new Map();
495
+ function readClaudeHelp(bin, env) {
496
+ const cached = claudeHelpCache.get(bin);
497
+ if (cached)
498
+ return cached;
499
+ const help = new Promise((resolve) => {
500
+ let output = "";
501
+ let settled = false;
502
+ const done = (text) => {
503
+ if (settled)
504
+ return;
505
+ settled = true;
506
+ clearTimeout(timer);
507
+ resolve(text);
508
+ };
509
+ const child = (0, node_child_process_1.spawn)(bin, ["--help"], { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "ignore"] });
510
+ const timer = setTimeout(() => {
511
+ child.kill("SIGKILL");
512
+ done("");
513
+ }, 10_000);
514
+ child.stdout?.on("data", (chunk) => { output += chunk.toString(); });
515
+ child.on("error", () => done(""));
516
+ child.on("close", (code) => done(code === 0 ? output : ""));
517
+ });
518
+ // A failed probe is not remembered: the CLI may be installed or fixed later.
519
+ void help.then((text) => { if (!text)
520
+ claudeHelpCache.delete(bin); });
521
+ claudeHelpCache.set(bin, help);
522
+ return help;
523
+ }
524
+ /**
525
+ * Native steering needs `--input-format stream-json` and
526
+ * `--replay-user-messages`, the host path (Docker `exec -i` has no streaming
527
+ * runner here), and the stream formats left to Navarch.
528
+ */
529
+ async function probeClaudeSteering(context) {
530
+ if (context.sandboxMode !== "host")
531
+ return null;
532
+ if (hasFlag(context.extraArgs, "--input-format") || hasFlag(context.extraArgs, "--output-format"))
533
+ return null;
534
+ const help = await readClaudeHelp(context.bin, context.env);
535
+ if (!/--input-format\b/.test(help) || !/--replay-user-messages\b/.test(help))
536
+ return null;
537
+ return { steer: "native", queue: "boundary", interrupt: "restart" };
538
+ }
300
539
  async function runViaDocker(options, args) {
301
540
  const { containerName, runner } = options.dockerExec;
302
541
  const quoted = [options.bin, ...args].map(shellQuote).join(" ");
@@ -351,6 +590,8 @@ exports.claudeCodeAdapter = {
351
590
  agentType: "claude-code",
352
591
  run: runClaudeCodeAdapter,
353
592
  resumesTurns: true,
593
+ steering: types_cjs_1.TURN_RESTART_STEERING,
594
+ probeSteering: probeClaudeSteering,
354
595
  // `usage` is per turn; `total_cost_usd` covers the whole resumed conversation.
355
596
  cumulativeResumeUsage: ["costUsd"],
356
597
  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
  };
@@ -1,2 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NO_STEERING = exports.TURN_RESTART_STEERING = void 0;
4
+ /**
5
+ * Every adapter that runs inside session.cts's turn loop gets at least this:
6
+ * a queued message joins the next turn once the current one ends, and an
7
+ * interrupt stops the process and starts the next turn in the same worktree
8
+ * (resuming the conversation when `resumesTurns` is set). There is no channel
9
+ * for input during a turn, so `steer` is `none` and falls back to `queue`.
10
+ */
11
+ exports.TURN_RESTART_STEERING = Object.freeze({
12
+ steer: "none",
13
+ queue: "boundary",
14
+ interrupt: "restart",
15
+ });
16
+ /** Retired adapters take no messages: every send falls back to the next session. */
17
+ exports.NO_STEERING = Object.freeze({
18
+ steer: "none",
19
+ queue: "none",
20
+ interrupt: "none",
21
+ });
package/dist/api.cjs CHANGED
@@ -63,11 +63,16 @@ class NavarchApiClient {
63
63
  }
64
64
  const requestUrl = `${this.baseUrl}${pathname}`;
65
65
  const abortController = new AbortController();
66
+ const timeoutMs = opts?.timeoutMs ?? this.requestTimeoutMs;
66
67
  const requestTimeout = setTimeout(() => {
67
- const timeoutError = new Error(`request timed out after ${this.requestTimeoutMs}ms`);
68
+ const timeoutError = new Error(`request timed out after ${timeoutMs}ms`);
68
69
  timeoutError.name = "TimeoutError";
69
70
  abortController.abort(timeoutError);
70
- }, this.requestTimeoutMs);
71
+ }, timeoutMs);
72
+ const onCallerAbort = () => abortController.abort(opts?.signal?.reason);
73
+ opts?.signal?.addEventListener("abort", onCallerAbort, { once: true });
74
+ if (opts?.signal?.aborted)
75
+ onCallerAbort();
71
76
  try {
72
77
  const response = await this.fetchImpl(requestUrl, {
73
78
  method,
@@ -96,6 +101,7 @@ class NavarchApiClient {
96
101
  }
97
102
  finally {
98
103
  clearTimeout(requestTimeout);
104
+ opts?.signal?.removeEventListener("abort", onCallerAbort);
99
105
  }
100
106
  }
101
107
  /** Global machine registration using the operator-configured enrollment secret. */
@@ -141,6 +147,29 @@ class NavarchApiClient {
141
147
  throw new Error("heartbeatLease: empty response from control plane.");
142
148
  return result;
143
149
  }
150
+ /**
151
+ * `GET /api/dispatch/:leaseId/steering?after_seq=N&wait=S`: the live-steering
152
+ * long-poll (docs/navarch/live-steering.md section 6.2). The server answers as
153
+ * soon as a message is dispatched to the lease or a cancel is requested, else
154
+ * after `waitSeconds` with the lease's unacked messages. It does not renew the
155
+ * lease. An older control plane answers 404.
156
+ */
157
+ async pollSteering(leaseId, opts) {
158
+ const query = new URLSearchParams({
159
+ after_seq: String(opts.afterSeq),
160
+ wait: String(opts.waitSeconds),
161
+ });
162
+ const result = await this.request("GET", `/api/dispatch/${encodeURIComponent(leaseId)}/steering?${query}`, undefined,
163
+ // The server holds the request for up to `wait` seconds; leave room for it.
164
+ { timeoutMs: this.requestTimeoutMs + opts.waitSeconds * 1000, signal: opts.signal });
165
+ if (!result)
166
+ throw new Error("pollSteering: empty response from control plane.");
167
+ return result;
168
+ }
169
+ /** `POST /api/dispatch/:leaseId/steering/:messageId/ack` (section 6.3). A 409 means the message is no longer this lease's to deliver. */
170
+ async ackSteering(leaseId, messageId, ack) {
171
+ await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/steering/${encodeURIComponent(messageId)}/ack`, ack, { allowEmpty: true });
172
+ }
144
173
  /** Best-effort, lease-scoped activity signal for connected machines. */
145
174
  async reportActivity(leaseId, phase, detail) {
146
175
  await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/activity`, { phase, ...(detail ? { detail } : {}) }, { allowEmpty: true });
@@ -4,6 +4,8 @@ exports.ClaimLoop = void 0;
4
4
  const node_crypto_1 = require("node:crypto");
5
5
  const api_cjs_1 = require("./api.cjs");
6
6
  const logger_cjs_1 = require("./logger.cjs");
7
+ const index_cjs_1 = require("./adapters/index.cjs");
8
+ const steering_cjs_1 = require("./steering.cjs");
7
9
  const log = (0, logger_cjs_1.createLogger)("claim");
8
10
  const MAX_CLAIM_BACKOFF_MS = 60_000;
9
11
  const pendingClaimSlot = "pending-claim-request";
@@ -77,11 +79,16 @@ class ClaimLoop {
77
79
  this.capacity.acquire(pendingClaimSlot);
78
80
  let claimed;
79
81
  try {
82
+ // The record is the primary adapter's. When the control plane assigns
83
+ // another advertised runtime, the session corrects it on its first
84
+ // lease heartbeat (session.cts).
85
+ const steeringCapabilities = (0, steering_cjs_1.liveSteeringCapabilities)((0, index_cjs_1.selectAdapter)(this.config.agentType), this.config);
80
86
  claimed = await this.api.claim({
81
87
  available_capacity: availableCapacity,
82
88
  capabilities: this.config.capabilities,
83
89
  agent_type: this.config.agentType,
84
90
  runtimes: this.config.runtimes,
91
+ ...(steeringCapabilities ? { steering_capabilities: steeringCapabilities } : {}),
85
92
  session_id: sessionId,
86
93
  });
87
94
  }