mixdog 0.9.38 → 0.9.40

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 (124) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/execution-completion-dedup-test.mjs +157 -0
  8. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  9. package/scripts/execution-resume-esc-integration-test.mjs +176 -0
  10. package/scripts/explore-bench.mjs +38 -2
  11. package/scripts/explore-prompt-policy-test.mjs +152 -11
  12. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  13. package/scripts/internal-comms-bench-test.mjs +226 -0
  14. package/scripts/internal-comms-bench.mjs +185 -58
  15. package/scripts/internal-comms-smoke.mjs +171 -23
  16. package/scripts/live-worker-smoke.mjs +38 -2
  17. package/scripts/memory-cycle-routing-test.mjs +111 -0
  18. package/scripts/memory-rule-contract-test.mjs +93 -0
  19. package/scripts/output-style-smoke.mjs +2 -2
  20. package/scripts/rg-runner-test.mjs +240 -0
  21. package/scripts/routing-corpus-test.mjs +349 -0
  22. package/scripts/routing-corpus.mjs +211 -32
  23. package/scripts/session-orphan-sweep-test.mjs +83 -0
  24. package/scripts/steering-drain-buckets-test.mjs +316 -2
  25. package/scripts/tool-smoke.mjs +21 -13
  26. package/scripts/tool-tui-presentation-test.mjs +202 -0
  27. package/scripts/tui-transcript-perf-test.mjs +279 -0
  28. package/src/agents/heavy-worker/AGENT.md +10 -7
  29. package/src/agents/reviewer/AGENT.md +6 -4
  30. package/src/agents/worker/AGENT.md +7 -5
  31. package/src/rules/agent/00-common.md +4 -4
  32. package/src/rules/agent/00-core.md +11 -14
  33. package/src/rules/agent/20-skip-protocol.md +3 -3
  34. package/src/rules/agent/30-explorer.md +50 -60
  35. package/src/rules/agent/40-cycle1-agent.md +15 -24
  36. package/src/rules/agent/41-cycle2-agent.md +33 -57
  37. package/src/rules/agent/42-cycle3-agent.md +28 -42
  38. package/src/rules/lead/01-general.md +7 -10
  39. package/src/rules/lead/lead-brief.md +11 -14
  40. package/src/rules/lead/lead-tool.md +6 -5
  41. package/src/rules/shared/01-tool.md +44 -45
  42. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +44 -1
  43. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  44. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  47. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  48. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
  50. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  51. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  52. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +67 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +232 -43
  56. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
  59. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  60. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  62. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  63. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  65. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  66. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  68. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  70. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  72. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  74. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  75. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  76. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  77. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  78. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  79. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  81. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  82. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  83. package/src/runtime/shared/buffered-appender.mjs +13 -2
  84. package/src/runtime/shared/tool-primitives.mjs +4 -1
  85. package/src/runtime/shared/tool-status.mjs +27 -0
  86. package/src/runtime/shared/tool-surface.mjs +6 -3
  87. package/src/session-runtime/config-helpers.mjs +14 -0
  88. package/src/session-runtime/context-status.mjs +1 -0
  89. package/src/session-runtime/effort.mjs +6 -2
  90. package/src/session-runtime/lifecycle-api.mjs +4 -0
  91. package/src/session-runtime/model-recency.mjs +5 -2
  92. package/src/session-runtime/runtime-core.mjs +36 -2
  93. package/src/session-runtime/session-turn-api.mjs +12 -0
  94. package/src/session-runtime/tool-catalog.mjs +34 -0
  95. package/src/standalone/agent-tool/notify.mjs +13 -0
  96. package/src/standalone/agent-tool.mjs +53 -70
  97. package/src/standalone/explore-tool.mjs +6 -7
  98. package/src/tui/App.jsx +33 -1
  99. package/src/tui/app/model-options.mjs +5 -3
  100. package/src/tui/app/model-picker.mjs +12 -24
  101. package/src/tui/app/transcript-window.mjs +10 -10
  102. package/src/tui/app/use-transcript-window.mjs +38 -56
  103. package/src/tui/components/ToolExecution.jsx +11 -6
  104. package/src/tui/components/TranscriptItem.jsx +1 -1
  105. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  106. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  107. package/src/tui/dist/index.mjs +866 -300
  108. package/src/tui/engine/agent-job-feed.mjs +216 -19
  109. package/src/tui/engine/agent-response-tail.mjs +68 -0
  110. package/src/tui/engine/notification-plan.mjs +16 -0
  111. package/src/tui/engine/session-api.mjs +14 -2
  112. package/src/tui/engine/session-flow.mjs +22 -2
  113. package/src/tui/engine/tool-card-results.mjs +64 -37
  114. package/src/tui/engine/tool-result-status.mjs +75 -21
  115. package/src/tui/engine/turn.mjs +172 -77
  116. package/src/tui/engine.mjs +199 -39
  117. package/src/tui/index.jsx +2 -2
  118. package/src/workflows/bench/WORKFLOW.md +25 -35
  119. package/src/workflows/default/WORKFLOW.md +38 -32
  120. package/src/workflows/solo/WORKFLOW.md +19 -22
  121. package/scripts/_jitter-fuzz.mjs +0 -44410
  122. package/scripts/_jitter-fuzz2.mjs +0 -44400
  123. package/scripts/_jitter-probe.mjs +0 -44397
  124. package/scripts/_jp2.mjs +0 -45614
