specrails-core 6.6.5 → 6.7.0

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.
Files changed (118) hide show
  1. package/dist/agent-runtime/capabilities.d.ts +5 -2
  2. package/dist/agent-runtime/capabilities.js +8 -2
  3. package/dist/agent-runtime/change-scope.d.ts +2 -0
  4. package/dist/agent-runtime/change-scope.js +5 -0
  5. package/dist/agent-runtime/cli-executor.js +7 -0
  6. package/dist/agent-runtime/cli.d.ts +6 -1
  7. package/dist/agent-runtime/cli.js +15 -4
  8. package/dist/agent-runtime/compact/reviewer.js +3 -1
  9. package/dist/agent-runtime/config.js +16 -5
  10. package/dist/agent-runtime/efficiency-summary.d.ts +7 -0
  11. package/dist/agent-runtime/efficiency-summary.js +7 -1
  12. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +1 -1
  13. package/dist/agent-runtime/engine/checkpoint/database.d.ts +9 -0
  14. package/dist/agent-runtime/engine/checkpoint/database.js +40 -3
  15. package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +1 -0
  16. package/dist/agent-runtime/engine/checkpoint/ledger.js +7 -2
  17. package/dist/agent-runtime/engine/cli.d.ts +12 -0
  18. package/dist/agent-runtime/engine/cli.js +15 -4
  19. package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
  20. package/dist/agent-runtime/engine/definition-schema.js +1 -0
  21. package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
  22. package/dist/agent-runtime/engine/definition-types.js +1 -1
  23. package/dist/agent-runtime/engine/definition-validator.js +8 -0
  24. package/dist/agent-runtime/engine/efficiency-summary.d.ts +7 -0
  25. package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
  26. package/dist/agent-runtime/engine/invocation-context.js +2 -0
  27. package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
  28. package/dist/agent-runtime/engine/piece-registry.js +1 -1
  29. package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +5 -0
  30. package/dist/agent-runtime/engine/pieces/artifact-contract.js +6 -2
  31. package/dist/agent-runtime/engine/pieces/derivation-coverage.d.ts +30 -0
  32. package/dist/agent-runtime/engine/pieces/derivation-coverage.js +95 -0
  33. package/dist/agent-runtime/engine/pieces/derive-change.d.ts +50 -0
  34. package/dist/agent-runtime/engine/pieces/derive-change.js +209 -0
  35. package/dist/agent-runtime/engine/pieces/index.js +2 -1
  36. package/dist/agent-runtime/engine/pieces/openspec.d.ts +16 -1
  37. package/dist/agent-runtime/engine/pieces/openspec.js +39 -30
  38. package/dist/agent-runtime/engine/pieces/role-turn.js +26 -5
  39. package/dist/agent-runtime/engine/preflight.d.ts +2 -1
  40. package/dist/agent-runtime/engine/preflight.js +3 -1
  41. package/dist/agent-runtime/engine/regex.d.ts +12 -2
  42. package/dist/agent-runtime/engine/regex.js +25 -11
  43. package/dist/agent-runtime/engine/run-status.d.ts +34 -0
  44. package/dist/agent-runtime/engine/run-status.js +7 -1
  45. package/dist/agent-runtime/engine/runs.d.ts +56 -0
  46. package/dist/agent-runtime/engine/runs.js +88 -5
  47. package/dist/agent-runtime/engine/steering/inbox.d.ts +15 -0
  48. package/dist/agent-runtime/engine/steering/inbox.js +21 -1
  49. package/dist/agent-runtime/engine/storage/private-path.js +7 -1
  50. package/dist/agent-runtime/evaluation.js +1 -1
  51. package/dist/agent-runtime/executor-types.d.ts +19 -0
  52. package/dist/agent-runtime/executor-types.js +10 -0
  53. package/dist/agent-runtime/failure-classification.d.ts +51 -0
  54. package/dist/agent-runtime/failure-classification.js +110 -0
  55. package/dist/agent-runtime/graph/artifacts.d.ts +3 -1
  56. package/dist/agent-runtime/graph/artifacts.js +5 -1
  57. package/dist/agent-runtime/graph/baseline.d.ts +50 -0
  58. package/dist/agent-runtime/graph/baseline.js +145 -0
  59. package/dist/agent-runtime/graph/capability-map.d.ts +29 -0
  60. package/dist/agent-runtime/graph/capability-map.js +65 -0
  61. package/dist/agent-runtime/graph/clarifications.d.ts +75 -0
  62. package/dist/agent-runtime/graph/clarifications.js +158 -0
  63. package/dist/agent-runtime/graph/nodes.js +178 -19
  64. package/dist/agent-runtime/graph/review-brief-record.d.ts +25 -0
  65. package/dist/agent-runtime/graph/review-brief-record.js +26 -0
  66. package/dist/agent-runtime/graph/review-brief.d.ts +73 -0
  67. package/dist/agent-runtime/graph/review-brief.js +127 -0
  68. package/dist/agent-runtime/graph/roles.js +5 -3
  69. package/dist/agent-runtime/graph/spec-trace-record.d.ts +33 -0
  70. package/dist/agent-runtime/graph/spec-trace-record.js +65 -0
  71. package/dist/agent-runtime/graph/state.d.ts +8 -0
  72. package/dist/agent-runtime/index.d.ts +7 -0
  73. package/dist/agent-runtime/index.js +7 -0
  74. package/dist/agent-runtime/openspec.d.ts +1 -0
  75. package/dist/agent-runtime/openspec.js +1 -1
  76. package/dist/agent-runtime/project-rules.d.ts +139 -0
  77. package/dist/agent-runtime/project-rules.js +683 -0
  78. package/dist/agent-runtime/prompts.d.ts +32 -1
  79. package/dist/agent-runtime/prompts.js +180 -7
  80. package/dist/agent-runtime/role-routing.d.ts +2 -1
  81. package/dist/agent-runtime/session/application/active-session.d.ts +43 -2
  82. package/dist/agent-runtime/session/application/active-session.js +163 -7
  83. package/dist/agent-runtime/session/application/session-service.d.ts +2 -2
  84. package/dist/agent-runtime/session/domain/descriptor.js +5 -0
  85. package/dist/agent-runtime/session/domain/interruption.js +1 -0
  86. package/dist/agent-runtime/session/domain/policy.js +2 -0
  87. package/dist/agent-runtime/session/domain/redaction.d.ts +19 -0
  88. package/dist/agent-runtime/session/domain/redaction.js +71 -0
  89. package/dist/agent-runtime/session/domain/types.d.ts +17 -1
  90. package/dist/agent-runtime/session/drivers/claude/argv.d.ts +14 -0
  91. package/dist/agent-runtime/session/drivers/claude/argv.js +19 -2
  92. package/dist/agent-runtime/session/drivers/claude/driver.js +11 -1
  93. package/dist/agent-runtime/session/drivers/claude/translator.d.ts +4 -0
  94. package/dist/agent-runtime/session/drivers/claude/translator.js +16 -3
  95. package/dist/agent-runtime/session/drivers/codex/driver.js +2 -0
  96. package/dist/agent-runtime/session/drivers/executor/driver.js +2 -0
  97. package/dist/agent-runtime/session/host/protocol.d.ts +12 -0
  98. package/dist/agent-runtime/session/host/protocol.js +10 -2
  99. package/dist/agent-runtime/session/host/server.js +6 -5
  100. package/dist/agent-runtime/session/index.d.ts +1 -1
  101. package/dist/agent-runtime/session/journal/sqlite-journal.js +6 -2
  102. package/dist/agent-runtime/session/ports.d.ts +6 -2
  103. package/dist/agent-runtime/session/testing/driver-conformance.js +16 -2
  104. package/dist/agent-runtime/session/testing/memory-journal.js +4 -2
  105. package/dist/installer/cli.js +10 -1
  106. package/dist/installer/commands/init.js +14 -3
  107. package/dist/installer/phases/scaffold.js +56 -5
  108. package/dist/pipeline/pipeline-state.d.ts +15 -0
  109. package/dist/pipeline/pipeline-state.js +36 -3
  110. package/dist/pipeline/spec-trace.d.ts +134 -0
  111. package/dist/pipeline/spec-trace.js +392 -0
  112. package/dist/shared/secret-redaction.d.ts +42 -0
  113. package/dist/shared/secret-redaction.js +184 -0
  114. package/integration-contract.json +177 -7
  115. package/package.json +7 -7
  116. package/schemas/agent-runtime.schema.json +3 -1
  117. package/schemas/workflow-definition.schema.json +1 -0
  118. package/templates/settings/codex-config.toml +3 -2
