mixdog 0.9.45 → 0.9.46

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 (116) hide show
  1. package/package.json +5 -2
  2. package/scripts/agent-dispatch-abort-compose-test.mjs +31 -0
  3. package/scripts/agent-model-liveness-test.mjs +618 -0
  4. package/scripts/agent-trace-io-test.mjs +68 -4
  5. package/scripts/bench-run.mjs +30 -3
  6. package/scripts/compact-pressure-test.mjs +187 -4
  7. package/scripts/compact-prior-context-flatten-test.mjs +252 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +8 -6
  9. package/scripts/compacted-placeholder-scrub-test.mjs +20 -34
  10. package/scripts/context-mcp-metering-test.mjs +75 -0
  11. package/scripts/explore-prompt-policy-test.mjs +15 -16
  12. package/scripts/explore-timeout-cancel-test.mjs +345 -0
  13. package/scripts/headless-pristine-execution-test.mjs +614 -0
  14. package/scripts/internal-comms-smoke.mjs +15 -6
  15. package/scripts/live-worker-smoke.mjs +1 -1
  16. package/scripts/memory-core-input-test.mjs +137 -0
  17. package/scripts/memory-rule-contract-test.mjs +5 -5
  18. package/scripts/openai-oauth-refresh-race-test.mjs +120 -0
  19. package/scripts/openai-oauth-ws-1006-retry-test.mjs +26 -0
  20. package/scripts/parent-abort-link-test.mjs +22 -0
  21. package/scripts/provider-toolcall-test.mjs +22 -0
  22. package/scripts/reactive-compact-persist-smoke.mjs +8 -4
  23. package/scripts/session-bench.mjs +3 -70
  24. package/scripts/task-bench.mjs +0 -2
  25. package/scripts/terminal-bench-isolation-guards-test.mjs +102 -0
  26. package/scripts/tool-smoke.mjs +21 -21
  27. package/scripts/tool-tui-presentation-test.mjs +68 -0
  28. package/scripts/tui-transcript-jitter-harness-entry.jsx +91 -10
  29. package/src/app.mjs +28 -103
  30. package/src/cli.mjs +17 -13
  31. package/src/headless-command.mjs +139 -0
  32. package/src/headless-role.mjs +121 -10
  33. package/src/help.mjs +4 -1
  34. package/src/rules/agent/00-common.md +3 -3
  35. package/src/rules/agent/00-core.md +8 -9
  36. package/src/rules/agent/20-skip-protocol.md +2 -3
  37. package/src/rules/agent/30-explorer.md +50 -56
  38. package/src/rules/agent/40-cycle1-agent.md +10 -12
  39. package/src/rules/agent/41-cycle2-agent.md +12 -9
  40. package/src/rules/agent/42-cycle3-agent.md +4 -6
  41. package/src/rules/lead/01-general.md +5 -6
  42. package/src/rules/lead/02-channels.md +1 -1
  43. package/src/rules/lead/lead-brief.md +14 -17
  44. package/src/rules/lead/lead-tool.md +3 -3
  45. package/src/rules/shared/01-tool.md +41 -43
  46. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
  47. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
  48. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
  49. package/src/runtime/agent/orchestrator/config.mjs +96 -30
  50. package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
  51. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  52. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
  53. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  54. package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
  55. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
  56. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
  57. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
  58. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
  59. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
  60. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
  61. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
  62. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
  63. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
  64. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
  65. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
  66. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
  67. package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
  69. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
  71. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
  74. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
  75. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
  76. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
  77. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
  78. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
  80. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
  81. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  82. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  83. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
  84. package/src/runtime/memory/index.mjs +0 -1
  85. package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
  86. package/src/runtime/memory/lib/http-router.mjs +0 -193
  87. package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
  88. package/src/runtime/memory/tool-defs.mjs +5 -6
  89. package/src/runtime/shared/config.mjs +11 -34
  90. package/src/runtime/shared/pristine-execution-contract.json +75 -0
  91. package/src/runtime/shared/pristine-execution.mjs +356 -0
  92. package/src/runtime/shared/provider-api-key.mjs +43 -0
  93. package/src/runtime/shared/provider-auth-binding.mjs +21 -0
  94. package/src/session-runtime/context-status.mjs +61 -13
  95. package/src/session-runtime/mcp-glue.mjs +29 -2
  96. package/src/session-runtime/plugin-mcp.mjs +7 -0
  97. package/src/session-runtime/resource-api.mjs +38 -5
  98. package/src/session-runtime/runtime-core.mjs +5 -1
  99. package/src/session-runtime/session-turn-api.mjs +14 -2
  100. package/src/session-runtime/settings-api.mjs +5 -0
  101. package/src/session-runtime/tool-catalog.mjs +13 -2
  102. package/src/session-runtime/tool-defs.mjs +1 -3
  103. package/src/standalone/agent-task-status.mjs +50 -11
  104. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  105. package/src/standalone/explore-tool.mjs +257 -49
  106. package/src/standalone/seeds.mjs +1 -0
  107. package/src/tui/App.jsx +23 -10
  108. package/src/tui/app/use-transcript-scroll.mjs +4 -3
  109. package/src/tui/app/use-transcript-window.mjs +12 -21
  110. package/src/tui/components/ContextPanel.jsx +19 -25
  111. package/src/tui/dist/index.mjs +77 -65
  112. package/src/tui/engine/agent-envelope.mjs +16 -5
  113. package/src/tui/engine/labels.mjs +1 -1
  114. package/src/workflows/default/WORKFLOW.md +21 -51
  115. package/src/workflows/solo/WORKFLOW.md +12 -17
  116. package/src/workflows/solo-review/WORKFLOW.md +0 -47
