@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 +64 -3
- package/dist/adapters/acp.cjs +2 -0
- package/dist/adapters/antigravity.cjs +2 -1
- package/dist/adapters/claude.cjs +242 -1
- package/dist/adapters/codex.cjs +2 -0
- package/dist/adapters/gemini.cjs +2 -1
- package/dist/adapters/muse.cjs +2 -1
- package/dist/adapters/opencode.cjs +2 -0
- package/dist/adapters/pi-local.cjs +2 -1
- package/dist/adapters/pi.cjs +2 -0
- package/dist/adapters/qoder.cjs +2 -0
- package/dist/adapters/types.cjs +19 -0
- package/dist/api.cjs +31 -2
- package/dist/claim-loop.cjs +7 -0
- package/dist/commit-attribution.cjs +124 -0
- package/dist/config.cjs +1 -0
- package/dist/git-worktree.cjs +8 -2
- package/dist/prompt.cjs +66 -2
- package/dist/session.cjs +249 -14
- package/dist/steering.cjs +458 -0
- package/package.json +1 -1
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
|
|
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. |
|
package/dist/adapters/acp.cjs
CHANGED
|
@@ -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 };
|
package/dist/adapters/claude.cjs
CHANGED
|
@@ -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
|
|
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,
|
package/dist/adapters/codex.cjs
CHANGED
|
@@ -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
|
};
|
package/dist/adapters/gemini.cjs
CHANGED
|
@@ -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 };
|
package/dist/adapters/muse.cjs
CHANGED
|
@@ -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 };
|
package/dist/adapters/pi.cjs
CHANGED
|
@@ -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
|
};
|
package/dist/adapters/qoder.cjs
CHANGED
|
@@ -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
|
};
|
package/dist/adapters/types.cjs
CHANGED
|
@@ -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 ${
|
|
68
|
+
const timeoutError = new Error(`request timed out after ${timeoutMs}ms`);
|
|
68
69
|
timeoutError.name = "TimeoutError";
|
|
69
70
|
abortController.abort(timeoutError);
|
|
70
|
-
},
|
|
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 });
|
package/dist/claim-loop.cjs
CHANGED
|
@@ -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
|
}
|