@sema-agent/core 2.13.0 → 3.0.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 (95) hide show
  1. package/dist/agents/cascade.d.ts +4 -0
  2. package/dist/agents/cascade.js +54 -9
  3. package/dist/agents/repair-loop.d.ts +2 -0
  4. package/dist/agents/repair-loop.js +101 -2
  5. package/dist/agents/retain-ledger.d.ts +18 -2
  6. package/dist/agents/retain-ledger.js +37 -8
  7. package/dist/agents/roster-store.d.ts +4 -0
  8. package/dist/agents/roster-store.js +22 -1
  9. package/dist/agents/teacher.js +4 -2
  10. package/dist/agents/team.js +7 -4
  11. package/dist/agents/verify.d.ts +15 -2
  12. package/dist/agents/verify.js +36 -1
  13. package/dist/bin/sema-tb.js +2 -2
  14. package/dist/brain/circuit-breaker.d.ts +1 -0
  15. package/dist/brain/circuit-breaker.js +26 -9
  16. package/dist/brain/degrading.js +13 -1
  17. package/dist/brain/failover.js +2 -0
  18. package/dist/brain/repetition.js +10 -1
  19. package/dist/brain/routing.js +5 -1
  20. package/dist/brain/status-sink.js +5 -1
  21. package/dist/brain/tool-call-repair.js +32 -14
  22. package/dist/core/auto-compaction.js +17 -7
  23. package/dist/core/checkpoint-store.d.ts +1 -0
  24. package/dist/core/checkpoint-store.js +28 -10
  25. package/dist/core/consolidate-scope.js +4 -3
  26. package/dist/core/context-edit.js +3 -0
  27. package/dist/core/context-guard.js +3 -0
  28. package/dist/core/git-worktree-env.d.ts +6 -1
  29. package/dist/core/git-worktree-env.js +12 -1
  30. package/dist/core/hooks.d.ts +1 -0
  31. package/dist/core/hooks.js +40 -2
  32. package/dist/core/lsp-diagnostics.js +13 -7
  33. package/dist/core/lsp-protocol.d.ts +1 -1
  34. package/dist/core/lsp-protocol.js +20 -8
  35. package/dist/core/lsp-session.d.ts +12 -2
  36. package/dist/core/lsp-session.js +114 -49
  37. package/dist/core/lsp.d.ts +13 -1
  38. package/dist/core/lsp.js +64 -9
  39. package/dist/core/memory-engine/data-plane.js +4 -0
  40. package/dist/core/memory-engine/engine.d.ts +2 -1
  41. package/dist/core/memory-engine/engine.js +82 -7
  42. package/dist/core/memory-engine/file-backend.d.ts +1 -0
  43. package/dist/core/memory-engine/file-backend.js +13 -3
  44. package/dist/core/memory-engine/layout.js +12 -1
  45. package/dist/core/memory.js +3 -0
  46. package/dist/core/permission-rules.d.ts +2 -1
  47. package/dist/core/permission-rules.js +24 -3
  48. package/dist/core/runner/assemble-result.d.ts +1 -1
  49. package/dist/core/runner/assemble-result.js +0 -1
  50. package/dist/core/runner/prepare-task.js +11 -0
  51. package/dist/core/runner/runtask.js +21 -22
  52. package/dist/core/runner/usage-accounting.d.ts +6 -0
  53. package/dist/core/runner/usage-accounting.js +9 -8
  54. package/dist/core/secret-env.js +5 -1
  55. package/dist/core/sensitive-path-policy.js +9 -3
  56. package/dist/core/store-contracts/session-repo-contract.js +40 -0
  57. package/dist/core/task-registry-monitor.js +7 -1
  58. package/dist/core/tool-policy.js +65 -17
  59. package/dist/core/trace.d.ts +1 -0
  60. package/dist/core/types.d.ts +1 -0
  61. package/dist/engine/compaction/compaction.d.ts +1 -0
  62. package/dist/engine/compaction/compaction.js +14 -5
  63. package/dist/engine/lsp/frame-decoder.js +6 -3
  64. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  65. package/dist/engine/lsp/node-lsp-manager.js +103 -26
  66. package/dist/engine/lsp/stdio-lsp-transport.js +16 -7
  67. package/dist/internal/harness.d.ts +1 -1
  68. package/dist/internal/harness.js +1 -1
  69. package/dist/orchestration/workflow-governance.js +43 -9
  70. package/dist/orchestration/workflow-sandbox-conformance.js +85 -51
  71. package/dist/orchestration/workflow-script-store.js +34 -4
  72. package/dist/orchestration/workflow.js +2 -2
  73. package/dist/prompt-assembly/artifact-store.d.ts +2 -0
  74. package/dist/prompt-assembly/artifact-store.js +39 -24
  75. package/dist/prompt-assembly/assemble.js +38 -7
  76. package/dist/prompt-assembly/epoch.js +19 -2
  77. package/dist/prompt-assembly/event-registry.js +3 -3
  78. package/dist/prompt-assembly/explain.js +1 -1
  79. package/dist/prompt-assembly/tool-catalog.js +16 -1
  80. package/dist/prompt-assembly/turn-snapshot.js +4 -1
  81. package/dist/stores/file/fs-atomic.d.ts +3 -0
  82. package/dist/stores/file/fs-atomic.js +53 -8
  83. package/dist/stores/file/index.d.ts +1 -0
  84. package/dist/stores/file/index.js +7 -0
  85. package/dist/stores/file/session-store.d.ts +2 -0
  86. package/dist/stores/file/session-store.js +28 -1
  87. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -0
  88. package/dist/tools/fs/bash-readonly-classifier.js +19 -2
  89. package/dist/tools/fs/fs-bash.d.ts +1 -1
  90. package/dist/tools/fs/fs-bash.js +93 -5
  91. package/dist/tools/loop-tick.js +1 -1
  92. package/dist/tools/monitor.js +55 -9
  93. package/dist/tools/worktree.d.ts +2 -0
  94. package/dist/tools/worktree.js +233 -143
  95. package/package.json +5 -2