@@ -42,11 +42,26 @@ export class ActiveSession {
42
42
  descriptor;
43
43
  /** Inputs held back for drivers without a native input queue. */
44
44
  heldInputs = [];
45
+ /** Interrupting inputs held for drivers without a native input queue; released before `heldInputs`. */
46
+ interruptQueue = [];
47
+ /**
48
+ * The turn an `interrupt` delivery is cutting and the interrupting inputs that
49
+ * have not started yet. Cleared when one of them starts a turn.
50
+ */
51
+ pendingInterrupt = null;
45
52
  /** Inputs the application wrote on its own (policy-driven turns). */
46
53
  systemInputIds = new Set();
47
54
  pendingDeferred = null;
48
55
  roster = null;
49
56
  handoffs = 0;
57
+ /** Tool calls (the turn's and its sub-agents') that started and have not completed. */
58
+ toolsInFlight = new Set();
59
+ /**
60
+ * Text the turn streams around its tool calls arrives as separate messages
61
+ * with no whitespace between them. `broken` = text was streamed and a tool
62
+ * call followed, so the next text starts a new paragraph.
63
+ */
64
+ textSegment = 'none';
50
65
  finishedSinceLastTurn = new Set();
51
66
  /** Finished delegated sub-agents whose results the agent has not received yet. */
52
67
  delegatedResults = new Map();
@@ -201,12 +216,95 @@ export class ActiveSession {
201
216
  }
202
217
  // ── Delivery and process lifecycle ────────────────────────────────────────
203
218
  async deliver(input) {
219
+ if (input.delivery === 'interrupt')
220
+ return this.deliverInterrupt(input);
204
221
  const caps = this.descriptor.capabilities;
205
222
  const turnRunning = this.state.openTurn !== null;
206
223
  if (!caps.nativeInputQueue && turnRunning && !(input.delivery === 'steer' && caps.steer)) {
207
224
  this.heldInputs.push(input);
208
225
  return;
209
226
  }
227
+ await this.write(input);
228
+ }
229
+ /**
230
+ * Cut the running turn and run `input` next. Idle sessions deliver it as a
231
+ * queued input. A driver with a native queue and a native primitive does both
232
+ * in one call; otherwise the application stops the turn and releases the
233
+ * input first when the turn ends (Codex: `turn/interrupt` then `turn/start`).
234
+ * A second interrupt while one is pending does not stop again: it follows the
235
+ * pending input, ahead of queued ones.
236
+ */
237
+ async deliverInterrupt(input) {
238
+ const turn = this.state.openTurn;
239
+ const driver = this.driver;
240
+ const pending = this.pendingInterrupt;
241
+ if (!pending && (!turn || !driver))
242
+ return this.deliver({ ...input, delivery: 'queue' });
243
+ const caps = this.descriptor.capabilities;
244
+ if (pending)
245
+ pending.inputs.push(input);
246
+ else
247
+ this.pendingInterrupt = { turnId: turn.turnId, inputs: [input] };
248
+ if (!caps.nativeInputQueue) {
249
+ this.interruptQueue.push(input);
250
+ // A later interrupt waits behind the pending one; it is released when that one's turn ends.
251
+ if (!pending)
252
+ await this.stopForInterrupt(driver, turn.turnId);
253
+ return;
254
+ }
255
+ if (caps.interrupt === 'native') {
256
+ if (!pending)
257
+ this.armInterruptGrace(turn.turnId);
258
+ await this.write(input);
259
+ return;
260
+ }
261
+ if (!pending)
262
+ await this.stopForInterrupt(driver, turn.turnId);
263
+ await this.write({ ...input, delivery: 'queue' });
264
+ }
265
+ async stopForInterrupt(driver, turnId) {
266
+ this.armInterruptGrace(turnId);
267
+ try {
268
+ await driver.interrupt();
269
+ }
270
+ catch (error) {
271
+ // The input is held either way: it runs when the turn ends, or after the grace retires the process.
272
+ this.commit([{ type: 'provider.diagnostic', level: 'warning', code: 'provider.interrupt_failed', message: error.message.slice(0, 2_000) }]);
273
+ }
274
+ }
275
+ armInterruptGrace(turnId) {
276
+ this.timers.arm('interruptGrace', this.deps.interruptGraceMs ?? 5_000, () => {
277
+ if (this.state.openTurn?.turnId === turnId)
278
+ void this.retireForInterrupt();
279
+ });
280
+ }
281
+ /**
282
+ * The provider did not end the cut turn in time: retire the process without
283
+ * losing the interrupting input or the inputs the application still holds,
284
+ * then deliver them to a fresh process (the provider session is resumed).
285
+ */
286
+ async retireForInterrupt() {
287
+ const caps = this.descriptor.capabilities;
288
+ const waiting = (input) => {
289
+ const state = this.state.inputs[input.inputId]?.state;
290
+ return state === 'accepted' || state === 'queued';
291
+ };
292
+ const held = caps.nativeInputQueue ? (this.pendingInterrupt?.inputs ?? []) : [...this.interruptQueue, ...this.heldInputs];
293
+ const carried = held.filter(waiting).map((input) => ({ ...input, delivery: input.delivery === 'interrupt' ? 'queue' : input.delivery }));
294
+ await this.retire('user_stop', { turnStatus: 'interrupted', subagentPhase: 'interrupted', reason: 'user_interrupt', completeStarted: true, keepInputIds: new Set(carried.map((input) => input.inputId)) });
295
+ if (this.state.status === 'closed' || carried.length === 0)
296
+ return;
297
+ if (caps.nativeInputQueue) {
298
+ for (const input of carried)
299
+ await this.write(input).catch(() => undefined);
300
+ return;
301
+ }
302
+ const [first, ...rest] = carried;
303
+ this.heldInputs.push(...rest);
304
+ await this.write(first).catch(() => undefined);
305
+ }
306
+ /** Write one input to the provider; a failed write leaves the input `interrupted`, never half-sent. */
307
+ async write(input) {
210
308
  try {
211
309
  const driver = await this.ensureDriver();
212
310
  await driver.send(input);
@@ -216,6 +314,7 @@ export class ActiveSession {
216
314
  if (current === 'accepted' || current === 'queued') {
217
315
  this.commit([{ type: 'input.state', inputId: input.inputId, state: 'interrupted', reason: 'delivery_failed' }]);
218
316
  }
317
+ this.forgetPendingInterrupt([input.inputId]);
219
318
  throw isSessionError(error) ? error : new SessionError('internal', `Could not deliver input: ${error.message}`);
220
319
  }
221
320
  }
@@ -271,6 +370,7 @@ export class ActiveSession {
271
370
  }
272
371
  }
273
372
  handleDriverEvent(event) {
373
+ this.trackToolInFlight(event);
274
374
  this.markActivity();
275
375
  switch (event.kind) {
276
376
  case 'process.started':
@@ -283,6 +383,7 @@ export class ActiveSession {
283
383
  this.driver = null;
284
384
  this.liveGeneration = null;
285
385
  this.timers.cancel('idle', 'stall', 'backgroundMax', 'turnInactivity', 'settle', 'interruptGrace');
386
+ this.toolsInFlight.clear();
286
387
  this.endRunningWork({ turnStatus: 'failed', subagentPhase: 'interrupted', reason: 'crashed', error: `Provider process exited (code ${event.exitCode ?? 'none'}${event.signal ? `, signal ${event.signal}` : ''})` });
287
388
  this.commit([{ type: 'session.process', state: 'exited', generation: this.generation, exitCode: event.exitCode, reason: 'crashed' }]);
288
389
  this.deps.touched(this);
@@ -299,6 +400,8 @@ export class ActiveSession {
299
400
  const input = this.state.inputs[event.inputId];
300
401
  if (!input || input.state === event.state)
301
402
  return;
403
+ if (event.state === 'rejected')
404
+ this.forgetPendingInterrupt([event.inputId]);
302
405
  this.commit([{ type: 'input.state', inputId: event.inputId, state: event.state, ...(this.state.openTurn && event.state !== 'queued' ? { turnId: this.state.openTurn.turnId } : {}), ...(event.reason ? { reason: event.reason } : {}) }]);
303
406
  return;
304
407
  }
@@ -306,11 +409,13 @@ export class ActiveSession {
306
409
  return this.startTurn(event.trigger, event.inputIds);
307
410
  case 'turn.output': {
308
411
  const turn = this.requireTurn();
309
- this.commitBuffered(this.output.add({ type: 'turn.output', turnId: turn.turnId, channel: event.channel, delta: event.delta }));
412
+ this.commitBuffered(this.output.add({ type: 'turn.output', turnId: turn.turnId, channel: event.channel, delta: event.channel === 'text' ? this.separateTextSegment(event.delta) : event.delta }));
310
413
  return;
311
414
  }
312
415
  case 'turn.tool': {
313
416
  const turn = this.requireTurn();
417
+ if (this.textSegment === 'open')
418
+ this.textSegment = 'broken';
314
419
  const { kind: _kind, ...tool } = event;
315
420
  this.commit([{ type: 'turn.tool', turnId: turn.turnId, ...tool }]);
316
421
  return;
@@ -369,6 +474,9 @@ export class ActiveSession {
369
474
  this.handoffs = 0;
370
475
  }
371
476
  this.finishedSinceLastTurn.clear();
477
+ this.textSegment = 'none';
478
+ if (this.pendingInterrupt?.inputs.some((input) => inputIds.includes(input.inputId)))
479
+ this.pendingInterrupt = null;
372
480
  this.timers.cancel('idle', 'stall');
373
481
  this.commit([
374
482
  ...(this.state.phase === 'turn' ? [] : [{ type: 'session.phase', phase: 'turn' }]),
@@ -376,12 +484,20 @@ export class ActiveSession {
376
484
  ]);
377
485
  this.armTurnInactivity();
378
486
  }
379
- completeTurn(status, text, reported, error) {
487
+ completeTurn(reportedStatus, reportedText, reported, error) {
380
488
  const turn = this.requireTurn();
381
489
  this.timers.cancel('turnInactivity', 'interruptGrace');
490
+ this.toolsInFlight.clear();
491
+ // A stop the application asked for to run an interrupting input is an interruption.
492
+ const status = reportedStatus === 'stopped' && this.pendingInterrupt?.turnId === turn.turnId ? 'interrupted' : reportedStatus;
493
+ // Commit buffered deltas first so a cut turn keeps the partial text it streamed.
494
+ if (this.output.hasPending()) {
495
+ this.timers.cancel('flush');
496
+ this.commit(this.output.flush());
497
+ }
498
+ const text = reportedText || (status === 'interrupted' ? this.state.openTurn?.text ?? '' : '');
382
499
  const { usage, baselineOption } = this.turnUsage(reported);
383
500
  this.commit([
384
- ...this.output.flush(),
385
501
  ...this.output.drainTurn(turn.turnId),
386
502
  { type: 'turn.completed', turnId: turn.turnId, status, text, usage, ...(error ? { error } : {}) },
387
503
  ...this.completeTurnInputs(turn.inputIds, turn.turnId),
@@ -456,6 +572,9 @@ export class ActiveSession {
456
572
  return;
457
573
  if (this.finishedSinceLastTurn.size === 0 || this.handoffs >= policy.limits.maxSettleHandoffs || !this.driver)
458
574
  return;
575
+ // A user correction runs first; the next settle point collects the sub-agents.
576
+ if (this.pendingInterrupt)
577
+ return;
459
578
  this.handoffs += 1;
460
579
  const inputId = this.deps.ids.input();
461
580
  this.systemInputIds.add(inputId);
@@ -466,6 +585,9 @@ export class ActiveSession {
466
585
  maybeDeliverDelegatedResults() {
467
586
  if (this.delegatedResults.size === 0 || this.state.openTurn || this.state.status === 'closed' || this.closing)
468
587
  return false;
588
+ // A user correction runs first; the results follow at the next settle point.
589
+ if (this.pendingInterrupt)
590
+ return false;
469
591
  if (this.handoffs >= this.config.policy.limits.maxSettleHandoffs)
470
592
  return false;
471
593
  const results = [...this.delegatedResults.values()];
@@ -546,9 +668,27 @@ export class ActiveSession {
546
668
  idleTeardown(reason) {
547
669
  return { turnStatus: 'interrupted', subagentPhase: 'interrupted', reason };
548
670
  }
671
+ separateTextSegment(delta) {
672
+ const separate = this.textSegment === 'broken' && !/^\s*\n/.test(delta);
673
+ this.textSegment = 'open';
674
+ return separate ? `\n\n${delta}` : delta;
675
+ }
676
+ trackToolInFlight(event) {
677
+ const tool = event.kind === 'turn.tool' ? event : event.kind === 'subagent.output' ? event.tool : undefined;
678
+ if (!tool?.toolUseId)
679
+ return;
680
+ if (tool.phase === 'started')
681
+ this.toolsInFlight.add(tool.toolUseId);
682
+ else
683
+ this.toolsInFlight.delete(tool.toolUseId);
684
+ }
549
685
  armTurnInactivity() {
550
- this.timers.arm('turnInactivity', this.config.policy.limits.turnInactivityMs, () => {
551
- void this.retire('stalled', { turnStatus: 'failed', subagentPhase: 'interrupted', reason: 'stalled', error: 'No provider activity before the inactivity limit' });
686
+ const limits = this.config.policy.limits;
687
+ const waitingOnTool = this.toolsInFlight.size > 0;
688
+ const limit = waitingOnTool ? Math.max(limits.turnInactivityMs, limits.toolInactivityMs) : limits.turnInactivityMs;
689
+ this.timers.arm('turnInactivity', limit, () => {
690
+ const error = waitingOnTool ? 'A tool call produced no provider activity before the tool inactivity limit' : 'No provider activity before the inactivity limit';
691
+ void this.retire('stalled', { turnStatus: 'failed', subagentPhase: 'interrupted', reason: 'stalled', error });
552
692
  });
553
693
  }
554
694
  markActivity() {
@@ -575,9 +715,17 @@ export class ActiveSession {
575
715
  async releaseHeldInput() {
576
716
  if (this.state.openTurn)
577
717
  return;
578
- const next = this.heldInputs.shift();
718
+ const next = this.interruptQueue.shift() ?? this.heldInputs.shift();
579
719
  if (next)
580
- await this.deliver(next).catch(() => undefined);
720
+ await this.deliver(next.delivery === 'interrupt' ? { ...next, delivery: 'queue' } : next).catch(() => undefined);
721
+ }
722
+ forgetPendingInterrupt(inputIds) {
723
+ const pending = this.pendingInterrupt;
724
+ if (!pending)
725
+ return;
726
+ pending.inputs = pending.inputs.filter((input) => !inputIds.includes(input.inputId));
727
+ if (pending.inputs.length === 0)
728
+ this.pendingInterrupt = null;
581
729
  }
582
730
  /** End whatever was running; used by retire and unexpected exits. */
583
731
  endRunningWork(teardown) {
@@ -605,11 +753,19 @@ export class ActiveSession {
605
753
  }
606
754
  }
607
755
  for (const input of Object.values(this.state.inputs)) {
756
+ if (teardown.keepInputIds?.has(input.inputId))
757
+ continue;
758
+ if (input.state === 'started' && teardown.completeStarted) {
759
+ bodies.push({ type: 'input.state', inputId: input.inputId, state: 'completed', ...(input.turnId ? { turnId: input.turnId } : {}) });
760
+ continue;
761
+ }
608
762
  if (input.state === 'accepted' || input.state === 'queued' || input.state === 'started') {
609
763
  bodies.push({ type: 'input.state', inputId: input.inputId, state: 'interrupted', reason: teardown.reason });
610
764
  }
611
765
  }
612
766
  this.heldInputs.length = 0;
767
+ this.interruptQueue.length = 0;
768
+ this.pendingInterrupt = null;
613
769
  // With delegated work still running the session stays in the background.
614
770
  const nextPhase = this.delegatedLive().length > 0 ? 'background' : 'idle';
615
771
  if (this.state.phase !== nextPhase || turn)
@@ -1,7 +1,7 @@
1
1
  import type { SessionEventEnvelope } from '../domain/events.js';
2
2
  import { type SessionPolicyInput } from '../domain/policy.js';
3
3
  import { type SessionSnapshot } from '../domain/snapshot.js';
4
- import type { Attachment, DriverDescriptor } from '../domain/types.js';
4
+ import type { Attachment, DriverDescriptor, InputDelivery } from '../domain/types.js';
5
5
  import type { RateCard } from '../domain/usage.js';
6
6
  import type { Clock, DriverCatalog, Ids, SessionJournal, SessionSummary } from '../ports.js';
7
7
  import { type DelegateParams, type DelegatedResult } from './delegation.js';
@@ -38,7 +38,7 @@ export interface SendParams {
38
38
  inputId: string;
39
39
  text: string;
40
40
  attachments?: Attachment[];
41
- delivery: 'queue' | 'steer';
41
+ delivery: InputDelivery;
42
42
  }
43
43
  export type SessionListener = (envelopes: SessionEventEnvelope[]) => void;
44
44
  export declare const DEFAULT_MAX_RESIDENT = 6;
@@ -27,6 +27,11 @@ export function validateDescriptor(descriptor) {
27
27
  fail('a native input queue requires a resident process');
28
28
  if (!caps.resident && caps.autonomousContinuation)
29
29
  fail('autonomous continuation requires a resident process');
30
+ if (caps.interrupt !== undefined && caps.interrupt !== 'native' && caps.interrupt !== 'emulated')
31
+ fail('capabilities.interrupt must be native|emulated when present');
32
+ // A native interrupt without a turn-level stop that keeps the session is meaningless for one-shot drivers.
33
+ if (caps.interrupt === 'native' && !caps.resident)
34
+ fail('a native interrupt requires a resident process');
30
35
  if (!COST.has(caps.usage?.costUsd) || !TOKENS.has(caps.usage?.tokens))
31
36
  fail('capabilities.usage is invalid');
32
37
  }
@@ -1,5 +1,6 @@
1
1
  const REASONS = Object.freeze({
2
2
  user_stop: 'stopped by the user',
3
+ user_interrupt: 'interrupted by a newer user message',
3
4
  host_request: 'stopped by the host',
4
5
  restart: 'interrupted by an application restart',
5
6
  shutdown: 'interrupted when the application closed',
@@ -5,6 +5,7 @@ export const DEFAULT_LIMITS = Object.freeze({
5
5
  stallMs: 15 * 60_000,
6
6
  backgroundMaxMs: 2 * 60 * 60_000,
7
7
  turnInactivityMs: 5 * 60_000,
8
+ toolInactivityMs: 60 * 60_000,
8
9
  maxSettleHandoffs: 3,
9
10
  settleDebounceMs: 1_500,
10
11
  });
@@ -13,6 +14,7 @@ const LIMIT_BOUNDS = Object.freeze({
13
14
  stallMs: [1_000, 24 * 60 * 60_000],
14
15
  backgroundMaxMs: [1_000, 7 * 24 * 60 * 60_000],
15
16
  turnInactivityMs: [1_000, 24 * 60 * 60_000],
17
+ toolInactivityMs: [1_000, 24 * 60 * 60_000],
16
18
  maxSettleHandoffs: [0, 20],
17
19
  settleDebounceMs: [0, 60_000],
18
20
  });
@@ -0,0 +1,19 @@
1
+ import type { SessionEvent } from './events.js';
2
+ import type { SessionSnapshot } from './snapshot.js';
3
+ /**
4
+ * Secret redaction at the journal boundary (docs/agent-sessions/journal.md).
5
+ *
6
+ * Only free text and tool I/O are redacted. Identifiers, sequence numbers,
7
+ * phases, usage numbers and host-supplied configuration (`systemPrompt`,
8
+ * `session.updated.changes`, `policy`) are kept as they are: configuration is
9
+ * read back from the snapshot to resume a session, so changing it would change
10
+ * what the provider receives. Event shapes never change: a redacted field keeps
11
+ * its type and an absent field stays absent.
12
+ */
13
+ export declare function redactSessionEvent(event: SessionEvent): SessionEvent;
14
+ /**
15
+ * Redact the free text a snapshot carries (open turn text, sub-agent
16
+ * descriptions, reasons and results). Input fingerprints, configuration and
17
+ * usage are untouched.
18
+ */
19
+ export declare function redactSessionSnapshot(snapshot: SessionSnapshot): SessionSnapshot;
@@ -0,0 +1,71 @@
1
+ import { redactSecrets, redactSecretsDeep } from '../../../shared/secret-redaction.js';
2
+ /**
3
+ * Secret redaction at the journal boundary (docs/agent-sessions/journal.md).
4
+ *
5
+ * Only free text and tool I/O are redacted. Identifiers, sequence numbers,
6
+ * phases, usage numbers and host-supplied configuration (`systemPrompt`,
7
+ * `session.updated.changes`, `policy`) are kept as they are: configuration is
8
+ * read back from the snapshot to resume a session, so changing it would change
9
+ * what the provider receives. Event shapes never change: a redacted field keeps
10
+ * its type and an absent field stays absent.
11
+ */
12
+ export function redactSessionEvent(event) {
13
+ switch (event.type) {
14
+ case 'input.accepted':
15
+ return { ...event, text: redactSecrets(event.text) };
16
+ case 'turn.output':
17
+ return { ...event, delta: redactSecrets(event.delta) };
18
+ case 'turn.tool':
19
+ return { ...event, ...redactTool(event) };
20
+ case 'turn.completed':
21
+ return { ...event, text: redactSecrets(event.text), ...(event.error === undefined ? {} : { error: redactSecrets(event.error) }) };
22
+ case 'subagent.started':
23
+ return { ...event, description: redactSecrets(event.description), ...(event.prompt === undefined ? {} : { prompt: redactSecrets(event.prompt) }) };
24
+ case 'subagent.output':
25
+ return {
26
+ ...event,
27
+ ...(event.delta === undefined ? {} : { delta: redactSecrets(event.delta) }),
28
+ ...(event.tool === undefined ? {} : { tool: { ...event.tool, ...redactTool(event.tool) } }),
29
+ };
30
+ case 'subagent.result':
31
+ return { ...event, summary: redactSecrets(event.summary) };
32
+ case 'subagent.phase':
33
+ case 'input.state':
34
+ case 'session.process':
35
+ return event.reason === undefined ? event : { ...event, reason: redactSecrets(event.reason) };
36
+ case 'session.closed':
37
+ return { ...event, reason: redactSecrets(event.reason) };
38
+ case 'provider.diagnostic':
39
+ return { ...event, message: redactSecrets(event.message) };
40
+ default:
41
+ return event;
42
+ }
43
+ }
44
+ function redactTool(tool) {
45
+ return {
46
+ ...(tool.input === undefined ? {} : { input: redactSecretsDeep(tool.input) }),
47
+ ...(tool.output === undefined ? {} : { output: redactSecrets(tool.output) }),
48
+ };
49
+ }
50
+ /**
51
+ * Redact the free text a snapshot carries (open turn text, sub-agent
52
+ * descriptions, reasons and results). Input fingerprints, configuration and
53
+ * usage are untouched.
54
+ */
55
+ export function redactSessionSnapshot(snapshot) {
56
+ const subagents = {};
57
+ for (const [id, node] of Object.entries(snapshot.subagents)) {
58
+ subagents[id] = {
59
+ ...node,
60
+ description: redactSecrets(node.description),
61
+ reason: node.reason === null ? null : redactSecrets(node.reason),
62
+ resultSummary: node.resultSummary === null ? null : redactSecrets(node.resultSummary),
63
+ };
64
+ }
65
+ return {
66
+ ...snapshot,
67
+ openTurn: snapshot.openTurn === null ? null : { ...snapshot.openTurn, text: redactSecrets(snapshot.openTurn.text) },
68
+ subagents,
69
+ closedReason: snapshot.closedReason === null ? null : redactSecrets(snapshot.closedReason),
70
+ };
71
+ }
@@ -7,7 +7,11 @@ export type SessionPhase = 'idle' | 'turn' | 'background';
7
7
  export type SessionStatus = 'open' | 'closed';
8
8
  export type TurnOrigin = 'user' | 'subagent' | 'system';
9
9
  export type TurnStatus = 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted';
10
- export type InputDelivery = 'queue' | 'steer';
10
+ /**
11
+ * `queue`: run as a later turn. `steer`: deliver into the running turn.
12
+ * `interrupt`: cut the running turn and run this input next (like `queue` when idle).
13
+ */
14
+ export type InputDelivery = 'queue' | 'steer' | 'interrupt';
11
15
  export type InputState = 'accepted' | 'queued' | 'started' | 'completed' | 'rejected' | 'interrupted';
12
16
  export type SubagentKind = 'foreground' | 'background';
13
17
  export type SubagentPhase = 'running' | 'idle' | 'failed' | 'stopped' | 'killed' | 'interrupted';
@@ -52,6 +56,12 @@ export interface SessionLimits {
52
56
  backgroundMaxMs: number;
53
57
  /** Fail a running turn when no provider frame (including sub-agent frames) arrives for this long. */
54
58
  turnInactivityMs: number;
59
+ /**
60
+ * Replaces `turnInactivityMs` while a tool call (the turn's or a sub-agent's)
61
+ * has started and not completed: a long build or test run is silent until
62
+ * it returns, so it gets a wider bound instead of failing the turn.
63
+ */
64
+ toolInactivityMs: number;
55
65
  /** Maximum policy-driven `resume-agent` turns per settle cycle. */
56
66
  maxSettleHandoffs: number;
57
67
  /** Debounce before an empty sub-agent roster counts as settled. */
@@ -114,6 +124,12 @@ export interface DriverCapabilities {
114
124
  steer: boolean;
115
125
  /** The tool set can be restricted (`policy.tools` other than the default). */
116
126
  toolFiltering: boolean;
127
+ /**
128
+ * How `delivery: 'interrupt'` cuts a running turn. `native`: the provider's own
129
+ * stop primitive, keeping its session. `emulated`: the runtime stops the turn
130
+ * as `session.interrupt` does, then runs the input next. Absent on older Cores.
131
+ */
132
+ interrupt?: 'native' | 'emulated';
117
133
  usage: UsageSemantics;
118
134
  }
119
135
  export interface DriverDescriptor {
@@ -2,6 +2,20 @@ import type { DriverOpenSpec } from '../../ports.js';
2
2
  /** Tools that launch sub-agents in Claude Code; removed when policy disables sub-agents. */
3
3
  export declare const CLAUDE_SUBAGENT_TOOLS: readonly ['Agent', 'Task'];
4
4
  export declare const CLAUDE_READ_ONLY_TOOLS: readonly ['Read', 'Grep', 'Glob'];
5
+ /**
6
+ * Built-in tools that change files or run commands. A read-only session that
7
+ * allows native sub-agents also denies them: deny rules bind every sub-agent of
8
+ * the session, including project-defined agents that name their own tools.
9
+ */
10
+ export declare const CLAUDE_WRITE_TOOLS: readonly ['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash'];
11
+ /**
12
+ * The read-only tool set. Native sub-agents keep the parent's restriction
13
+ * (verified live with Claude Code 2.1.309: a general-purpose sub-agent of a
14
+ * `--tools Read,Grep,Glob,Agent` session saw only those tools and could not
15
+ * write), so the sub-agent tool is offered when the policy allows them.
16
+ * `Task` is the tool's legacy name; unknown names in `--tools` are ignored.
17
+ */
18
+ export declare function claudeReadOnlyTools(policy: DriverOpenSpec['policy']): string[];
5
19
  /**
6
20
  * Map an open spec + policy to Claude Code argv. Lists are passed as one
7
21
  * comma-separated argument (variadic flags would otherwise swallow later args).
@@ -2,6 +2,22 @@ import { nativeSubagentsAllowed } from '../../domain/policy.js';
2
2
  /** Tools that launch sub-agents in Claude Code; removed when policy disables sub-agents. */
3
3
  export const CLAUDE_SUBAGENT_TOOLS = ['Agent', 'Task'];
4
4
  export const CLAUDE_READ_ONLY_TOOLS = ['Read', 'Grep', 'Glob'];
5
+ /**
6
+ * Built-in tools that change files or run commands. A read-only session that
7
+ * allows native sub-agents also denies them: deny rules bind every sub-agent of
8
+ * the session, including project-defined agents that name their own tools.
9
+ */
10
+ export const CLAUDE_WRITE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash'];
11
+ /**
12
+ * The read-only tool set. Native sub-agents keep the parent's restriction
13
+ * (verified live with Claude Code 2.1.309: a general-purpose sub-agent of a
14
+ * `--tools Read,Grep,Glob,Agent` session saw only those tools and could not
15
+ * write), so the sub-agent tool is offered when the policy allows them.
16
+ * `Task` is the tool's legacy name; unknown names in `--tools` are ignored.
17
+ */
18
+ export function claudeReadOnlyTools(policy) {
19
+ return [...CLAUDE_READ_ONLY_TOOLS, ...(nativeSubagentsAllowed(policy) ? CLAUDE_SUBAGENT_TOOLS : [])];
20
+ }
5
21
  function mcpConfig(servers) {
6
22
  return JSON.stringify({
7
23
  mcpServers: Object.fromEntries(servers.map((server) => [server.name, server.url
@@ -39,10 +55,11 @@ export function claudeArgs(spec, options = {}) {
39
55
  if (policy.tools.mode === 'none')
40
56
  args.push('--tools', '');
41
57
  else if (policy.tools.mode === 'read-only')
42
- args.push('--tools', CLAUDE_READ_ONLY_TOOLS.join(','));
58
+ args.push('--tools', claudeReadOnlyTools(policy).join(','));
43
59
  if (policy.tools.allow?.length)
44
60
  args.push('--allowedTools', policy.tools.allow.join(','));
45
- const denied = [...new Set([...(policy.tools.deny ?? []), ...(!nativeSubagentsAllowed(policy) ? CLAUDE_SUBAGENT_TOOLS : [])])];
61
+ const readOnlySubagents = policy.tools.mode === 'read-only' && nativeSubagentsAllowed(policy);
62
+ const denied = [...new Set([...(policy.tools.deny ?? []), ...(!nativeSubagentsAllowed(policy) ? CLAUDE_SUBAGENT_TOOLS : []), ...(readOnlySubagents ? CLAUDE_WRITE_TOOLS : [])])];
46
63
  if (denied.length)
47
64
  args.push('--disallowedTools', denied.join(','));
48
65
  if (policy.mcp.servers.length)
@@ -19,6 +19,7 @@ export const CLAUDE_DESCRIPTOR = Object.freeze({
19
19
  autonomousContinuation: true,
20
20
  steer: true,
21
21
  toolFiltering: true,
22
+ interrupt: 'native',
22
23
  usage: Object.freeze({ costUsd: 'session-cumulative', tokens: 'per-turn' }),
23
24
  }),
24
25
  });
@@ -47,6 +48,12 @@ class ClaudeDriverSession {
47
48
  this.cleanup = cleanup;
48
49
  }
49
50
  async send(input) {
51
+ // Interrupt delivery: stop the running turn (once per cut turn), then put the
52
+ // input first. Verified on 2.1.299: after the stop, `now` runs the input alone
53
+ // in the next turn and already queued commands follow; `next` or the default
54
+ // would batch it behind them.
55
+ if (input.delivery === 'interrupt' && this.translator.interruptible)
56
+ this.writeInterrupt();
50
57
  this.translator.noteInput(input.inputId);
51
58
  const frame = {
52
59
  type: 'user',
@@ -54,13 +61,16 @@ class ClaudeDriverSession {
54
61
  session_id: '',
55
62
  parent_tool_use_id: null,
56
63
  // `next` delivers into the running turn; otherwise the CLI queues it for the next turn.
57
- ...(input.delivery === 'steer' ? { priority: 'next' } : {}),
64
+ ...(input.delivery === 'steer' ? { priority: 'next' } : input.delivery === 'interrupt' ? { priority: 'now' } : {}),
58
65
  message: { role: 'user', content: userContent(input) },
59
66
  };
60
67
  if (!this.process.write(JSON.stringify(frame)))
61
68
  throw new Error('Claude input is closed');
62
69
  }
63
70
  async interrupt() {
71
+ this.writeInterrupt();
72
+ }
73
+ writeInterrupt() {
64
74
  this.translator.noteInterrupt();
65
75
  this.process.write(JSON.stringify({ type: 'control_request', request_id: randomUUID(), request: { subtype: 'interrupt' } }));
66
76
  }
@@ -15,6 +15,8 @@ export declare class ClaudeTranslator {
15
15
  private turnOpen;
16
16
  private interruptRequested;
17
17
  private readonly written;
18
+ /** Inputs the CLI reported started; a later `cancelled` only echoes their interrupted turn. */
19
+ private readonly started;
18
20
  private startedInputs;
19
21
  /** tool_use id → task id of the agent that issued it (null = main agent). */
20
22
  private readonly toolOwner;
@@ -26,6 +28,8 @@ export declare class ClaudeTranslator {
26
28
  /** The driver wrote this input; its receipts and replays belong to us. */
27
29
  noteInput(inputId: string): void;
28
30
  noteInterrupt(): void;
31
+ /** A turn is running and no stop was requested for it yet. */
32
+ get interruptible(): boolean;
29
33
  translate(frame: Frame): DriverEvent[];
30
34
  private system;
31
35
  private taskStarted;
@@ -6,7 +6,8 @@ const TASK_PHASE = Object.freeze({
6
6
  });
7
7
  /** System subtypes that carry nothing the session model needs. */
8
8
  const IGNORED_SYSTEM = new Set(['thinking_tokens', 'task_summary', 'post_turn_summary', 'status', 'hook_response', 'hook_started', 'compact_boundary']);
9
- const IGNORED_TYPES = new Set(['rate_limit_event', 'stream_event', 'keep_alive']);
9
+ // `control_response` acknowledges our interrupt (`still_queued` lists commands that survive it).
10
+ const IGNORED_TYPES = new Set(['rate_limit_event', 'stream_event', 'keep_alive', 'control_response']);
10
11
  function str(value) {
11
12
  return typeof value === 'string' ? value : undefined;
12
13
  }
@@ -25,6 +26,8 @@ export class ClaudeTranslator {
25
26
  turnOpen = false;
26
27
  interruptRequested = false;
27
28
  written = new Set();
29
+ /** Inputs the CLI reported started; a later `cancelled` only echoes their interrupted turn. */
30
+ started = new Set();
28
31
  startedInputs = [];
29
32
  /** tool_use id → task id of the agent that issued it (null = main agent). */
30
33
  toolOwner = new Map();
@@ -37,6 +40,8 @@ export class ClaudeTranslator {
37
40
  noteInput(inputId) { this.written.add(inputId); }
38
41
  noteInterrupt() { if (this.turnOpen)
39
42
  this.interruptRequested = true; }
43
+ /** A turn is running and no stop was requested for it yet. */
44
+ get interruptible() { return this.turnOpen && !this.interruptRequested; }
40
45
  translate(frame) {
41
46
  const type = str(frame.type);
42
47
  switch (type) {
@@ -145,8 +150,16 @@ export class ClaudeTranslator {
145
150
  const state = str(frame.state);
146
151
  if (!inputId || !this.written.has(inputId))
147
152
  return [];
148
- if (state === 'started' && !this.turnOpen)
149
- this.startedInputs.push(inputId);
153
+ if (state === 'started') {
154
+ this.started.add(inputId);
155
+ if (!this.turnOpen)
156
+ this.startedInputs.push(inputId);
157
+ }
158
+ if (state === 'completed')
159
+ this.started.delete(inputId);
160
+ // After an interrupt the CLI reports the cut turn's input `cancelled`; the turn result already settled it.
161
+ if (state === 'cancelled' && this.started.has(inputId))
162
+ return [];
150
163
  if (state === 'queued' || state === 'started' || state === 'completed')
151
164
  return [{ kind: 'input.receipt', inputId, state }];
152
165
  if (state === 'failed' || state === 'rejected' || state === 'cancelled')
@@ -18,6 +18,8 @@ export const CODEX_DESCRIPTOR = Object.freeze({
18
18
  autonomousContinuation: false,
19
19
  steer: true,
20
20
  toolFiltering: false,
21
+ // `turn/interrupt`, then `turn/start` once the turn completed (sequenced by the application).
22
+ interrupt: 'native',
21
23
  usage: Object.freeze({ costUsd: 'none', tokens: 'cumulative' }),
22
24
  }),
23
25
  });