@@ -9,8 +9,6 @@ import { ensureProcessListenerHeadroom } from '../runtime/shared/process-listene
9
9
 
10
10
  ensureProcessListenerHeadroom(64);
11
11
 
12
- const EXPLORE_NO_RELOOKUP_CONTRACT = 'Every returned requested path:line freezes the LOCATION only; read/code_graph detail inspection is valid when content was not returned; never re-locate it, and search only unresolved facets.';
13
-
14
12
  // Ported from the original mixdog tool-defs.mjs `explore` entry.
15
13
  // `aiWrapped` is dropped: in the standalone build there is no aiWrapped
16
14
  // dispatch hub — execution is wired directly in the runtime executor below
@@ -20,7 +18,7 @@ export const EXPLORE_TOOL = {
20
18
  name: 'explore',
21
19
  title: 'Explore',
22
20
  annotations: { title: 'Explore', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
23
- description: `Broad/uncertain locator with no known path; use after input-state classification for repo anchors or out-of-repo/machine-wide locations. Hardened find searches dot-directories. Array = independent targets; array-first, facets in one query[] (max 8, parallel), never rephrasings. ${EXPLORE_NO_RELOOKUP_CONTRACT}`,
21
+ description: 'Locator for broad/uncertain targets with no known path, repo or machine-wide (dot dirs included). Array = independent targets: query[] fans out facets (max 8), never rephrasings. Every returned path:line freezes the LOCATION only; read/code_graph detail inspection is valid when content was not returned; never re-locate it — search only unresolved facets.',
24
22
  inputSchema: {
25
23
  type: 'object',
26
24
  properties: {
@@ -52,11 +50,15 @@ const EXPLORE_RESULT_CACHE_TTL_MS = (() => {
52
50
  return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 5 * 60_000;
53
51
  })();
54
52
  // Hard ceiling for one explorer compute. The dispatch-level watchdog is the
55
- // primary abort; this race is the last line of defence so a wedged compute can
56
- // never hang the awaiting tool call (and its cache key) forever.
53
+ // primary abort; this race + its AbortController is the last line of defence so
54
+ // a wedged compute can never hang the awaiting tool call (and its cache key)
55
+ // forever. Default 60s (was 10min): a locator sub-session that has not produced
56
+ // anchors within a minute is wedged, and holding the tool call open longer only
57
+ // delays the caller and risks the compute outliving the turn. The
58
+ // MIXDOG_EXPLORE_HARD_TIMEOUT_MS override (including 0 = disabled) is preserved.
57
59
  export const EXPLORE_COMPUTE_HARD_TIMEOUT_MS = (() => {
58
60
  const raw = Number(process.env.MIXDOG_EXPLORE_HARD_TIMEOUT_MS);
59
- return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 10 * 60_000;
61
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 60_000;
60
62
  })();
61
63
  const EXPLORE_RESULT_CACHE = new Map(); // key -> { ts, value?, promise? }
62
64
  // Fan-out launch stagger: when >1 query, dispatch the first sub-session
@@ -72,17 +74,93 @@ const EXPLORE_FANOUT_STAGGER_MS = (() => {
72
74
  return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 0;
73
75
  })();
74
76
 
75
- function withExploreHardTimeout(promise) {
76
- if (!(EXPLORE_COMPUTE_HARD_TIMEOUT_MS > 0)) return promise;
77
- let timer = null;
78
- const timeout = new Promise((_resolve, reject) => {
79
- timer = setTimeout(
80
- () => reject(new Error(`explorer timed out after ${EXPLORE_COMPUTE_HARD_TIMEOUT_MS}ms`)),
81
- EXPLORE_COMPUTE_HARD_TIMEOUT_MS,
82
- );
77
+ // Build a promise that rejects the MOMENT `signal` aborts (immediately if it is
78
+ // already aborted), plus a `cancel` to detach the listener once the caller has
79
+ // settled by another path. This is what makes a canceled/timed-out explore
80
+ // reject RIGHT AWAY even when the underlying compute is non-cooperative (ignores
81
+ // its AbortSignal) instead of hanging until the wall-clock timeout fires.
82
+ function abortRejectionPromise(signal) {
83
+ let cancel = () => {};
84
+ const promise = new Promise((_resolve, reject) => {
85
+ if (!(signal instanceof AbortSignal)) return; // never settles
86
+ const onAbort = () => {
87
+ const reason = signal.reason;
88
+ reject(reason instanceof Error ? reason : new Error(String(reason ?? 'explore aborted')));
89
+ };
90
+ if (signal.aborted) { onAbort(); return; }
91
+ signal.addEventListener('abort', onAbort, { once: true });
92
+ cancel = () => { try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ } };
93
+ });
94
+ return { promise, cancel };
95
+ }
96
+
97
+ // Arm the wall-clock hard timeout (default 60s; MIXDOG_EXPLORE_HARD_TIMEOUT_MS
98
+ // override incl. 0 = disabled): when it fires it ABORTS `controller` — which
99
+ // both tears down a cooperative compute (child dispatch + provider call) AND,
100
+ // via abortRejectionPromise(controller.signal), rejects the awaiting call.
101
+ // Returns a disposer that clears the timer.
102
+ function armExploreHardTimeout(controller, timeoutMs = EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
103
+ if (!(timeoutMs > 0)) return () => {};
104
+ const timer = setTimeout(() => {
105
+ try { controller.abort(new Error(`explorer timed out after ${timeoutMs}ms`)); } catch { /* ignore */ }
106
+ }, timeoutMs);
107
+ if (typeof timer.unref === 'function') timer.unref();
108
+ return () => clearTimeout(timer);
109
+ }
110
+
111
+ // Cascade a parent AbortSignal into a compute controller (immediately if the
112
+ // parent is already aborted). Returns a detach fn for the listener, or null.
113
+ function linkParentAbort(parentSignal, controller) {
114
+ if (!(parentSignal instanceof AbortSignal)) return null;
115
+ const onParentAbort = () => {
116
+ try { controller.abort(parentSignal.reason); } catch { try { controller.abort(); } catch { /* ignore */ } }
117
+ };
118
+ if (parentSignal.aborted) { onParentAbort(); return null; }
119
+ parentSignal.addEventListener('abort', onParentAbort, { once: true });
120
+ return () => { try { parentSignal.removeEventListener('abort', onParentAbort); } catch { /* ignore */ } };
121
+ }
122
+
123
+ // Interruptible stagger delay: resolves after `ms`, but REJECTS the instant
124
+ // `signal` aborts (or immediately if already aborted) so a canceled fan-out
125
+ // cancels the pending stagger BEFORE it dispatches the (now-pointless) child.
126
+ // Exported for focused stagger-cancellation regression tests.
127
+ export function exploreStaggerDelay(ms, signal) {
128
+ return new Promise((resolve, reject) => {
129
+ const rejectCanceled = () => {
130
+ const reason = signal?.reason;
131
+ reject(reason instanceof Error ? reason : new Error(String(reason ?? 'explore canceled')));
132
+ };
133
+ if (signal instanceof AbortSignal && signal.aborted) { rejectCanceled(); return; }
134
+ const timer = setTimeout(() => { cleanup(); resolve(); }, ms);
83
135
  if (typeof timer.unref === 'function') timer.unref();
136
+ const onAbort = () => { cleanup(); rejectCanceled(); };
137
+ const cleanup = () => {
138
+ clearTimeout(timer);
139
+ if (signal instanceof AbortSignal) { try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ } }
140
+ };
141
+ if (signal instanceof AbortSignal) signal.addEventListener('abort', onAbort, { once: true });
142
+ });
143
+ }
144
+
145
+ // Run one explorer compute under an AbortController that fires on EITHER the
146
+ // caller's cancellation (parentSignal) OR the wall-clock hard timeout, and RACE
147
+ // the compute against that abort so a canceled/timed-out call rejects
148
+ // IMMEDIATELY (it never waits on a non-cooperative compute). The compute
149
+ // receives the controller signal and threads it into the child dispatch so the
150
+ // abort tears down every child + its provider call at once. This is the
151
+ // single-caller path (cache disabled / non-shared); the shared-cache path uses
152
+ // startSharedCompute + subscribeToSharedCompute so one caller's cancellation
153
+ // never poisons the other subscribers. Exported for focused regression tests.
154
+ export function runExploreComputeWithAbort(compute, parentSignal = null, timeoutMs = EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
155
+ const controller = new AbortController();
156
+ const detachParent = linkParentAbort(parentSignal, controller);
157
+ const disarm = armExploreHardTimeout(controller, timeoutMs);
158
+ const { promise: aborted } = abortRejectionPromise(controller.signal);
159
+ const computePromise = Promise.resolve().then(() => compute(controller.signal));
160
+ return Promise.race([computePromise, aborted]).finally(() => {
161
+ disarm();
162
+ if (detachParent) detachParent();
84
163
  });
85
- return Promise.race([promise, timeout]).finally(() => { if (timer) clearTimeout(timer); });
86
164
  }
87
165
 
88
166
  function escapeXml(str) {
@@ -124,7 +202,13 @@ function resolveExploreCwd(input, callerCwd) {
124
202
 
125
203
  function settledExplorerResult(result, cwd = null) {
126
204
  if (result?.status !== 'fulfilled') {
127
- return { ok: false, text: `[explorer error] ${result?.reason?.message || String(result?.reason)}` };
205
+ const message = result?.reason?.message || String(result?.reason);
206
+ // The hard deadline has one caller-visible convention regardless of
207
+ // whether it expires during provider warmup or child dispatch.
208
+ if (/^explorer timed out after \d+ms$/.test(message)) {
209
+ return { ok: true, text: 'EXPLORATION_FAILED' };
210
+ }
211
+ return { ok: false, text: `[explorer error] ${message}` };
128
212
  }
129
213
  const text = cleanExplorerText(typeof result.value === 'string' ? result.value : responseText(result.value), cwd);
130
214
  if (!text) return { ok: false, text: '[explorer error] empty response' };
@@ -220,12 +304,34 @@ export function resolveExploreRoute(config) {
220
304
  return findConfigPreset(config, routeOrName);
221
305
  }
222
306
 
223
- async function ensureExploreProviderReady(config, route) {
307
+ export async function ensureExploreProviderReady(config, route, signal = null, init = initProviders) {
224
308
  const provider = clean(route?.provider);
225
309
  if (!provider) return;
226
310
  const providers = { ...(config?.providers || {}) };
227
311
  providers[provider] = { ...(providers[provider] || {}), enabled: true };
228
- await initProviders(providers);
312
+ await init(providers, { signal });
313
+ }
314
+
315
+ // Race provider warmup against the caller's cancellation so an ESC landing
316
+ // DURING provider init returns IMMEDIATELY (and no child dispatch follows)
317
+ // instead of finishing warmup and then spinning up subs it must tear down.
318
+ // Returns true when the caller canceled (already-aborted up front, or aborted
319
+ // before/at warmup completion), false when the provider is ready to dispatch. A
320
+ // genuine provider-init failure (not a cancel) still propagates. Exported for
321
+ // focused provider-init cancellation regression tests.
322
+ export async function awaitExploreProviderReadyOrCancel(readyPromise, parentSignal) {
323
+ if (!(parentSignal instanceof AbortSignal)) { await readyPromise; return false; }
324
+ if (parentSignal.aborted) return true;
325
+ const { promise: aborted, cancel } = abortRejectionPromise(parentSignal);
326
+ try {
327
+ await Promise.race([readyPromise, aborted]);
328
+ } catch (err) {
329
+ if (parentSignal.aborted) return true; // canceled during warmup
330
+ throw err; // a real init failure still surfaces to the caller
331
+ } finally {
332
+ cancel();
333
+ }
334
+ return parentSignal.aborted;
229
335
  }
230
336
 
231
337
  function scheduleExploreCodeGraphPrewarm(cwd) {
@@ -260,43 +366,119 @@ function getCachedExploreResult(key) {
260
366
  return null;
261
367
  }
262
368
 
263
- async function runExploreCached(key, compute) {
264
- const cached = getCachedExploreResult(key);
265
- if (cached !== null) return cached;
266
- if (!exploreResultCacheEnabled()) return await withExploreHardTimeout(compute());
267
- const entry = EXPLORE_RESULT_CACHE.get(key);
268
- if (entry?.promise) {
269
- // Pending in-flight dedup — but never trust a pending entry forever: a
270
- // compute that outlived the hard timeout is wedged (its own race should
271
- // have rejected); drop the poisoned key and recompute fresh.
272
- if (!(EXPLORE_COMPUTE_HARD_TIMEOUT_MS > 0) || Date.now() - entry.ts <= EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
273
- return await entry.promise;
274
- }
275
- EXPLORE_RESULT_CACHE.delete(key);
369
+ function trimExploreResultCache() {
370
+ while (EXPLORE_RESULT_CACHE.size > EXPLORE_RESULT_CACHE_MAX_ENTRIES) {
371
+ const oldest = EXPLORE_RESULT_CACHE.keys().next().value;
372
+ if (!oldest) break;
373
+ EXPLORE_RESULT_CACHE.delete(oldest);
276
374
  }
277
- const promise = Promise.resolve()
278
- .then(() => withExploreHardTimeout(compute()))
375
+ }
376
+
377
+ // Start ONE shared, caller-agnostic compute for `key`. Its AbortController is
378
+ // driven ONLY by the wall-clock hard timeout (a shared deadline) and by the
379
+ // subscriber ref-count reaching zero (see subscribeToSharedCompute) — NEVER by a
380
+ // single subscriber's cancellation. So one caller canceling can neither abort
381
+ // the shared compute nor poison the OTHER subscribers. On success the pending
382
+ // entry is replaced by a value entry; on timeout/failure the entry is purged
383
+ // (identity-guarded) so a future call recomputes instead of awaiting a dead
384
+ // promise (which is exactly what surfaces later as an empty tool result).
385
+ function startSharedCompute(key, compute, timeoutMs = EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
386
+ const controller = new AbortController();
387
+ const entry = { ts: Date.now(), controller, subscribers: new Set(), promise: null };
388
+ const disarm = armExploreHardTimeout(controller, timeoutMs);
389
+ const { promise: aborted } = abortRejectionPromise(controller.signal);
390
+ const computePromise = Promise.resolve().then(() => compute(controller.signal));
391
+ entry.promise = Promise.race([computePromise, aborted])
279
392
  .then((value) => {
393
+ // Identity-guard EVERY eventual write: only cache/purge when THIS entry is
394
+ // still the live one. A hard-timeout TTL eviction (runExploreCached) may
395
+ // have retired us and started a fresh compute; a late resolve from the
396
+ // retired compute must NEVER overwrite that newer entry (stale overwrite).
397
+ if (EXPLORE_RESULT_CACHE.get(key) !== entry) return value;
280
398
  const text = typeof value === 'string' ? value : responseText(value);
281
399
  const cleaned = cleanExplorerText(text);
282
400
  if (cleaned && cleaned !== 'EXPLORATION_FAILED') {
283
401
  EXPLORE_RESULT_CACHE.set(key, { ts: Date.now(), value: text });
284
- while (EXPLORE_RESULT_CACHE.size > EXPLORE_RESULT_CACHE_MAX_ENTRIES) {
285
- const oldest = EXPLORE_RESULT_CACHE.keys().next().value;
286
- if (!oldest) break;
287
- EXPLORE_RESULT_CACHE.delete(oldest);
288
- }
402
+ trimExploreResultCache();
289
403
  } else {
290
404
  EXPLORE_RESULT_CACHE.delete(key);
291
405
  }
292
406
  return value;
293
407
  })
294
408
  .catch((err) => {
295
- EXPLORE_RESULT_CACHE.delete(key);
409
+ // Canceled / timed-out / failed shared compute: purge the pending entry
410
+ // (only when it is still ours) so a later call recomputes fresh instead of
411
+ // awaiting a dead promise that would surface as "no tool output".
412
+ if (EXPLORE_RESULT_CACHE.get(key) === entry) EXPLORE_RESULT_CACHE.delete(key);
296
413
  throw err;
297
- });
298
- EXPLORE_RESULT_CACHE.set(key, { ts: Date.now(), promise });
299
- return await promise;
414
+ })
415
+ .finally(() => { disarm(); });
416
+ EXPLORE_RESULT_CACHE.set(key, entry);
417
+ return entry;
418
+ }
419
+
420
+ // Subscribe ONE caller (with its OWN signal) to a shared compute. The
421
+ // subscriber's await is raced against its own cancellation so a canceled caller
422
+ // is RELEASED IMMEDIATELY (never left waiting on the shared promise). When the
423
+ // LAST subscriber cancels, the shared compute is aborted and its entry purged;
424
+ // while any subscriber remains the shared compute keeps running for them, so one
425
+ // caller's cancellation never disturbs the unaffected subscribers.
426
+ function subscribeToSharedCompute(key, entry, subscriberSignal) {
427
+ const token = {};
428
+ entry.subscribers.add(token);
429
+ return new Promise((resolve, reject) => {
430
+ let done = false;
431
+ const detach = () => {
432
+ if (subscriberSignal instanceof AbortSignal) {
433
+ try { subscriberSignal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
434
+ }
435
+ };
436
+ const settle = (fn, val) => {
437
+ if (done) return;
438
+ done = true;
439
+ entry.subscribers.delete(token);
440
+ detach();
441
+ fn(val);
442
+ };
443
+ function onAbort() {
444
+ const reason = subscriberSignal?.reason;
445
+ const err = reason instanceof Error ? reason : new Error(String(reason ?? 'explore canceled'));
446
+ settle(reject, err);
447
+ if (entry.subscribers.size === 0) {
448
+ // Last subscriber gone: tear the shared compute down and purge the entry
449
+ // so a future call recomputes instead of reusing an abandoned promise.
450
+ try { entry.controller.abort(err); } catch { /* ignore */ }
451
+ if (EXPLORE_RESULT_CACHE.get(key) === entry) EXPLORE_RESULT_CACHE.delete(key);
452
+ }
453
+ }
454
+ if (subscriberSignal instanceof AbortSignal) {
455
+ if (subscriberSignal.aborted) { onAbort(); return; }
456
+ subscriberSignal.addEventListener('abort', onAbort, { once: true });
457
+ }
458
+ entry.promise.then((v) => settle(resolve, v), (e) => settle(reject, e));
459
+ });
460
+ }
461
+
462
+ export async function runExploreCached(key, compute, parentSignal = null, timeoutMs = EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
463
+ const cached = getCachedExploreResult(key);
464
+ if (cached !== null) return cached;
465
+ if (!exploreResultCacheEnabled()) return await runExploreComputeWithAbort(compute, parentSignal);
466
+ let entry = EXPLORE_RESULT_CACHE.get(key);
467
+ if (entry?.promise
468
+ && EXPLORE_COMPUTE_HARD_TIMEOUT_MS > 0
469
+ && Date.now() - entry.ts > EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
470
+ // A pending entry that outlived the hard timeout is wedged — ABORT its
471
+ // compute (tearing down any in-flight child dispatch + provider call) and
472
+ // drop it, then recompute rather than subscribe to a promise that may never
473
+ // settle (which later surfaces as an empty "no tool output" result). The
474
+ // abort + identity-guarded writes in startSharedCompute guarantee the
475
+ // retired compute can never overwrite the fresh entry with a stale value.
476
+ if (EXPLORE_RESULT_CACHE.get(key) === entry) EXPLORE_RESULT_CACHE.delete(key);
477
+ try { entry.controller?.abort(new Error('explore pending entry evicted after hard timeout')); } catch { /* ignore */ }
478
+ entry = null;
479
+ }
480
+ if (!entry?.promise) entry = startSharedCompute(key, compute, timeoutMs);
481
+ return await subscribeToSharedCompute(key, entry, parentSignal);
300
482
  }
301
483
 
302
484
  function responseText(response) {
@@ -404,6 +586,13 @@ async function runExploreSync(args = {}, ctx = {}) {
404
586
  const queries = normalizeExploreQueries(args.query);
405
587
  if (queries.length === 0) return fail('query is required (one or more non-empty strings)');
406
588
 
589
+ // Already-canceled call: short-circuit BEFORE any provider warmup / dispatch
590
+ // so a canceled explore never spins up child sub-sessions it must immediately
591
+ // tear down (and never continues into a dead "no tool output" state).
592
+ if (ctx.signal instanceof AbortSignal && ctx.signal.aborted) {
593
+ return fail('explore canceled before dispatch');
594
+ }
595
+
407
596
  let working = queries;
408
597
  let capNotice = '';
409
598
  if (working.length > MAX_FANOUT_QUERIES) {
@@ -416,7 +605,8 @@ async function runExploreSync(args = {}, ctx = {}) {
416
605
  scheduleExploreFindPrewarm(resolvedCwd);
417
606
  const config = loadConfig();
418
607
  const route = resolveExploreRoute(config);
419
- await ensureExploreProviderReady(config, route);
608
+ const parentSignal = ctx.signal instanceof AbortSignal ? ctx.signal : null;
609
+ if (parentSignal?.aborted) return fail('explore canceled before dispatch');
420
610
  // Turn budget is enforced BOTH ways: the prompt contract (rules/agent/
421
611
  // 30-explorer.md, "hard max 3 tool turns, expected 1") steers the model,
422
612
  // and maxLoopIterations mechanically backstops it — live traces showed
@@ -439,14 +629,25 @@ async function runExploreSync(args = {}, ctx = {}) {
439
629
  // provider prompt-cache write. Index 0 fires immediately; a cached-result
440
630
  // query resolves without delay regardless of index.
441
631
  const stagger = working.length > 1 ? EXPLORE_FANOUT_STAGGER_MS : 0;
632
+ // Caller cancellation (ESC / owner-session abort) cascades into every child
633
+ // dispatch: the per-compute AbortController links this signal (and the hard
634
+ // timeout) so aborting the explore tool call tears down all fan-out subs and
635
+ // their provider calls at once.
442
636
  const settled = await Promise.allSettled(working.map((q, i) => {
443
637
  const key = exploreResultCacheKey({ cwd: resolvedCwd, route, query: q });
444
- return runExploreCached(key, () => {
445
- const delay = (stagger > 0 && i > 0) ? new Promise((r) => setTimeout(r, stagger)) : null;
446
- return delay
447
- ? delay.then(() => llm({ prompt: buildExplorerPrompt(q) }))
448
- : llm({ prompt: buildExplorerPrompt(q) });
449
- });
638
+ return runExploreCached(key, async (computeSignal) => {
639
+ // Provider initialization is part of the timed compute: a wedged init
640
+ // must consume the same 60s wall-clock budget as dispatch, never block
641
+ // runExplore before its hard-timeout AbortController has been armed.
642
+ await ensureExploreProviderReady(config, route, computeSignal);
643
+ // Interruptible stagger: if the compute signal aborts during the delay,
644
+ // exploreStaggerDelay rejects and the (now-pointless) child dispatch is
645
+ // never launched.
646
+ const delay = (stagger > 0 && i > 0) ? exploreStaggerDelay(stagger, computeSignal) : null;
647
+ return await (delay
648
+ ? delay.then(() => llm({ prompt: buildExplorerPrompt(q), parentSignal: computeSignal }))
649
+ : llm({ prompt: buildExplorerPrompt(q), parentSignal: computeSignal }));
650
+ }, parentSignal);
450
651
  }));
451
652
 
452
653
  const fatal = fatalExploreError(settled);
@@ -457,3 +658,10 @@ async function runExploreSync(args = {}, ctx = {}) {
457
658
  const out = capNotice + merged;
458
659
  return allFailed ? fail(out) : ok(out);
459
660
  }
661
+
662
+ // Test-only hook: inspect the explore result cache so cancellation/timeout
663
+ // regression tests can assert poisoned entries are purged. Not part of the
664
+ // runtime surface.
665
+ export function __exploreResultCacheForTest() {
666
+ return EXPLORE_RESULT_CACHE;
667
+ }
@@ -22,6 +22,7 @@ export function ensureStandaloneEnvironment({ rootDir, dataDir }) {
22
22
  // user data skills dir, but only when the target dir does not already exist —
23
23
  // never overwrite user-owned skill dirs.
24
24
  export function seedBundledSkills({ rootDir, dataDir }) {
25
+ if (/^(?:1|true|on|yes)$/i.test(String(process.env.MIXDOG_DISABLE_SKILLS || ''))) return;
25
26
  const bundledDir = join(rootDir, 'defaults', 'skills');
26
27
  if (!existsSync(bundledDir)) return;
27
28
  const targetRoot = join(dataDir, 'skills');
package/src/tui/App.jsx CHANGED
@@ -1211,7 +1211,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1211
1211
  return;
1212
1212
  }
1213
1213
  if (count === previousCount || dragRef.current.active) return;
1214
- if (scrollTargetRef.current <= transcriptBottomSlackRows || followingRef.current) followingRef.current = true;
1214
+ if (scrollTargetRef.current === 0 || followingRef.current) followingRef.current = true;
1215
1215
  }, [state.items.length, resetTranscriptScroll]);
1216
1216
 
1217
1217
  // `exiting` removes the inline caret (PromptInput draws none when disabled) and
@@ -1496,7 +1496,13 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1496
1496
  };
1497
1497
 
1498
1498
  const openContextPicker = () => {
1499
- const tools = store.toolsStatus?.() || { activeCount: 0, count: 0, activeTools: [] };
1499
+ const tools = store.toolsStatus?.() || {
1500
+ activeCount: 0,
1501
+ count: 0,
1502
+ mcpToolCount: 0,
1503
+ activeMcpToolCount: 0,
1504
+ activeTools: [],
1505
+ };
1500
1506
  const mcp = store.mcpStatus?.() || { connectedCount: 0, configuredCount: 0, failedCount: 0 };
1501
1507
  const skills = store.skillsStatus?.() || { count: 0 };
1502
1508
  const plugins = store.pluginsStatus?.() || { count: 0 };
@@ -1504,6 +1510,13 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1504
1510
  const usage = context.usage || {};
1505
1511
  const messages = context.messages || {};
1506
1512
  const request = context.request || {};
1513
+ const schemaBreakdown = request.toolSchemaBreakdown || {};
1514
+ const schemaTokensFor = (buckets) => buckets.reduce(
1515
+ (sum, bucket) => sum + Number(schemaBreakdown?.[bucket]?.tokens || 0),
1516
+ 0,
1517
+ );
1518
+ const builtInToolSchemaTokens = schemaTokensFor(['code', 'web', 'mutation', 'channels', 'setup', 'other']);
1519
+ const mcpToolSchemaTokens = schemaTokensFor(['mcp']);
1507
1520
  const compaction = context.compaction || {};
1508
1521
  const windowTokens = Number(context.contextWindow || state.contextWindow || context.rawContextWindow || state.rawContextWindow || 0);
1509
1522
  const rawWindowTokens = Number(context.rawContextWindow || state.rawContextWindow || windowTokens || 0);
@@ -1593,7 +1606,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1593
1606
  {
1594
1607
  value: 'tools',
1595
1608
  label: 'Tools',
1596
- description: `${fmt(request.toolSchemaTokens)} schema tokens (${pct(request.toolSchemaTokens)}) · ${tools.activeCount || 0}/${tools.count || 0} active`,
1609
+ description: `${fmt(builtInToolSchemaTokens)} schema tokens (${pct(builtInToolSchemaTokens)}) · ${tools.activeCount || 0}/${tools.count || 0} active`,
1597
1610
  _action: 'tools',
1598
1611
  },
1599
1612
  {
@@ -1667,7 +1680,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1667
1680
  semantic: messages.semantic,
1668
1681
  },
1669
1682
  tools: {
1670
- schemaTokens: request.toolSchemaTokens,
1683
+ schemaTokens: builtInToolSchemaTokens,
1671
1684
  active: tools.activeCount,
1672
1685
  count: tools.count,
1673
1686
  },
@@ -1699,6 +1712,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1699
1712
  connected: mcp.connectedCount,
1700
1713
  configured: mcp.configuredCount,
1701
1714
  failed: mcp.failedCount,
1715
+ tools: tools.mcpToolCount,
1716
+ activeTools: tools.activeMcpToolCount,
1717
+ schemaTokens: mcpToolSchemaTokens,
1702
1718
  },
1703
1719
  },
1704
1720
  rows: contextRows,
@@ -2871,12 +2887,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2871
2887
  1,
2872
2888
  viewportHeight - transcriptGuardRows - panelCloseMaskRows - welcomePromptHintRows - overlayHintBandRows,
2873
2889
  );
2874
- // Bottom-follow / pin semantics must NOT widen with the scroll-time guard, or
2875
- // the "pinned to tail" threshold would drift and streaming could freeze a row
2876
- // above bottom. Keep the slack anchored to the BASE (single) guard: if a stale
2877
- // pre-guard offset of 1 survives while no reading anchor is active, still treat
2878
- // the viewport as pinned to the live tail so streaming/tool output continues
2879
- // to auto-follow instead of freezing one row above bottom.
2890
+ // Keep the keyboard-selection edge step anchored to the base guard. This is
2891
+ // not a follow threshold: any positive wheel target is a reading position;
2892
+ // only the true bottom may auto-follow a live tail.
2880
2893
  const transcriptBottomSlackRows = Math.max(0, baseGuardRows);
2881
2894
  transcriptBottomSlackRowsRef.current = transcriptBottomSlackRows;
2882
2895
  transcriptViewportRef.current = {
@@ -451,10 +451,11 @@ export function useTranscriptScroll({
451
451
  // A manual scroll moves the reading position. Capture the new reading anchor
452
452
  // SYNCHRONOUSLY from the latest published geometry so the very next render
453
453
  // already locks to it — no one-frame "dirty" window where concurrent
454
- // streaming growth could lurch the view. At/over the bottom, drop the anchor
455
- // so the bottom-follow path owns the viewport again.
454
+ // streaming growth could lurch the view. Only at the true bottom drop the
455
+ // anchor so the bottom-follow path owns the viewport again: a positive
456
+ // wheel offset is an explicit reading position, even inside the old slack.
456
457
  if (appliedDelta !== 0) {
457
- if (target <= Math.max(0, Number(transcriptBottomSlackRowsRef.current) || 0)) {
458
+ if (target === 0) {
458
459
  transcriptAnchorRef.current = null;
459
460
  transcriptAnchorDirtyRef.current = false;
460
461
  } else {
@@ -6,8 +6,7 @@
6
6
  * memos, the same-frame anchor lock + capture, the per-commit Yoga height
7
7
  * harvest, bottom-follow/anchor post-commit sync, selection layout shift and
8
8
  * the scroll clamp. Scroll/anchor/drag refs stay App-owned (injected); this
9
- * hook owns the measurement maps, measuredRowsVersion and the totalRows/
10
- * preserved-delta bookkeeping refs.
9
+ * hook owns the measurement maps, measuredRowsVersion and totalRows bookkeeping.
11
10
  */
12
11
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
13
12
  import {
@@ -61,7 +60,6 @@ export function useTranscriptWindow({
61
60
  setMeasuredRowsVersion,
62
61
  }) {
63
62
  const transcriptTotalRowsRef = useRef(0);
64
- const preservedScrollDeltaRef = useRef(0);
65
63
  // Pessimistic "committed" max-scroll for the IMMEDIATE wheel/keyboard clamp.
66
64
  // transcriptWindow.maxScrollRows is derived from the row index, which uses
67
65
  // ESTIMATED heights for rows in the mounted slice. On the frame a scroll-up
@@ -138,7 +136,7 @@ export function useTranscriptWindow({
138
136
  : null;
139
137
  const scrolledUpRowsForPin = Math.max(0, Number(scrollTargetRef.current) || 0);
140
138
  const transcriptPinnedForStreaming = followingRef.current
141
- || scrolledUpRowsForPin <= transcriptBottomSlackRows;
139
+ || scrolledUpRowsForPin === 0;
142
140
  // Publish the pinned state to the streaming-tail estimator BEFORE resolving
143
141
  // tailSig / the row-index memo this render, so max(measuredFloor, liveEstimate)
144
142
  // applies while bottom-pinned (right geometry on first commit, no judder) and
@@ -156,7 +154,7 @@ export function useTranscriptWindow({
156
154
  );
157
155
  const transcriptStructureSig = `${revision}#${tailSig}`;
158
156
  const transcriptStreamingActive = !!streamingTailItem;
159
- const scrolledUpForStreamingMeasure = scrolledUpRowsForPin > transcriptBottomSlackRows;
157
+ const scrolledUpForStreamingMeasure = scrolledUpRowsForPin > 0;
160
158
  const prevEstimateGeometry = transcriptGeomRef.current?.suppressMeasuredRowHeights === true;
161
159
  const hasStreamingReadingAnchor = !!transcriptAnchorRef.current
162
160
  || transcriptAnchorDirtyRef.current;
@@ -197,10 +195,10 @@ export function useTranscriptWindow({
197
195
  // (bottom-follow / pinned) or it cannot be aligned (anchor item gone).
198
196
  const hasReadingAnchor = !!transcriptAnchorRef.current && !transcriptAnchorDirtyRef.current;
199
197
  const scrolledUpRows = Math.max(0, Number(scrollTargetRef.current) || 0);
200
- // "Genuinely scrolled up" = the viewport is above the bottom slack band. The
201
- // bottom-follow / pinned path owns everything at-or-below the slack; anything
202
- // above it is the user reading older transcript.
203
- const scrolledUp = scrolledUpRows > transcriptBottomSlackRows;
198
+ // Any positive offset is a user reading position. Only the true bottom is
199
+ // pinned, so a wheel notch inside the former slack band cannot re-enable
200
+ // follow while a stream is appending rows.
201
+ const scrolledUp = scrolledUpRows > 0;
204
202
  // A genuine reading anchor wins even if followingRef is stale-true while the
205
203
  // user is scrolled up. The follow-arm SHOULD have been cleared when the anchor
206
204
  // was captured, but if it lingers true we must not fall through to the stale-
@@ -470,7 +468,7 @@ export function useTranscriptWindow({
470
468
  overlayHintAttachItemIndex = i;
471
469
  break;
472
470
  }
473
- const transcriptTailPinned = Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0) <= transcriptBottomSlackRows;
471
+ const transcriptTailPinned = Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0) === 0;
474
472
  const overlayHintOnLastItem = overlayHintRequested
475
473
  && floatingPanelRows <= 0
476
474
  && transcriptWindow.bottomSpacerRows === 0
@@ -635,7 +633,7 @@ export function useTranscriptWindow({
635
633
  const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
636
634
  const currentOffset = Math.max(0, Number(scrollOffset) || 0);
637
635
  const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
638
- const nearBottom = followingRef.current || currentTarget <= transcriptBottomSlackRows;
636
+ const nearBottom = followingRef.current || currentTarget === 0;
639
637
  const pinnedToBottom = nearBottom;
640
638
  // A genuine reading anchor must win over ordinary stream growth, but an
641
639
  // explicit follow arm (prompt submit / pinned bottom) must win over stale
@@ -731,22 +729,20 @@ export function useTranscriptWindow({
731
729
  stopSmoothScroll();
732
730
  scrollTargetRef.current = desired;
733
731
  scrollPositionRef.current = Math.max(0, Math.min(maxRows, currentPosition + appliedDelta));
734
- preservedScrollDeltaRef.current += appliedDelta;
735
732
  setScrollOffset(Math.max(0, Math.round(desired)));
736
- }, [transcriptWindow.totalRows, transcriptWindow.maxScrollRows, transcriptRowIndex, transcriptContentHeight, transcriptBottomSlackRows, scrollOffset, stopSmoothScroll]);
733
+ }, [transcriptWindow.totalRows, transcriptWindow.maxScrollRows, transcriptRowIndex, transcriptContentHeight, scrollOffset, stopSmoothScroll]);
737
734
  useLayoutEffect(() => {
738
- if (transcriptBottomSlackRows <= 0) return;
739
735
  if (transcriptAnchorRef.current || transcriptAnchorDirtyRef.current || followingRef.current) return;
740
736
  const currentTarget = Math.max(0, Number(scrollTargetRef.current) || 0);
741
737
  const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
742
738
  const currentOffset = Math.max(0, Number(scrollOffset) || 0);
739
+ if (currentTarget !== 0) return;
743
740
  if (Math.max(currentTarget, currentPosition, currentOffset) === 0) return;
744
- if (Math.max(currentTarget, currentPosition, currentOffset) > transcriptBottomSlackRows) return;
745
741
  stopSmoothScroll();
746
742
  scrollTargetRef.current = 0;
747
743
  scrollPositionRef.current = 0;
748
744
  setScrollOffset(0);
749
- }, [transcriptBottomSlackRows, scrollOffset, stopSmoothScroll]);
745
+ }, [scrollOffset, stopSmoothScroll]);
750
746
  useLayoutEffect(() => {
751
747
  const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
752
748
  const next = {
@@ -755,11 +751,6 @@ export function useTranscriptWindow({
755
751
  totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
756
752
  scrollOffset: Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0),
757
753
  };
758
- const preservedDelta = Number(preservedScrollDeltaRef.current) || 0;
759
- if (preservedDelta !== 0) {
760
- next.scrollOffset = Math.max(0, next.scrollOffset + preservedDelta);
761
- preservedScrollDeltaRef.current = 0;
762
- }
763
754
  const previous = selectionLayoutRef.current;
764
755
  selectionLayoutRef.current = next;
765
756
  if (!previous || !dragRef.current.rect || dragRef.current.active) return;