@sagentlab/navarch-runtime 0.1.131 → 0.1.133

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.
@@ -247,12 +247,25 @@ function attachSession(result, options) {
247
247
  ...(rejected ? { resumeRejected: true } : {}),
248
248
  };
249
249
  }
250
- /** Parses the final `claude -p --output-format stream-json` result record for usage and folds it onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd unset when parsing fails). */
250
+ /**
251
+ * Parses the `claude -p --output-format stream-json` result records for usage
252
+ * and folds them onto the raw result (best-effort; leaves
253
+ * tokensIn/tokensOut/costUsd unset when parsing fails). A streaming launch runs
254
+ * one turn per interrupt or late message, each ending in its own `result`:
255
+ * `usage` covers that turn alone, so the tokens are summed, while
256
+ * `total_cost_usd` is already the process's running total, so the last one counts.
257
+ */
251
258
  function attachUsage(result) {
252
259
  const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout);
253
260
  if (!parsed)
254
261
  return result;
255
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
+ }
256
269
  return {
257
270
  ...result,
258
271
  tokensIn: usage.tokensIn,
@@ -261,6 +274,26 @@ function attachUsage(result) {
261
274
  costUsd: usage.costUsd,
262
275
  };
263
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
+ }
264
297
  async function runOnHost(options, args) {
265
298
  return new Promise((resolve) => {
266
299
  let stdout = "";
@@ -316,6 +349,12 @@ async function runOnHost(options, args) {
316
349
  }
317
350
  /** How long the process may stay open for a written message the agent has not echoed after its turn ended. */
318
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;
319
358
  /**
320
359
  * `claude -p --input-format stream-json --replay-user-messages`: the prompt is
321
360
  * the first user message, and stdin stays open so a steering message written
@@ -329,6 +368,14 @@ const UNECHOED_INPUT_GRACE_MS = 30_000;
329
368
  * in the pipe even after stdin ends and runs it as a further turn, but a
330
369
  * message written after the process has finished is silently dropped, which is
331
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).
332
379
  */
333
380
  async function runOnHostStreaming(options, args, live) {
334
381
  return new Promise((resolve) => {
@@ -339,8 +386,14 @@ async function runOnHostStreaming(options, args, live) {
339
386
  let inputOpen = true;
340
387
  let lineBuffer = "";
341
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;
342
393
  // Written steering messages the agent has not echoed yet.
343
394
  const pending = new Map();
395
+ // Inputs waiting for the turn an interrupt is stopping, in the order they were sent.
396
+ const held = [];
344
397
  const child = (0, node_child_process_1.spawn)(options.bin, args, {
345
398
  detached: process.platform !== "win32",
346
399
  cwd: options.cwd,
@@ -374,20 +427,78 @@ async function runOnHostStreaming(options, args, live) {
374
427
  detach();
375
428
  child.stdin?.end();
376
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
+ };
377
466
  const promptId = (0, node_crypto_1.randomUUID)();
378
467
  writeUser(promptId, options.prompt);
379
468
  attached = true;
380
469
  live.attach({
381
470
  send: (input) => {
382
- if (!inputOpen || child.exitCode !== null || child.killed)
471
+ if (!canWrite())
383
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;
384
489
  try {
385
- writeUser(input.id, input.text);
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`);
386
496
  }
387
497
  catch {
498
+ held.length = 0;
388
499
  return false;
389
500
  }
390
- pending.set(input.id, { afterTurnEnd: false });
501
+ interruptTimer = setTimeout(() => releaseHeld(null), INTERRUPT_RESULT_TIMEOUT_MS);
391
502
  return true;
392
503
  },
393
504
  });
@@ -405,7 +516,7 @@ async function runOnHostStreaming(options, args, live) {
405
516
  }
406
517
  if (typeof record !== "object" || record === null || Array.isArray(record))
407
518
  return true;
408
- const { type, uuid, isReplay } = record;
519
+ const { type, subtype, uuid, isReplay } = record;
409
520
  if (type === "user" && isReplay === true && typeof uuid === "string") {
410
521
  // The echo of the task brief would copy the whole prompt into every
411
522
  // transcript; a one-shot launch never prints it. Steering messages are
@@ -416,13 +527,33 @@ async function runOnHostStreaming(options, args, live) {
416
527
  if (!entry)
417
528
  return true;
418
529
  pending.delete(uuid);
419
- live.consumed(uuid, entry.afterTurnEnd);
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
+ }
420
543
  }
421
544
  else if (type === "result") {
422
545
  // 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.
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;
424
550
  for (const entry of pending.values())
425
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");
426
557
  if (pending.size === 0)
427
558
  closeInput();
428
559
  else {
@@ -468,7 +599,9 @@ async function runOnHostStreaming(options, args, live) {
468
599
  });
469
600
  const finish = (exitCode) => {
470
601
  clearTimeout(timer);
471
- clearTimeout(graceTimer);
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;
472
605
  inputOpen = false;
473
606
  detach();
474
607
  if (lineBuffer) {
@@ -480,6 +613,9 @@ async function runOnHostStreaming(options, args, live) {
480
613
  options.onOutput?.("stdout", rest);
481
614
  }
482
615
  }
616
+ // After the last line: a trailing `result` there can arm the grace timer.
617
+ clearTimeout(graceTimer);
618
+ clearTimeout(interruptTimer);
483
619
  options.signal?.removeEventListener("abort", onAbort);
484
620
  resolve({ exitCode, timedOut, killedByLeaseLoss, stdout, stderr });
485
621
  };
@@ -524,7 +660,9 @@ function readClaudeHelp(bin, env) {
524
660
  /**
525
661
  * Native steering needs `--input-format stream-json` and
526
662
  * `--replay-user-messages`, the host path (Docker `exec -i` has no streaming
527
- * runner here), and the stream formats left to Navarch.
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`.
528
666
  */
529
667
  async function probeClaudeSteering(context) {
530
668
  if (context.sandboxMode !== "host")
@@ -534,7 +672,7 @@ async function probeClaudeSteering(context) {
534
672
  const help = await readClaudeHelp(context.bin, context.env);
535
673
  if (!/--input-format\b/.test(help) || !/--replay-user-messages\b/.test(help))
536
674
  return null;
537
- return { steer: "native", queue: "boundary", interrupt: "restart" };
675
+ return { steer: "native", queue: "boundary", interrupt: "native" };
538
676
  }
539
677
  async function runViaDocker(options, args) {
540
678
  const { containerName, runner } = options.dockerExec;
@@ -12,7 +12,11 @@ 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
+ // `interrupt: native` is MSP `turn/cancel` on the live connection, then a new
16
+ // `turn/start` in the same session. It is a protocol request and is not the
17
+ // lease-loss path, which waits CANCEL_GRACE_MS and then kills the host. There
18
+ // is no mid-turn input channel: `steer` is `none` and `queue` waits for the turn.
19
+ const MUSE_STEERING = Object.freeze({ steer: "none", queue: "boundary", interrupt: "native" });
16
20
  const CANCEL_GRACE_MS = 2_000;
17
21
  const SHUTDOWN_GRACE_MS = 30_000;
18
22
  const TERMINATE_GRACE_MS = 2_000;
@@ -82,7 +86,9 @@ async function runMuseProcess(options, mcpServers, sdk) {
82
86
  let protocolFailed = false;
83
87
  const closed = deferred();
84
88
  const stopped = deferred();
85
- const completed = deferred();
89
+ let completed = deferred();
90
+ let pendingInterrupt;
91
+ let sawTurn = false; // An earlier turn ended, so the session is resumable even if the next start fails.
86
92
  const items = new Map();
87
93
  const usage = new Map();
88
94
  const pending = [];
@@ -252,19 +258,56 @@ async function runMuseProcess(options, mcpServers, sdk) {
252
258
  }
253
259
  if (timedOut || killedByLeaseLoss || protocolFailed)
254
260
  return;
255
- const ack = await connection.command("turn/start", {
256
- sessionId,
257
- input: [{ type: "text", text: options.prompt }],
258
- ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}),
259
- }, { maxAttempts: 1 });
260
- if (typeof ack.turnId !== "string" || ack.disposition !== "started")
261
- throw new Error("Muse did not start the requested turn.");
262
- turnId = ack.turnId;
263
- for (const notification of pending.splice(0))
264
- consume(notification);
265
- await Promise.race([completed.promise, connection.closed]);
266
- if (!terminal && !timedOut && !killedByLeaseLoss)
267
- throw new Error("Muse closed before the turn completed.");
261
+ const startTurn = async (text) => {
262
+ const ack = await connection.command("turn/start", {
263
+ sessionId,
264
+ input: [{ type: "text", text }],
265
+ ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}),
266
+ }, { maxAttempts: 1 });
267
+ if (typeof ack.turnId !== "string" || ack.disposition !== "started")
268
+ throw new Error("Muse did not start the requested turn.");
269
+ turnId = ack.turnId;
270
+ for (const notification of pending.splice(0))
271
+ consume(notification);
272
+ };
273
+ const channel = {
274
+ send: () => false,
275
+ // Native interrupt: cancel the running turn on this connection. The
276
+ // host stays up; the run loop starts the next turn once this one ends.
277
+ interrupt: (input) => {
278
+ if (!turnId || terminal || pendingInterrupt || cancelling || timedOut || killedByLeaseLoss || protocolFailed)
279
+ return false;
280
+ pendingInterrupt = input;
281
+ void connection.command("turn/cancel", { sessionId, turnId }, { maxAttempts: 1 }).catch(() => undefined);
282
+ return true;
283
+ },
284
+ };
285
+ await startTurn(options.prompt);
286
+ options.liveInput?.attach(channel);
287
+ for (;;) {
288
+ await Promise.race([completed.promise, connection.closed]);
289
+ if (!terminal && !timedOut && !killedByLeaseLoss)
290
+ throw new Error("Muse closed before the turn completed.");
291
+ const next = pendingInterrupt;
292
+ const endedCleanly = terminal?.terminal === "cancelled" || terminal?.terminal === "completed";
293
+ if (!next || !endedCleanly || timedOut || killedByLeaseLoss || protocolFailed)
294
+ break;
295
+ // The cancelled turn is the interrupt, not a failure. A turn that
296
+ // finished before the cancel landed makes this a boundary delivery.
297
+ const stoppedTurn = terminal?.terminal === "cancelled";
298
+ pendingInterrupt = undefined;
299
+ sawTurn = true;
300
+ terminal = undefined;
301
+ turnId = undefined;
302
+ completed = deferred();
303
+ items.clear();
304
+ await startTurn(next.text);
305
+ // Teardown may have run while the ack was in flight; do not re-attach.
306
+ if (timedOut || killedByLeaseLoss || protocolFailed || cancelling)
307
+ return;
308
+ options.liveInput?.consumed(next.id, !stoppedTurn, stoppedTurn);
309
+ options.liveInput?.attach(channel);
310
+ }
268
311
  };
269
312
  await Promise.race([run(), stopped.promise]);
270
313
  }
@@ -276,6 +319,7 @@ async function runMuseProcess(options, mcpServers, sdk) {
276
319
  diagnostic(error instanceof Error ? error.message : "Muse protocol request failed.");
277
320
  }
278
321
  finally {
322
+ options.liveInput?.attach(null);
279
323
  await cancelling;
280
324
  // EOF releases Muse's writer lease. A bounded shutdown always reaps the
281
325
  // child, including a host that ignores EOF or died during a request.
@@ -312,7 +356,7 @@ async function runMuseProcess(options, mcpServers, sdk) {
312
356
  timedOut, killedByLeaseLoss, stdout, stderr,
313
357
  ...(reportText ? { reportText } : {}),
314
358
  ...(authError ? { authError } : {}),
315
- ...(durable && sessionId && terminal ? { agentSessionId: sessionId } : {}),
359
+ ...(durable && sessionId && (terminal || sawTurn) ? { agentSessionId: sessionId } : {}),
316
360
  ...normalizedUsage([...usage.values()], !options.resumeSessionId),
317
361
  };
318
362
  }
@@ -431,4 +475,4 @@ function spawnMuse(options) {
431
475
  }
432
476
  // Read-only Navi conversations are deliberately absent: an approval mode is
433
477
  // not a filesystem read boundary, and the SDK cannot yet prove that contract.
434
- exports.museAdapter = { agentType: "muse", run: runMuseAdapter, steering: types_cjs_1.TURN_RESTART_STEERING };
478
+ exports.museAdapter = { agentType: "muse", run: runMuseAdapter, steering: MUSE_STEERING };
package/dist/prompt.cjs CHANGED
@@ -4,6 +4,7 @@ exports.renderPrompt = renderPrompt;
4
4
  exports.renderGuidanceCorrectionPrompt = renderGuidanceCorrectionPrompt;
5
5
  exports.renderMidFlightGuidanceSection = renderMidFlightGuidanceSection;
6
6
  exports.renderSteerInjection = renderSteerInjection;
7
+ exports.renderInterruptInjection = renderInterruptInjection;
7
8
  exports.steeringPromptNote = steeringPromptNote;
8
9
  exports.renderResumedTurnPreamble = renderResumedTurnPreamble;
9
10
  exports.renderResumedGuidancePrompt = renderResumedGuidancePrompt;
@@ -133,6 +134,20 @@ function renderSteerInjection(entry) {
133
134
  block,
134
135
  ].join("\n");
135
136
  }
137
+ /**
138
+ * The input a native interrupt hands the agent once its turn has stopped: the
139
+ * interrupt and every earlier message it carries, in `seq` order (section 6.4).
140
+ * The text is written before the outcome is known (the turn may have finished
141
+ * first), so it does not claim that a step was cut short.
142
+ */
143
+ function renderInterruptInjection(entries) {
144
+ const blocks = entries.map((entry) => entry.steering ? steeringBlock(entry, entry.steering).trimEnd() : entry.content);
145
+ return [
146
+ "A teammate sent an interrupt: stop what you were doing and follow the messages below. They take precedence over earlier context. Continue in the same worktree. If your last step was cut short, it may have left half-finished edits, so run `git status` and review the recent changes first. If you already received a message with the same id, ignore this repeat.",
147
+ "",
148
+ blocks.join("\n\n"),
149
+ ].join("\n");
150
+ }
136
151
  /**
137
152
  * The brief section that makes live messages credible and spoof-resistant. The
138
153
  * token is generated per lease and appears nowhere an attacker's content could
package/dist/session.cjs CHANGED
@@ -914,8 +914,8 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
914
914
  signal: turnAbort.signal,
915
915
  // Native steering: the adapter keeps the agent's input open and
916
916
  // reports each message the agent reads. Absent for every adapter
917
- // whose record says `steer: none`.
918
- liveInput: steering && steeringCapabilities?.steer === "native" ? steering.liveInput : undefined,
917
+ // whose record says neither `steer: native` nor `interrupt: native`.
918
+ liveInput: steering && (steeringCapabilities?.steer === "native" || steeringCapabilities?.interrupt === "native") ? steering.liveInput : undefined,
919
919
  onProcessStarted: () => {
920
920
  if (steeringTaken.length > 0 && !steeringTurnStarted) {
921
921
  clearTimeout(steeringStartTimer);
package/dist/steering.cjs CHANGED
@@ -12,7 +12,10 @@
12
12
  // Most adapters take no input during a turn, so their messages are delivered
13
13
  // at the turn boundary or by restarting the turn. An adapter that reports
14
14
  // `steer: native` (Claude Code over streaming input) also takes a message into
15
- // the running turn, and confirms it when the agent has read it.
15
+ // the running turn, and confirms it when the agent has read it. One that also
16
+ // reports `interrupt: native` stops the running turn in the same process and
17
+ // hands the agent an `interrupt_native` message as the next turn, with every
18
+ // earlier message that was still waiting (section 6.4).
16
19
  Object.defineProperty(exports, "__esModule", { value: true });
17
20
  exports.SteeringSession = void 0;
18
21
  exports.liveSteeringCapabilities = liveSteeringCapabilities;
@@ -95,7 +98,7 @@ class SteeringSession {
95
98
  /** What the adapter calls while it runs a turn that takes input (native steering). */
96
99
  liveInput = {
97
100
  attach: (channel) => this.attachChannel(channel),
98
- consumed: (id, afterTurnEnd) => this.steerConsumed(id, afterTurnEnd),
101
+ consumed: (id, afterTurnEnd, stoppedTurn) => this.steerConsumed(id, afterTurnEnd, stoppedTurn ?? false),
99
102
  };
100
103
  constructor(options) {
101
104
  this.options = options;
@@ -203,9 +206,12 @@ class SteeringSession {
203
206
  */
204
207
  turnEnded() {
205
208
  this.channel = null;
206
- for (const entry of this.entries.values())
207
- if (entry.phase === "sent")
208
- entry.phase = "held";
209
+ for (const entry of this.entries.values()) {
210
+ if (entry.phase !== "sent")
211
+ continue;
212
+ entry.phase = "held";
213
+ entry.carriedBy = null;
214
+ }
209
215
  }
210
216
  /** The agent process for the turn started, so it holds the messages: report them delivered. */
211
217
  markInjected(taken) {
@@ -268,10 +274,43 @@ class SteeringSession {
268
274
  this.channel = channel;
269
275
  if (!channel)
270
276
  return;
271
- // A steer that arrived after this turn's prompt was built has no other way in.
272
- for (const entry of this.entries.values())
277
+ // A message that arrived after this turn's prompt was built has no other way
278
+ // in. The latest waiting interrupt carries everything before it, so a steer
279
+ // written first cannot overtake it; later steers follow it.
280
+ const interrupt = this.bySeq().filter((entry) => entry.phase === "held" &&
281
+ entry.message.effective_mode === "interrupt_native").at(-1);
282
+ if (interrupt)
283
+ this.tryInterrupt(interrupt);
284
+ for (const entry of this.bySeq())
273
285
  this.trySteer(entry);
274
286
  }
287
+ bySeq() {
288
+ return [...this.entries.values()].sort((a, b) => a.message.seq - b.message.seq);
289
+ }
290
+ /**
291
+ * Stops the running turn natively and hands the agent the interrupt, with
292
+ * every earlier message still waiting, as one input in `seq` order (section
293
+ * 6.4). Left held when there is no channel that can interrupt: the next turn
294
+ * then carries it, and the ack reports a boundary delivery.
295
+ */
296
+ tryInterrupt(entry) {
297
+ if (entry.phase !== "held" || entry.message.effective_mode !== "interrupt_native" || !this.channel?.interrupt)
298
+ return;
299
+ const carried = this.bySeq().filter((other) => other.phase === "held" && other.message.seq <= entry.message.seq);
300
+ const sent = this.channel.interrupt({
301
+ id: entry.message.id,
302
+ text: (0, prompt_cjs_1.renderInterruptInjection)(carried.map((other) => this.guidanceFor(other.message, other === entry))),
303
+ });
304
+ if (!sent)
305
+ return;
306
+ for (const other of carried) {
307
+ other.phase = "sent";
308
+ other.carriedBy = entry.message.id;
309
+ }
310
+ const earlier = carried.slice(0, -1).map((other) => other.message.seq);
311
+ log.info(`interrupt message ${entry.message.id} (seq ${entry.message.seq}) is stopping the running turn` +
312
+ `${earlier.length > 0 ? `, carrying seq ${earlier.join(", ")}` : ""}.`);
313
+ }
275
314
  /** Writes a held `steer` message to the running agent. Left held when there is no open input. */
276
315
  trySteer(entry) {
277
316
  if (entry.phase !== "held" || entry.message.effective_mode !== "steer" || !this.channel)
@@ -285,30 +324,42 @@ class SteeringSession {
285
324
  entry.phase = "sent";
286
325
  log.info(`steer message ${entry.message.id} (seq ${entry.message.seq}) written to the running agent.`);
287
326
  }
288
- /** The agent read a steered message: only now is it delivered. */
289
- steerConsumed(id, afterTurnEnd) {
327
+ /**
328
+ * The agent read a message written to its input: only now is it delivered.
329
+ * For a native interrupt, that is every message its input carried.
330
+ */
331
+ steerConsumed(id, afterTurnEnd, stoppedTurn) {
290
332
  const entry = this.entries.get(id);
291
333
  if (!entry || entry.phase !== "sent")
292
334
  return;
293
- entry.phase = "done";
294
- this.options.onSteerConsumed?.(this.guidanceFor(entry.message, false));
295
- // A message the agent read only after its turn ended began the next turn:
296
- // that is a boundary delivery, whatever it was sent as.
297
- const mode = afterTurnEnd ? "queue" : "steer";
298
- void this.sendAck(id, entry.cancelRequested
299
- ? {
300
- state: "too_late",
301
- effective_mode: mode,
302
- detail: "The cancel arrived after the message was handed to the agent.",
303
- }
304
- : { state: "delivered", effective_mode: mode });
335
+ const batch = entry.carriedBy
336
+ ? this.bySeq().filter((other) => other.phase === "sent" && other.carriedBy === entry.carriedBy)
337
+ : [entry];
338
+ for (const item of batch) {
339
+ item.phase = "done";
340
+ item.carriedBy = null;
341
+ // `interrupt_native` only for the interrupt whose input really stopped the
342
+ // running turn; what it carried is acked `queue`. Anything else the agent
343
+ // read only after its turn ended began the next turn: that is a boundary
344
+ // delivery, whatever it was sent as.
345
+ const interrupted = stoppedTurn && item.message.id === id;
346
+ this.options.onSteerConsumed?.(this.guidanceFor(item.message, interrupted));
347
+ const mode = interrupted ? "interrupt_native" : afterTurnEnd || stoppedTurn ? "queue" : "steer";
348
+ void this.sendAck(item.message.id, item.cancelRequested
349
+ ? {
350
+ state: "too_late",
351
+ effective_mode: mode,
352
+ detail: "The cancel arrived after the message was handed to the agent.",
353
+ }
354
+ : { state: "delivered", effective_mode: mode });
355
+ }
305
356
  }
306
357
  ingestOne(message) {
307
358
  const known = this.entries.get(message.id);
308
359
  if (message.cancel_requested) {
309
360
  if (!known) {
310
361
  // Never seen, so never delivered: honor the cancel.
311
- this.entries.set(message.id, { message, phase: "done", stoppedTurn: false, cancelRequested: true });
362
+ this.entries.set(message.id, { message, phase: "done", stoppedTurn: false, carriedBy: null, cancelRequested: true });
312
363
  void this.sendAck(message.id, { state: "canceled", detail: "Canceled before the runtime read it." });
313
364
  }
314
365
  else if (known.phase === "held") {
@@ -329,10 +380,12 @@ class SteeringSession {
329
380
  if (known.phase === "held" && known.message.effective_mode !== message.effective_mode) {
330
381
  known.message = message;
331
382
  this.stopTurnForInterrupt(known);
383
+ this.tryInterrupt(known);
384
+ this.trySteer(known);
332
385
  }
333
386
  return;
334
387
  }
335
- const entry = { message, phase: "held", stoppedTurn: false, cancelRequested: false };
388
+ const entry = { message, phase: "held", stoppedTurn: false, carriedBy: null, cancelRequested: false };
336
389
  this.entries.set(message.id, entry);
337
390
  const refusal = this.refusal(message);
338
391
  if (refusal) {
@@ -342,6 +395,7 @@ class SteeringSession {
342
395
  return;
343
396
  }
344
397
  this.stopTurnForInterrupt(entry);
398
+ this.tryInterrupt(entry);
345
399
  this.trySteer(entry);
346
400
  }
347
401
  /** Why this runtime cannot deliver `message` as the control plane resolved it, or null. */
@@ -351,11 +405,14 @@ class SteeringSession {
351
405
  case "queue":
352
406
  return capabilities.queue === "none" ? "This runtime does not queue messages." : null;
353
407
  case "interrupt_restart":
354
- return capabilities.interrupt === "restart" ? null : "This runtime cannot restart a turn for an interrupt.";
408
+ // A runtime that interrupts natively can still restart a turn: a message
409
+ // resolved against the claim's baseline record, before the probe's
410
+ // upgrade reached the control plane, is delivered as it was resolved.
411
+ return capabilities.interrupt === "none" ? "This runtime cannot restart a turn for an interrupt." : null;
355
412
  case "steer":
356
413
  return capabilities.steer === "native" ? null : "This runtime cannot inject a message into a running turn.";
357
414
  case "interrupt_native":
358
- return "This runtime cannot interrupt a running turn without restarting it.";
415
+ return capabilities.interrupt === "native" ? null : "This runtime cannot interrupt a running turn without restarting it.";
359
416
  default:
360
417
  return `This runtime cannot deliver a message resolved as ${String(message.effective_mode)}.`;
361
418
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.131",
3
+ "version": "0.1.133",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",