mixdog 0.9.22 → 0.9.24

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 (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
@@ -74,6 +74,39 @@ function recallMemoryTimeoutMs(session) {
74
74
  // misconfigured tiny value can't turn the bound into a busy no-wait.
75
75
  return Math.max(250, configured || RECALL_MEMORY_CALL_TIMEOUT_MS);
76
76
  }
77
+ // Cold-start allowance (clear/manual path only): a booting memory daemon can
78
+ // miss the tight first bound (waitForPort + first-RPC warmup ~2-10s). On a
79
+ // timeout we retry ONCE with a longer bound before honoring the bail-to-
80
+ // semantic contract, so a rebooting runtime succeeds instead of instantly
81
+ // failing. Non-timeout errors and outer aborts propagate immediately.
82
+ // 15s: memory boot is ~2-4s warm-cache / ~10s worst since the PG fast-start
83
+ // fix; keeping this tight bounds the clear path's worst case (2 memory calls
84
+ // retried + 120s semantic) under the TUI auto-clear watchdog (180s).
85
+ const RECALL_COLD_START_TIMEOUT_MS = 15_000;
86
+ function isTimeoutError(err) {
87
+ return typeof err?.message === 'string' && err.message.includes('timed out after');
88
+ }
89
+ async function callMemoryColdStart(args, callerCtx, timeoutMs) {
90
+ try {
91
+ return await callMemoryBounded(args, callerCtx, timeoutMs);
92
+ } catch (err) {
93
+ if (!isTimeoutError(err) || callerCtx?.signal?.aborted) throw err;
94
+ const coldMs = Math.max(timeoutMs, RECALL_COLD_START_TIMEOUT_MS);
95
+ if (coldMs <= timeoutMs) throw err;
96
+ try { process.stderr.write(`[session] recall-fasttrack ${args?.action || 'call'} cold-start retry (${timeoutMs}ms -> ${coldMs}ms)\n`); } catch {}
97
+ return await callMemoryBounded(args, callerCtx, coldMs);
98
+ }
99
+ }
100
+ // Semantic-compact timeout scales with transcript size (clear/manual path):
101
+ // default max(30s, ~10s per 25k estimated message tokens) capped at 120s, so a
102
+ // large (~100k-token) transcript no longer dies on a fixed 30s bound.
103
+ // session.compaction.timeoutMs still overrides.
104
+ function semanticCompactTimeoutMs(session, messageTokens) {
105
+ const override = positiveContextWindow(session?.compaction?.timeoutMs);
106
+ if (override) return override;
107
+ const scaled = Math.ceil((messageTokens || 0) / 25_000) * 10_000;
108
+ return Math.min(120_000, Math.max(30_000, scaled));
109
+ }
77
110
  async function callMemoryBounded(args, callerCtx, timeoutMs) {
78
111
  const ac = new AbortController();
79
112
  const outer = callerCtx?.signal;
@@ -118,7 +151,7 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
118
151
  || Math.max(500, Math.min(5000, messages.length || 0));
119
152
  const memoryTimeoutMs = recallMemoryTimeoutMs(session);
120
153
  try {
121
- await callMemoryBounded({
154
+ await callMemoryColdStart({
122
155
  action: 'ingest_session',
123
156
  sessionId,
124
157
  messages,
@@ -140,7 +173,7 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
140
173
  // digest.
141
174
  let recallText = '';
142
175
  try {
143
- const browsed = await callMemoryBounded({
176
+ const browsed = await callMemoryColdStart({
144
177
  action: 'search',
145
178
  sessionId,
146
179
  limit: positiveContextWindow(session?.compaction?.recallDigestLimit) || 30,
@@ -298,7 +331,7 @@ export async function runSessionCompaction(session, opts = {}) {
298
331
  signal: opts.signal || null,
299
332
  promptCacheKey: session.promptCacheKey || null,
300
333
  providerCacheKey: session.promptCacheKey || null,
301
- timeoutMs: positiveContextWindow(session.compaction?.timeoutMs) || 30_000,
334
+ timeoutMs: semanticCompactTimeoutMs(session, beforeMessageTokens),
302
335
  tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
303
336
  keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
304
337
  preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
@@ -341,7 +374,7 @@ export async function runSessionCompaction(session, opts = {}) {
341
374
  signal: opts.signal || null,
342
375
  promptCacheKey: session.promptCacheKey || null,
343
376
  providerCacheKey: session.promptCacheKey || null,
344
- timeoutMs: positiveContextWindow(session.compaction?.timeoutMs) || 30_000,
377
+ timeoutMs: semanticCompactTimeoutMs(session, beforeMessageTokens),
345
378
  tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
346
379
  keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
347
380
  preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
@@ -108,6 +108,7 @@ export function markSessionAskStart(id) {
108
108
  entry.lastStreamDeltaAt = null;
109
109
  entry.lastToolCall = null;
110
110
  entry.toolStartedAt = null;
111
+ entry.toolSelfDeadlineMs = null;
111
112
  entry.lastError = null;
112
113
  // A new ask starts a fresh turn lifecycle — clear any stale empty-final
113
114
  // classification from the prior turn so inspectBridgeEntry doesn't keep
@@ -166,11 +167,18 @@ export async function markSessionStreamDelta(id) {
166
167
  }
167
168
  entry.updatedAt = now;
168
169
  }
169
- export function markSessionToolCall(id, toolName) {
170
+ export function markSessionToolCall(id, toolName, selfDeadlineMs) {
170
171
  if (!id) return;
171
172
  const entry = _touchRuntime(id);
172
173
  entry.stage = 'tool_running';
173
174
  entry.lastToolCall = toolName || null;
175
+ // Self-enforced deadline (ms) for tools that kill themselves at a known
176
+ // budget (shell timeout / task wait). The watchdog raises the tool-running
177
+ // ceiling to this + grace instead of aborting at toolRunningMs. Null/<=0
178
+ // means unknown -> plain toolRunningMs behavior.
179
+ entry.toolSelfDeadlineMs = (typeof selfDeadlineMs === 'number' && selfDeadlineMs > 0)
180
+ ? selfDeadlineMs
181
+ : null;
174
182
  entry.toolStartedAt = Date.now();
175
183
  entry.lastProgressAt = entry.toolStartedAt;
176
184
  entry.updatedAt = entry.toolStartedAt;
@@ -289,6 +297,8 @@ export function getSessionProgressSnapshot(sessionId) {
289
297
  firstActivityAt,
290
298
  lastStreamDeltaAt: entry.lastStreamDeltaAt || 0,
291
299
  toolStartedAt: entry.toolStartedAt || 0,
300
+ currentTool: entry.lastToolCall || null,
301
+ toolSelfDeadlineMs: entry.toolSelfDeadlineMs || 0,
292
302
  lastProgressAt: entry.lastProgressAt || 0,
293
303
  updatedAt: entry.updatedAt || 0,
294
304
  hasFirstActivity: Boolean(firstActivityAt && (!askStartedAt || firstActivityAt >= askStartedAt)),
@@ -167,8 +167,9 @@ export function createSession(opts) {
167
167
  // - read-only roles (reviewer / debugger / hidden retrieval, i.e. any
168
168
  // session resolving to permission 'read') -> 'readonly' bundle:
169
169
  // read builtins (code_graph/find/glob/list/grep/read) + retrieval
170
- // (explore/search/web_fetch/Skill), no apply_patch/shell/task, no
171
- // MCP-write. applyToolPermissionNarrowing('read') below trims the
170
+ // (explore/search/web_fetch/Skill) + shell/task for self-verification
171
+ // (agent-owned readonly bundle only), no apply_patch, no MCP-write.
172
+ // applyToolPermissionNarrowing('read') below trims the
172
173
  // bundle to AGENT_STRING_PERMISSION_READ_ALLOW so the final surface is
173
174
  // bit-identical across these roles regardless of MCP registry state.
174
175
  // - write roles (worker / heavy-worker / maintainer / …) -> 'full'
@@ -188,7 +189,10 @@ export function createSession(opts) {
188
189
  // fail closed (zero tools) rather than silently falling back to the full
189
190
  // preset, which would grant the role more surface than declared.
190
191
  if (ownerIsAgent) {
191
- toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, opts.agent || null);
192
+ // Pass the RESOLVED agent (opts.agent || opts.role): narrowing keys
193
+ // retrieval/locator roles (explore etc.) off this name to strip
194
+ // shell/task; verifying read roles (reviewer/debugger) keep them.
195
+ toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, resolvedAgent);
192
196
  }
193
197
 
194
198
  const { baseRules, stableSystemContext, sessionMarker, volatileTail } = composeSystemPrompt({
@@ -314,6 +318,11 @@ export function createSession(opts) {
314
318
  },
315
319
  tools,
316
320
  preset: toolPreset,
321
+ // Persisted so the deferred call-through gate (deferred-call-through.mjs
322
+ // resolveDeferredSelectMode) can resolve the session's tool mode; without
323
+ // this every session read `undefined` and write-capable deferred tools
324
+ // (e.g. MCP) were permanently denied auto-promotion.
325
+ toolSpec,
317
326
  presetName: presetObj?.name || null,
318
327
  effort,
319
328
  fast,
@@ -459,6 +468,8 @@ export async function resumeSession(sessionId, preset) {
459
468
  if (ownerIsAgent) {
460
469
  toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, session.toolPermission, session.agent || null);
461
470
  }
471
+ // Keep the persisted tool mode in sync on resume (see createSession note).
472
+ session.toolSpec = toolSpec;
462
473
  session.tools = finalizeSessionToolList(toolsForRouting, {
463
474
  schemaAllowedTools: Array.isArray(session.schemaAllowedTools) ? session.schemaAllowedTools : null,
464
475
  disallowedTools: getHiddenAgent(session.agent || null) ? ['Skill'] : null,
@@ -74,14 +74,39 @@ const AGENT_STRING_PERMISSION_READ_ALLOW = Object.freeze([
74
74
  'list',
75
75
  'grep',
76
76
  'read',
77
+ // shell/task: read-role agents (reviewer/debugger) must run their own
78
+ // verification (tests/repro). task is required because shell's
79
+ // auto-background transition settles with a bare jobId — agent sessions
80
+ // get no completion notification, so `task wait/read` is the only way to
81
+ // collect a long-running result. apply_patch stays excluded: read roles
82
+ // keep the no-edit contract.
83
+ 'shell',
84
+ 'task',
77
85
  'explore',
78
86
  'search',
79
87
  'web_fetch',
80
88
  'Skill',
81
89
  ]);
82
90
 
83
- function stringToolPermissionAllowList(toolPermission) {
84
- if (toolPermission === 'read') return AGENT_STRING_PERMISSION_READ_ALLOW;
91
+ // Retrieval/locator roles never self-verify, so they keep the historical
92
+ // no-shell read surface. Covers hidden retrieval agents (kind 'retrieval')
93
+ // AND public wrapper roles (explore + any invokedBy wrapper) — the same set
94
+ // pre-dispatch-deny treats as recursive wrappers. Only verifying read roles
95
+ // (reviewer/debugger) get shell/task.
96
+ const AGENT_STRING_PERMISSION_READ_RETRIEVAL_ALLOW = Object.freeze(
97
+ AGENT_STRING_PERMISSION_READ_ALLOW.filter((n) => n !== 'shell' && n !== 'task'),
98
+ );
99
+
100
+ function isRetrievalToolRole(agent) {
101
+ if (!agent) return false;
102
+ if (getHiddenAgent(agent)?.kind === 'retrieval') return true;
103
+ return Boolean(recursiveWrapperToolNameForPublicAgent(agent));
104
+ }
105
+
106
+ function stringToolPermissionAllowList(toolPermission, { retrieval = false } = {}) {
107
+ if (toolPermission === 'read') {
108
+ return retrieval ? AGENT_STRING_PERMISSION_READ_RETRIEVAL_ALLOW : AGENT_STRING_PERMISSION_READ_ALLOW;
109
+ }
85
110
  if (toolPermission === 'read-write') return AGENT_STRING_PERMISSION_READ_WRITE_ALLOW;
86
111
  if (toolPermission === 'none') return [];
87
112
  return null;
@@ -109,7 +134,7 @@ const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
109
134
 
110
135
  export function applyToolPermissionNarrowing(tools, toolPermission, warnRole = null) {
111
136
  if (toolPermission === 'none') return [];
112
- const allowList = stringToolPermissionAllowList(toolPermission);
137
+ const allowList = stringToolPermissionAllowList(toolPermission, { retrieval: isRetrievalToolRole(warnRole) });
113
138
  if (allowList) {
114
139
  const allowSet = new Set(allowList.map((n) => String(n).toLowerCase()));
115
140
  return tools.filter((t) => allowSet.has(String(t?.name || '').toLowerCase()));
@@ -203,7 +228,7 @@ export function resolveSessionTools(toolSpec, skills, { ownerIsAgentSession = fa
203
228
  // time (loop.mjs), so the schema bytes stay bit-identical across roles /
204
229
  // cwds and the provider cache shard does not fragment.
205
230
  const skillTools = buildSkillToolDefs(skills, { ownerIsAgentSession });
206
- return _computeBaseTools(toolSpec, mcp, skillTools);
231
+ return _computeBaseTools(toolSpec, mcp, skillTools, { ownerIsAgentSession });
207
232
  }
208
233
 
209
234
  export function previewSessionTools(toolSpec, skills = [], options = {}) {
@@ -230,7 +255,7 @@ function _dedupByName(tools) {
230
255
  // Tools with agentHidden:true are stripped from agent sessions at schema
231
256
  // build time (see deny filtering below). No code-level name list needed.
232
257
 
233
- function _computeBaseTools(toolSpec, mcp, skillTools) {
258
+ function _computeBaseTools(toolSpec, mcp, skillTools, { ownerIsAgentSession = false } = {}) {
234
259
  if (Array.isArray(toolSpec)) {
235
260
  if (toolSpec.length === 0) {
236
261
  // Explicit "no tools" — skill meta tools still travel so the model
@@ -285,7 +310,16 @@ function _computeBaseTools(toolSpec, mcp, skillTools) {
285
310
  return _dedupByName([...mcp, ...skillTools]);
286
311
  case 'readonly': {
287
312
  const readTools = ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name));
288
- return _dedupByName([...readTools, ...mcp, ...skillTools]);
313
+ // Read-ROLE agent sessions (reviewer/debugger) must self-verify, so
314
+ // their base bundle carries shell/task defs. The 'read' permission
315
+ // allowlist (AGENT_STRING_PERMISSION_READ_ALLOW) is a pure FILTER —
316
+ // it can only keep tools already assembled here, so without this the
317
+ // allowlist's shell/task entries were dead letters. Non-agent
318
+ // 'readonly' profiles stay strictly read-only.
319
+ const verifyTools = ownerIsAgentSession
320
+ ? ALL_BUILTIN_SESSION_TOOLS.filter(t => t.name === 'shell' || t.name === 'task')
321
+ : [];
322
+ return _dedupByName([...readTools, ...verifyTools, ...mcp, ...skillTools]);
289
323
  }
290
324
  case 'full':
291
325
  default:
@@ -45,17 +45,20 @@ const _lastSaveError = new Map(); // id -> { message, at }
45
45
  // starving the model of the image mid-conversation. Returns the same object
46
46
  // reference when nothing changed (no-image sessions pay only a shallow scan).
47
47
  function _sessionForDisk(session) {
48
- // Strip the in-flight live-turn alias (askSession sets session.liveTurnMessages
49
- // for the turn duration so contextStatus() can estimate live context growth).
50
- // It is a transient duplicate of the working transcript and must never be
51
- // serialized: mid-turn saves (persistIterationMetrics) would otherwise bloat
52
- // the session file and persist a non-canonical message array.
53
- const hasLiveTurn = session && typeof session === 'object'
54
- && Object.prototype.hasOwnProperty.call(session, 'liveTurnMessages');
48
+ // Strip transient in-flight aliases askSession sets for the turn duration:
49
+ // - liveTurnMessages: live working transcript (so contextStatus() can
50
+ // estimate live context growth) — a duplicate of the working transcript
51
+ // that must never be serialized (mid-turn saves would bloat the file and
52
+ // persist a non-canonical message array).
53
+ // - toolApprovalHook: the askOpts.onToolApproval callback wired for the
54
+ // turn — a function that must never be serialized.
55
+ const hasTransient = session && typeof session === 'object'
56
+ && (Object.prototype.hasOwnProperty.call(session, 'liveTurnMessages')
57
+ || Object.prototype.hasOwnProperty.call(session, 'toolApprovalHook'));
55
58
  const messages = Array.isArray(session?.messages) ? session.messages : null;
56
59
  if (!messages || messages.length === 0) {
57
- if (!hasLiveTurn) return session;
58
- const { liveTurnMessages: _drop, ...rest } = session;
60
+ if (!hasTransient) return session;
61
+ const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
59
62
  return rest;
60
63
  }
61
64
  let changed = false;
@@ -66,11 +69,11 @@ function _sessionForDisk(session) {
66
69
  return m;
67
70
  });
68
71
  if (!changed) {
69
- if (!hasLiveTurn) return session;
70
- const { liveTurnMessages: _drop, ...rest } = session;
72
+ if (!hasTransient) return session;
73
+ const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
71
74
  return rest;
72
75
  }
73
- const { liveTurnMessages: _drop, ...rest } = session;
76
+ const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
74
77
  return { ...rest, messages: out };
75
78
  }
76
79
 
@@ -305,13 +308,26 @@ export function saveSessionAsync(session, opts) {
305
308
  setLiveSession(session);
306
309
  const reqId = ++_saveWorkerReqId;
307
310
  const safeOpts = opts || null;
311
+ // The Worker `postMessage` below structured-clones the whole session on the
312
+ // main thread. `session.liveTurnMessages` (live working transcript) and
313
+ // `session.toolApprovalHook` (askOpts.onToolApproval callback) are transient
314
+ // in-flight aliases askSession sets for the turn duration; both carry
315
+ // non-cloneable values (a function, and raw messages that can hold functions),
316
+ // which makes structuredClone throw "could not be cloned" for every mid-turn
317
+ // iteration save. The worker strips both via _sessionForDisk anyway, so drop
318
+ // them from the cloned payload here WITHOUT mutating the live session object.
319
+ const clonePayload = (session && typeof session === 'object'
320
+ && (Object.prototype.hasOwnProperty.call(session, 'liveTurnMessages')
321
+ || Object.prototype.hasOwnProperty.call(session, 'toolApprovalHook')))
322
+ ? (() => { const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session; return rest; })()
323
+ : session;
308
324
  return new Promise((resolve, reject) => {
309
325
  // Persist {session, opts} so drainSessionStore can sync-flush
310
326
  // outstanding writes if process exit interrupts the worker queue.
311
- _saveWorkerPending.set(reqId, { resolve, reject, session, opts: safeOpts });
327
+ _saveWorkerPending.set(reqId, { resolve, reject, session: clonePayload, opts: safeOpts });
312
328
  try {
313
329
  const w = _getOrSpawnWorker();
314
- w.postMessage({ session, opts: safeOpts, reqId });
330
+ w.postMessage({ session: clonePayload, opts: safeOpts, reqId });
315
331
  // Ref AFTER successful postMessage so a queue/throw failure path
316
332
  // does not leave the worker held alive with no pending message.
317
333
  // Paired with the unref in the message handler when count hits 0.
@@ -11,6 +11,7 @@ import { takeApplyPatchUiDiff } from '../tools/patch.mjs';
11
11
  import { compressToolResult } from '../tools/result-compression.mjs';
12
12
  import { appendAgentTrace, traceAgentTool, traceAgentToolFailure } from '../agent-trace.mjs';
13
13
  import { markSessionToolCall, updateSessionStage } from './manager.mjs';
14
+ import { resolveToolSelfDeadlineMs } from '../agent-runtime/agent-progress-watchdog.mjs';
14
15
  import { classifyResultKind } from './result-classification.mjs';
15
16
  import { normalizeToolEnvelope } from './tool-envelope.mjs';
16
17
  import { maybeOffloadToolResult } from './tool-result-offload.mjs';
@@ -150,7 +151,7 @@ export async function processToolBatch(ctx) {
150
151
  continue;
151
152
  }
152
153
  }
153
- if (sessionId) markSessionToolCall(sessionId, call.name);
154
+ if (sessionId) markSessionToolCall(sessionId, call.name, resolveToolSelfDeadlineMs(call.name, call.arguments));
154
155
  let result;
155
156
  let toolStartedAt;
156
157
  let toolEndedAt;
@@ -54,12 +54,16 @@ globalThis.__mixdogBashSessionRuntimeLoaded = true;
54
54
  // Claude Code parity (refs/claude-code src/utils/timeouts.ts): default 120 s
55
55
  // (2 min), max 600 s (10 min), BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS
56
56
  // env overrides (max floored at default). Matches the one-shot bash tool
57
- // (builtin/bash-tool.mjs); per-call `timeout` still overrides, capped at
58
- // MAX_TIMEOUT_MS.
57
+ // (builtin/bash-tool.mjs): an omitted `timeout` uses the 120 s default bounded
58
+ // by MAX_TIMEOUT_MS; an explicit per-call `timeout` is honored uncapped, clamped
59
+ // only by TIMER_MAX_MS so JS/PS 32-bit timers stay valid.
59
60
  const _envDefaultTimeout = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
60
61
  const DEFAULT_TIMEOUT_MS = _envDefaultTimeout > 0 ? _envDefaultTimeout : 120_000;
61
62
  const _envMaxTimeout = parseInt(process.env.BASH_MAX_TIMEOUT_MS ?? '', 10);
62
63
  const MAX_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 600_000, DEFAULT_TIMEOUT_MS);
64
+ // JS setTimeout / PS WaitForExit(ms) are 32-bit: a delay above 2^31-1 wraps and
65
+ // fires immediately. Hard ceiling (~24.8 days) for an uncapped explicit timeout.
66
+ const TIMER_MAX_MS = 2_147_483_647;
63
67
  const IDLE_TIMEOUT_MS = 5 * 60_000;
64
68
  const MAX_SESSIONS = 10;
65
69
  const STDERR_DRAIN_MS = 25;
@@ -645,9 +649,13 @@ async function bash_session(args, cwd = process.cwd(), opts = {}) {
645
649
  }
646
650
  return requestedCwd;
647
651
  })();
648
- const rawTimeout = typeof args?.timeout === 'number' ? args.timeout : DEFAULT_TIMEOUT_MS;
649
- const timeoutMs = rawTimeout;
650
- const effectiveTimeout = Math.min(Math.max(timeoutMs, 1), wmicRewrite?.timeoutMs || MAX_TIMEOUT_MS);
652
+ const hasExplicitTimeout = typeof args?.timeout === 'number' && args.timeout > 0;
653
+ const timeoutMs = hasExplicitTimeout ? args.timeout : DEFAULT_TIMEOUT_MS;
654
+ // Explicit per-call timeout uncapped (only the wmic-rewrite ceiling or the
655
+ // 32-bit TIMER_MAX_MS still bound it); omitted default keeps MAX_TIMEOUT_MS.
656
+ const effectiveTimeout = hasExplicitTimeout
657
+ ? Math.min(Math.max(timeoutMs, 1), wmicRewrite?.timeoutMs || TIMER_MAX_MS)
658
+ : Math.min(Math.max(timeoutMs, 1), wmicRewrite?.timeoutMs || MAX_TIMEOUT_MS);
651
659
  const resolved = _getOrCreate(requestedSessionId || args?.session_id, baseCwd, { create: args?.create === true });
652
660
  if (resolved.error) return resolved.error;
653
661
  const { id, entry } = resolved;
@@ -21,6 +21,7 @@ import {
21
21
  import {
22
22
  analyzeShellCommandEffects,
23
23
  foregroundLongCommandHint,
24
+ isAutobackgroundingAllowed,
24
25
  } from './shell-analysis.mjs';
25
26
  import {
26
27
  cancelBackgroundTask,
@@ -208,41 +209,56 @@ export async function executeBashTool(args, workDir, options = {}) {
208
209
  }
209
210
  // Keep foreground commands on a long tool-owned timeout. The MCP dispatch
210
211
  // layer must not add a shorter fallback ceiling when timeout is omitted.
211
- // Claude Code parity (refs/claude-code src/utils/timeouts.ts): default
212
- // 120 s (2 min), max 600 s (10 min); BASH_DEFAULT_TIMEOUT_MS /
213
- // BASH_MAX_TIMEOUT_MS env overrides, max floored at default.
212
+ // Reference-CLI parity (opencode/codex/claude-code): sync-first, no hard
213
+ // upper ceiling on a caller-provided timeout. Default 120 s (2 min) when
214
+ // omitted; BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS env overrides keep
215
+ // their semantics (max floored at default) but only bound the *omitted*
216
+ // default — an explicit args.timeout is honored UNCAPPED.
214
217
  const _envDefaultTimeout = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
215
218
  const DEFAULT_BASH_TIMEOUT_MS = _envDefaultTimeout > 0 ? _envDefaultTimeout : 120_000;
216
- const DEFAULT_BACKGROUND_BASH_TIMEOUT_MS = DEFAULT_BASH_TIMEOUT_MS;
219
+ // Background (async / run_in_background) jobs get NO omitted default: 0
220
+ // means "unlimited" and flows unchanged through startBackgroundShellJob →
221
+ // task meta (detail.timeoutMs 0). An explicit args.timeout is still honored
222
+ // and enforced exactly as before. Sync path keeps the 120s omitted default.
223
+ const DEFAULT_BACKGROUND_BASH_TIMEOUT_MS = 0;
217
224
  const _envMaxTimeout = parseInt(process.env.BASH_MAX_TIMEOUT_MS ?? '', 10);
218
225
  const MAX_BASH_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 600_000, DEFAULT_BASH_TIMEOUT_MS);
219
226
  const defaultTimeoutMs = runInBackground
220
227
  ? DEFAULT_BACKGROUND_BASH_TIMEOUT_MS
221
228
  : DEFAULT_BASH_TIMEOUT_MS;
222
- const rawTimeout = (typeof args.timeout === 'number' && args.timeout > 0)
223
- ? args.timeout : defaultTimeoutMs;
224
- const timeoutMs = rawTimeout;
225
- const timeout = Math.min(timeoutMs, wmicRewrite?.timeoutMs || MAX_BASH_TIMEOUT_MS);
229
+ const hasExplicitTimeout = typeof args.timeout === 'number' && args.timeout > 0;
230
+ const timeoutMs = hasExplicitTimeout ? args.timeout : defaultTimeoutMs;
231
+ // Explicit caller timeout is uncapped (only the wmic-rewrite ceiling, when
232
+ // present, still bounds it); the omitted default keeps the MAX ceiling.
233
+ // JS timers (setTimeout) and PS WaitForExit(ms) are 32-bit: a delay above
234
+ // 2^31-1 wraps to a tiny/negative value and fires immediately. Clamp the
235
+ // uncapped explicit timeout once here (~24.8 days ceiling) so every
236
+ // downstream timer — foreground, background job, hard-stop watcher — stays
237
+ // valid without per-site guards.
238
+ const TIMER_MAX_MS = 2_147_483_647;
239
+ // timeoutMs <= 0 (omitted background default) means unlimited: pass it
240
+ // through untouched — the min() clamps below must not turn 0 into a bound.
241
+ const timeout = timeoutMs <= 0
242
+ ? 0
243
+ : (hasExplicitTimeout
244
+ ? Math.min(timeoutMs, wmicRewrite?.timeoutMs || TIMER_MAX_MS)
245
+ : Math.min(timeoutMs, wmicRewrite?.timeoutMs || MAX_BASH_TIMEOUT_MS));
226
246
  const mergeStderr = args.merge_stderr === true;
227
247
  const longForegroundHint = foregroundLongCommandHint(command, timeout, { ...args, run_in_background: runInBackground });
228
248
  if (longForegroundHint) return longForegroundHint;
229
- // Auto-background threshold (CC ASSISTANT_BLOCKING_BUDGET_MS analogue):
230
- // a foreground one-shot that is still running after this many ms is
231
- // detached into a tracked shell-job instead of blocking the tool call
232
- // indefinitely. Only the foreground one-shot path uses it — never
233
- // run_in_background (already detached) or persistent sessions (handled
234
- // far above). Capped below the hard timeout so the 600 s upper bound
235
- // stays a separate, later ceiling.
236
- // Soft config: reuse the sync task-wait budget (30 s, see waitForShellJob
237
- // default in executeTaskTool) as the default promotion threshold. Override
238
- // with MIXDOG_SHELL_AUTO_BACKGROUND_MS (positive ms). This is a soft hint,
239
- // not a hard cap — the value is clamped below `timeout` so the hard ceiling
240
- // stays a separate, later bound.
249
+ // Auto-background threshold. Reference-CLI parity: sync commands run to
250
+ // their timeout without any default auto-promotion, so the default is 0
251
+ // (disabled) for ALL callers. It is an explicit opt-in only: set
252
+ // MIXDOG_SHELL_AUTO_BACKGROUND_MS (positive ms) to re-enable detaching a
253
+ // still-running foreground one-shot into a tracked shell-job. When enabled,
254
+ // the value stays a soft hint clamped below `timeout` so the hard ceiling
255
+ // remains a separate, later bound. Never applies to run_in_background
256
+ // (already detached) or persistent sessions (handled far above).
241
257
  const _autoBgEnvMs = Number(process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS);
242
258
  const DEFAULT_AUTO_BACKGROUND_MS = Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs > 0
243
259
  ? Math.floor(_autoBgEnvMs)
244
- : 30_000;
245
- const autoBackgroundMs = runInBackground
260
+ : 0;
261
+ const autoBackgroundMs = (runInBackground || DEFAULT_AUTO_BACKGROUND_MS <= 0)
246
262
  ? 0
247
263
  : Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
248
264
 
@@ -348,6 +364,19 @@ export async function executeBashTool(args, workDir, options = {}) {
348
364
  : wrapBashWithCwdProbe(wrappedCommand, _stateFile);
349
365
  }
350
366
  } catch { syncCommand = wrappedCommand; }
367
+ // Promote-at-timeout (CC shouldAutoBackground parity). When a
368
+ // foreground one-shot hits its timeout and is still running, adopt it
369
+ // as a background job (task_id + notify) instead of tree-killing it.
370
+ // Opt-outs restore the old kill behavior: (a) disallowed sleep-like
371
+ // base commands (isAutobackgroundingAllowed), (b) the truthy
372
+ // MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS env. Never applies to
373
+ // run_in_background (already detached, handled above).
374
+ const _bgTasksDisabled = /^(1|true|yes|on)$/i.test(
375
+ String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
376
+ );
377
+ const backgroundOnTimeout = !runInBackground
378
+ && !_bgTasksDisabled
379
+ && isAutobackgroundingAllowed(command, shellType);
351
380
  const result = await execShellCommand({
352
381
  shell, shellArg, shellArgs, command: syncCommand,
353
382
  env: spawnEnv,
@@ -355,6 +384,9 @@ export async function executeBashTool(args, workDir, options = {}) {
355
384
  timeoutMs: timeout,
356
385
  abortSignal: combinedBashAbort,
357
386
  autoBackgroundMs,
387
+ // On a foreground timeout, promote the still-running child to a
388
+ // tracked background job (unlimited) instead of killing it.
389
+ backgroundOnTimeout,
358
390
  // Threaded so an auto-backgrounded foreground job is stamped with
359
391
  // the dispatching terminal's claude.exe pid (per-terminal scope).
360
392
  clientHostPid: options?.clientHostPid,
@@ -388,7 +420,10 @@ export async function executeBashTool(args, workDir, options = {}) {
388
420
  stdout: result.stdoutPath ? normalizeOutputPath(result.stdoutPath) : null,
389
421
  stderr: (!mergeStderr && result.stderrPath) ? normalizeOutputPath(result.stderrPath) : null,
390
422
  cwd: bashWorkDir,
391
- timeoutMs: timeout,
423
+ // Adopted foreground jobs run unlimited (timeoutMs 0)
424
+ // — matches the async default; the original
425
+ // foreground timeout no longer bounds the promoted job.
426
+ timeoutMs: 0,
392
427
  },
393
428
  resultType: 'shell_task_result',
394
429
  cancel: () => killShellJob(result.jobId),
@@ -430,8 +465,11 @@ export async function executeBashTool(args, workDir, options = {}) {
430
465
  // Timeout marker carries an inline recovery hint so the caller can
431
466
  // act in one round (increase ceiling or detach) instead of repeating
432
467
  // the same command and hitting the same wall.
468
+ const timeoutHint = result.timedOut
469
+ ? ` — command killed after ${timeout} ms; if it legitimately needs longer, retry with a larger timeout`
470
+ : '';
433
471
  const statusMarker = result.timedOut
434
- ? `[timeout: ${timeout}ms signal: ${signal || 'SIGTERM'}]`
472
+ ? `[timeout: ${timeout}ms signal: ${signal || 'SIGTERM'}]${timeoutHint}`
435
473
  : (signal
436
474
  ? `[signal: ${signal}]`
437
475
  : (isReallyErrored ? `[exit code: ${exitCode}]` : ''));
@@ -12,10 +12,11 @@ import {
12
12
  executionModeSchemaDescription,
13
13
  } from '../../../../shared/background-tasks.mjs';
14
14
 
15
- // Shell timeout envelope surfaced in the tool schema. Mirrors Claude Code:
16
- // default 120 s / max 600 s, BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS
17
- // env overrides, max floored at default. Keep in sync with
18
- // builtin/bash-tool.mjs and bash-session.mjs.
15
+ // Shell timeout envelope surfaced in the tool schema. Reference-CLI parity:
16
+ // default 120 s when omitted; an explicit timeout is honored uncapped.
17
+ // BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS env overrides only bound the
18
+ // omitted default (max floored at default). Keep in sync with
19
+ // builtin/bash-tool.mjs.
19
20
  function _shellDefaultTimeoutMs() {
20
21
  const parsed = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
21
22
  return parsed > 0 ? parsed : 120_000;
@@ -72,7 +73,7 @@ export const BUILTIN_TOOLS = [
72
73
  properties: {
73
74
  command: { type: 'string', description: 'Command.' },
74
75
  cwd: { type: 'string', description: 'Working directory. Persists across shell calls in a session — omit to reuse the previous command\'s directory (e.g. after cd); pass an absolute path to change it.' },
75
- timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min); long-running commands may raise it up to ${_shellMaxTimeoutMs()} (${_shellMaxTimeoutMs() / 60000} min).` },
76
+ timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min) when omitted; an explicit value is honored uncapped — long-running commands may set it higher. On timeout the command is MOVED TO BACKGROUND (a task_id you can wait/status/read/cancel) and keeps running instead of being killed; sleep-like commands and MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS opt out (killed with a [timeout] marker). async/background runs with timeout omitted have NO timeout (run until done/cancelled); an explicit timeout is still enforced.` },
76
77
  merge_stderr: { type: 'boolean', description: 'Merge stderr.' },
77
78
  mode: { type: 'string', enum: ['sync', 'async'], description: executionModeSchemaDescription('sync') },
78
79
  shell: { type: 'string', enum: ['bash', 'powershell'], description: 'Force shell. Windows defaults to PowerShell; bash = Git Bash/POSIX.' },
@@ -167,7 +168,7 @@ export const BUILTIN_TOOLS = [
167
168
  name: 'find',
168
169
  title: 'Mixdog Find Files',
169
170
  annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
170
- description: 'Find files by partial path/name. Returns verified paths. Batch names as query[].',
171
+ description: 'Find files by partial path/name, searching dot-directories (e.g. .git-adjacent, .mixdog) too. The right tool for locating a file at an unknown, possibly machine-wide path — run it from a verified broad root. Returns verified paths. Batch names as query[].',
171
172
  inputSchema: {
172
173
  type: 'object',
173
174
  properties: {
@@ -357,6 +357,22 @@ export async function lstatPathsForMtime(paths, workDir, concurrency = 64, opts
357
357
  return out;
358
358
  }
359
359
 
360
+ // Extra invalidation listeners: sibling modules with their own derived caches
361
+ // (e.g. the broad find-enumeration cache in list-tool) register a clear
362
+ // callback here so every write-invalidation event that drops the result/stat/
363
+ // raw caches also drops theirs. Full clear is intentional — those entries are
364
+ // cheap to rebuild and a path-scoped diff is not worth the coupling.
365
+ const EXTRA_INVALIDATION_LISTENERS = new Set();
366
+ export function registerCacheInvalidationListener(fn) {
367
+ if (typeof fn === 'function') EXTRA_INVALIDATION_LISTENERS.add(fn);
368
+ return () => EXTRA_INVALIDATION_LISTENERS.delete(fn);
369
+ }
370
+ function runExtraInvalidationListeners() {
371
+ for (const fn of EXTRA_INVALIDATION_LISTENERS) {
372
+ try { fn(); } catch { /* best-effort: one listener must not block others */ }
373
+ }
374
+ }
375
+
360
376
  function cacheInvalidateAll() {
361
377
  RESULT_CACHE.clear();
362
378
  RESULT_CACHE_INFLIGHT.clear();
@@ -366,6 +382,7 @@ function cacheInvalidateAll() {
366
382
  RAW_CONTENT_CACHE_BYTES = 0;
367
383
  PATH_MUTATION_GENERATIONS.clear();
368
384
  PATH_MUTATION_GLOBAL_GENERATION += 1;
385
+ runExtraInvalidationListeners();
369
386
  }
370
387
 
371
388
  function cacheInvalidatePaths(paths) {
@@ -394,6 +411,9 @@ function cacheInvalidatePaths(paths) {
394
411
  deleteReadRangeIndexForPath(affected);
395
412
  bumpPathMutationGeneration(affected);
396
413
  }
414
+ // Broad enumeration entries are not path-scoped, so any partial
415
+ // invalidation still fully drops them (cheap to rebuild).
416
+ runExtraInvalidationListeners();
397
417
  }
398
418
 
399
419
  export function invalidateBuiltinResultCache(paths = null) {