@sema-agent/core 5.54.0 → 5.56.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 (85) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/dist/agents/cumulative-stats.d.ts +26 -0
  3. package/dist/agents/cumulative-stats.js +56 -0
  4. package/dist/agents/observer.d.ts +11 -7
  5. package/dist/agents/observer.js +2 -4
  6. package/dist/agents/send-message-tool.js +48 -2
  7. package/dist/agents/subagent.js +250 -89
  8. package/dist/agents/verify.d.ts +27 -3
  9. package/dist/agents/verify.js +7 -2
  10. package/dist/core/auto-compaction.d.ts +17 -4
  11. package/dist/core/auto-compaction.js +3 -0
  12. package/dist/core/context-edit.d.ts +55 -6
  13. package/dist/core/context-edit.js +12 -1
  14. package/dist/core/governance-codes.js +14 -0
  15. package/dist/core/hooks.d.ts +293 -11
  16. package/dist/core/hooks.js +159 -12
  17. package/dist/core/human-input-projection.d.ts +20 -2
  18. package/dist/core/human-input-projection.js +9 -0
  19. package/dist/core/lsp-diagnostics.d.ts +19 -17
  20. package/dist/core/lsp-diagnostics.js +11 -5
  21. package/dist/core/mcp.d.ts +46 -0
  22. package/dist/core/mcp.js +132 -6
  23. package/dist/core/memory-engine/consolidation.d.ts +378 -0
  24. package/dist/core/memory-engine/consolidation.js +342 -0
  25. package/dist/core/memory-engine/dual-root.js +3 -0
  26. package/dist/core/memory-engine/engine.d.ts +237 -4
  27. package/dist/core/memory-engine/engine.js +1111 -4
  28. package/dist/core/memory-engine/export-bundle.js +9 -0
  29. package/dist/core/memory-engine/file-backend.js +27 -1
  30. package/dist/core/memory-engine/frontmatter.d.ts +20 -1
  31. package/dist/core/memory-engine/frontmatter.js +111 -0
  32. package/dist/core/memory-engine/index.d.ts +4 -2
  33. package/dist/core/memory-engine/index.js +3 -1
  34. package/dist/core/memory-engine/memory-backend-contract.js +131 -0
  35. package/dist/core/memory-engine/sync-client.js +26 -0
  36. package/dist/core/memory-engine/tools.d.ts +9 -0
  37. package/dist/core/memory-engine/tools.js +57 -13
  38. package/dist/core/memory-engine/types.d.ts +99 -0
  39. package/dist/core/memory-recall.js +4 -3
  40. package/dist/core/memory.d.ts +33 -3
  41. package/dist/core/memory.js +6 -4
  42. package/dist/core/permission-rules.d.ts +30 -0
  43. package/dist/core/permission-rules.js +71 -8
  44. package/dist/core/reminder-disclosure.d.ts +29 -4
  45. package/dist/core/reminder-disclosure.js +60 -12
  46. package/dist/core/runner/prepare-memory.js +7 -2
  47. package/dist/core/runner/prepare-task.d.ts +39 -1
  48. package/dist/core/runner/prepare-task.js +63 -35
  49. package/dist/core/runner/runtask.d.ts +8 -1
  50. package/dist/core/runner/runtask.js +170 -31
  51. package/dist/core/runner/session-rule-policy.js +5 -3
  52. package/dist/core/runner/synthetic-tools.js +4 -2
  53. package/dist/core/runner/turn-attachments.d.ts +16 -6
  54. package/dist/core/runner/turn-attachments.js +34 -20
  55. package/dist/core/session-reconcile.d.ts +32 -0
  56. package/dist/core/session-reconcile.js +15 -0
  57. package/dist/core/task-notification.d.ts +34 -7
  58. package/dist/core/task-notification.js +11 -1
  59. package/dist/core/task-registry-agent.d.ts +20 -3
  60. package/dist/core/task-registry-agent.js +31 -2
  61. package/dist/core/tool-policy.d.ts +23 -0
  62. package/dist/core/tool-policy.js +29 -13
  63. package/dist/core/types.d.ts +126 -17
  64. package/dist/core/untrusted-egress.js +12 -2
  65. package/dist/core/untrusted-text.d.ts +189 -3
  66. package/dist/core/untrusted-text.js +424 -6
  67. package/dist/engine/compaction/compaction.d.ts +77 -7
  68. package/dist/engine/compaction/compaction.js +98 -9
  69. package/dist/engine/compaction/utils.d.ts +4 -0
  70. package/dist/engine/compaction/utils.js +6 -0
  71. package/dist/engine/harness/agent-harness.d.ts +84 -0
  72. package/dist/engine/harness/agent-harness.js +88 -12
  73. package/dist/engine/harness/messages.d.ts +4 -2
  74. package/dist/engine/harness/messages.js +7 -2
  75. package/dist/engine/harness/types.d.ts +11 -5
  76. package/dist/engine/loop/types.d.ts +14 -0
  77. package/dist/engine/session/import-validate.js +10 -0
  78. package/dist/engine/session/session.js +2 -2
  79. package/dist/index.d.ts +1 -1
  80. package/dist/index.js +1 -1
  81. package/dist/orchestration/run-spec.js +8 -1
  82. package/dist/prompts/default.d.ts +22 -6
  83. package/dist/tools/fs/index.d.ts +3 -1
  84. package/package.json +1 -1
  85. package/test/export-surface.snapshot.json +28 -1
@@ -31,23 +31,23 @@ import { toImageContent } from "./image.js";
31
31
  import { OUTPUT_TOOL_NAME, SKILLS_LISTING_PROBE_HEADER, resolveOutputRetries } from "./synthetic-tools.js";
32
32
  import { cacheFamilyOf, usageCostMicroUsd } from "./usage-accounting.js";
33
33
  import { assembleResult, errorCodeOf } from "./assemble-result.js";
