@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 +63 -2
- package/dist/adapters/acp.cjs +2 -0
- package/dist/adapters/antigravity.cjs +2 -1
- package/dist/adapters/claude.cjs +381 -2
- 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/config.cjs +1 -0
- package/dist/prompt.cjs +81 -2
- package/dist/session.cjs +242 -14
- package/dist/steering.cjs +515 -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,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. |
|
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
|
/**
|
|
@@ -230,12 +247,25 @@ function attachSession(result, options) {
|
|
|
230
247
|
...(rejected ? { resumeRejected: true } : {}),
|
|
231
248
|
};
|
|
232
249
|
}
|
|
233
|
-
/**
|
|
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,
|
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
|
};
|