@@ -26,6 +26,7 @@ let _localTracePath = null;
26
26
  let _localTraceBuffer = [];
27
27
  let _localTraceTimer = null;
28
28
  let _localTraceFlushInFlight = false;
29
+ let _localTraceFlushPromise = null;
29
30
  let _toolFailurePath = null;
30
31
  let _toolFailureBuffer = [];
31
32
  let _toolFailureTimer = null;
@@ -132,12 +133,10 @@ function _flushLocalTrace() {
132
133
  clearTimeout(_localTraceTimer);
133
134
  _localTraceTimer = null;
134
135
  }
135
- if (_localTraceBuffer.length === 0) return;
136
136
  if (_localTraceFlushInFlight) {
137
- _localTraceTimer = setTimeout(_flushLocalTrace, 25);
138
- _localTraceTimer.unref?.();
139
- return;
137
+ return _localTraceFlushPromise;
140
138
  }
139
+ if (_localTraceBuffer.length === 0) return null;
141
140
  const path = _resolveLocalTracePath();
142
141
  if (!path) return;
143
142
  const chunk = _localTraceBuffer.join('');
@@ -157,17 +156,20 @@ function _flushLocalTrace() {
157
156
  // mode only applies on file creation; existing files keep their mode.
158
157
  // Windows ignores POSIX bits — ACL governs there.
159
158
  _localTraceFlushInFlight = true;
160
- appendFile(path, chunk, { encoding: 'utf8', mode: 0o600 })
159
+ const pending = appendFile(path, chunk, { encoding: 'utf8', mode: 0o600 })
161
160
  .catch((err) => {
162
161
  warnAgentOnce('agent-trace:local-spool', `[agent-trace] local spool failed (${err?.message})`);
163
162
  })
164
163
  .finally(() => {
165
164
  _localTraceFlushInFlight = false;
165
+ _localTraceFlushPromise = null;
166
166
  if (_localTraceBuffer.length > 0) {
167
167
  _localTraceTimer = setTimeout(_flushLocalTrace, 0);
168
168
  _localTraceTimer.unref?.();
169
169
  }
170
170
  });
171
+ _localTraceFlushPromise = pending;
172
+ return pending;
171
173
  }
172
174
 
173
175
  function _flushLocalTraceSync() {
@@ -294,7 +296,20 @@ function _scheduleFlush(immediate = false) {
294
296
  }
295
297
  }
296
298
 