34
- import { ATTACHMENT_BYTE_CAP, CHANGED_FILES_MAX, AGENT_LISTING_REMOVED_HEADER, SKILLS_LISTING_DELTA_HEADER, SKILLS_LISTING_REMOVED_HEADER, advanceCadenceClock, agentListingDeltaHeader, agentListingInitialHeader, replayAnnouncedListing, replayAnnouncedModels, clipToBytes, collectDateChange, collectDueAttachments, collectInstructionsChange, commitAgentListing, commitInstructionsChange, commitSkillsListing, createAttachmentState, rebaseCadenceWindows, reduceToolEnd, renderAgentListingDelta, renderMcpDroppedTools, renderMcpInstructionsDelta, renderOrphanedBackgroundTasks, selectMcpDroppedBatch, renderSkillsListingDelta, renderToolsDelta, stampWriteAnchor } from "./turn-attachments.js";
34
+ import { ATTACHMENT_BYTE_CAP, CHANGED_FILES_MAX, AGENT_LISTING_REMOVED_HEADER, SKILLS_LISTING_DELTA_HEADER, SKILLS_LISTING_REMOVED_HEADER, advanceCadenceClock, agentListingDeltaHeader, attachmentEnvelopeTags, agentListingInitialHeader, replayAnnouncedListing, replayAnnouncedModels, clipToBytes, collectDateChange, collectDueAttachments, collectInstructionsChange, commitAgentListing, commitInstructionsChange, commitSkillsListing, createAttachmentState, rebaseCadenceWindows, reduceToolEnd, renderAgentListingDelta, renderMcpDroppedTools, renderMcpInstructionsDelta, renderOrphanedBackgroundTasks, selectMcpDroppedBatch, renderSkillsListingDelta, renderToolsDelta, stampWriteAnchor } from "./turn-attachments.js";
35
35
  import { buildWorkingFileAttachments, centerAdoptionOption, emitInputTruncated, forkContextOption } from "./compaction-call-options.js";
36
- import { prepareTask, resolveCheckpointStore } from "./prepare-task.js";
36
+ import { gatedCallIdOf, prepareTask, resolveCheckpointStore } from "./prepare-task.js";
37
37
  import { settleTeardownLeg } from "./teardown-bounded.js";
38
38
  import { TOOL_SEARCH_NAME } from "./tool-disclosure.js";
39
39
  import { hasVerifiableStructureSignal } from "./grounding-signal.js";
40
40
  import { hasDestroy, isIsolated } from "../remote-env.js";
41
41
  import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.js";
42
- import { cloneObserverInput, formatHookFeedback } from "../hooks.js";
43
- import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
44
- import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText } from "../untrusted-text.js";
45
- import { reconcileInterruptedSession } from "../session-reconcile.js";
42
+ import { cloneObserverInput, formatHookFeedback, hookSeatExpiredError, resolveHookTimeoutMs, runHookSeat } from "../hooks.js";
43
+ import { buildHumanInputEvent, frameMidTurnUserInput, projectHumanInput } from "../human-input-projection.js";
44
+ import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
45
+ import { appendInterruptionMarker, reconcileInterruptedSession } from "../session-reconcile.js";
46
46
  import { RunnerSharedToolResultStore } from "../tool-result-store.js";
47
47
  import { formatDiagnosticsBlock } from "../lsp-diagnostics.js";
48
48
  import { checkToolPolicyProjection, constraintChainDigest, constraintChainEntryOf, isApprovalSettledBy, refuseOutOfContractDecision, screenApproverAttribution, toolPolicyNameSets } from "../tool-policy.js";
49
49
  import { defaultTaskRegistry } from "../task-registry.js";