@@ -102,7 +102,8 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
102
102
  signal: specBase.signal,
103
103
  });
104
104
  try {
105
- return { verdict: v.structuredOutput, cost: (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0) };
105
+ const cost = (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0);
106
+ return { verdict: v.structuredOutput, cost, ...(isDurablePause(v.status) ? { paused: v } : {}) };
106
107
  }
107
108
  finally {
108
109
  if (!isDurablePause(v.status))
@@ -115,9 +116,43 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
115
116
  let spend = 0;
116
117
  let verifierSpend = 0;
117
118
  for (let round = 1; round <= maxRounds; round++) {
119
+ if (round > 1) {
120
+ if (config.totalTimeoutMs != null && Date.now() - startedAt >= config.totalTimeoutMs)
121
+ break;
122
+ if (config.costCeilingMicroUsd != null && spend >= config.costCeilingMicroUsd)
123
+ break;
124
+ }
118
125
  const v = await runVerifier(current, round);
119
126
  spend += v.cost;
120
127
  verifierSpend += v.cost;
128
+ if (v.paused) {
129
+ outcome = {
130
+ verdict: "unverified",
131
+ unverifiedReason: "verifier_paused",
132
+ rounds: round,
133
+ findings: [],
134
+ verifierCost: verifierSpend,
135
+ verifierPause: {
136
+ status: v.paused.status,
137
+ sessionId: v.paused.sessionId,
138
+ ...(v.paused.checkpointToken ? { checkpointToken: v.paused.checkpointToken } : {}),
139
+ ...(v.paused.checkpointGate ? { checkpointGate: v.paused.checkpointGate } : {}),
140
+ },
141
+ };
142
+ try {
143
+ config.onVerifierPause?.({
144
+ round,
145
+ status: v.paused.status,
146
+ sessionId: v.paused.sessionId,
147
+ ...(v.paused.checkpointToken ? { checkpointToken: v.paused.checkpointToken } : {}),
148
+ ...(v.paused.checkpointGate ? { checkpointGate: v.paused.checkpointGate } : {}),
149
+ });
150
+ }
151
+ catch {
152
+ }
153
+ config.onRound?.({ round, verdict: outcome.verdict, findings: outcome.findings });
154
+ break;
155
+ }
121
156
  const rawVerdict = v.verdict?.verdict ?? "unverified";
122
157
  const verdict = rawVerdict === "PARTIAL" ? "unverified" : rawVerdict;
123
158
  const unverifiedReason = !v.verdict ? "no_verdict" : rawVerdict === "PARTIAL" ? "could_not_verify" : undefined;
@@ -289,7 +289,7 @@ async function main() {
289
289
  switch (e.kind) {
290
290
  case "brain.call":
291
291
  lastServedModel = e.model;
292
- line(`[call] model=${e.model} in=${e.promptTokens} out=${e.completionTokens} cache=${e.cacheRead} latMs=${e.latencyMs}` +
292
+ line(`[call] model=${e.model} in=${e.promptTokens} tot=${e.totalInputTokens} out=${e.completionTokens} cache=${e.cacheRead} latMs=${e.latencyMs}` +
293
293
  (e.firstTokenMs !== undefined ? ` ttftMs=${e.firstTokenMs}` : "") +
294
294
  (e.stopReason !== undefined ? ` stop=${e.stopReason}` : "") +
295
295
  (e.callCap !== undefined ? ` cap=${e.callCap}${e.capThinkingSkipped ? "(think-skip)" : ""}` : "") +
@@ -403,7 +403,7 @@ async function main() {
403
403
  reason.flush();
404
404
  turns++;
405
405
  const u = ev.usage;
406
- line(`[turn] ${turns}${u ? ` in=${u.inputTokens} out=${u.outputTokens} cache=${u.cacheReadTokens}` : ""}` +
406
+ line(`[turn] ${turns}${u ? ` in=${u.inputTokens} tot=${u.totalInputTokens} out=${u.outputTokens} cache=${u.cacheReadTokens}` : ""}` +
407
407
  (ev.usageMissing ? " usageMissing=1" : "") +
408
408
  (ev.stopReason !== undefined ? ` stop=${ev.stopReason}` : ""));
409
409
  break;
@@ -8,6 +8,7 @@ export interface BreakerSnapshot {
8
8
  failures: number;
9
9
  openedAt?: number;
10
10
  halfOpenInFlight?: number;
11
+ halfOpenAt?: number;
11
12
  }
12
13
  export interface BreakerState {
13
14
  get(key: string): BreakerSnapshot | undefined;
@@ -11,14 +11,14 @@ class InMemoryBreakerState {
11
11
  this.m.set(k, s);
12
12
  }
13
13
  }
14
- function errorAssistantMessage(model, code, detail) {
14
+ function errorAssistantMessage(model, code, detail, stopReason = "error") {
15
15
  return {
16
16
  role: "assistant",
17
17
  content: [{ type: "text", text: "" }],
18
18
  api: model.api,
19
19
  provider: model.provider,
20
20
  model: model.id,
21
- stopReason: "error",
21
+ stopReason,
22
22
  errorMessage: `[${code}] ${detail}`,
23
23
  usage: ZERO_USAGE,
24
24
  timestamp: Date.now(),
@@ -44,21 +44,33 @@ export function createCircuitBreakerBrain(inner, opts = {}) {
44
44
  if (s.phase === "closed")
45
45
  return { pass: true };
46
46
  if (s.phase === "open") {
47
- if (s.openedAt === undefined)
48
- return { pass: false };
47
+ if (s.openedAt === undefined) {
48
+ state.set(key, { ...s, openedAt: now() });
49
+ return { pass: false, retryInMs: cooldownMs };
50
+ }
49
51
  const elapsed = now() - s.openedAt;
50
52
  if (elapsed >= cooldownMs) {
51
- setState(key, s.phase, { ...s, phase: "half-open", halfOpenInFlight: 1 });
53
+ setState(key, s.phase, { ...s, phase: "half-open", halfOpenInFlight: 1, halfOpenAt: now() });
52
54
  return { pass: true };
53
55
  }
54
56
  return { pass: false, retryInMs: cooldownMs - elapsed };
55
57
  }
56
58
  const inFlight = s.halfOpenInFlight ?? 0;
57
59
  if (inFlight < halfOpenProbes) {
58
- state.set(key, { ...s, halfOpenInFlight: inFlight + 1 });
60
+ state.set(key, { ...s, halfOpenInFlight: inFlight + 1, halfOpenAt: s.halfOpenAt ?? now() });
59
61
  return { pass: true };
60
62
  }
61
- return { pass: false };
63
+ const leaseStart = s.halfOpenAt;
64
+ if (leaseStart === undefined) {
65
+ state.set(key, { ...s, halfOpenAt: now() });
66
+ return { pass: false, retryInMs: cooldownMs };
67
+ }
68
+ const held = now() - leaseStart;
69
+ if (held >= cooldownMs) {
70
+ state.set(key, { ...s, halfOpenInFlight: 1, halfOpenAt: now() });
71
+ return { pass: true };
72
+ }
73
+ return { pass: false, retryInMs: cooldownMs - held };
62
74
  };
63
75
  const record = (key, failedCode, viaProbe = true) => {
64
76
  const s = snap(key);
@@ -118,9 +130,14 @@ export function createCircuitBreakerBrain(inner, opts = {}) {
118
130
  }
119
131
  }
120
132
  catch (e) {
133
+ const aborted = options?.signal?.aborted === true || (e instanceof Error && e.name === "AbortError");
121
134
  const code = e instanceof BrainError ? e.code : "network";
122
- record(key, code, !bypass);
123
- out.push({ type: "error", reason: "error", error: errorAssistantMessage(model, code, e instanceof Error ? e.message : String(e)) });
135
+ record(key, aborted ? "http" : code, !bypass);
136
+ out.push({
137
+ type: "error",
138
+ reason: aborted ? "aborted" : "error",
139
+ error: errorAssistantMessage(model, code, e instanceof Error ? e.message : String(e), aborted ? "aborted" : "error"),
140
+ });
124
141
  }
125
142
  })();
126
143
  return out;
@@ -91,8 +91,20 @@ export function passthroughStream(brain, model, context, options) {
91
91
  void (async () => {
92
92
  try {
93
93
  const inner = await brain.stream(model, context, options);
94
- for await (const ev of inner)
94
+ let sawTerminal = false;
95
+ for await (const ev of inner) {
96
+ if (ev.type === "done" || ev.type === "error")
97
+ sawTerminal = true;
95
98
  out.push(ev);
99
+ }
100
+ if (!sawTerminal) {
101
+ await inner.result();
102
+ out.push({
103
+ type: "error",
104
+ reason: "error",
105
+ error: errorMessageOf(model, new Error("brain stream ended without a terminal event"), false),
106
+ });
107
+ }
96
108
  }
97
109
  catch (e) {
98
110
  const aborted = options?.signal?.aborted === true ||
@@ -53,6 +53,8 @@ export function createFailoverBrain(brains) {
53
53
  ...(lastFailedCode !== undefined ? { errorCode: lastFailedCode } : {}),
54
54
  });
55
55
  }
56
+ if (!sawContent && heldStart)
57
+ out.push(heldStart);
56
58
  out.push(terminal ?? {
57
59
  type: "done",
58
60
  reason: message.stopReason,
@@ -11,6 +11,15 @@ const SEGMENT_MAX = 120;
11
11
  function clipSegment(s) {
12
12
  return s.length > SEGMENT_MAX ? s.slice(0, SEGMENT_MAX) : s;
13
13
  }
14
+ function trailingRepeats(text, unit, cap) {
15
+ let n = 0;
16
+ let end = text.length;
17
+ while (n < cap && end - unit.length >= 0 && text.startsWith(unit, end - unit.length)) {
18
+ n++;
19
+ end -= unit.length;
20
+ }
21
+ return n;
22
+ }
14
23
  function looksLikeCodeLineUnit(unit) {
15
24
  if (!unit.includes("\n")) {
16
25
  return false;
@@ -59,7 +68,7 @@ export function inspectDegenerate(text) {
59
68
  if (!looksLikeCodeLineUnit(unit)) {
60
69
  return { degenerate: true, cut: event, spared };
61
70
  }
62
- if (t === WINDOW && reps >= SATURATION_REPS && reps * p >= t - p) {
71
+ if (t === WINDOW && reps * p >= t - p && trailingRepeats(text, unit, SATURATION_REPS) >= SATURATION_REPS) {
63
72
  return { degenerate: true, cut: event, spared };
64
73
  }
65
74
  if (!sparedUnitLoop) {
@@ -17,7 +17,11 @@ export function createRoutingBrain(routes, opts = {}) {
17
17
  const by = opts.by ?? "provider";
18
18
  const stream = (model, context, options) => {
19
19
  const key = by === "api" ? model.api : model.provider;
20
- const target = routes[key] ?? opts.fallback;
20
+ const routed = Object.hasOwn(routes, key) ? routes[key] : undefined;
21
+ const matched = routed !== null && typeof routed === "object" && typeof routed.stream === "function"
22
+ ? routed
23
+ : undefined;
24
+ const target = matched ?? opts.fallback;
21
25
  if (!target) {
22
26
  const out = createAssistantMessageEventStream();
23
27
  out.push({
@@ -4,7 +4,11 @@ export function runWithStatusSink(emit, fn) {
4
4
  return statusSinkStore.run({ emit }, fn);
5
5
  }
6
6
  export function emitBrainStatus(status) {
7
- statusSinkStore.getStore()?.emit(status);
7
+ try {
8
+ statusSinkStore.getStore()?.emit(status);
9
+ }
10
+ catch {
11
+ }
8
12
  }
9
13
  const telemetrySinkStore = new AsyncLocalStorage();
10
14
  export function runWithBrainTelemetry(emit, fn) {
@@ -3,7 +3,7 @@ const FENCE_RE = /```(?:json|tool_call|tool|function)?\s*([\s\S]*?)```/gi;
3
3
  function asRecord(v) {
4
4
  return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
5
5
  }
6
- function extractCall(obj, toolNames) {
6
+ function extractCall(obj, toolNames, framed) {
7
7
  const rec = asRecord(obj);
8
8
  if (!rec) {
9
9
  return undefined;
@@ -13,16 +13,32 @@ function extractCall(obj, toolNames) {
13
13
  if (typeof name !== "string" || !toolNames.has(name)) {
14
14
  return undefined;
15
15
  }
16
- let rawArgs = rec.arguments ?? rec.parameters ?? rec.input ?? rec.args ?? fn?.arguments;
17
- if (typeof rawArgs === "string") {
16
+ const rawArgs = rec.arguments ?? rec.parameters ?? rec.input ?? rec.args ?? fn?.arguments;
17
+ let args;
18
+ if (rawArgs === undefined) {
19
+ if (!framed)
20
+ return undefined;
21
+ args = {};
22
+ }
23
+ else if (typeof rawArgs === "string") {
24
+ let parsed;
18
25
  try {
19
- rawArgs = JSON.parse(rawArgs);
26
+ parsed = JSON.parse(rawArgs);
20
27
  }
21
28
  catch {
22
- rawArgs = {};
29
+ return undefined;
23
30
  }
31
+ const rec2 = asRecord(parsed);
32
+ if (!rec2)
33
+ return undefined;
34
+ args = rec2;
35
+ }
36
+ else {
37
+ const rec2 = asRecord(rawArgs);
38
+ if (!rec2)
39
+ return undefined;
40
+ args = rec2;
24
41
  }
25
- const args = asRecord(rawArgs) ?? {};
26
42
  return {
27
43
  type: "toolCall",
28
44
  id: `repair_${name}_${Math.random().toString(36).slice(2, 8)}`,
@@ -30,28 +46,30 @@ function extractCall(obj, toolNames) {
30
46
  arguments: args,
31
47
  };
32
48
  }
33
- function parseBlock(raw, toolNames, out) {
49
+ function parseBlock(raw, toolNames, out, framed) {
34
50
  let parsed;
35
51
  try {
36
52
  parsed = JSON.parse(raw.trim());
37
53
  }
38
54
  catch {
39
- return false;
55
+ return "none";
40
56
  }
41
57
  const items = Array.isArray(parsed) ? parsed : [parsed];
42
- let any = false;
58
+ let promoted = 0;
43
59
  for (const item of items) {
44
- const call = extractCall(item, toolNames);
60
+ const call = extractCall(item, toolNames, framed);
45
61
  if (call) {
46
62
  out.push(call);
47
- any = true;
63
+ promoted++;
48
64
  }
49
65
  }
50
- return any;
66
+ if (promoted === 0)
67
+ return "none";
68
+ return promoted === items.length ? "all" : "some";
51
69
  }
52
70
  export function repairTextToolCalls(text, toolNames) {
53
71
  const toolCalls = [];
54
- let cleaned = text.replace(TAG_RE, (m, _tag, inner) => parseBlock(inner, toolNames, toolCalls) ? "" : m);
55
- cleaned = cleaned.replace(FENCE_RE, (m, inner) => parseBlock(inner, toolNames, toolCalls) ? "" : m);
72
+ let cleaned = text.replace(TAG_RE, (m, _tag, inner) => parseBlock(inner, toolNames, toolCalls, true) === "all" ? "" : m);
73
+ cleaned = cleaned.replace(FENCE_RE, (m, inner) => parseBlock(inner, toolNames, toolCalls, false) === "all" ? "" : m);
56
74
  return { toolCalls, cleanedText: cleaned.trim() };
57
75
  }
@@ -1,4 +1,4 @@
1
- import { DEFAULT_CHARS_PER_TOKEN, DEFAULT_CLAMP_TOLERANCE, DEFAULT_COMPACTION_SETTINGS, CompactionError, compact, computeFileLists, dryRunSummarizationClamp, estimateContextTokens, estimateTokens, formatPersistedOutputRefs, prepareCompaction, shouldCompact, } from "../internal/harness.js";
1
+ import { DEFAULT_CHARS_PER_TOKEN, DEFAULT_CLAMP_TOLERANCE, DEFAULT_COMPACTION_SETTINGS, CompactionError, compact, computeFileLists, dryRunSummarizationClamp, estimateContextTokens, estimateTokens, formatFileOperations, formatPersistedOutputRefs, prepareCompaction, shouldCompact, } from "../internal/harness.js";
2
2
  import { fileArgPath } from "../tools/fs/safety.js";
3
3
  import { contextEditFrontier } from "./context-edit.js";
4
4
  import { selectCompactionEpoch } from "../prompt-assembly/epoch.js";
@@ -17,6 +17,10 @@ export function sanitizeCompactionInstructions(text) {
17
17
  ? cps.slice(0, COMPACTION_INSTRUCTIONS_MAX_CHARS).join("")
18
18
  : defused;
19
19
  }
20
+ const WORKING_FILE_BREAKOUT_TAGS = ["working-file", "summary"];
21
+ function escapeWorkingFilePathAttr(path) {
22
+ return path.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/[\r\n]+/g, " ");
23
+ }
20
24
  export const COMPACTION_THROUGHPUT_PRIOR_TOKENS_PER_MS = 3;
21
25
  const COMPACTION_THROUGHPUT_EWMA_ALPHA = 0.3;
22
26
  const COMPACTION_WALLTIME_ABORT_FLAG = "semaCompactionWalltimeAbort";
@@ -69,8 +73,11 @@ export async function maybeCompact(opts) {
69
73
  const anchorStale = est.usageTokens > 0 && est.tokens > structuralTokens * STALE_ANCHOR_STRUCTURAL_MARGIN + overhead;
70
74
  const tokens = anchorStale || est.usageTokens === 0 ? structuralTokens + overhead : est.tokens;
71
75
  const window = opts.model.autoCompactTokens ?? opts.model.contextTokens ?? opts.model.contextWindow;
76
+ const windowKnown = Number.isFinite(window) && window > 0;
72
77
  const settings = sanitizeCompactionSettings(rawSettings, window);
73
- const contextUsage = { usedTokens: tokens, windowTokens: window, compactAtTokens: window - settings.reserveTokens };
78
+ const contextUsage = windowKnown
79
+ ? { usedTokens: tokens, windowTokens: window, compactAtTokens: window - settings.reserveTokens }
80
+ : undefined;
74
81
  const wantsCompact = shouldCompact(tokens, window, settings);
75
82
  const naturalTrigger = wantsCompact && tokens >= (opts.minTokens ?? 0);
76
83
  const force = opts.force || (opts.forceUnderThreshold === true && !naturalTrigger);
@@ -118,7 +125,7 @@ export async function maybeCompact(opts) {
118
125
  : hookSanitized
119
126
  : specInstructions;
120
127
  const maxReuse = opts.maxConsecutiveProviderReuse ?? 3;
121
- const forceRealSummary = (opts.consecutiveProviderReuse ?? 0) >= maxReuse;
128
+ const forceRealSummary = maxReuse > 0 && (opts.consecutiveProviderReuse ?? 0) >= maxReuse;
122
129
  let providerSummary;
123
130
  if (opts.summaryProvider && !forceRealSummary && !prep.value.isSplitTurn) {
124
131
  const providerModifiedFiles = computeFileLists(prep.value.fileOps).modifiedFilesByRecency;
@@ -151,7 +158,9 @@ export async function maybeCompact(opts) {
151
158
  summary = providerSummary;
152
159
  firstKeptEntryId = prep.value.firstKeptEntryId;
153
160
  summaryTokensBefore = prep.value.tokensBefore;
154
- details = computeFileLists(prep.value.fileOps);
161
+ const providerFileLists = computeFileLists(prep.value.fileOps);
162
+ details = providerFileLists;
163
+ summary += formatFileOperations(providerFileLists.readFiles, providerFileLists.modifiedFiles);
155
164
  if (prep.value.invokedSkills.length > 0) {
156
165
  details.invokedSkills = prep.value.invokedSkills;
157
166
  }
@@ -330,7 +339,7 @@ export async function maybeCompact(opts) {
330
339
  const rawCandidateFiles = recentlyRead.length > 0 ? recentlyRead : (details?.modifiedFilesByRecency ?? details?.modifiedFiles);
331
340
  const maxFilesForSelection = Math.max(1, att?.maxFiles ?? 3);
332
341
  const excludedReadStatePreserveKeys = [];
333
- const candidateFiles = Array.isArray(rawCandidateFiles) && rawCandidateFiles.length > 0
342
+ const candidateFiles = att !== undefined && Array.isArray(rawCandidateFiles) && rawCandidateFiles.length > 0
334
343
  ? await (async () => {
335
344
  const keptTailReadPathsRaw = new Set();
336
345
  const firstKeptIdx = branch.findIndex((e) => e.id === firstKeptEntryId);
@@ -417,7 +426,8 @@ export async function maybeCompact(opts) {
417
426
  if (att && Array.isArray(candidateFiles) && candidateFiles.length > 0) {
418
427
  const maxFiles = maxFilesForSelection;
419
428
  const perFileCap = Math.max(200, att.maxCharsPerFile ?? 16_000);
420
- let remaining = Math.min(maxFiles * perFileCap, Math.floor(window * 0.15) * cpt);
429
+ const windowShareChars = windowKnown ? Math.floor(window * 0.15) * cpt : Number.POSITIVE_INFINITY;
430
+ let remaining = Math.min(maxFiles * perFileCap, windowShareChars);
421
431
  const blocks = [];
422
432
  for (const path of candidateFiles.slice(0, maxFiles)) {
423
433
  if (remaining <= 0)
@@ -437,7 +447,7 @@ export async function maybeCompact(opts) {
437
447
  ? `${content.slice(0, cap)}\n[... ${content.length - cap} more characters truncated — read the file for the rest]`
438
448
  : content;
439
449
  remaining -= clipped.length;
440
- blocks.push(`<working-file path="${path}">\n${clipped}\n</working-file>`);
450
+ blocks.push(`<working-file path="${escapeWorkingFilePathAttr(path)}">\n${sanitizeUntrustedText(clipped, WORKING_FILE_BREAKOUT_TAGS)}\n</working-file>`);
441
451
  attachedFiles.push({ path, chars: Math.min(content.length, cap), truncated: wasClipped });
442
452
  if (!wasClipped)
443
453
  attachedComplete.push({ path, content });
@@ -30,6 +30,7 @@ export declare function riskSeverity(axes: {
30
30
  shell?: boolean;
31
31
  }): 1 | 2 | 3 | 4 | 5;
32
32
  export declare const MAX_TOOL_INPUT_PREVIEW_CHARS = 512;
33
+ export declare const MAX_PENDING_STEER_CHARS = 16000;
33
34
  export declare function buildRiskDescriptor(input: {
34
35
  toolName: string;
35
36
  args: unknown;
@@ -25,6 +25,7 @@ const MAX_TOUCHED_PATHS = 8;
25
25
  const MAX_DIGEST_KEYS = 16;
26
26
  const MAX_DIGEST_SCAN_KEYS = 256;
27
27
  export const MAX_TOOL_INPUT_PREVIEW_CHARS = 512;
28
+ export const MAX_PENDING_STEER_CHARS = 16_000;
28
29
  function isPlainRecord(x) {
29
30
  try {
30
31
  if (x === null || typeof x !== "object" || Array.isArray(x))
@@ -130,26 +131,40 @@ export const MAX_SUPPORTED_CHECKPOINT_VERSION = 4;
130
131
  export function checkpointVersionOf(cp) {
131
132
  return cp.version ?? 0;
132
133
  }
134
+ function finiteBound(v) {
135
+ return v !== undefined && Number.isFinite(v) ? v : undefined;
136
+ }
137
+ function finiteSpend(v) {
138
+ return v !== undefined && Number.isFinite(v) ? v : undefined;
139
+ }
133
140
  export function debitLedger(prior, slice, total, opts) {
134
141
  return {
135
- totalBudgetMicroUsd: prior?.totalBudgetMicroUsd ?? total?.totalBudgetMicroUsd,
136
- totalWalltimeSec: prior?.totalWalltimeSec ?? total?.totalWalltimeSec,
137
- spentMicroUsd: (prior?.spentMicroUsd ?? 0) + Math.max(0, slice.costMicroUsd),
138
- spentTokens: (prior?.spentTokens ?? 0) + Math.max(0, slice.tokens),
139
- spentTurns: (prior?.spentTurns ?? 0) + Math.max(0, slice.turns),
140
- spentWalltimeMs: (prior?.spentWalltimeMs ?? 0) + Math.max(0, slice.walltimeMs ?? 0),
142
+ totalBudgetMicroUsd: finiteBound(prior?.totalBudgetMicroUsd) ?? finiteBound(total?.totalBudgetMicroUsd),
143
+ totalWalltimeSec: finiteBound(prior?.totalWalltimeSec) ?? finiteBound(total?.totalWalltimeSec),
144
+ spentMicroUsd: (finiteSpend(prior?.spentMicroUsd) ?? 0) + Math.max(0, finiteSpend(slice.costMicroUsd) ?? 0),
145
+ spentTokens: (finiteSpend(prior?.spentTokens) ?? 0) + Math.max(0, finiteSpend(slice.tokens) ?? 0),
146
+ spentTurns: (finiteSpend(prior?.spentTurns) ?? 0) + Math.max(0, finiteSpend(slice.turns) ?? 0),
147
+ spentWalltimeMs: (finiteSpend(prior?.spentWalltimeMs) ?? 0) + Math.max(0, finiteSpend(slice.walltimeMs) ?? 0),
141
148
  sliceCount: (prior?.sliceCount ?? 0) + (opts?.countSlice === false ? 0 : 1),
142
149
  };
143
150
  }
144
151
  export function remainingBudgetMicroUsd(ledger) {
145
- if (ledger?.totalBudgetMicroUsd === undefined)
152
+ const total = ledger?.totalBudgetMicroUsd;
153
+ if (total === undefined || !Number.isFinite(total))
146
154
  return undefined;
147
- return Math.max(0, ledger.totalBudgetMicroUsd - ledger.spentMicroUsd);
155
+ const spent = ledger?.spentMicroUsd;
156
+ if (spent !== undefined && !Number.isFinite(spent))
157
+ return 0;
158
+ return Math.max(0, total - (spent ?? 0));
148
159
  }
149
160
  export function remainingWalltimeMs(ledger) {
150
- if (ledger?.totalWalltimeSec === undefined)
161
+ const total = ledger?.totalWalltimeSec;
162
+ if (total === undefined || !Number.isFinite(total))
151
163
  return undefined;
152
- return Math.max(0, ledger.totalWalltimeSec * 1000 - (ledger.spentWalltimeMs ?? 0));
164
+ const spent = ledger?.spentWalltimeMs;
165
+ if (spent !== undefined && !Number.isFinite(spent))
166
+ return 0;
167
+ return Math.max(0, total * 1000 - (spent ?? 0));
153
168
  }
154
169
  export function summarizeCheckpoint(cp) {
155
170
  const riskDescriptor = cp.gate.kind === "human" || cp.gate.kind === "irreversible_ask" ? cp.gate.riskDescriptor : undefined;
@@ -217,6 +232,9 @@ export function validatePendingSteer(steer) {
217
232
  if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(steer.text)) {
218
233
  throw new CheckpointError("steering.invalid_content", "steering text must not contain control characters");
219
234
  }
235
+ if (steer.text.length > MAX_PENDING_STEER_CHARS) {
236
+ throw new CheckpointError("steering.invalid_content", `steering text must be at most ${MAX_PENDING_STEER_CHARS} characters (got ${steer.text.length})`);
237
+ }
220
238
  return { text: steer.text, trusted: steer.trusted };
221
239
  }
222
240
  export class InMemoryCheckpointStore {
@@ -3,9 +3,9 @@ import { runMemoryConsolidation, DEFAULT_CONSOLIDATION_BAND, DEFAULT_CONSOLIDATI
3
3
  function maxMarker(a, b) {
4
4
  return a === undefined || b > a ? b : a;
5
5
  }
6
- function cappedHighWater(seen, failed) {
6
+ function cappedHighWater(seen, blockers) {
7
7
  let floor;
8
- for (const id of failed)
8
+ for (const id of blockers)
9
9
  if (floor === undefined || id < floor)
10
10
  floor = id;
11
11
  let hw;
@@ -66,7 +66,8 @@ export async function consolidateScope(scope, deps, opts = {}) {
66
66
  });
67
67
  }
68
68
  const seen = [...batchIds, ...(stats?.addedIds ?? [])];
69
- const highWater = cappedHighWater(seen, stats?.failedIds ?? []);
69
+ const unfedTail = pending.slice(maxNotes).map((h) => h.id);
70
+ const highWater = cappedHighWater(seen, [...(stats?.failedIds ?? []), ...unfedTail]);
70
71
  if (highWater !== undefined && highWater !== cursor && (cursor === undefined || highWater > cursor)) {
71
72
  await store.setConsolidationCursor(scope, highWater);
72
73
  }
@@ -114,5 +114,8 @@ export function clearStaleToolResults(messages, opts) {
114
114
  }
115
115
  export function editBudget(model) {
116
116
  const window = model.autoCompactTokens ?? model.contextTokens ?? model.contextWindow;
117
+ if (!Number.isFinite(window) || window <= 0) {
118
+ return Number.POSITIVE_INFINITY;
119
+ }
117
120
  return contextEditFrontier(window);
118
121
  }
@@ -116,5 +116,8 @@ export function dropOrphanToolResults(messages) {
116
116
  }
117
117
  export function guardBudget(model) {
118
118
  const window = model.contextTokens ?? model.contextWindow;
119
+ if (!Number.isFinite(window) || window <= 0) {
120
+ return Number.POSITIVE_INFINITY;
121
+ }
119
122
  return Math.max(window - GUARD_HEADROOM_TOKENS, Math.floor(window * GUARD_FRACTION));
120
123
  }
@@ -12,4 +12,9 @@ export declare function addWorktree(baseEnv: ExecutionEnv, opts: AddWorktreeOpti
12
12
  };
13
13
  worktreeDir: string;
14
14
  }>;
15
- export declare function pruneWorktrees(baseEnv: ExecutionEnv, repoRoot: string): Promise<void>;
15
+ export declare function pruneWorktrees(baseEnv: ExecutionEnv, repoRoot: string): Promise<{
16
+ ok: true;
17
+ } | {
18
+ ok: false;
19
+ detail: string;
20
+ }>;
@@ -59,5 +59,16 @@ export async function addWorktree(baseEnv, opts) {
59
59
  return { env, worktreeDir };
60
60
  }
61
61
  export async function pruneWorktrees(baseEnv, repoRoot) {
62
- await baseEnv.exec("git worktree prune", { cwd: repoRoot });
62
+ try {
63
+ const r = await baseEnv.exec("git worktree prune", { cwd: repoRoot });
64
+ if (r.ok && r.value.exitCode === 0)
65
+ return { ok: true };
66
+ return {
67
+ ok: false,
68
+ detail: r.ok ? (r.value.stderr || r.value.stdout || `exit ${r.value.exitCode}`).trim() : String(r.error),
69
+ };
70
+ }
71
+ catch (err) {
72
+ return { ok: false, detail: String(err) };
73
+ }
63
74
  }
@@ -121,6 +121,7 @@ export interface ToolGateInput {
121
121
  approvalTimeoutMs?: number;
122
122
  abortSignal?: AbortSignal;
123
123
  permissionDenied?: (payload: PermissionDeniedPayload) => void | Promise<void>;
124
+ onHookError?: (err: unknown) => void;
124
125
  shellGated?: boolean;
125
126
  autoMode?: {
126
127
  decider: import("./auto-mode.js").AutoModeDecider;
@@ -16,6 +16,20 @@ export function cloneObserverInput(input) {
16
16
  export function formatHookFeedback(text) {
17
17
  return `<system-reminder>\n${text}\n</system-reminder>`;
18
18
  }
19
+ function preToolUseCrashReason(subject, err) {
20
+ const raw = err instanceof Error ? err.message.trim() || err.name : String(err);
21
+ const cause = inlineUntrusted(raw, 200);
22
+ return (`a PreToolUse hook crashed while screening ${subject}; the call was NOT executed (fail-closed). ` +
23
+ `This is a failure of the deployment's hook, NOT of the tool itself — an identical retry reaches the ` +
24
+ `same hook and fails the same way. Hook error: ${cause || "(no message)"}`);
25
+ }
26
+ function traceHookCrash(input, err) {
27
+ try {
28
+ input.onHookError?.(err);
29
+ }
30
+ catch {
31
+ }
32
+ }
19
33
  function withProbeTimeout(p, ms, signal) {
20
34
  if (ms === undefined && signal === undefined)
21
35
  return p;
@@ -49,7 +63,22 @@ export async function runToolGate(input) {
49
63
  const preToolContext = [];
50
64
  let hookAsk;
51
65
  if (preToolUse) {
52
- const r = await preToolUse(toolName, currentInput, { toolCallId, toolName });
66
+ let r;
67
+ try {
68
+ r = await preToolUse(toolName, currentInput, { toolCallId, toolName });
69
+ }
70
+ catch (err) {
71
+ const reason = preToolUseCrashReason(`this call to "${toolName}"`, err);
72
+ traceHookCrash(input, err);
73
+ if (input.permissionDenied) {
74
+ try {
75
+ await input.permissionDenied({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook" });
76
+ }
77
+ catch {
78
+ }
79
+ }
80
+ return { block: true, reason: formatHookFeedback(reason), preToolContext };
81
+ }
53
82
  if (r) {
54
83
  if (r.additionalContext) {
55
84
  preToolContext.push(r.additionalContext);
@@ -182,7 +211,16 @@ export async function runToolGate(input) {
182
211
  break;
183
212
  }
184
213
  if (preToolUse) {
185
- const hr = await preToolUse(toolName, editArgs, { toolCallId, toolName });
214
+ let hr;
215
+ try {
216
+ hr = await preToolUse(toolName, editArgs, { toolCallId, toolName });
217
+ }
218
+ catch (err) {
219
+ traceHookCrash(input, err);
220
+ editDenied = { action: "deny", reason: preToolUseCrashReason(`the approved edit for "${toolName}"`, err) };
221
+ denySource = "hook";
222
+ break;
223
+ }
186
224
  if (hr) {
187
225
  if (hr.additionalContext)
188
226
  preToolContext.push(hr.additionalContext);