299
+ async function _drainLocalTrace() {
300
+ if (!_resolveLocalTracePath()) return;
301
+ if (_localTraceTimer) {
302
+ clearTimeout(_localTraceTimer);
303
+ _localTraceTimer = null;
304
+ }
305
+ while (_localTraceBuffer.length > 0 || _localTraceFlushInFlight) {
306
+ const pending = _flushLocalTrace();
307
+ if (pending) await pending;
308
+ }
309
+ }
310
+
297
311
  async function drainAgentTrace() {
312
+ await _drainLocalTrace();
298
313
  if (!_resolveServiceUrl()) return;
299
314
  if (_flushTimer) { clearTimeout(_flushTimer); _flushTimer = null; }
300
315
  for (let i = 0; i < 10 && _buffer.length > 0; i++) {
@@ -16,7 +16,10 @@
16
16
  import os from 'node:os';
17
17
 
18
18
  // Offline fallback only; live value refreshes from npm (24h TTL, in-process).
19
- export const CODEX_CLIENT_VERSION_FLOOR = '0.142.5';
19
+ // The backend gates model exposure AND per-request model access on the client
20
+ // version (gpt-5.6-* require >= 0.144.0 per codex models-manager/models.json,
21
+ // verified 2026-07-09), so keep this at the current release when bumping.
22
+ export const CODEX_CLIENT_VERSION_FLOOR = '0.144.1';
20
23
  const VERSION_TTL_MS = 24 * 60 * 60_000;
21
24
  let _cache = { value: null, fetchedAt: 0 };
22
25
  let _refreshInFlight = null;
@@ -52,6 +55,23 @@ export function codexClientVersionSync() {
52
55
  return _cache.value || CODEX_CLIENT_VERSION_FLOOR;
53
56
  }
54
57
 
58
+ /**
59
+ * Awaitable warmup for cold-start paths: resolves the live npm version (or
60
+ * floor on failure) and fills the shared cache so codexClientVersionSync()
61
+ * and codexVersionHeader() stop reporting the floor. Never rejects; dedupes
62
+ * with the in-flight background refresh. First turns await this so the
63
+ * backend's minimal_client_version gate never sees a stale floor.
64
+ */
65
+ export function warmCodexClientVersion() {
66
+ if (_cache.value && Date.now() - _cache.fetchedAt < VERSION_TTL_MS) {
67
+ return Promise.resolve(_cache.value);
68
+ }
69
+ if (!_refreshInFlight) {
70
+ _refreshInFlight = _refresh().finally(() => { _refreshInFlight = null; });
71
+ }
72
+ return _refreshInFlight;
73
+ }
74
+
55
75
  function _osType() {
56
76
  // codex os_info reports "Windows"/"Mac OS"/"Linux"; node os.type() gives
57
77
  // Windows_NT/Darwin/Linux. Map to codex's vocabulary.
@@ -59,6 +59,7 @@ import {
59
59
  _shouldUseOpenAIHttpFallback,
60
60
  } from './openai-oauth-http-sse.mjs';
61
61
  import { createOpenAIOAuthLogin } from './openai-oauth-login.mjs';
62
+ import { warmCodexClientVersion } from './codex-client-meta.mjs';
62
63
  import {
63
64
  _displayCodexModel,
64
65
  _codexFamily,
@@ -77,42 +78,13 @@ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
77
78
  export const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
78
79
  const TOKEN_URL = 'https://auth.openai.com/oauth/token';
79
80
  export const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
80
- // Version string baked into the models endpoint query the OAuth backend
81
- // rejects the request without it, and gates new model exposures (e.g.
82
- // gpt-5.5 only on >= 0.130.0) on this client_version header; older versions
83
- // trigger a visibility-filtered catalog (e.g. only rollout models). Resolved
84
- // dynamically from npm so newly-shipped models surface within a day instead
85
- // of waiting on a hardcoded bump here. Cached 24h in-process; npm failure
86
- // falls back to the floor below.
87
- // Offline fallback only — _resolveCodexClientVersion() fetches the live
88
- // @openai/codex latest from npm first. Bumped to the current release
89
- // (0.142.5, verified 2026-07-03) so the offline path stays close to what the
90
- // backend expects for client-version gating.
91
- const CODEX_CLIENT_VERSION_FLOOR = '0.142.5';
92
- const CODEX_VERSION_CACHE_TTL_MS = 24 * 60 * 60_000;
93
- let _codexVersionCache = { value: null, fetchedAt: 0 };
94
-
95
- async function _resolveCodexClientVersion() {
96
- const now = Date.now();
97
- if (_codexVersionCache.value && now - _codexVersionCache.fetchedAt < CODEX_VERSION_CACHE_TTL_MS) {
98
- return _codexVersionCache.value;
99
- }
100
- try {
101
- const res = await fetch('https://registry.npmjs.org/@openai/codex/latest', {
102
- signal: AbortSignal.timeout(5_000),
103
- });
104
- if (res.ok) {
105
- const j = await res.json();
106
- const v = String(j?.version || '').trim();
107
- if (/^\d+\.\d+\.\d+/.test(v)) {
108
- _codexVersionCache = { value: v, fetchedAt: now };
109
- return v;
110
- }
111
- }
112
- } catch { /* network down / npm rejects — use floor */ }
113
- _codexVersionCache = { value: CODEX_CLIENT_VERSION_FLOOR, fetchedAt: now };
114
- return CODEX_CLIENT_VERSION_FLOOR;
115
- }
81
+ // Client version for the models endpoint query and the `version`/User-Agent
82
+ // request headers — the OAuth backend rejects requests without it, gates new
83
+ // model exposures on it (gpt-5.6-* require >= 0.144.0), and rejects turns on
84
+ // gated models when the reported version is below the model's
85
+ // minimal_client_version. Resolution is unified in codex-client-meta.mjs
86
+ // (live npm @openai/codex latest, 24h in-process cache, offline floor) so the
87
+ // catalog query and the transport headers can never disagree.
116
88
  const CODEX_MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
117
89
  const CODEX_MODEL_CACHE_SCHEMA_VERSION = 3;
118
90
  const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
@@ -784,7 +756,14 @@ export class OpenAIOAuthProvider {
784
756
  ? Promise.resolve(opts._prebuiltBody)
785
757
  : Promise.resolve().then(() => buildRequestBody(messages, useModel, tools, bodyOpts));
786
758
  const _authP = this.ensureAuth();
759
+ // Cold-start guard: the WS/SSE transports read the client version via
760
+ // the SYNC accessor for the `version` header + User-Agent. Await the
761
+ // shared resolver (parallel with auth; never rejects; no-op once
762
+ // cached) so the first turn after boot doesn't report the offline
763
+ // floor and trip the backend's minimal_client_version gate.
764
+ const _verP = warmCodexClientVersion();
787
765
  let auth = await _authP;
766
+ await _verP;
788
767
  const body = await _bodyP;
789
768
  // poolKey != cacheKey by design (see openai-oauth-ws.mjs header note).
790
769
  // poolKey is per-session so parallel reviewer/worker callers each get
@@ -1038,7 +1017,7 @@ export class OpenAIOAuthProvider {
1038
1017
  }
1039
1018
  try {
1040
1019
  const auth = await this.ensureAuth();
1041
- const clientVersion = await _resolveCodexClientVersion();
1020
+ const clientVersion = await warmCodexClientVersion();
1042
1021
  const url = `https://chatgpt.com/backend-api/codex/models?client_version=${clientVersion}`;
1043
1022
  const res = await fetch(url, {
1044
1023
  signal: AbortSignal.timeout(10_000),
@@ -1076,7 +1055,7 @@ export class OpenAIOAuthProvider {
1076
1055
  _codexRefreshInFlight = (async () => {
1077
1056
  try {
1078
1057
  const auth = await this.ensureAuth();
1079
- const clientVersion = await _resolveCodexClientVersion();
1058
+ const clientVersion = await warmCodexClientVersion();
1080
1059
  const url = `https://chatgpt.com/backend-api/codex/models?client_version=${clientVersion}`;
1081
1060
  const res = await fetch(url, {
1082
1061
  signal: AbortSignal.timeout(10_000),
@@ -305,11 +305,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
305
305
  // cut off early. Other runaway protection is behavior-based (steering
306
306
  // ladder hints, REPEAT_FAIL_LIMIT), never a lower iteration count.
307
307
  let _iterWarnStage = 0;
308
- const _iterWarnAt = [
309
- Math.floor(maxLoopIterations * 0.5),
310
- Math.floor(maxLoopIterations * 0.75),
311
- Math.floor(maxLoopIterations * 0.9),
312
- ];
308
+ // Tiny-cap loops (e.g. explorer cap=3) can't afford staged 50/75/90%
309
+ // steers — the 50% stage lands on iteration 1 in every session, spamming
310
+ // the normal batch→answer path. For caps < 10 emit ONE wrap-up warning at
311
+ // the penultimate iteration instead; caps >= 10 keep staged behavior.
312
+ const _singleWarn = maxLoopIterations < 10;
313
+ const _iterWarnAt = _singleWarn
314
+ ? [Math.max(1, maxLoopIterations - 1)]
315
+ : [
316
+ Math.floor(maxLoopIterations * 0.5),
317
+ Math.floor(maxLoopIterations * 0.75),
318
+ Math.floor(maxLoopIterations * 0.9),
319
+ ];
313
320
  while (true) {
314
321
  throwIfAborted();
315
322
  if (iterations >= maxLoopIterations) {
@@ -341,9 +348,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
341
348
  if (_iterWarnStage < _iterWarnAt.length && iterations >= _iterWarnAt[_iterWarnStage]) {
342
349
  _iterWarnStage += 1;
343
350
  const warnAt = _iterWarnAt[_iterWarnStage - 1];
344
- const stageMsg = _iterWarnStage === 1
345
- ? `Iteration budget notice: ${warnAt} of ${maxLoopIterations} iterations used. Converge on a conclusion: prefer finishing the current objective over opening new exploration.`
346
- : `Iteration budget warning (stage ${_iterWarnStage}): ${warnAt} of ${maxLoopIterations} iterations used — the loop hard-stops at ${maxLoopIterations}. Wrap up now: summarize progress, state what remains, and finish with your best current result.`;
351
+ const stageMsg = _singleWarn
352
+ ? `Iteration budget nearly spent: ${warnAt} of ${maxLoopIterations} iterations used answer NOW with the best anchors you already hold.`
353
+ : _iterWarnStage === 1
354
+ ? `Iteration budget notice: ${warnAt} of ${maxLoopIterations} iterations used. Converge on a conclusion: prefer finishing the current objective over opening new exploration.`
355
+ : `Iteration budget warning (stage ${_iterWarnStage}): ${warnAt} of ${maxLoopIterations} iterations used — the loop hard-stops at ${maxLoopIterations}. Wrap up now: summarize progress, state what remains, and finish with your best current result.`;
347
356
  messages.push({ role: 'user', content: `<system-reminder>\n${stageMsg}\n</system-reminder>`, meta: 'hook' });
348
357
  process.stderr.write(`[loop] iteration warning stage ${_iterWarnStage} at ${iterations} (sess=${sessionId || 'unknown'}); continuing with steer.\n`);
349
358
  try {
@@ -99,6 +99,212 @@ export function estimateMessagesTokens(messages) {
99
99
  return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0);
100
100
  }
101
101
 
102
+ // Context status is polled while the agent loop mutates and replaces message
103
+ // arrays. Keep the accumulated summary on that array, but cheaply validate
104
+ // every entry before reusing its contribution. The fingerprint deliberately
105
+ // avoids serializing content/blocks on the warm path; it compares the
106
+ // references of every estimator-visible string instead.
107
+ // Producer invariant: compaction copies message/call objects, transcript repair
108
+ // replaces array entries, stored-tool-args replaces `arguments`, and MCP reload
109
+ // replaces tool descriptors; settled nested non-string payloads are not mutated
110
+ // in place without replacing their containing reference.
111
+ const contextMessageMemo = new WeakMap();
112
+ const contextTranscriptMemo = new WeakMap();
113
+
114
+ function contextValueFingerprint(value) {
115
+ if (typeof value === 'string') return { value, entries: null };
116
+ if (Array.isArray(value)) return {
117
+ value,
118
+ entries: value.map(contextBlockFingerprint),
119
+ };
120
+ return value && typeof value === 'object'
121
+ ? { value, entries: [contextBlockFingerprint(value)] }
122
+ : { value, entries: null };
123
+ }
124
+
125
+ function contextBlockFingerprint(block) {
126
+ if (!block || typeof block !== 'object') return { value: block };
127
+ const fn = block.function;
128
+ return {
129
+ value: block,
130
+ text: typeof block.text === 'string' ? block.text : null,
131
+ content: typeof block.content === 'string' ? block.content : null,
132
+ args: typeof block.args === 'string' ? block.args : null,
133
+ arguments: block.arguments,
134
+ input: typeof block.input === 'string' ? block.input : null,
135
+ function: fn,
136
+ functionArguments: typeof fn?.arguments === 'string' ? fn.arguments : null,
137
+ };
138
+ }
139
+
140
+ function contextMessageFingerprint(message) {
141
+ if (!message || typeof message !== 'object') {
142
+ return {
143
+ role: undefined,
144
+ content: contextValueFingerprint(''),
145
+ toolCalls: contextValueFingerprint(null),
146
+ thinkingBlocks: contextValueFingerprint(null),
147
+ assistantBlocks: contextValueFingerprint(null),
148
+ toolCallId: null,
149
+ };
150
+ }
151
+ return {
152
+ role: message.role,
153
+ content: contextValueFingerprint(message.content),
154
+ toolCalls: contextValueFingerprint(message.toolCalls),
155
+ thinkingBlocks: contextValueFingerprint(message.thinkingBlocks),
156
+ assistantBlocks: contextValueFingerprint(message.assistantBlocks),
157
+ toolCallId: message?.toolCallId || null,
158
+ };
159
+ }
160
+
161
+ function sameContextValueFingerprint(a, b) {
162
+ if (!a || !b || a.value !== b.value) return false;
163
+ if (a.entries === null || b.entries === null) return a.entries === b.entries;
164
+ if (a.entries.length !== b.entries.length) return false;
165
+ for (let index = 0; index < a.entries.length; index += 1) {
166
+ if (!sameContextBlockFingerprint(a.entries[index], b.entries[index])) return false;
167
+ }
168
+ return true;
169
+ }
170
+
171
+ function sameContextBlockFingerprint(a, b) {
172
+ return !!a && !!b
173
+ && a.value === b.value
174
+ && a.text === b.text
175
+ && a.content === b.content
176
+ && a.args === b.args
177
+ && Object.is(a.arguments, b.arguments)
178
+ && a.input === b.input
179
+ && a.function === b.function
180
+ && a.functionArguments === b.functionArguments;
181
+ }
182
+
183
+ function sameContextMessageFingerprint(a, b) {
184
+ return !!a && a.role === b.role
185
+ && sameContextValueFingerprint(a.content, b.content)
186
+ && sameContextValueFingerprint(a.toolCalls, b.toolCalls)
187
+ && sameContextValueFingerprint(a.thinkingBlocks, b.thinkingBlocks)
188
+ && sameContextValueFingerprint(a.assistantBlocks, b.assistantBlocks)
189
+ && a.toolCallId === b.toolCallId;
190
+ }
191
+
192
+ function contextMessageContribution(message) {
193
+ const fingerprint = contextMessageFingerprint(message);
194
+ if (message && typeof message === 'object') {
195
+ const cached = contextMessageMemo.get(message);
196
+ if (cached && sameContextMessageFingerprint(cached.fingerprint, fingerprint)) return cached.contribution;
197
+ }
198
+ const role = ['system', 'user', 'assistant', 'tool'].includes(fingerprint.role) ? fingerprint.role : 'other';
199
+ const text = messageEstimateText(message);
200
+ const tokens = estimateTokens(text) + 4;
201
+ const contribution = {
202
+ role,
203
+ tokens,
204
+ reminderBuckets: null,
205
+ toolCallCount: 0,
206
+ toolCallTokens: 0,
207
+ toolResultCount: role === 'tool' ? 1 : 0,
208
+ toolResultTokens: role === 'tool' ? tokens : 0,
209
+ };
210
+ if (role === 'user' && String(text || '').trim().startsWith('<system-reminder>')) {
211
+ const buckets = { tokens: contribution.tokens, otherTokens: contribution.tokens };
212
+ let sectionTokens = 0;
213
+ for (const section of splitMarkdownSections(stripSystemReminder(text))) {
214
+ const bucket = reminderSectionBucket(section);
215
+ const sectionTokenCount = estimateTokens(section);
216
+ buckets[bucket] = (buckets[bucket] || 0) + sectionTokenCount;
217
+ sectionTokens += sectionTokenCount;
218
+ }
219
+ buckets.otherTokens = Math.max(0, contribution.tokens - sectionTokens);
220
+ contribution.reminderBuckets = buckets;
221
+ }
222
+ if (fingerprint.role === 'assistant' && Array.isArray(message?.toolCalls) && message.toolCalls.length) {
223
+ contribution.toolCallCount = message.toolCalls.length;
224
+ try { contribution.toolCallTokens = estimateTokens(JSON.stringify(message.toolCalls)); }
225
+ catch { contribution.toolCallTokens = estimateTokens(`[${message.toolCalls.length} tool calls]`); }
226
+ }
227
+ if (message && typeof message === 'object') {
228
+ contextMessageMemo.set(message, { fingerprint, contribution });
229
+ }
230
+ return contribution;
231
+ }
232
+
233
+ function emptyContextSummaryState() {
234
+ return {
235
+ rows: {
236
+ system: { count: 0, tokens: 0 },
237
+ user: { count: 0, tokens: 0 },
238
+ assistant: { count: 0, tokens: 0 },
239
+ tool: { count: 0, tokens: 0 },
240
+ other: { count: 0, tokens: 0 },
241
+ },
242
+ semantic: {
243
+ system: { count: 0, tokens: 0 },
244
+ chat: { count: 0, tokens: 0 },
245
+ assistant: { count: 0, tokens: 0 },
246
+ toolResults: { count: 0, tokens: 0 },
247
+ reminders: { count: 0, tokens: 0, otherTokens: 0 },
248
+ workflow: { tokens: 0 },
249
+ memory: { tokens: 0 },
250
+ workspace: { tokens: 0 },
251
+ environment: { tokens: 0 },
252
+ other: { tokens: 0 },
253
+ },
254
+ estimatedTokens: 0,
255
+ toolCallCount: 0,
256
+ toolCallTokens: 0,
257
+ toolResultCount: 0,
258
+ toolResultTokens: 0,
259
+ };
260
+ }
261
+
262
+ function applyContextMessageContribution(state, contribution, direction) {
263
+ const { role, tokens } = contribution;
264
+ state.estimatedTokens += direction * tokens;
265
+ state.rows[role].count += direction;
266
+ state.rows[role].tokens += direction * tokens;
267
+ if (role === 'system') {
268
+ state.semantic.system.count += direction;
269
+ state.semantic.system.tokens += direction * tokens;
270
+ } else if (role === 'user') {
271
+ if (contribution.reminderBuckets) {
272
+ state.semantic.reminders.count += direction;
273
+ state.semantic.reminders.tokens += direction * contribution.reminderBuckets.tokens;
274
+ state.semantic.reminders.otherTokens += direction * contribution.reminderBuckets.otherTokens;
275
+ for (const bucket of ['workflow', 'memory', 'workspace', 'environment', 'other']) {
276
+ state.semantic[bucket].tokens += direction * (contribution.reminderBuckets[bucket] || 0);
277
+ }
278
+ } else {
279
+ state.semantic.chat.count += direction;
280
+ state.semantic.chat.tokens += direction * tokens;
281
+ }
282
+ } else if (role === 'assistant') {
283
+ state.semantic.assistant.count += direction;
284
+ state.semantic.assistant.tokens += direction * tokens;
285
+ } else if (role === 'tool') {
286
+ state.semantic.toolResults.count += direction;
287
+ state.semantic.toolResults.tokens += direction * tokens;
288
+ }
289
+ state.toolCallCount += direction * contribution.toolCallCount;
290
+ state.toolCallTokens += direction * contribution.toolCallTokens;
291
+ state.toolResultCount += direction * contribution.toolResultCount;
292
+ state.toolResultTokens += direction * contribution.toolResultTokens;
293
+ }
294
+
295
+ function contextSummaryResult(state, count) {
296
+ return {
297
+ count,
298
+ estimatedTokens: state.estimatedTokens,
299
+ roles: Object.fromEntries(Object.entries(state.rows).map(([role, row]) => [role, { ...row }])),
300
+ semantic: Object.fromEntries(Object.entries(state.semantic).map(([name, row]) => [name, { ...row }])),
301
+ toolCallCount: state.toolCallCount,
302
+ toolCallTokens: state.toolCallTokens,
303
+ toolResultCount: state.toolResultCount,
304
+ toolResultTokens: state.toolResultTokens,
305
+ };
306
+ }
307
+
102
308
  export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
103
309
  export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
104
310
  export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
@@ -301,81 +507,23 @@ function reminderSectionBucket(section) {
301
507
  }
302
508
 
303
509
  export function summarizeContextMessages(messages) {
304
- const rows = {
305
- system: { count: 0, tokens: 0 },
306
- user: { count: 0, tokens: 0 },
307
- assistant: { count: 0, tokens: 0 },
308
- tool: { count: 0, tokens: 0 },
309
- other: { count: 0, tokens: 0 },
310
- };
311
- const semantic = {
312
- system: { count: 0, tokens: 0 },
313
- chat: { count: 0, tokens: 0 },
314
- assistant: { count: 0, tokens: 0 },
315
- toolResults: { count: 0, tokens: 0 },
316
- reminders: { count: 0, tokens: 0, otherTokens: 0 },
317
- workflow: { tokens: 0 },
318
- memory: { tokens: 0 },
319
- workspace: { tokens: 0 },
320
- environment: { tokens: 0 },
321
- other: { tokens: 0 },
322
- };
323
- let toolCallCount = 0;
324
- let toolCallTokens = 0;
325
- let toolResultCount = 0;
326
- let toolResultTokens = 0;
327
- for (const message of messages || []) {
328
- const role = rows[message?.role] ? message.role : 'other';
329
- const text = messageEstimateText(message);
330
- const tokens = estimateMessageTokens(message);
331
- rows[role].count += 1;
332
- rows[role].tokens += tokens;
333
- if (role === 'system') {
334
- semantic.system.count += 1;
335
- semantic.system.tokens += tokens;
336
- } else if (role === 'user') {
337
- if (String(text || '').trim().startsWith('<system-reminder>')) {
338
- semantic.reminders.count += 1;
339
- semantic.reminders.tokens += tokens;
340
- let sectionTokens = 0;
341
- for (const section of splitMarkdownSections(stripSystemReminder(text))) {
342
- const bucket = reminderSectionBucket(section);
343
- const sectionTokenCount = estimateTokens(section);
344
- semantic[bucket].tokens += sectionTokenCount;
345
- sectionTokens += sectionTokenCount;
346
- }
347
- semantic.reminders.otherTokens += Math.max(0, tokens - sectionTokens);
348
- } else {
349
- semantic.chat.count += 1;
350
- semantic.chat.tokens += tokens;
351
- }
352
- } else if (role === 'assistant') {
353
- semantic.assistant.count += 1;
354
- semantic.assistant.tokens += tokens;
355
- } else if (role === 'tool') {
356
- semantic.toolResults.count += 1;
357
- semantic.toolResults.tokens += tokens;
358
- }
359
- if (message?.role === 'assistant' && Array.isArray(message.toolCalls) && message.toolCalls.length) {
360
- toolCallCount += message.toolCalls.length;
361
- try { toolCallTokens += estimateTokens(JSON.stringify(message.toolCalls)); }
362
- catch { toolCallTokens += estimateTokens(`[${message.toolCalls.length} tool calls]`); }
363
- }
364
- if (message?.role === 'tool') {
365
- toolResultCount += 1;
366
- toolResultTokens += tokens;
367
- }
510
+ if (!Array.isArray(messages)) return contextSummaryResult(emptyContextSummaryState(), 0);
511
+ let cached = contextTranscriptMemo.get(messages);
512
+ if (!cached || messages.length < cached.count) {
513
+ cached = { count: 0, contributions: [], state: emptyContextSummaryState() };
514
+ contextTranscriptMemo.set(messages, cached);
368
515
  }
369
- return {
370
- count: Array.isArray(messages) ? messages.length : 0,
371
- estimatedTokens: Array.isArray(messages) ? estimateMessagesTokens(messages) : 0,
372
- roles: rows,
373
- semantic,
374
- toolCallCount,
375
- toolCallTokens,
376
- toolResultCount,
377
- toolResultTokens,
378
- };
516
+ for (let index = 0; index < messages.length; index += 1) {
517
+ const previous = cached.contributions[index];
518
+ const contribution = contextMessageContribution(messages[index]);
519
+ if (previous === contribution) continue;
520
+ if (previous) applyContextMessageContribution(cached.state, previous, -1);
521
+ cached.contributions[index] = contribution;
522
+ applyContextMessageContribution(cached.state, contribution, 1);
523
+ }
524
+ cached.contributions.length = messages.length;
525
+ cached.count = messages.length;
526
+ return contextSummaryResult(cached.state, messages.length);
379
527
  }
380
528
 
381
529
  // Per-request overhead the provider injects that never appears in the
@@ -383,6 +531,39 @@ export function summarizeContextMessages(messages) {
383
531
  // provider wraps around the request. The chars/4 message estimate misses all
384
532
  // of it, so a "fits" verdict computed from messages alone is optimistic.
385
533
  const REQUEST_OVERHEAD_TOKENS = 512;
534
+ const toolSchemaTokenMemo = new WeakMap();
535
+ const requestReserveTokenMemo = new WeakMap();
536
+
537
+ function sameToolArrayEntries(cached, tools) {
538
+ if (!cached || cached.entries.length !== tools.length) return false;
539
+ for (let index = 0; index < tools.length; index += 1) {
540
+ const entry = cached.entries[index];
541
+ const tool = tools[index];
542
+ if (entry.tool !== tool
543
+ || entry.name !== tool?.name
544
+ || entry.description !== tool?.description
545
+ || entry.inputSchema !== tool?.inputSchema
546
+ || entry.input_schema !== tool?.input_schema
547
+ || entry.parameters !== tool?.parameters
548
+ || entry.schema !== tool?.schema) return false;
549
+ }
550
+ return true;
551
+ }
552
+
553
+ function cacheToolArrayValue(tools, value) {
554
+ return {
555
+ entries: tools.map((tool) => ({
556
+ tool,
557
+ name: tool?.name,
558
+ description: tool?.description,
559
+ inputSchema: tool?.inputSchema,
560
+ input_schema: tool?.input_schema,
561
+ parameters: tool?.parameters,
562
+ schema: tool?.schema,
563
+ })),
564
+ value,
565
+ };
566
+ }
386
567
 
387
568
  /**
388
569
  * Estimate the token cost of the tool/function schemas a provider appends to
@@ -394,10 +575,14 @@ const REQUEST_OVERHEAD_TOKENS = 512;
394
575
  */
395
576
  export function estimateToolSchemaTokens(tools) {
396
577
  if (!Array.isArray(tools) || tools.length === 0) return 0;
578
+ const cached = toolSchemaTokenMemo.get(tools);
579
+ if (sameToolArrayEntries(cached, tools)) return cached.value;
397
580
  let text = '';
398
581
  try { text = JSON.stringify(tools); }
399
582
  catch { text = tools.map(t => String(t?.name ?? '')).join(''); }
400
- return estimateTokens(text);
583
+ const tokens = estimateTokens(text);
584
+ toolSchemaTokenMemo.set(tools, cacheToolArrayValue(tools, tokens));
585
+ return tokens;
401
586
  }
402
587
 
403
588
  /**
@@ -407,7 +592,12 @@ export function estimateToolSchemaTokens(tools) {
407
592
  * request-side bytes the message estimate cannot see.
408
593
  */
409
594
  export function estimateRequestReserveTokens(tools) {
410
- return estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
595
+ if (!Array.isArray(tools)) return estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
596
+ const cached = requestReserveTokenMemo.get(tools);
597
+ if (sameToolArrayEntries(cached, tools)) return cached.value;
598
+ const reserve = estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
599
+ requestReserveTokenMemo.set(tools, cacheToolArrayValue(tools, reserve));
600
+ return reserve;
411
601
  }
412
602
 
413
603
  /**
@@ -418,13 +608,15 @@ export function estimateRequestReserveTokens(tools) {
418
608
  *
419
609
  * @param {unknown[]} messages
420
610
  * @param {unknown[]|number} toolsOrReserve tool list or precomputed reserve tokens
421
- * @param {{ messageCount?: number }} [opts]
611
+ * @param {{ messageCount?: number, estimatedMessageTokens?: number }} [opts]
422
612
  */
423
613
  export function estimateTranscriptContextUsage(messages, toolsOrReserve, opts = {}) {
424
614
  const list = Array.isArray(messages) ? messages : [];
425
615
  const count = Number.isFinite(Number(opts.messageCount)) ? Number(opts.messageCount) : list.length;
426
616
  if (count <= 0 || list.length === 0) return 0;
427
- const messageTokens = estimateMessagesTokens(list);
617
+ const messageTokens = Number.isFinite(Number(opts.estimatedMessageTokens))
618
+ ? Number(opts.estimatedMessageTokens)
619
+ : summarizeContextMessages(list).estimatedTokens;
428
620
  const reserve = typeof toolsOrReserve === 'number' && Number.isFinite(toolsOrReserve)
429
621
  ? Math.max(0, toolsOrReserve)
430
622
  : estimateRequestReserveTokens(toolsOrReserve);
@@ -15,7 +15,8 @@ import { isAgentOwner } from '../../agent-owner.mjs';
15
15
 
16
16
  // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
17
17
  // to execute during SSE parsing so tool work overlaps with the rest of the
18
- // stream. Writes, bash, MCP and skills stay serial after send() returns.
18
+ // stream. MCP follows that annotation; writes, bash, and skills stay serial
19
+ // after send() returns.
19
20
  // Memoized: the read-only name Set is built once per distinct `tools` array
20
21
  // (keyed by identity via a module-level WeakMap) so repeated per-call lookups
21
22
  // are O(1) instead of O(N) tools.find scans.
@@ -26,6 +26,9 @@ import {
26
26
  compactTypeForSession,
27
27
  } from './context-meta.mjs';
28
28
  import { uncachedInputTokensForProvider } from './usage-metrics.mjs';
29
+ import { pruneOffloadSession } from '../tool-result-offload.mjs';
30
+ import { _getPendingMessagesForSession } from './pending-messages.mjs';
31
+ import { isSessionCompactionBlocked } from './runtime-liveness.mjs';
29
32
 
30
33
  // 'compacting' is a transient in-flight stage written just before semantic /
31
34
  // recall-fasttrack compaction runs. If the process crashes or only partially
@@ -489,6 +492,18 @@ export async function runSessionCompaction(session, opts = {}) {
489
492
  const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
490
493
  const now = Date.now();
491
494
  session.messages = compacted;
495
+ // Best-effort GC only: the 10-minute mtime gate plus this idle-only guard
496
+ // lets an in-flight turn's sidecars survive until a later compaction/close.
497
+ const pruneSessionId = opts.sessionId || session.id;
498
+ if (!isSessionCompactionBlocked(pruneSessionId)) {
499
+ try {
500
+ await pruneOffloadSession(pruneSessionId, () => [
501
+ session.messages,
502
+ session.liveTurnMessages,
503
+ _getPendingMessagesForSession(pruneSessionId),
504
+ ]);
505
+ } catch { /* best-effort */ }
506
+ }
492
507
  session.providerState = undefined;
493
508
  session.compaction = {
494
509
  ...(session.compaction || {}),