50
- import { discloseDroppedPending, isDelegatedAgentTerminal, PendingSessionNotifications, renderTaskNotificationXml, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
50
+ import { discloseDroppedPending, isDelegatedAgentTerminal, isSystemInjectionPriority, PendingSessionNotifications, renderTaskNotificationXml, SYSTEM_INJECTION_PRIORITIES, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
51
51
  import { ToolDetachHub } from "../tool-detach.js";
52
52
  import { createPeerInboundChainRef, createPeerSelfRef } from "../../agents/peer-admission.js";
53
53
  import { workflowSizeGuidelineChangeNotice } from "../../orchestration/workflow-size-guideline.js";
@@ -141,17 +141,19 @@ function resumeDecisionWasNegative(resume) {
141
141
  }
142
142
  const DEFERRED_REISSUE = "[DEFERRED] This tool call shared a batch with a call that suspended for durable approval, so it was " +
143
143
  "NOT executed on resume. If you still need it, issue it again now.";
144
- function toolEndBodyFrom(result, isError, settledBy, approver, resolution) {
144
+ function toolEndBodyFrom(result, isError, settledBy, approver, resolution, gatedCallIdOfRun) {
145
145
  const o = toolOutputFrom(result);
146
146
  const st = structuredFrom(result);
147
147
  const det = isError ? result?.details : undefined;
148
148
  const codeRaw = det?.code;
149
149
  const kindRaw = det?.errorKind;
150
150
  const code = typeof codeRaw === "string" ? codeRaw : typeof kindRaw === "string" ? kindRaw : undefined;
151
+ const gatedCallId = code === "gate.parked" ? gatedCallIdOfRun : undefined;
151
152
  return {
152
153
  ...(o !== undefined ? { output: o.output, ...(o.truncated ? { truncated: true } : {}), ...(o.totalChars !== undefined ? { totalChars: o.totalChars } : {}) } : {}),
153
154
  ...(st !== undefined ? { structured: st } : {}),
154
155
  ...(typeof code === "string" ? { errorCode: code } : {}),
156
+ ...(gatedCallId !== undefined ? { gatedCallId } : {}),
155
157
  ...(settledBy !== undefined ? { settledBy } : {}),
156
158
  ...(approver !== undefined ? { approver } : {}),
157
159
  ...(resolution !== undefined ? { resolution } : {}),
@@ -543,7 +545,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
543
545
  const files = prepared.lspDiagnostics.registry.drain(prepared.lspDiagnostics.runIdent);
544
546
  if (files.length > 0) {
545
547
  queue.push({ type: "diagnostics", files, isNew: true, ...ident() });
546
- const block = formatDiagnosticsBlock(files);
548
+ const block = mintSystemReminder(formatDiagnosticsBlock(files), prepared.reminderMark);
547
549
  void prepared.harness.steer(block, { provenance: "engine-note" }).catch(() => undefined);
548
550
  boundarySteered = true;
549
551
  }
@@ -746,7 +748,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
746
748
  }
747
749
  }
748
750
  if (due.length > 0) {
749
- attachmentsPayload = due.map((a) => mintSystemReminder(sanitizeUntrustedText(a.body), prepared.reminderMark)).join("\n");
751
+ attachmentsPayload = due.map((a) => mintSystemReminder(sanitizeUntrustedText(a.body, attachmentEnvelopeTags(a.source)), prepared.reminderMark)).join("\n");
750
752
  for (const a of due)
751
753
  boundaryAttachmentBytes += Buffer.byteLength(a.body, "utf8");
752
754
  rs.attach.attachmentsInjected += due.length;
@@ -765,9 +767,13 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
765
767
  const injectedThisTurn = finalVerifyInjectedThisTurn ? "final_verification" : undefined;
766
768
  if (!boundarySteered && !prepared.abortController.signal.aborted) {
767
769
  try {
768
- const r = await postToolBatchHook(batch, injectedThisTurn !== undefined ? { injectedThisTurn } : undefined, { identity: prepared.hookIdentity });
770
+ const batchSeat = await runHookSeat("postToolBatch", { timeoutMs: prepared.hookTimeoutMs, signal: prepared.abortController.signal }, (sig) => postToolBatchHook(batch, injectedThisTurn !== undefined ? { injectedThisTurn } : undefined, { identity: prepared.hookIdentity, signal: sig }));
771
+ if (batchSeat.expired) {
772
+ runnerHooks.onError?.(hookSeatExpiredError("postToolBatch", prepared.hookTimeoutMs, batchSeat.cause, "the boundary observation was abandoned and its additionalContext dropped; the turn boundary itself is unchanged"), { phase: "hook", sessionId: prepared.sessionId });
773
+ }
774
+ const r = batchSeat.expired ? undefined : batchSeat.value;
769
775
  if (r?.additionalContext && injectedThisTurn === undefined) {
770
- const body = sanitizeUntrustedText(r.additionalContext);
776
+ const body = sanitizeUntrustedText(r.additionalContext, SHELLED_BODY_ENVELOPE_TAGS);
771
777
  const budget = ATTACHMENT_BYTE_CAP - boundaryAttachmentBytes;
772
778
  if (budget >= BATCH_CONTEXT_MIN_KEEP_BYTES) {
773
779
  const clipped = clipToBytes(body, budget) === body
@@ -1008,7 +1014,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
1008
1014
  return onTurnBoundary;
1009
1015
  }
1010
1016
  function wrapGitFrame(body, mark) {
1011
- return mintSystemReminder(sanitizeUntrustedText(body), mark);
1017
+ return mintSystemReminder(sanitizeUntrustedText(body, SHELLED_BODY_ENVELOPE_TAGS), mark);
1012
1018
  }
1013
1019
  async function resolveGitLegDelivery(prepared, cpMirror, report) {
1014
1020
  const ref = prepared.gitStatusRef;
@@ -1344,7 +1350,7 @@ function makeHarnessHandlers(prepared, stats, rs, deps) {
1344
1350
  toolName: event.toolName,
1345
1351
  ...(toolLabels.get(event.toolName) !== undefined ? { label: toolLabels.get(event.toolName) } : {}),
1346
1352
  isError: event.isError,
1347
- ...toolEndBodyFrom(event.result, event.isError, settlement?.settledBy, settlement?.approver, settlement?.resolution),
1353
+ ...toolEndBodyFrom(event.result, event.isError, settlement?.settledBy, settlement?.approver, settlement?.resolution, prepared.abortController.signal.aborted ? gatedCallIdOf(prepared) : undefined),
1348
1354
  ...ident(),
1349
1355
  });
1350
1356
  announceWorkspaceMove();
@@ -1502,6 +1508,7 @@ export class Runner {
1502
1508
  ...(typeof h.postCompact === "function" ? { postCompact: (c) => h.postCompact(c) } : {}),
1503
1509
  ...(typeof h.stopFailure === "function" ? { stopFailure: (c) => h.stopFailure(c) } : {}),
1504
1510
  ...(typeof h.permissionDenied === "function" ? { permissionDenied: (p) => h.permissionDenied(p) } : {}),
1511
+ ...(h.timeoutMs !== undefined ? { timeoutMs: h.timeoutMs } : {}),
1505
1512
  }
1506
1513
  : undefined;
1507
1514
  return {
@@ -1781,6 +1788,13 @@ export class Runner {
1781
1788
  const actorIn = options?.actor;
1782
1789
  const actor = actorIn === undefined ? undefined : snapshotActorAssertion(actorIn);
1783
1790
  const projected = projectHumanInput({ text, actor, source: "steer" });
1791
+ const effectiveInputId = typeof inputId === "string" ? inputId : uuidv7();
1792
+ const parkRecord = {
1793
+ text,
1794
+ trusted,
1795
+ inputId: effectiveInputId,
1796
+ ...(actor !== undefined ? { actor } : {}),
1797
+ };
1784
1798
  let payload;
1785
1799
  let mintsAFrame;
1786
1800
  let replay;
@@ -1795,7 +1809,7 @@ export class Runner {
1795
1809
  source: "steer",
1796
1810
  delivery: "queued",
1797
1811
  sessionSeq: nextHumanInputSeq(h.harness),
1798
- ...(typeof inputId === "string" ? { inputId } : {}),
1812
+ inputId: effectiveInputId,
1799
1813
  ...(actor !== undefined ? { actor } : {}),
1800
1814
  ...(actor?.issuer !== undefined ? { issuer: actor.issuer } : {}),
1801
1815
  ...(spec.principal !== undefined ? { principal: spec.principal } : {}),
@@ -1812,7 +1826,7 @@ export class Runner {
1812
1826
  const h = handle ?? (await orTimeout(ready));
1813
1827
  if (!h)
1814
1828
  throw steeringError("the task is not running");
1815
- payload = trusted ? formatHookFeedback(projected, h.reminderMark) : projected;
1829
+ payload = trusted ? formatHookFeedback(projected, h.reminderMark) : frameMidTurnUserInput(projected);
1816
1830
  mintsAFrame = payload.trim().length !== 0;
1817
1831
  replay = { payload, trusted, ...(actor !== undefined ? { actor } : {}) };
1818
1832
  if (typeof inputId === "string") {
@@ -1828,7 +1842,7 @@ export class Runner {
1828
1842
  }
1829
1843
  }
1830
1844
  try {
1831
- await h.harness.steer(payload, { provenance: "engine-note", callerAuthored: true, ...(actor !== undefined ? { actor } : {}) });
1845
+ await h.harness.steer(payload, { provenance: "engine-note", callerAuthored: true, parkRecord, ...(actor !== undefined ? { actor } : {}) });
1832
1846
  noteAccepted(h);
1833
1847
  return;
1834
1848
  }
@@ -1839,7 +1853,7 @@ export class Runner {
1839
1853
  const birthDeadline = Date.now() + READY_TIMEOUT_MS;
1840
1854
  while (resultValue === undefined && !h.loop.ended && Date.now() < birthDeadline) {
1841
1855
  try {
1842
- await h.harness.steer(payload, { provenance: "engine-note", callerAuthored: true, ...(actor !== undefined ? { actor } : {}) });
1856
+ await h.harness.steer(payload, { provenance: "engine-note", callerAuthored: true, parkRecord, ...(actor !== undefined ? { actor } : {}) });
1843
1857
  noteAccepted(h);
1844
1858
  return;
1845
1859
  }
@@ -1880,6 +1894,9 @@ export class Runner {
1880
1894
  if (input.source !== undefined && typeof input.source !== "string") {
1881
1895
  throw notifyError("input.source must be a string when present", "notify.invalid_payload");
1882
1896
  }
1897
+ if (opts?.priority !== undefined && !isSystemInjectionPriority(opts.priority)) {
1898
+ throw notifyError(`opts.priority must be one of ${SYSTEM_INJECTION_PRIORITIES.join("/")} when present`, "notify.invalid_payload");
1899
+ }
1883
1900
  const payload = {
1884
1901
  task_id: input.task_id,
1885
1902
  task_type: "external",
@@ -1943,6 +1960,8 @@ export class Runner {
1943
1960
  const h = handle ?? (await orTimeout(ready));
1944
1961
  if (!h)
1945
1962
  return;
1963
+ if (!h.abortController.signal.aborted)
1964
+ h.loop.userInterrupted = true;
1946
1965
  h.abortController.abort();
1947
1966
  void h.harness.abort();
1948
1967
  },
@@ -2027,7 +2046,18 @@ export class Runner {
2027
2046
  });
2028
2047
  const upstreamTaskNotification = internals?.onTaskNotification;
2029
2048
  const deliveredAtTurnOpen = new Set();
2049
+ let priorityNowDisclosed = false;
2030
2050
  const injectTaskNotification = (notification, opts) => {
2051
+ if (opts?.priority === "now" && !priorityNowDisclosed) {
2052
+ priorityNowDisclosed = true;
2053
+ deliverEngineNotice(this.deps.onNotice, {
2054
+ code: "task.injection_priority_unimplemented",
2055
+ message: `a notification was injected with priority "now", which this engine does not implement: all injection ` +
2056
+ `priorities deliver at the NEXT turn boundary and none aborts the running turn. The notification is ` +
2057
+ `delivered — only the interrupting semantics are absent.`,
2058
+ detail: { priority: "now", ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}) },
2059
+ });
2060
+ }
2031
2061
  if (deliveredAtTurnOpen.has(taskNotificationDedupKey(notification)))
2032
2062
  return Promise.resolve("dropped_duplicate");
2033
2063
  if (!notificationLaneLive) {
@@ -2096,7 +2126,12 @@ export class Runner {
2096
2126
  this.pendingSessionNotifications.pend(notificationSessionId, p);
2097
2127
  };
2098
2128
  prepared.harness.engineInjectionsHeld = () => prepared.batchHaltRef.current !== undefined;
2129
+ let undrainedUserAtEnd;
2099
2130
  prepared.harness.onUndrainedUserInputs = (counts) => {
2131
+ if (prepared.suspendRef.token !== undefined || prepared.reviewRef.token !== undefined) {
2132
+ undrainedUserAtEnd = counts;
2133
+ return;
2134
+ }
2100
2135
  for (const notice of undrainedUserInputNotices(counts, spec.taskId)) {
2101
2136
  deliverEngineNotice(this.deps.onNotice, notice);
2102
2137
  }
@@ -2125,7 +2160,7 @@ export class Runner {
2125
2160
  }
2126
2161
  }
2127
2162
  }
2128
- const loopLatch = { ended: false };
2163
+ const loopLatch = { ended: false, userInterrupted: false };
2129
2164
  onReady({ harness: prepared.harness, abortController: prepared.abortController, loop: loopLatch, reminderMark: prepared.reminderMark });
2130
2165
  const stats = { turns: 0, tokens: 0, toolCalls: 0, promptTokens: 0, totalInputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, cacheWriteTokensLong: 0, outputTokens: 0, costMicroUsd: 0 };
2131
2166
  prepared.liveSpendRef.get = () => ({ costMicroUsd: stats.costMicroUsd, tokens: stats.tokens, turns: stats.turns, walltimeMs: Math.round(performance.now() - rs.telemetry.taskStartMonotonic) });
@@ -2807,12 +2842,21 @@ export class Runner {
2807
2842
  return [];
2808
2843
  let result;
2809
2844
  try {
2810
- result = await stopHook({
2845
+ const stopSeat = await runHookSeat("stop", { timeoutMs: prepared.hookTimeoutMs, signal: prepared.abortController.signal, abortEnds: true }, (sig) => stopHook({
2811
2846
  stopHookActive: consecutiveBlocks > 0,
2812
2847
  consecutiveBlocks,
2813
2848
  getBranch: () => prepared.session.getBranch(),
2814
2849
  identity: prepared.hookIdentity,
2815
- });
2850
+ signal: sig,
2851
+ }));
2852
+ if (stopSeat.expired) {
2853
+ if (stopSeat.cause === "timeout") {
2854
+ this.deps.onError?.(hookSeatExpiredError("stop", prepared.hookTimeoutMs, stopSeat.cause, "the run was allowed to END (the seat's own no-opinion answer); no pushback and no additional context were injected"), { phase: "hook", sessionId: prepared.sessionId });
2855
+ }
2856
+ consecutiveBlocks = 0;
2857
+ return [];
2858
+ }
2859
+ result = stopSeat.value;
2816
2860
  }
2817
2861
  catch (err) {
2818
2862
  this.deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId: prepared.sessionId });
@@ -2887,7 +2931,7 @@ export class Runner {
2887
2931
  ...this.seamCCompactionOptions(prepared),
2888
2932
  ...gitRestateOption(prepared),
2889
2933
  ...windowSafetyOptions(prepared.harness.getModel()),
2890
- ...this.compactionHookOptions(spec, prepared.sessionId, "forced", prepared.hookIdentity),
2934
+ ...this.compactionHookOptions(spec, prepared.sessionId, "forced", prepared.hookIdentity, { timeoutMs: prepared.hookTimeoutMs, signal: prepared.abortController.signal }),
2891
2935
  });
2892
2936
  if (comp.compacted) {
2893
2937
  compactionBreaker.failures = 0;
@@ -2952,7 +2996,7 @@ export class Runner {
2952
2996
  runnerHooks: {
2953
2997
  onError: this.deps.onError,
2954
2998
  seamCCompactionOptions: (p) => this.seamCCompactionOptions(p),
2955
- compactionHookOptions: (s, sid, trig) => this.compactionHookOptions(s, sid, trig, prepared.hookIdentity),
2999
+ compactionHookOptions: (s, sid, trig) => this.compactionHookOptions(s, sid, trig, prepared.hookIdentity, { timeoutMs: prepared.hookTimeoutMs, signal: prepared.abortController.signal }),
2956
3000
  recordCompactionReuse: (p, c) => this.recordCompactionReuse(p, c),
2957
3001
  },
2958
3002
  });
@@ -3055,6 +3099,7 @@ export class Runner {
3055
3099
  let final;
3056
3100
  let threw;
3057
3101
  let abortedLive = false;
3102
+ let userInterruptedLive = false;
3058
3103
  let strandedHumanAnswers = [];
3059
3104
  try {
3060
3105
  if (prepared.abortController.signal.aborted) {
@@ -3182,7 +3227,23 @@ export class Runner {
3182
3227
  const userPromptSubmit = (spec.hooks ?? this.deps.hooks)?.userPromptSubmit;
3183
3228
  if (userPromptSubmit) {
3184
3229
  try {
3185
- const decision = await userPromptSubmit(spec.objective, { identity: prepared.hookIdentity });
3230
+ const promptSeat = await runHookSeat("userPromptSubmit", { timeoutMs: prepared.hookTimeoutMs, signal: prepared.abortController.signal, abortEnds: true }, (sig) => userPromptSubmit(spec.objective, { identity: prepared.hookIdentity, signal: sig }));
3231
+ if (promptSeat.expired) {
3232
+ if (promptSeat.cause === "timeout") {
3233
+ try {
3234
+ this.deps.onError?.(hookSeatExpiredError("userPromptSubmit", prepared.hookTimeoutMs, promptSeat.cause, "the prompt was NOT submitted (fail-closed) and the task ends blocked"), { phase: "hook", sessionId: prepared.sessionId });
3235
+ }
3236
+ catch {
3237
+ }
3238
+ }
3239
+ prepared.blockedRef.reason = formatHookFeedback(promptSeat.cause === "timeout"
3240
+ ? `the deployment's userPromptSubmit hook did not answer within its ${prepared.hookTimeoutMs}ms bound while screening this prompt; ` +
3241
+ `the prompt was NOT submitted (fail-closed)`
3242
+ : `the task was cancelled while the deployment's userPromptSubmit hook was still screening this prompt; ` +
3243
+ `the prompt was NOT submitted (fail-closed)`, prepared.reminderMark);
3244
+ promptBlocked = true;
3245
+ }
3246
+ const decision = promptSeat.expired ? undefined : promptSeat.value;
3186
3247
  if (decision?.block) {
3187
3248
  prepared.blockedRef.reason = formatHookFeedback(decision.block, prepared.reminderMark);
3188
3249
  promptBlocked = true;
@@ -3258,7 +3319,7 @@ export class Runner {
3258
3319
  }
3259
3320
  }
3260
3321
  if (firstFrames.length > 0) {
3261
- effectiveObjective = `${firstFrames.map((f) => mintSystemReminder(sanitizeUntrustedText(f.body), prepared.reminderMark)).join("\n")}\n${effectiveObjective}`;
3322
+ effectiveObjective = `${firstFrames.map((f) => mintSystemReminder(sanitizeUntrustedText(f.body, attachmentEnvelopeTags(f.source)), prepared.reminderMark)).join("\n")}\n${effectiveObjective}`;
3262
3323
  }
3263
3324
  }
3264
3325
  const gitQueuedChars = gitLegDelivered !== undefined && gitLegDelivered.standalone ? gitLegDelivered.wrapped.length : 0;
@@ -3351,6 +3412,7 @@ export class Runner {
3351
3412
  }
3352
3413
  loopLatch.ended = true;
3353
3414
  abortedLive = prepared.abortController.signal.aborted;
3415
+ userInterruptedLive = loopLatch.userInterrupted;
3354
3416
  }
3355
3417
  catch (err) {
3356
3418
  loopLatch.ended = true;
@@ -3362,6 +3424,7 @@ export class Runner {
3362
3424
  }
3363
3425
  threw = err;
3364
3426
  abortedLive = prepared.abortController.signal.aborted;
3427
+ userInterruptedLive = loopLatch.userInterrupted;
3365
3428
  }
3366
3429
  finally {
3367
3430
  timeout.clear();
@@ -3399,13 +3462,17 @@ export class Runner {
3399
3462
  const durablyPaused = suspended || reviewPaused;
3400
3463
  const interrupted = !durablyPaused &&
3401
3464
  (threw !== undefined || timeout.fired || rs.limits.turnsExceeded || rs.limits.budgetHit !== undefined || abortedLive || final?.stopReason === "aborted");
3465
+ let orphansClosed = 0;
3466
+ let reconcileComplete = false;
3402
3467
  if (interrupted) {
3403
3468
  try {
3404
3469
  const report = await reconcileInterruptedSession(prepared.session, prepared.toolEffects, undefined, startedToolCallIds);
3470
+ orphansClosed = report.recovered.length;
3405
3471
  for (const orphan of report.recovered) {
3406
3472
  queue.push({ type: "tool_end", toolCallId: orphan.toolCallId, toolName: orphan.toolName, ...(toolLabels.has(orphan.toolName) ? { label: toolLabels.get(orphan.toolName) } : {}), isError: true, ...reconciledToolEndBody(orphan), ...ident() });
3407
3473
  emitCommitted(orphan.entryId, "toolResult", orphan.toolCallId);
3408
3474
  }
3475
+ reconcileComplete = true;
3409
3476
  }
3410
3477
  catch (reconcileErr) {
3411
3478
  this.deps.onError?.(reconcileErr instanceof Error ? reconcileErr : new Error(String(reconcileErr)), {
@@ -3423,6 +3490,52 @@ export class Runner {
3423
3490
  });
3424
3491
  }
3425
3492
  }
3493
+ if (interrupted && userInterruptedLive && reconcileComplete) {
3494
+ try {
3495
+ const entryId = await appendInterruptionMarker(prepared.session, { toolUseInFlight: orphansClosed > 0 });
3496
+ emitCommitted(entryId, "user");
3497
+ }
3498
+ catch (markerErr) {
3499
+ this.deps.onError?.(markerErr instanceof Error ? markerErr : new Error(String(markerErr)), {
3500
+ phase: "interrupt-reconcile",
3501
+ sessionId: prepared.sessionId,
3502
+ });
3503
+ }
3504
+ }
3505
+ let migratedParked = { steer: 0, followUp: 0 };
3506
+ const parkToken = prepared.suspendRef.token ?? prepared.reviewRef.token;
3507
+ const parkScope = prepared.suspendRef.token !== undefined ? prepared.suspendRef.scope : prepared.reviewRef.scope;
3508
+ if (durablyPaused && parkToken !== undefined && parkScope !== undefined && this.deps.checkpointStore !== undefined) {
3509
+ const store = this.deps.checkpointStore;
3510
+ const carried = [];
3511
+ for (const record of prepared.harness.readParkableUserInputs()) {
3512
+ try {
3513
+ if (!(await store.setPendingSteer(parkToken, parkScope, record)))
3514
+ break;
3515
+ carried.push(record);
3516
+ }
3517
+ catch (parkErr) {
3518
+ this.deps.onError?.(parkErr instanceof Error ? parkErr : new Error(String(parkErr)), {
3519
+ phase: "config",
3520
+ sessionId: prepared.sessionId,
3521
+ });
3522
+ const parkCode = errorCodeOf(parkErr);
3523
+ if (parkCode === "steering.invalid_content" || parkCode === "steering.queue_full")
3524
+ continue;
3525
+ break;
3526
+ }
3527
+ }
3528
+ migratedParked = prepared.harness.dropParkedUserInputs(carried);
3529
+ }
3530
+ if (undrainedUserAtEnd !== undefined) {
3531
+ const remaining = {
3532
+ steer: Math.max(0, undrainedUserAtEnd.steer - migratedParked.steer),
3533
+ followUp: Math.max(0, undrainedUserAtEnd.followUp - migratedParked.followUp),
3534
+ };
3535
+ for (const notice of undrainedUserInputNotices(remaining, spec.taskId)) {
3536
+ deliverEngineNotice(this.deps.onNotice, notice);
3537
+ }
3538
+ }
3426
3539
  if (rs.attach.attachState?.postCompactPending === true && (rs.attach.attachmentsCfg?.backgroundTasks ?? eventDefaultOn("background_tasks")) === true) {
3427
3540
  emitTrace(rs.telemetry.tracer, () => ({ kind: "compaction.announce_dropped", version: 1, taskId: rs.telemetry.taskId, ts: Date.now() }));
3428
3541
  }
@@ -3432,6 +3545,7 @@ export class Runner {
3432
3545
  brain: compactionBrain,
3433
3546
  windowSafety: windowSafetyOptions(prepared.model),
3434
3547
  onCompactionFailed: (reason) => queue.push({ type: "compaction_outcome", outcome: "failed", trigger: "auto", reason, ...ident() }),
3548
+ cancelled: abortedLive,
3435
3549
  });
3436
3550
  try {
3437
3551
  if (comp?.compacted) {
@@ -3594,12 +3708,20 @@ export class Runner {
3594
3708
  !abortedLive &&
3595
3709
  result.errorCode !== "conflict") {
3596
3710
  try {
3597
- await stopFailureHook({
3711
+ const failureSeat = await runHookSeat("stopFailure", { timeoutMs: prepared.hookTimeoutMs }, (sig) => stopFailureHook({
3598
3712
  identity: prepared.hookIdentity,
3599
3713
  error: result.errorMessage ?? "model error",
3600
3714
  ...(result.errorCode !== undefined ? { errorKind: result.errorCode } : {}),
3601
3715
  turns: stats.turns,
3602
- });
3716
+ signal: sig,
3717
+ }));
3718
+ if (failureSeat.expired) {
3719
+ try {
3720
+ this.deps.onError?.(hookSeatExpiredError("stopFailure", prepared.hookTimeoutMs, failureSeat.cause, "the terminal observation was abandoned; the assembled TaskResult is unchanged"), { phase: "hook", sessionId: prepared.sessionId });
3721
+ }
3722
+ catch {
3723
+ }
3724
+ }
3603
3725
  }
3604
3726
  catch (err) {
3605
3727
  try {
@@ -4610,10 +4732,12 @@ export class Runner {
4610
4732
  consecutiveProviderReuse: prepared.compactionReuseRef.consecutive,
4611
4733
  };
4612
4734
  }
4613
- compactionHookOptions(spec, sessionId, trigger, identity) {
4735
+ compactionHookOptions(spec, sessionId, trigger, identity, seatBound) {
4614
4736
  const hooks = spec.hooks ?? this.deps.hooks;
4615
4737
  const pre = hooks?.preCompact;
4616
4738
  const post = hooks?.postCompact;
4739
+ const seatMs = seatBound?.timeoutMs ?? resolveHookTimeoutMs(hooks?.timeoutMs);
4740
+ const seatSignal = seatBound?.signal;
4617
4741
  const withIdentity = (ctx) => identity !== undefined ? { ...ctx, identity } : ctx;
4618
4742
  return {
4619
4743
  trigger,
@@ -4629,7 +4753,12 @@ export class Runner {
4629
4753
  }
4630
4754
  };
4631
4755
  try {
4632
- const r = await pre.call(hooks, ctx);
4756
+ const seat = await runHookSeat("preCompact", { timeoutMs: seatMs, ...(seatSignal !== undefined ? { signal: seatSignal } : {}) }, (sig) => pre.call(hooks, { ...ctx, signal: sig }));
4757
+ if (seat.expired) {
4758
+ report(hookSeatExpiredError("preCompact", seatMs, seat.cause, `the "${ctx.trigger}" compaction PROCEEDED unblocked and with no additional instructions`));
4759
+ return undefined;
4760
+ }
4761
+ const r = seat.value;
4633
4762
  if (r?.block && ctx.trigger === "forced") {
4634
4763
  report(new Error(`a preCompact callback blocked a "forced" compaction — ignored (PTL/trim-pressure compaction is not optional): ${r.block}`));
4635
4764
  }
@@ -4646,7 +4775,14 @@ export class Runner {
4646
4775
  ? {
4647
4776
  postCompact: async (rawCtx) => {
4648
4777
  try {
4649
- await post.call(hooks, withIdentity(rawCtx));
4778
+ const seat = await runHookSeat("postCompact", { timeoutMs: seatMs, ...(seatSignal !== undefined ? { signal: seatSignal } : {}) }, (sig) => post.call(hooks, { ...withIdentity(rawCtx), signal: sig }));
4779
+ if (seat.expired) {
4780
+ try {
4781
+ this.deps.onError?.(hookSeatExpiredError("postCompact", seatMs, seat.cause, "the observation was abandoned; the compaction that already landed is unchanged"), { phase: "hook", sessionId });
4782
+ }
4783
+ catch {
4784
+ }
4785
+ }
4650
4786
  }
4651
4787
  catch (err) {
4652
4788
  try {
@@ -4690,7 +4826,10 @@ export class Runner {
4690
4826
  ...(prepared.onCompactionApplied ? { onApplied: prepared.onCompactionApplied } : {}),
4691
4827
  ...this.seamCCompactionOptions(prepared),
4692
4828
  ...gitRestateOption(prepared),
4693
- ...this.compactionHookOptions(spec, prepared.sessionId, "auto", prepared.hookIdentity),
4829
+ ...this.compactionHookOptions(spec, prepared.sessionId, "auto", prepared.hookIdentity, {
4830
+ timeoutMs: prepared.hookTimeoutMs,
4831
+ ...(opts?.cancelled === true ? { signal: prepared.abortController.signal } : {}),
4832
+ }),
4694
4833
  });
4695
4834
  this.recordCompactionReuse(prepared, finishComp);
4696
4835
  if (finishComp.unevaluableWindow) {
@@ -1,6 +1,6 @@
1
1
  import { canonicalizeTarget, writeTargetPath } from "../../tools/fs/safety.js";
2
2
  import { isWinFormPath } from "../../tools/fs/safety.js";
3
- import { createCoarseCommandNamePolicy } from "../tool-policy.js";
3
+ import { createCoarseCommandNamePolicy, namespacedCoveringEntries, namespacedCoveringHit } from "../tool-policy.js";
4
4
  export const PATH_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
5
5
  export const PATH_CONFINABLE_WRITE_TOOLS = new Set([...PATH_WRITE_TOOLS, "NotebookEdit"]);
6
6
  export function isWithin(root, p) {
@@ -24,6 +24,8 @@ export function createSessionRulePolicy(rules, opts) {
24
24
  const { env, rootPath, toolEffects } = opts;
25
25
  const toolDeny = new Set(rules.toolDeny ?? []);
26
26
  const toolAllow = rules.toolAllow ? new Set(rules.toolAllow) : undefined;
27
+ const toolDenyCovering = namespacedCoveringEntries(rules.toolDeny);
28
+ const toolAllowCovering = namespacedCoveringEntries(rules.toolAllow);
27
29
  const cmdPolicy = rules.commandAllow || rules.commandDeny
28
30
  ? createCoarseCommandNamePolicy({
29
31
  ...(rules.commandAllow ? { allow: rules.commandAllow } : {}),
@@ -36,9 +38,9 @@ export function createSessionRulePolicy(rules, opts) {
36
38
  nameSets: [{ ...(rules.toolDeny?.length ? { deny: [...rules.toolDeny] } : {}), ...(rules.toolAllow?.length ? { allow: [...rules.toolAllow] } : {}) }],
37
39
  async check(req, signal) {
38
40
  const toolName = req.toolName;
39
- if (toolDeny.has(toolName))
41
+ if (toolDeny.has(toolName) || namespacedCoveringHit(toolDenyCovering, toolName))
40
42
  return deny(`tool "${req.toolName}" is denied by a session rule`);
41
- if (toolAllow && !toolAllow.has(toolName)) {
43
+ if (toolAllow && !toolAllow.has(toolName) && !namespacedCoveringHit(toolAllowCovering, toolName)) {
42
44
  return deny(`tool "${req.toolName}" is not in the session-rule allowlist`);
43
45
  }
44
46
  if (cmdPolicy) {
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool } from "../tools.js";
3
+ import { sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
3
4
  export const OUTPUT_TOOL_NAME = "StructuredOutput";
4
5
  export const REPORT_BLOCKED_TOOL_NAME = "ReportBlocked";
5
6
  export const REPORT_FINDINGS_TOOL_NAME = "ReportFindings";
@@ -123,7 +124,8 @@ export function skillListingLine(s) {
123
124
  const desc = s.description.length > SKILL_DESC_MAX_CHARS
124
125
  ? `${s.description.slice(0, SKILL_DESC_MAX_CHARS - 1)}…`
125
126
  : s.description;
126
- return `- ${s.name}: ${desc}${skillFilesSuffix(s.files)}`;
127
+ const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
128
+ return `- ${safe(s.name)}: ${safe(desc)}${safe(skillFilesSuffix(s.files))}`;
127
129
  }
128
130
  const SKILLS_BLOCK_CAP_MARKER = "(block size cap reached — lower-priority skills are listed name-only; descriptions via the tool)";
129
131
  export function buildSkillsBlock(skills) {
@@ -141,7 +143,7 @@ export function buildSkillsBlock(skills) {
141
143
  const lineCost = (l) => bytes(l) + 1;
142
144
  const budget = SKILLS_BLOCK_MAX_BYTES - bytes(render([]));
143
145
  const fullLines = skills.map((s) => skillListingLine(s));
144
- const nameLines = skills.map((s) => `- ${s.name}`);
146
+ const nameLines = skills.map((s) => `- ${sanitizeUntrustedText(s.name, SHELLED_BODY_ENVELOPE_TAGS)}`);
145
147
  const fullTotal = fullLines.reduce((acc, l) => acc + lineCost(l), 0);
146
148
  if (fullTotal <= budget)
147
149
  return render(fullLines);
@@ -65,6 +65,7 @@ export interface BackgroundTaskSnapshot {
65
65
  description?: string;
66
66
  status: string;
67
67
  }
68
+ export declare function attachmentEnvelopeTags(source: AttachmentSource): string[];
68
69
  /** One due reminder. `body` is the inner text — the steerer adds the `<system-reminder>` wrapper (F3). */
69
70
  export interface TurnAttachment {
70
71
  source: AttachmentSource;
@@ -638,7 +639,9 @@ export declare function replayAnnouncedListing(texts: readonly string[], headers
638
639
  }): Set<string> | undefined;
639
640
  /** Removal-frame header for the agent-type roster (single-sourced for renderer + replay parser). */
640
641
  export declare const AGENT_LISTING_REMOVED_HEADER = "The following agent types are no longer available:";
641
- /** CC-verbatim roster-drift header — same single-source rationale as {@link agentListingInitialHeader}. */
642
+ /** CC-verbatim roster-drift header — same single-source rationale as {@link agentListingInitialHeader}.
643
+ * The interpolated tool name is neutralized on both headers: a caller renames the delegation tool, so
644
+ * the name is deployment text arriving on the marked-reminder lane like every other field here. */
642
645
  export declare function agentListingDeltaHeader(toolName: string): string;
643
646
  /**
644
647
  * G1 续批 — agent-type roster announce (CC 2.1.198 `agent_listing_delta`, bundle pretty.js:599199-599223,
@@ -745,11 +748,18 @@ export declare const MCP_INSTRUCTIONS_MAX_CHARS: number;
745
748
  * — fenced on the delta lane, RAW-INTERPOLATED (no fence, no bound) into the stable prompt. A single
746
749
  * source makes the divergence unrepresentable.
747
750
  *
748
- * Truncation is DISCLOSED (§25 honesty): `delimitUntrusted` clips silently with an ellipsis, which
749
- * reads to the model as the server's own text. The trailing note lives OUTSIDE the fence, on a
750
- * trusted line, so the model can tell "the server said less" from "we cut it". The over-length test
751
- * mirrors `delimitUntrusted`'s own internal condition exactly (both transforms are idempotent, so
752
- * re-running them here is a no-op and cannot drift from the clip actually applied).
751
+ * Truncation is DISCLOSED (§25 honesty): the fence clips silently with an ellipsis, which reads to the
752
+ * model as the server's own text. The trailing note lives OUTSIDE the fence, on a trusted line, so the
753
+ * model can tell "the server said less" from "we cut it".
754
+ *
755
+ * The clip verdict comes FROM the fence (`delimitUntrustedWithClip`), never from a length test
756
+ * re-derived here. The earlier shape did re-derive it — a local `sanitizeUntrustedText` pre-pass with
757
+ * the DEFAULT (reminder-only) tag set, measured against the cap — and that mirror silently stopped
758
+ * matching once the fence widened its neutralization to the whole authority-envelope family: each
759
+ * widened-family tag occurrence adds a ZWSP the pre-count never saw, so a server could land its text
760
+ * in the `(cap − N, cap]` window and get the body clipped with the honesty note withheld, N being its
761
+ * own choice of tag count. One measurement, taken where the cut happens, is the only shape that cannot
762
+ * drift.
753
763
  */
754
764
  export declare function fenceMcpServerInstructions(server: string, text: string): string;
755
765
  /**