@sema-agent/core 7.16.0 → 7.17.1

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 (207) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/dist/agents/builtin-agents.d.ts +3 -3
  3. package/dist/agents/cross-session-envelope.d.ts +0 -1
  4. package/dist/agents/cross-session-envelope.js +0 -1
  5. package/dist/agents/peer-notices.d.ts +0 -9
  6. package/dist/agents/peer-notices.js +0 -63
  7. package/dist/agents/subagent.js +7 -15
  8. package/dist/brain/request-params.d.ts +2 -2
  9. package/dist/config/defaults.d.ts +0 -7
  10. package/dist/config/defaults.js +0 -2
  11. package/dist/core/artifact-host.d.ts +218 -0
  12. package/dist/core/artifact-host.js +92 -0
  13. package/dist/core/ask-origin.d.ts +29 -3
  14. package/dist/core/ask-origin.js +17 -9
  15. package/dist/core/ask-question.d.ts +25 -0
  16. package/dist/core/ask-question.js +4 -0
  17. package/dist/core/ask-unresolvable-notice.d.ts +30 -21
  18. package/dist/core/ask-unresolvable-notice.js +16 -8
  19. package/dist/core/auto-compaction.d.ts +10 -10
  20. package/dist/core/auto-mode-arming.d.ts +2 -2
  21. package/dist/core/background-agent-store.d.ts +5 -5
  22. package/dist/core/background-shell.d.ts +1 -1
  23. package/dist/core/checkpoint-execution-record.d.ts +0 -3
  24. package/dist/core/checkpoint-execution-record.js +0 -4
  25. package/dist/core/checkpoint-store.d.ts +71 -99
  26. package/dist/core/checkpoint-store.js +9 -16
  27. package/dist/core/context-edit.d.ts +36 -104
  28. package/dist/core/context-edit.js +37 -60
  29. package/dist/core/effective-path-target.d.ts +68 -8
  30. package/dist/core/effective-path-target.js +42 -30
  31. package/dist/core/engine-notice.d.ts +46 -32
  32. package/dist/core/env-budget.d.ts +31 -0
  33. package/dist/core/env-budget.js +39 -0
  34. package/dist/core/exec-output-tail.d.ts +5 -5
  35. package/dist/core/file-history-store.js +3 -6
  36. package/dist/core/fs-write-gate-policy.d.ts +1 -1
  37. package/dist/core/fs-write-gate-policy.js +3 -3
  38. package/dist/core/gate-lanes.js +9 -15
  39. package/dist/core/governance-codes.d.ts +1 -1
  40. package/dist/core/governance-codes.js +2 -0
  41. package/dist/core/hands-band.d.ts +1 -1
  42. package/dist/core/hooks.d.ts +18 -17
  43. package/dist/core/lsp-session.d.ts +1 -1
  44. package/dist/core/lsp.d.ts +1 -1
  45. package/dist/core/lsp.js +5 -4
  46. package/dist/core/mcp.d.ts +5 -5
  47. package/dist/core/mcp.js +4 -48
  48. package/dist/core/memory-engine/engine.js +2 -1
  49. package/dist/core/memory-engine/layout.d.ts +18 -6
  50. package/dist/core/memory-engine/layout.js +40 -21
  51. package/dist/core/memory-recall.d.ts +0 -8
  52. package/dist/core/memory.d.ts +1 -1
  53. package/dist/core/oracle-isolation.js +2 -2
  54. package/dist/core/parent-spec-seat.d.ts +66 -0
  55. package/dist/core/parent-spec-seat.js +47 -0
  56. package/dist/core/permission-rule-model.d.ts +11 -6
  57. package/dist/core/permission-rule-model.js +53 -37
  58. package/dist/core/permission-rules.js +8 -3
  59. package/dist/core/physical-path.d.ts +37 -0
  60. package/dist/core/physical-path.js +30 -0
  61. package/dist/core/protocol-naming.d.ts +2 -2
  62. package/dist/core/remote-env.d.ts +1 -1
  63. package/dist/core/retired-keys.d.ts +19 -0
  64. package/dist/core/retired-keys.js +15 -0
  65. package/dist/core/runner/active-skill-scope.js +3 -3
  66. package/dist/core/runner/assemble-result.d.ts +15 -13
  67. package/dist/core/runner/assemble-result.js +5 -4
  68. package/dist/core/runner/compaction-call-options.d.ts +25 -89
  69. package/dist/core/runner/contracts.d.ts +11 -10
  70. package/dist/core/runner/gate-exit.js +15 -1
  71. package/dist/core/runner/halt-attribution.d.ts +35 -0
  72. package/dist/core/runner/halt-attribution.js +16 -0
  73. package/dist/core/runner/memory-consolidation.d.ts +0 -2
  74. package/dist/core/runner/memory-consolidation.js +0 -1
  75. package/dist/core/runner/prepare-artifact.d.ts +52 -0
  76. package/dist/core/runner/prepare-artifact.js +63 -0
  77. package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
  78. package/dist/core/runner/prepare-caps-and-workflow.js +2 -3
  79. package/dist/core/runner/prepare-config-doors.d.ts +3 -3
  80. package/dist/core/runner/prepare-config-doors.js +5 -2
  81. package/dist/core/runner/prepare-context-lane.d.ts +2 -2
  82. package/dist/core/runner/prepare-context-lane.js +2 -13
  83. package/dist/core/runner/prepare-file-history.d.ts +1 -1
  84. package/dist/core/runner/prepare-file-history.js +3 -52
  85. package/dist/core/runner/prepare-hands-readface.d.ts +4 -7
  86. package/dist/core/runner/prepare-hands-readface.js +3 -2
  87. package/dist/core/runner/prepare-inherited-gate.d.ts +5 -2
  88. package/dist/core/runner/prepare-inherited-gate.js +7 -8
  89. package/dist/core/runner/prepare-memory.d.ts +4 -6
  90. package/dist/core/runner/prepare-memory.js +4 -2
  91. package/dist/core/runner/prepare-policy-chain.d.ts +3 -3
  92. package/dist/core/runner/prepare-policy-chain.js +15 -30
  93. package/dist/core/runner/prepare-prompt-assembly.js +1 -1
  94. package/dist/core/runner/prepare-question-face.js +2 -1
  95. package/dist/core/runner/prepare-safety-scan.d.ts +1 -1
  96. package/dist/core/runner/prepare-suspend-saga.js +0 -2
  97. package/dist/core/runner/prepare-task.js +12 -11
  98. package/dist/core/runner/prepare-wiring-manifest.js +9 -7
  99. package/dist/core/runner/resume-admission.js +2 -1
  100. package/dist/core/runner/resume-internals-and-config.js +4 -0
  101. package/dist/core/runner/retired-runner-deps.d.ts +3 -0
  102. package/dist/core/runner/retired-runner-deps.js +7 -0
  103. package/dist/core/runner/run-harness-handlers.js +5 -3
  104. package/dist/core/runner/run-settle-and-teardown.d.ts +1 -1
  105. package/dist/core/runner/run-settle-and-teardown.js +3 -1
  106. package/dist/core/runner/session-file-state-replay.js +3 -3
  107. package/dist/core/runner/session-rule-policy.js +3 -4
  108. package/dist/core/runner/stream-halt-verbs.js +1 -1
  109. package/dist/core/runner/stream-steer-verb.js +1 -4
  110. package/dist/core/runner/workspace-path.js +3 -0
  111. package/dist/core/runner-deps.d.ts +30 -49
  112. package/dist/core/safe-notify.d.ts +1 -1
  113. package/dist/core/sensitive-path-policy.d.ts +1 -1
  114. package/dist/core/sensitive-path-policy.js +11 -12
  115. package/dist/core/session.d.ts +2 -2
  116. package/dist/core/skill-tool-specifier.d.ts +1 -1
  117. package/dist/core/skills-directory.d.ts +1 -1
  118. package/dist/core/skills-directory.js +4 -3
  119. package/dist/core/spec-contract.js +5 -4
  120. package/dist/core/store-contracts/checkpoint-store-contract.js +1 -1
  121. package/dist/core/strategy-store.d.ts +8 -11
  122. package/dist/core/task-event.d.ts +20 -17
  123. package/dist/core/task-limits.d.ts +1 -1
  124. package/dist/core/task-notification.d.ts +5 -5
  125. package/dist/core/task-registry-agent.d.ts +5 -5
  126. package/dist/core/task-registry-monitor.d.ts +2 -2
  127. package/dist/core/task-registry-shared.d.ts +32 -28
  128. package/dist/core/task-registry-shared.js +4 -13
  129. package/dist/core/task-registry.d.ts +8 -8
  130. package/dist/core/task-result.d.ts +37 -37
  131. package/dist/core/task-spec.d.ts +5 -5
  132. package/dist/core/task-stream.d.ts +16 -11
  133. package/dist/core/tool-catalog-entries.d.ts +1 -0
  134. package/dist/core/tool-catalog-entries.js +6 -1
  135. package/dist/core/tool-conformance.d.ts +0 -2
  136. package/dist/core/tool-conformance.js +1 -3
  137. package/dist/core/tool-errors.d.ts +5 -0
  138. package/dist/core/tool-errors.js +1 -0
  139. package/dist/core/tool-policy.d.ts +66 -22
  140. package/dist/core/tool-policy.js +31 -5
  141. package/dist/core/tool-result-budget.d.ts +1 -1
  142. package/dist/core/tool-result-store.d.ts +11 -11
  143. package/dist/core/tool-spec.d.ts +34 -38
  144. package/dist/core/tools.d.ts +2 -2
  145. package/dist/core/trace.d.ts +12 -15
  146. package/dist/core/untrusted-text.d.ts +1 -1
  147. package/dist/core/wiring-manifest.d.ts +4 -3
  148. package/dist/core/workflow-journal-store.d.ts +11 -11
  149. package/dist/engine/execution-env/node-execution-env.d.ts +3 -2
  150. package/dist/engine/execution-env/node-execution-env.js +8 -5
  151. package/dist/engine/harness/agent-harness.d.ts +47 -4
  152. package/dist/engine/harness/agent-harness.js +52 -9
  153. package/dist/engine/loop/agent-loop.js +15 -8
  154. package/dist/engine/loop/types.d.ts +5 -0
  155. package/dist/index.d.ts +20 -14
  156. package/dist/index.js +16 -10
  157. package/dist/internal/harness.d.ts +1 -0
  158. package/dist/orchestration/run-spec.d.ts +3 -0
  159. package/dist/orchestration/run-spec.js +3 -3
  160. package/dist/orchestration/run-workflow-tool.d.ts +12 -6
  161. package/dist/orchestration/run-workflow-tool.js +23 -5
  162. package/dist/orchestration/workflow-primitives.d.ts +4 -2
  163. package/dist/orchestration/workflow-primitives.js +1 -4
  164. package/dist/orchestration/workflow-script-store.js +9 -25
  165. package/dist/orchestration/workflow-types.d.ts +34 -4
  166. package/dist/orchestration/workflow-types.js +2 -0
  167. package/dist/orchestration/workflow.d.ts +39 -7
  168. package/dist/orchestration/workflow.js +237 -113
  169. package/dist/prompt-assembly/assemble.d.ts +2 -2
  170. package/dist/prompt-assembly/assemble.js +9 -9
  171. package/dist/prompt-assembly/composer.d.ts +6 -7
  172. package/dist/prompt-assembly/composer.js +10 -10
  173. package/dist/prompt-assembly/epoch.js +1 -1
  174. package/dist/prompt-assembly/event-registry.d.ts +0 -21
  175. package/dist/prompt-assembly/packs/sema-default.js +30 -30
  176. package/dist/prompt-assembly/types.d.ts +14 -14
  177. package/dist/prompts/coordinator.d.ts +0 -13
  178. package/dist/prompts/coordinator.js +0 -3
  179. package/dist/stores/cc/task-list-store.js +2 -10
  180. package/dist/stores/file/checkpoint-store.js +1 -5
  181. package/dist/stores/file/file-history-store.js +2 -2
  182. package/dist/stores/file/fs-atomic.d.ts +15 -18
  183. package/dist/stores/file/fs-atomic.js +4 -14
  184. package/dist/stores/file/mailbox-store.d.ts +7 -11
  185. package/dist/stores/file/mailbox-store.js +4 -11
  186. package/dist/stores/file/permission-rule-store.d.ts +4 -35
  187. package/dist/stores/file/permission-rule-store.js +4 -148
  188. package/dist/tools/artifact/artifact-text.d.ts +62 -0
  189. package/dist/tools/artifact/artifact-text.js +68 -0
  190. package/dist/tools/artifact/artifact-tool.d.ts +52 -0
  191. package/dist/tools/artifact/artifact-tool.js +351 -0
  192. package/dist/tools/artifact/local-stub.d.ts +26 -0
  193. package/dist/tools/artifact/local-stub.js +297 -0
  194. package/dist/tools/fs/bash-readonly-classifier.d.ts +90 -8
  195. package/dist/tools/fs/bash-readonly-classifier.js +294 -70
  196. package/dist/tools/fs/fs-bash.d.ts +14 -10
  197. package/dist/tools/fs/fs-bash.js +50 -33
  198. package/dist/tools/fs/fs-search-tools.js +5 -2
  199. package/dist/tools/fs/fs-shared.d.ts +2 -0
  200. package/dist/tools/fs/fs-shared.js +3 -11
  201. package/dist/tools/fs/index.d.ts +4 -0
  202. package/dist/tools/fs/index.js +4 -1
  203. package/dist/tools/fs/safety.d.ts +117 -1
  204. package/dist/tools/fs/safety.js +131 -31
  205. package/dist/tools/fs/search.js +8 -6
  206. package/package.json +2 -1
  207. package/test/export-surface.snapshot.json +119 -39
@@ -58,7 +58,7 @@ export type TraceEvent = {
58
58
  kind: "task.start";
59
59
  version: 1;
60
60
  taskId: string;
61
- /** #499 (additive) — the ENGINE-minted identity of THIS run (`TaskResult.runId`). `taskId` is
61
+ /** The ENGINE-minted identity of THIS run (`TaskResult.runId`). `taskId` is
62
62
  * `spec.taskId ?? sessionId`, so two runs of one session that declare no host task id emit two
63
63
  * task.start frames under ONE id and a consumer pairing start↔end has no way to say which end
64
64
  * closes which start. Present on every `task.start` (the frame is emitted after prepare, which
@@ -264,7 +264,7 @@ export type TraceEvent = {
264
264
  kind: "task.end";
265
265
  version: 1;
266
266
  taskId: string;
267
- /** #499 (additive) — the run this terminal closes; pairs with the `task.start` frame's `runId`
267
+ /** The run this terminal closes; pairs with the `task.start` frame's `runId`
268
268
  * (see that field for why `taskId` cannot carry the pairing, and for the ASYMMETRY: this frame
269
269
  * is also emitted on the prepare-throw backstop, where no `task.start` exists — it still names
270
270
  * its run, because the id is minted at prepare's first statement and prepare-time disclosures
@@ -290,7 +290,7 @@ export type TraceEvent = {
290
290
  * loop breathes, so lateness this size means the environment starved the process (run89: an
291
291
  * abandoned compile group froze the loop 646s; the 1200s hard abort landed 683s late). */
292
292
  timerLatenessMs?: number;
293
- /** RB-20 (TB b-form): the run was stopped by the SYNCHRONOUS turn-boundary walltime
293
+ /** The run was stopped by the SYNCHRONOUS turn-boundary walltime
294
294
  * backstop, not the hard-abort timer — i.e. the deadline had passed but the setTimeout leg
295
295
  * had not fired yet (starved event loop). Pairs with `timerLatenessMs` for attribution. */
296
296
  walltimeSyncBackstop?: boolean;
@@ -308,7 +308,7 @@ export type TraceEvent = {
308
308
  model: string;
309
309
  provider?: string;
310
310
  /**
311
- * RB-410 — 1-based index of the turn this provider call served, same numbering as `turn.end` and as
311
+ * 1-based index of the turn this provider call served, same numbering as `turn.end` and as
312
312
  * `tool.call.turn`. Lets a bridge put a `chat` span and the `execute_tool` spans of the tools that
313
313
  * call requested under one parent without re-deriving turn boundaries from event order.
314
314
  *
@@ -316,10 +316,10 @@ export type TraceEvent = {
316
316
  * is not a loop turn, so any number here would be a different measurement wearing the same name.
317
317
  */
318
318
  turn?: number;
319
- /** RB-457-a: normalized prompt tokens that MISSED the cache (Anthropic-protocol `input_tokens`
319
+ /** Normalized prompt tokens that MISSED the cache (Anthropic-protocol `input_tokens`
320
320
  * shape) — disjoint from `cacheRead`/`cacheWrite`. Carried the cache-inclusive total up to 2.13.x. */
321
321
  promptTokens: number;
322
- /** RB-457-a: the cache-INCLUSIVE prompt total for this call (`promptTokens + cacheRead +
322
+ /** The cache-INCLUSIVE prompt total for this call (`promptTokens + cacheRead +
323
323
  * cacheWrite`) — the figure `costMicroUsd` is computed from. */
324
324
  totalInputTokens: number;
325
325
  completionTokens: number;
@@ -349,7 +349,7 @@ export type TraceEvent = {
349
349
  taskId: string;
350
350
  name: string;
351
351
  /**
352
- * RB-410 — the resolved call's id: the SAME value the wire's `tool_start`/`tool_end` `TaskEvent`
352
+ * The resolved call's id: the SAME value the wire's `tool_start`/`tool_end` `TaskEvent`
353
353
  * carries for this call. OTel GenAI names it `gen_ai.tool.call.id` on the `execute_tool` span.
354
354
  *
355
355
  * Why it matters: without it the trace face and the event face could only be paired by tool NAME
@@ -358,7 +358,7 @@ export type TraceEvent = {
358
358
  */
359
359
  toolCallId?: string;
360
360
  /**
361
- * RB-410 — 1-based index of the turn this call ran in, on the same numbering `turn.end` reports
361
+ * 1-based index of the turn this call ran in, on the same numbering `turn.end` reports
362
362
  * (a `tool.call` is emitted BEFORE the `turn.end` that closes its turn, so the two agree without
363
363
  * look-ahead). No GenAI attribute corresponds; it is the PARENT-span correlation key a bridge
364
364
  * needs to nest an `execute_tool` span under the `chat` call that requested the tool.
@@ -427,7 +427,7 @@ export type TraceEvent = {
427
427
  ts: number;
428
428
  } | {
429
429
  /**
430
- * #619 — the READ-ONLY reader cleared a shell call, so no person and no classifier was asked: the
430
+ * The READ-ONLY reader cleared a shell call, so no person and no classifier was asked: the
431
431
  * allow layer's second attribution channel beside `permission.persisted_rule_allowed`. `command` is
432
432
  * the FINAL command the gate judged (a policy rewrite included) — model-authored text, carried
433
433
  * verbatim as the audit fact this frame exists for (a consumer rendering it applies its own
@@ -802,12 +802,9 @@ export type TraceEvent = {
802
802
  * = the proactive request-build pass (anchored estimate crossed the edit budget),
803
803
  * `"refusal"` = the MC-R rejection-recovery arm (provider said input-too-long), `"blocking"`
804
804
  * = the slice-3 guard-chain arm A (the machine's one pre-guard shot when the frontier pass
805
- * is off, `microCompact.machine: "off"`). SCOPE (X1): emitted only by the cc machine — the
806
- * `"frontier"` arm requires `microCompact.machine: "cc"` (the default since the slice-3
807
- * flip), the `"refusal"` arm requires `microCompact.clearOnRejection` (also default-on);
808
- * the legacy OPT-OUT machine clears silently exactly as pre-374 (its clears are ledger-less
809
- * and re-fire per request, so a frame there would re-count the same occurrences and break
810
- * this frame's cardinality clause). Cardinality: exactly ONE frame per firing pass — a retry
805
+ * is off, `microCompact.machine: "off"`). The `"frontier"` arm requires
806
+ * `microCompact.machine: "cc"` (the default), the `"refusal"` arm requires
807
+ * `microCompact.clearOnRejection` (also default-on). Cardinality: exactly ONE frame per firing pass — a retry
811
808
  * chain re-sending an already-cleared view emits none. A `"refusal"` frame also marks one
812
809
  * attempt of the shared prompt-too-long recovery budget spent (the MC-R arm's retry rides
813
810
  * the same per-chain account the forced-compaction arm draws on — the knob doc on
@@ -394,7 +394,7 @@ export declare function defuseControlChars(text: string): string;
394
394
  * Defense-in-depth, NOT a guarantee (same posture as the rest of this module).
395
395
  */
396
396
  export declare function inlineUntrusted(text: string, maxLen?: number): string;
397
- /** backlog #239: the per-ENTRY ceiling inside a `ProbeCause` operand family (checkpoint-store's
397
+ /** The per-ENTRY ceiling inside a `ProbeCause` operand family (checkpoint-store's
398
398
  * structured probe account), sized like the descriptor's own `touchedPaths` entries (the same thing
399
399
  * on the same card). Per entry, not per cause: that is the whole point of the structured shape —
400
400
  * one pathological path costs only itself, where a single joined string let it consume every other
@@ -46,7 +46,8 @@ export type AskEffective = "human_reachable" | "auto_allow" | "auto_deny" | "par
46
46
  * The content-question channel, three-valued (design/173 §8.2 — a derived enum, not a bare
47
47
  * boolean): `"wired"` (a resolved `onQuestion` face exists), `"absent"` (none), or
48
48
  * `"stripped_bg_lane"` — the ENGINE deliberately stripped the spawn turn's per-request face from a
49
- * long-lived background/retained leg (correct design, not a configuration lie; the flag is minted
49
+ * leg that outlives the request: a session-scoped background/retained leg, or an agent the Workflow tool
50
+ * spawned (submit-and-return) — correct design, not a configuration lie; the flag is minted
50
51
  * ONLY by the engine's strip sites, pair-produced with the strip itself — never inferred from spec
51
52
  * shape).
52
53
  */
@@ -58,7 +59,7 @@ export type SeamProvenance = "spec" | "deps";
58
59
  /** Machine-readable reason codes for a park lane that is not (or not provably) effective. */
59
60
  export type ParkLaneReason = "no_checkpoint_store" | "no_durable_approval_opt_in" | "no_force_durable_gate" | "no_armed_safety_vocabulary" | "await_runtime_caps" | "await_tool_vocabulary";
60
61
  /**
61
- * #529 — the closed set of auto-mode ARMING outcomes a leg's manifest reports
62
+ * The closed set of auto-mode ARMING outcomes a leg's manifest reports
62
63
  * ({@link WiringManifest.autoMode}). Minted from the arming predicate's own three-arm read, as the
63
64
  * FIRST failing arm in predicate order:
64
65
  * - `no_intent` — no `TaskSpec.autoModeRequested` seat and no intent inherited on the chain /
@@ -244,7 +245,7 @@ export interface WiringManifest {
244
245
  restore: string;
245
246
  };
246
247
  /**
247
- * #529 — EFFECTIVE half only, and ALWAYS present on an engine-minted effective manifest (unlike
248
+ * EFFECTIVE half only, and ALWAYS present on an engine-minted effective manifest (unlike
248
249
  * `modelGate`, which is present only when a trim happened: auto mode has an answer on every leg,
249
250
  * so absence here means an older mint or an external derivation, never "not applicable"). The
250
251
  * per-leg READ face of the auto-mode arming decision: `armed` ⇔ `reason === "armed"` (the
@@ -15,7 +15,7 @@ export type WorkflowJournalEntry = {
15
15
  callKey: string;
16
16
  result?: never;
17
17
  /**
18
- * #642 — the ordinal's leg is PARKED at a durable approval gate: the child's own paused `TaskResult`
18
+ * The ordinal's leg is PARKED at a durable approval gate: the child's own paused `TaskResult`
19
19
  * (its `terminal` carries the checkpoint token + gate, its `sessionId` the pinned session). A resume
20
20
  * reaching this ordinal with a matching call key does NOT run the call live (the parked child's
21
21
  * session is pinned under a pending checkpoint; a fresh spawn would duplicate it) — it drives the
@@ -73,7 +73,7 @@ export interface WorkflowJournalStore {
73
73
  * line teaches only the TaskOutput route (which is always live). Engine-facing string, never parsed. */
74
74
  locator?(runId: string, scope: string): string | undefined;
75
75
  /**
76
- * RB-242 (ruled 2026-07-30) — ADMISSION for a resume, so two concurrent
76
+ * ADMISSION for a resume, so two concurrent
77
77
  * `resumeFromRunId` legs off ONE source run cannot fork its execution.
78
78
  *
79
79
  * Why a store seam at all: the engine's own mutex is in-process only (`startWorkflow` refuses a duplicate
@@ -84,7 +84,7 @@ export interface WorkflowJournalStore {
84
84
  * shared durable layer, i.e. here.
85
85
  *
86
86
  * OPTIONAL, and absence is a first-class state: a store that does NOT implement this has NO STORE-LEVEL lease
87
- * semantics. RB-367 (dual-process probe): a store without the hook is no longer completely
87
+ * semantics. A store without the hook is no longer completely
88
88
  * unguarded, though — `runWorkflow`'s engine falls back to a process-local admission table for exactly this
89
89
  * shape, so a same-process double `resumeFromRunId` off one source is still refused even against a store
90
90
  * that never implements this member. That fallback cannot see a SECOND process, which is the one thing a
@@ -132,7 +132,7 @@ export interface WorkflowJournalStore {
132
132
  holder?: string;
133
133
  }>;
134
134
  /**
135
- * RB-242 — release the claim {@link resumeClaim} granted to `newRunId` on `(sourceRunId, scope)`.
135
+ * Release the claim {@link resumeClaim} granted to `newRunId` on `(sourceRunId, scope)`.
136
136
  * Called by the engine at the resuming run's TERMINAL boundary (in a `finally`, so abort/error/abandon are
137
137
  * covered), and ONLY when the matching claim was actually granted.
138
138
  *
@@ -143,7 +143,7 @@ export interface WorkflowJournalStore {
143
143
  * A throw here is swallowed by the engine — a release failure must never replace the run's real outcome, and
144
144
  * the store's TTL is the standing backstop for the row it left behind.
145
145
  *
146
- * RB-355: the engine's terminal boundary now BOUNDS this call (and its wait on
146
+ * The engine's terminal boundary now BOUNDS this call (and its wait on
147
147
  * the {@link resumeClaim} verdict) by a timeout — a store that hangs would otherwise keep the run's `done`
148
148
  * promise pending forever, after the outcome is already decided. A release that outlives that bound is
149
149
  * treated exactly like one that throws: swallowed, with the TTL as the backstop. Implementations should not
@@ -158,7 +158,7 @@ export declare const MAX_JOURNAL_RESULT_BYTES: number;
158
158
  /** True when a serialized journal result is too large to store (UTF-8 byte length over {@link MAX_JOURNAL_RESULT_BYTES}). */
159
159
  export declare function oversizeJournalResult(serialized: string): boolean;
160
160
  export declare function callKeyOrdinal(callKey: string): number;
161
- /** RB-243: the errorCode a journal OVERSIZE TOMBSTONE carries — the resume path recognizes it to
161
+ /** The errorCode a journal OVERSIZE TOMBSTONE carries — the resume path recognizes it to
162
162
  * say WHY an ordinal never replays. `failed` + this code lands on the T2A-4 no-replay arm, so replay
163
163
  * behavior is identical to the entry simply being absent (the pre-tombstone shape). */
164
164
  export declare const JOURNAL_OVERSIZE_ERROR_CODE = "workflow.journal_oversize";
@@ -192,7 +192,7 @@ export declare class WorkflowJournalOversizeError extends Error {
192
192
  * answer is the rule, the engine's tombstone the one place that pre-empts it.
193
193
  */
194
194
  export declare function assertJournalEntryFits(entry: WorkflowJournalEntry): void;
195
- /** RB-243: mint the tiny FAILED entry the ENGINE journals in place of an oversize result. The
195
+ /** Mint the tiny FAILED entry the ENGINE journals in place of an oversize result. The
196
196
  * tombstone is minted in the engine, NOT in a store — a store REFUSES an oversize entry
197
197
  * ({@link assertJournalEntryFits}) and never invents one; the engine measures the durable copy first, so
198
198
  * the store sees the tombstone, not the payload. Never copies `result.result`/`structuredOutput` (the
@@ -200,7 +200,7 @@ export declare function assertJournalEntryFits(entry: WorkflowJournalEntry): voi
200
200
  * and the paid spend. Its own serialization is a few hundred bytes — always under the cap (pinned). */
201
201
  export declare function journalOversizeTombstone(result: TaskResult, bytes: number): TaskResult;
202
202
  /**
203
- * RB-367 (dual-process probe, core-side judgment) — the TTL a resume claim {@link
203
+ * The TTL a resume claim {@link
204
204
  * InMemoryWorkflowJournalStore} grants survives WITHOUT an engine release: the backstop for an engine that
205
205
  * abandoned a granted claim without ever reaching the terminal `finally` that releases it (a bug, not the
206
206
  * normal path — the normal path releases promptly). Same VALUE as the file store's `RESUME_CLAIM_TTL_MS`
@@ -214,7 +214,7 @@ export declare const IN_MEMORY_RESUME_CLAIM_TTL_MS: number;
214
214
  * service's Postgres store (SVC-2). Records each run's `scope` and enforces it on load (CORE-9 audit BLOCKER:
215
215
  * a cross-scope resumeFromRunId resolves to an empty journal — no cross-tenant disclosure).
216
216
  *
217
- * RB-367: also carries a REAL {@link WorkflowJournalStore.resumeClaim}/{@link
217
+ * Also carries a REAL {@link WorkflowJournalStore.resumeClaim}/{@link
218
218
  * WorkflowJournalStore.releaseResumeClaim} implementation (this store used to be the canonical example of a
219
219
  * store WITHOUT the seam — its own header said so). Left unimplemented, the DEFAULT deployment (no
220
220
  * `journalStore` supplied, or this store supplied explicitly) had NO protection against two same-process
@@ -231,7 +231,7 @@ export declare class InMemoryWorkflowJournalStore implements WorkflowJournalStor
231
231
  * collide across the boundary between the two fields. */
232
232
  private readonly resumeClaims;
233
233
  private claimsForScope;
234
- /** RB-367 — contract parity with the file store: keyed by `(sourceRunId, scope)`; idempotent re-grant to the
234
+ /** Contract parity with the file store: keyed by `(sourceRunId, scope)`; idempotent re-grant to the
235
235
  * SAME holder; an unexpired claim held by someone else refuses (naming the holder); an expired or absent
236
236
  * claim is free to take. Acquisition is a single synchronous Map read-then-write with no `await` between
237
237
  * the check and the publish — there is no window (matching the atomicity RB-354 requires of a durable
@@ -240,7 +240,7 @@ export declare class InMemoryWorkflowJournalStore implements WorkflowJournalStor
240
240
  granted: boolean;
241
241
  holder?: string;
242
242
  }>;
243
- /** RB-367 — release ONLY a claim `newRunId` still holds (a missing/foreign/already-taken-over claim is a
243
+ /** Release ONLY a claim `newRunId` still holds (a missing/foreign/already-taken-over claim is a
244
244
  * no-op, never an error — matches the file store's contract). */
245
245
  releaseResumeClaim(input: ResumeClaimArgs): Promise<void>;
246
246
  load(runId: string, scope: string): Promise<WorkflowJournalEntry[]>;
@@ -79,8 +79,9 @@ export declare function openSpoolPair(base: string): {
79
79
  };
80
80
  export declare class NodeExecutionEnv implements ExecutionEnv, BackgroundShellCapability, SchedulerCapability {
81
81
  cwd: string;
82
- /** #644 this adapter runs on the engine host, so the host's home IS the environment's (`ExecutionEnv.homeDir`). */
83
- readonly homeDir: string;
82
+ /** `ExecutionEnv.homeDir` = the `HOME` the shell child receives (its `~`/`$HOME`/`${HOME}` all expand to it), read LIVE off the same `shellEnv`-over-`inheritEnv` merge each exec gets; ABSENT when the
83
+ * child gets none (an allowlist that withholds it: `~` then falls back to passwd while `$HOME` is empty — two values, no single home, home-relative operands stay fail-closed). Residual: a per-exec `env.HOME` override. */
84
+ get homeDir(): string | undefined;
84
85
  private shellPath?;
85
86
  private shellEnv?;
86
87
  private inheritEnv;
@@ -1,13 +1,14 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { closeSync, constants, createReadStream, mkdtempSync, openSync, readSync, statSync, truncateSync, unlinkSync } from "node:fs";
4
- import { access, appendFile, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink, realpath, rename, rm, unlink, writeFile, } from "node:fs/promises";
5
- import { homedir, tmpdir } from "node:os";
4
+ import { access, appendFile, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink, rename, rm, unlink, writeFile, } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
6
  import { isAbsolute, join, resolve } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { ExecutionError, err, FileError, ok, toError, } from "../harness/types.js";
9
9
  import { killProcessTree, shutdownDebug } from "./kill-tree.js";
10
10
  import { scrubSecretEnv } from "../../core/secret-env.js";
11
+ import { physicalPathOfAsync } from "../../core/physical-path.js";
11
12
  import { RollingTailBuffer, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
12
13
  import { BackgroundShellError } from "../../core/background-shell.js";
13
14
  import { SchedulerError } from "../../core/scheduler.js";
@@ -284,7 +285,9 @@ export function openSpoolPair(base) {
284
285
  }
285
286
  export class NodeExecutionEnv {
286
287
  cwd;
287
- homeDir = homedir();
288
+ get homeDir() {
289
+ return getShellEnv(this.inheritEnv, this.shellEnv, undefined, () => { }).HOME;
290
+ }
288
291
  shellPath;
289
292
  shellEnv;
290
293
  inheritEnv;
@@ -800,7 +803,7 @@ export class NodeExecutionEnv {
800
803
  let hop;
801
804
  try {
802
805
  const link = await readlink(resolved);
803
- const parentReal = await realpath(resolve(resolved, "..")).catch(() => resolve(resolved, ".."));
806
+ const parentReal = await physicalPathOfAsync(resolve(resolved, "..")).catch(() => resolve(resolved, ".."));
804
807
  hop = isAbsolute(link) ? link : resolve(parentReal, link);
805
808
  }
806
809
  catch {
@@ -1002,7 +1005,7 @@ export class NodeExecutionEnv {
1002
1005
  async canonicalPath(path) {
1003
1006
  const resolved = resolvePath(this.cwd, path);
1004
1007
  try {
1005
- return ok(await realpath(resolved));
1008
+ return ok(await physicalPathOfAsync(resolved));
1006
1009
  }
1007
1010
  catch (error) {
1008
1011
  return err(toFileError(error, resolved));
@@ -6,6 +6,24 @@ import type { AbortResult, AgentHarnessEvent, AgentHarnessEventResultMap, AgentH
6
6
  * mints (see the field docs on {@link UserMessage}). Threaded from `prompt`/`steer`/`followUp`/
7
7
  * `nextTurn` options into the persisted message so derived views (auto-mode window, compaction
8
8
  * serializer) work from METADATA, never from reminder-shape guessing. */
9
+ /** Who aborted a turn seat: the bare user halt or the steer-now accelerator. A run-level abort reaches the seat
10
+ * through the loop's composition listener and stamps nothing. */
11
+ export type SeatCutSource = "halt" | "steer";
12
+ /** Who asked the clean-stop latch to end the run: the bare user halt, or a soft stop ({@link AgentHarness.requestStopAfterTurn}). */
13
+ export type StopRequestSource = "halt" | "soft";
14
+ /** The harness's STATEMENT of what a run's halt (and its stop siblings) did — the result assembly reads this in
15
+ * place of any inference from message shape: `haltedByUser` ⇔ `turnCut === "halt"` ∨ `"halt" ∈ boundaryStop.sources`.
16
+ * Per prompt: every seat is cleared at the next `prompt()`, so a read after the prompt settled describes that run. */
17
+ export interface HaltConsumption {
18
+ /** The loop's interrupt settlement consumed the cut of the LAST-published seat, stamped with its aborter. Absent: no
19
+ * seat cut was consumed on the final turn (never cut / a later seat cleared it / a run abort owned the ending / no stamp). */
20
+ readonly turnCut?: SeatCutSource;
21
+ /** The first boundary consult that stopped a non-unwinding loop, with the latch's sources at that instant. */
22
+ readonly boundaryStop?: {
23
+ readonly face: "pre_turn" | "post_turn";
24
+ readonly sources: ReadonlyArray<StopRequestSource>;
25
+ };
26
+ }
9
27
  export interface UserMessageProvenance {
10
28
  /** Exact char length of the engine-prepended prefix on `text` (first-frame reminders etc.).
11
29
  * Sugar for the single segment `[0, chars)` — when `engineSegments` is also supplied, segments win. */
@@ -106,10 +124,19 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
106
124
  readonly env: ExecutionEnv;
107
125
  private session;
108
126
  private phase;
109
- /** design/74 R3-A: a one-way soft-stop flag. When set, `shouldStopAfterTurn` returns true at the next turn
110
- * boundary so the loop exits CLEANLY (emit `agent_end` + return) WITHOUT an abort/interrupt — the path a
111
- * resource-slice suspend needs (an abort would trip the orphan-[INTERRUPTED] reconcile = corrupt resume). */
112
- private _stopAfterTurn;
127
+ /** design/74 R3-A: the one-way clean-stop latch SET iff this set is non-empty. When set, the boundary consults
128
+ * answer true so the loop exits CLEANLY (emit `agent_end` + return) WITHOUT an abort/interrupt — the path a
129
+ * resource-slice suspend needs (an abort would trip the orphan-[INTERRUPTED] reconcile = corrupt resume). It is
130
+ * the SET of its requesters (`halt()` adds `"halt"`, `requestStopAfterTurn()` adds `"soft"`; both may be present,
131
+ * no first-writer arbitration) so the consumption record can say WHOSE stop the loop stopped for. */
132
+ private readonly stopRequestSources;
133
+ private get stopRequested();
134
+ private boundaryStopConsumed?;
135
+ /** The seat the loop published LAST — retract does not clear it, only the next publish replaces it (a fresh controller
136
+ * per turn, so identity carries the generation); the loop's cut-consumption report is accepted for THIS seat only. */
137
+ private lastPublishedSeat?;
138
+ private readonly seatCutSources;
139
+ private turnCutConsumed?;
113
140
  /** backlog #389 — one-way latch set by {@link abort}. Read by `drainQueuedMessages`: no boundary may
114
141
  * hand a queued frame to the model once the run is unwinding, and — crucially — none may SPLICE one
115
142
  * out of the queue either (that would delete the terminal undrained account the abort path owes it). */
@@ -390,6 +417,22 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
390
417
  turnCut: boolean;
391
418
  accepted: boolean;
392
419
  };
420
+ /** The ONE seat-cut dispatch behind both verbs: stamp the aborter FIRST (a seat is never aborted twice), then abort.
421
+ * A run abort fired INSIDE this dispatch (a listener on the cut signal tearing the run down) was fired BY the cut —
422
+ * the loop takes the run-terminal path and never reports that settlement, so the consumption is recorded here, the
423
+ * only point that can see the causation (synchronous, single-threaded). A LATER abort is the run's own ending. */
424
+ private cutSeat;
425
+ /** The harness's statement of what this run's halt (and its stop siblings) did — {@link HaltConsumption}. Readable
426
+ * during the run and after `prompt()` settled (cleared by the NEXT prompt). */
427
+ haltConsumption(): HaltConsumption;
428
+ /** The ONE consult behind both boundary faces: a set latch answers true, and the FIRST true answer on a run that is
429
+ * NOT unwinding is the consumption (the loop exits on it), recorded with the latch's sources at that instant. The run
430
+ * signal always wins (the loop's cut arms' rule): an unwinding run ends FOR its abort — the latch's true answer is
431
+ * incidental (the next turn's abort guard would have exited anyway), so the attribution is withheld, not the exit. */
432
+ private consultStopLatch;
433
+ /** The loop's report that a seat's cut settled its turn (the loop-config seam). Identity is checked against the
434
+ * LAST-published seat, never the live one (the arms report around retraction); any other seat is refused loudly. */
435
+ private noteTurnCutConsumed;
393
436
  /**
394
437
  * design/373 §3.3 (the final-commit-point double check) — how many queued injection frames a
395
438
  * boundary drain could deliver RIGHT NOW: both lanes, minus frames the current state refuses to
@@ -187,7 +187,14 @@ export class AgentHarness {
187
187
  env;
188
188
  session;
189
189
  phase = "idle";
190
- _stopAfterTurn = false;
190
+ stopRequestSources = new Set();
191
+ get stopRequested() {
192
+ return this.stopRequestSources.size > 0;
193
+ }
194
+ boundaryStopConsumed;
195
+ lastPublishedSeat;
196
+ seatCutSources = new WeakMap();
197
+ turnCutConsumed;
191
198
  aborting = false;
192
199
  drainedPendingInjection = [];
193
200
  runAbortController;
@@ -586,8 +593,8 @@ export class AgentHarness {
586
593
  ? { streamingToolExecution: true }
587
594
  : {}),
588
595
  convertToLlm: (messages) => stripEngineMetadata(convertToLlm(messages)),
589
- shouldStopAfterTurn: () => this._stopAfterTurn,
590
- shouldHaltBeforeTurn: () => this._stopAfterTurn,
596
+ shouldStopAfterTurn: () => this.consultStopLatch("post_turn"),
597
+ shouldHaltBeforeTurn: () => this.consultStopLatch("pre_turn"),
591
598
  transformContext: async (messages, signal) => {
592
599
  const result = await this.emitHook({ type: "context", messages: [...messages], ...(signal !== undefined ? { signal } : {}) });
593
600
  if (result?.adoptSessionRebuild !== true) {
@@ -642,8 +649,13 @@ export class AgentHarness {
642
649
  },
643
650
  getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode),
644
651
  publishTurnInterruptSeat: (seat) => {
652
+ if (seat !== undefined) {
653
+ this.lastPublishedSeat = seat;
654
+ this.turnCutConsumed = undefined;
655
+ }
645
656
  this.turnInterruptSeat = seat;
646
657
  },
658
+ noteTurnCutConsumed: (seat) => this.noteTurnCutConsumed(seat),
647
659
  recheckImmediateInjections: async (pending) => this.absorbImmediateSteering(pending),
648
660
  pendingInjectionCount: () => this.pendingInjectionCount(),
649
661
  getFollowUpMessages: async () => {
@@ -875,7 +887,7 @@ export class AgentHarness {
875
887
  return mergeTruncatedOutputChain(newMessages, i);
876
888
  }
877
889
  }
878
- if (this._stopAfterTurn)
890
+ if (this.stopRequested)
879
891
  return undefined;
880
892
  throw new AgentHarnessError("invalid_state", "AgentHarness prompt completed without an assistant message");
881
893
  }
@@ -892,7 +904,10 @@ export class AgentHarness {
892
904
  if (this.phase !== "idle") {
893
905
  throw new AgentHarnessError("busy", "AgentHarness is busy");
894
906
  }
895
- this._stopAfterTurn = false;
907
+ this.stopRequestSources.clear();
908
+ this.boundaryStopConsumed = undefined;
909
+ this.turnCutConsumed = undefined;
910
+ this.lastPublishedSeat = undefined;
896
911
  this.aborting = false;
897
912
  if (this.drainedPendingInjection.length > 0) {
898
913
  const stranded = this.drainedPendingInjection;
@@ -1009,7 +1024,7 @@ export class AgentHarness {
1009
1024
  const seat = this.turnInterruptSeat;
1010
1025
  if (seat === undefined || seat.signal.aborted)
1011
1026
  return false;
1012
- seat.abort();
1027
+ this.cutSeat(seat, "steer");
1013
1028
  return true;
1014
1029
  }
1015
1030
  halt() {
@@ -1018,13 +1033,41 @@ export class AgentHarness {
1018
1033
  }
1019
1034
  if (this.aborting)
1020
1035
  return { turnCut: false, accepted: false };
1021
- this._stopAfterTurn = true;
1036
+ this.stopRequestSources.add("halt");
1022
1037
  const seat = this.turnInterruptSeat;
1023
1038
  if (seat === undefined || seat.signal.aborted)
1024
1039
  return { turnCut: false, accepted: true };
1025
- seat.abort();
1040
+ this.cutSeat(seat, "halt");
1026
1041
  return { turnCut: true, accepted: true };
1027
1042
  }
1043
+ cutSeat(seat, source) {
1044
+ this.seatCutSources.set(seat, source);
1045
+ const runQuietBefore = this.runAbortController?.signal.aborted !== true;
1046
+ seat.abort();
1047
+ if (runQuietBefore && this.runAbortController?.signal.aborted === true)
1048
+ this.turnCutConsumed = { source };
1049
+ }
1050
+ haltConsumption() {
1051
+ const turnCut = this.turnCutConsumed?.source;
1052
+ return {
1053
+ ...(turnCut !== undefined ? { turnCut } : {}),
1054
+ ...(this.boundaryStopConsumed !== undefined ? { boundaryStop: this.boundaryStopConsumed } : {}),
1055
+ };
1056
+ }
1057
+ consultStopLatch(face) {
1058
+ if (!this.stopRequested)
1059
+ return false;
1060
+ if (this.runAbortController?.signal.aborted !== true) {
1061
+ this.boundaryStopConsumed ??= { face, sources: [...this.stopRequestSources] };
1062
+ }
1063
+ return true;
1064
+ }
1065
+ noteTurnCutConsumed(seat) {
1066
+ if (seat !== this.lastPublishedSeat) {
1067
+ throw new AgentHarnessError("invalid_state", "turn-cut consumption reported for a seat the harness did not publish last");
1068
+ }
1069
+ this.turnCutConsumed = { source: this.seatCutSources.get(seat) };
1070
+ }
1028
1071
  pendingInjectionCount() {
1029
1072
  if (this.aborting)
1030
1073
  return 0;
@@ -1195,7 +1238,7 @@ export class AgentHarness {
1195
1238
  }
1196
1239
  }
1197
1240
  requestStopAfterTurn() {
1198
- this._stopAfterTurn = true;
1241
+ this.stopRequestSources.add("soft");
1199
1242
  }
1200
1243
  async flushQueuedSessionWrites() {
1201
1244
  try {
@@ -257,8 +257,9 @@ async function runTurnPhases(state, signal, turnController, publishSeat, retract
257
257
  const withholdEnabled = ptl !== undefined && (ptl.withholdErrorEvents ?? true);
258
258
  let withhold = withholdEnabled ? createPtlWithholdBuffer(emit, ptlDetect) : undefined;
259
259
  let message;
260
+ let cutAtFinalSettle = false;
260
261
  try {
261
- message = await streamAssistantResponse(state.context, state.config, turnController.signal, withhold?.sink ?? emit, streamFn, runtime, executor, staticReasoningCutDowngrade);
262
+ ({ message, cutAtSettle: cutAtFinalSettle } = await streamAssistantResponse(state.context, state.config, turnController.signal, withhold?.sink ?? emit, streamFn, runtime, executor, staticReasoningCutDowngrade));
262
263
  }
263
264
  finally {
264
265
  await executor.settle();
@@ -308,7 +309,7 @@ async function runTurnPhases(state, signal, turnController, publishSeat, retract
308
309
  trace?.({ kind: "continue", reason: "reactive_compact_retry" });
309
310
  executor = new StreamToolExecutor(state.context, state.config, turnController.signal, emit);
310
311
  try {
311
- message = await streamAssistantResponse(state.context, state.config, turnController.signal, withhold?.sink ?? emit, streamFn, runtime, executor, staticReasoningCutDowngrade);
312
+ ({ message, cutAtSettle: cutAtFinalSettle } = await streamAssistantResponse(state.context, state.config, turnController.signal, withhold?.sink ?? emit, streamFn, runtime, executor, staticReasoningCutDowngrade));
312
313
  }
313
314
  finally {
314
315
  await executor.settle();
@@ -322,9 +323,11 @@ async function runTurnPhases(state, signal, turnController, publishSeat, retract
322
323
  ptlDetect(message) &&
323
324
  isBlankFailureContent(message) &&
324
325
  executor.admittedCount === 0;
325
- const interruptOwnsTurn = (message.stopReason === "aborted" || interruptedInRecovery || unrecoveredPtlFailureUnderInterrupt) &&
326
- turnController.signal.aborted &&
326
+ const cutAtPtlLaneExit = turnController.signal.aborted;
327
+ const interruptOwnsTurn = ((message.stopReason === "aborted" && cutAtFinalSettle) || (unrecoveredPtlFailureUnderInterrupt && cutAtPtlLaneExit) || interruptedInRecovery) &&
327
328
  !signal?.aborted;
329
+ if (interruptOwnsTurn)
330
+ state.config.noteTurnCutConsumed?.(turnController);
328
331
  if (interruptOwnsTurn && message.stopReason === "error")
329
332
  withhold?.discard();
330
333
  await withhold?.flush();
@@ -379,8 +382,10 @@ async function runTurnPhases(state, signal, turnController, publishSeat, retract
379
382
  await closeOrphanedStreamEntry(orphan, emit);
380
383
  }
381
384
  }
385
+ const cutAtBatchSettle = turnController.signal.aborted;
382
386
  retractSeat();
383
- if (toolCalls.length > 0 && turnController.signal.aborted && !signal?.aborted) {
387
+ if (toolCalls.length > 0 && cutAtBatchSettle && !signal?.aborted) {
388
+ state.config.noteTurnCutConsumed?.(turnController);
384
389
  return await settleInterruptedTurn(state, message, executor, toolResults, signal, emit);
385
390
  }
386
391
  await emit({ type: "turn_end", message, toolResults });
@@ -603,7 +608,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFn,
603
608
  context.messages.push(abortedBeforeDispatch);
604
609
  await emit({ type: "message_start", message: { ...abortedBeforeDispatch } });
605
610
  await emit({ type: "message_end", message: abortedBeforeDispatch });
606
- return abortedBeforeDispatch;
611
+ return { message: abortedBeforeDispatch, cutAtSettle: true };
607
612
  }
608
613
  const response = await streamFunction(config.model, llmContext, {
609
614
  ...config,
@@ -651,6 +656,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFn,
651
656
  case "done":
652
657
  case "error": {
653
658
  const finalMessage = await response.result();
659
+ const cutAtSettle = signal?.aborted === true;
654
660
  if (executor && (finalMessage.stopReason === "error" || finalMessage.stopReason === "aborted")) {
655
661
  mergeBackAdmittedCalls(finalMessage, executor);
656
662
  }
@@ -664,11 +670,12 @@ async function streamAssistantResponse(context, config, signal, emit, streamFn,
664
670
  await emit({ type: "message_start", message: { ...finalMessage } });
665
671
  }
666
672
  await emit({ type: "message_end", message: finalMessage });
667
- return finalMessage;
673
+ return { message: finalMessage, cutAtSettle };
668
674
  }
669
675
  }
670
676
  }
671
677
  const finalMessage = await response.result();
678
+ const cutAtSettle = signal?.aborted === true;
672
679
  if (executor && (finalMessage.stopReason === "error" || finalMessage.stopReason === "aborted")) {
673
680
  mergeBackAdmittedCalls(finalMessage, executor);
674
681
  }
@@ -680,7 +687,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFn,
680
687
  await emit({ type: "message_start", message: { ...finalMessage } });
681
688
  }
682
689
  await emit({ type: "message_end", message: finalMessage });
683
- return finalMessage;
690
+ return { message: finalMessage, cutAtSettle };
684
691
  }
685
692
  async function executeToolCalls(currentContext, assistantMessage, config, signal, emit, executor) {
686
693
  const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
@@ -493,6 +493,11 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
493
493
  * wins: when both fire, the loop takes the run-terminal path.
494
494
  */
495
495
  publishTurnInterruptSeat?: (seat: AbortController | undefined) => void;
496
+ /** The loop's REPORT that the published seat's cut settled its turn (stream / batch / recovery arms), called
497
+ * SYNCHRONOUSLY at the judgment point (before any settlement await, retracted or not) with the very controller
498
+ * {@link publishTurnInterruptSeat} published (checked against the host's LAST-published seat). Never for a
499
+ * run-level abort (the run signal wins) nor a normal ending. */
500
+ noteTurnCutConsumed?: (seat: AbortController) => void;
496
501
  /**
497
502
  * design/373 S1 — the pre-request re-check (the level half of the immediate-class contract):
498
503
  * called once per turn, after the interrupt seat above is published and before the provider