mixdog 1.0.1 → 1.0.3

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 (58) hide show
  1. package/README.md +8 -0
  2. package/package.json +2 -2
  3. package/src/defaults/skills/browser-use/SKILL.md +14 -6
  4. package/src/defaults/skills/setup/references/actions.md +6 -3
  5. package/src/defaults/skills/setup/references/surfaces.md +8 -4
  6. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +0 -1
  7. package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +10 -1
  8. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +40 -99
  9. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +6 -1
  10. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +2 -0
  11. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +7 -3
  12. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +1 -1
  13. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +18 -38
  14. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-path-fanout.mjs +59 -32
  15. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +15 -10
  16. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-request.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/lib/shared-native-scan.mjs +12 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +14 -0
  19. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  20. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +3 -1
  21. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +17 -9
  22. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -0
  23. package/src/runtime/agent/orchestrator/tools/patch/wave.mjs +4 -0
  24. package/src/runtime/browser-bridge/input-fields.mjs +1 -1
  25. package/src/runtime/browser-bridge/tool-defs.mjs +1 -1
  26. package/src/runtime/computer-bridge/actions.mjs +0 -13
  27. package/src/runtime/media/adapters/antigravity-image.mjs +7 -3
  28. package/src/runtime/media/adapters/codex-image.mjs +12 -1
  29. package/src/runtime/media/adapters/gemini-image.mjs +17 -6
  30. package/src/runtime/media/adapters/gemini-video.mjs +19 -9
  31. package/src/runtime/media/adapters/xai-media.mjs +20 -7
  32. package/src/runtime/media/jobs.mjs +35 -3
  33. package/src/runtime/media/media-usage.mjs +126 -0
  34. package/src/runtime/media/tool.mjs +16 -4
  35. package/src/runtime/shared/llm/cost.mjs +50 -3
  36. package/src/runtime/shared/llm/model-catalog-projection.mjs +7 -2
  37. package/src/runtime/shared/llm/model-catalog.mjs +2 -1
  38. package/src/runtime/shared/llm/model-pricing-rates.mjs +21 -0
  39. package/src/runtime/shared/llm/usage-accounting.mjs +25 -5
  40. package/src/runtime/shared/llm/usage-context.mjs +6 -0
  41. package/src/runtime/shared/llm/usage-ledger-import.mjs +1 -0
  42. package/src/session-runtime/cwd-plugins/plugin-status.mjs +24 -4
  43. package/src/session-runtime/internal-tool-executor/feature-tools.mjs +9 -1
  44. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +38 -6
  45. package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +9 -2
  46. package/src/session-runtime/services/agent-tool/worker-index.mjs +6 -3
  47. package/src/session-runtime/services/hook-bus/event-runner.mjs +10 -2
  48. package/src/session-runtime/services/hook-bus/tool-gate.mjs +5 -2
  49. package/src/session-runtime/setup-tool/tool-defs.mjs +1 -1
  50. package/src/session-runtime/workflow-agents-api/agent-route.mjs +8 -5
  51. package/src/tui/app/doctor.mjs +78 -2
  52. package/src/tui/dist/index.mjs +7 -3
  53. package/scripts/lib/bench-sandbox.test.mjs +0 -54
  54. package/scripts/lib/cli-args.test.mjs +0 -64
  55. package/scripts/lib/embedding-model-bench-core.test.mjs +0 -47
  56. package/scripts/lib/smoke-loop-shared.test.mjs +0 -22
  57. package/scripts/lib/trace-row.test.mjs +0 -47
  58. package/scripts/lib/trace-stats.test.mjs +0 -62
package/README.md CHANGED
@@ -487,6 +487,14 @@ npm install
487
487
  npm run dev
488
488
  ```
489
489
 
490
+ Desktop development uses a fresh, isolated test profile on each launch, including
491
+ its data, daemon and tool connections. It does not copy the installed app's
492
+ settings or sign-ins. The profile path is printed and retained in the system
493
+ temporary directory for diagnosis. CDP uses port `9342`; if it is occupied, reuse
494
+ the running test app or choose another port with `npm run dev -- --port 9343`.
495
+ On Windows, `npm run e2e:direct` and `npm run e2e:direct:source` also use isolated
496
+ profiles and port `9342` (override with `-- -Port 9343`).
497
+
490
498
  Both packages discover `*.test.mjs` and `*-test.mjs` under their `src/` and
491
499
  `scripts/` directories. Fast, slow, and live tests run in separate lanes;
492
500
  live checks need their corresponding built artifacts or services. See
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -31,7 +31,7 @@
31
31
  "!scripts/bench/",
32
32
  "!scripts/recall-bench-*.txt",
33
33
  "!scripts/*-test.mjs",
34
- "!scripts/*.test.mjs",
34
+ "!scripts/**/*.test.mjs",
35
35
  "!scripts/tool-contracts/",
36
36
  "!scripts/provider-toolcall/",
37
37
  "!scripts/session-transport/",
@@ -159,12 +159,20 @@ not as two blocks joined.
159
159
 
160
160
  ## Foreground vs background
161
161
 
162
- - Use the visible foreground page for shared-screen work, a requested visual
163
- result, or the user's next action. `open` with `tab` reveals that exact page
164
- and retains it for user handoff; `background:false` is a temporary reveal.
165
- - For result-only work, prefer a named `background:true` page; keep the user's
166
- current page intact. Later calls may omit `background`: naming a support page
167
- does not promote it. Reporting a result does not require revealing a page.
162
+ - Default to named `background:true` pages; the panel stays closed and the
163
+ user's current page stays intact. If the answer can be given in the
164
+ conversation, keep the page hidden even when it produced the answer;
165
+ screenshots, reports, and result-only actions never need a reveal.
166
+ - Reveal a page only with `open` and its `tab`, and only when the page itself
167
+ is what the user needs after the turn:
168
+ - **Deliverable** — the user asked to see, show, or keep a page open, or the
169
+ page is the output: an edited document or dashboard, a cart or checkout,
170
+ a submitted form's result.
171
+ - **Handoff** — the user must act on the page: sign-in, CAPTCHA, 2FA,
172
+ payment, approval, or an unfinished workflow they continue.
173
+ - When the user refers to the page already visible in this session's panel,
174
+ act on that page instead of opening another.
175
+ - Later calls may omit `background`: naming a support page does not promote it.
168
176
  - Background pages run concurrently, so independent background work can share
169
177
  one assistant turn.
170
178
  - Pages, tabs, URLs, and targets are session-local; sign-in state, cookies,
@@ -64,11 +64,11 @@ Read this when choosing a `setup` mutation.
64
64
  | Delete an existing schedule/webhook | `delete_automation` |
65
65
  | Enable/disable a schedule/webhook | `set_automation_enabled` |
66
66
  | Webhook listener enabled state, port, or domain | `set_webhook_config` |
67
- | Desktop keep-awake, usage pin, or Computer observe-only state | `set_desktop_settings` |
67
+ | Desktop keep-awake, run in background after window close, usage pin, or Computer observe-only state | `set_desktop_settings` |
68
68
  | Desktop theme, display language, side panels, or zoom | `set_appearance` |
69
69
  | Register a Project and optionally set its display alias | `save_project` |
70
70
  | Unregister a Project without deleting its files | `remove_project` |
71
- | Revoke one linked device; re-pairing restores access | `revoke_linked_device` |
71
+ | Revoke one linked device by its client id from `status connection`; re-pairing restores access | `revoke_linked_device` |
72
72
  | Managed local model context allocation; null restores automatic | `set_local_context` |
73
73
 
74
74
  ## Routes and workflow
@@ -116,7 +116,10 @@ values through chat.
116
116
  ## Built-in features
117
117
 
118
118
  Read `status features` and distinguish installation, enabled state, and live
119
- bridge state.
119
+ bridge state. `status features` covers Git, Memory, Office, Code Tidy, Local
120
+ Provider and Web Search plus Browser/Computer bridge activity; voice status and
121
+ the Browser Use / Computer Use installed and enabled markers are read with
122
+ `status desktop`.
120
123
 
121
124
  - Git, Memory, Office, and Local Provider are install-first capabilities.
122
125
  - Git and Office installation run on the Desktop when this conversation is
@@ -37,7 +37,8 @@ An attached Desktop or TUI may navigate and return `opened:true`.
37
37
  - Settings → Providers: API-key, OAuth, local providers, and usage sign-in.
38
38
  - Extensions → Plugin → Git & GitHub: Git/GitHub CLI installation and connection.
39
39
  - Settings → Connection: web-app pairing and linked devices.
40
- - Settings → System: update, keep-awake, and Doctor.
40
+ - Settings → System: update, keep-awake, run in background after the window
41
+ closes, and Doctor.
41
42
  - Projects: Project registration, name, Project Memories, and Common Memory.
42
43
  - Workflows: workflow packs, Main and Web Search defaults, and agent editing.
43
44
  - Extensions → Plugin: Built-in cards and plugins.
@@ -48,12 +49,15 @@ the user to Desktop.
48
49
 
49
50
  ## Desktop-backed settings
50
51
 
51
- Setup can read and change Desktop appearance, keep-awake, usage pin,
52
- Computer observe-only, Browser/Computer/voice installation and toggles,
53
- Projects, and linked-device revocation through the attached
52
+ Setup can read and change Desktop appearance, keep-awake, run in background,
53
+ usage pin, Computer observe-only, Browser/Computer/voice installation and
54
+ toggles, Projects, and linked-device revocation through the attached
54
55
  Desktop. A receipt identifies `scope:desktop-host`; appearance is not applied
55
56
  to the paired browser. No Desktop claimant means no confirmed change.
56
57
 
58
+ Computer observe-only has no Desktop settings control; setup is its only
59
+ supported route. Usage pin is the pin toggle on the activity rail usage card.
60
+
57
61
  Schedules/webhooks and workflow/agent/skill definitions use their runtime
58
62
  actions directly. These do not require clicking their Desktop rails.
59
63
 
@@ -281,7 +281,6 @@ export function composeSystemPrompt(opts) {
281
281
  // pre-tool preamble toward English.
282
282
  const environmentParts = [
283
283
  opts.sessionStartContext,
284
- opts.projectInstructionsContext,
285
284
  opts.environmentContext,
286
285
  opts.languageContext,
287
286
  ].map(trimmedPromptSlice);
@@ -24,6 +24,7 @@ import {
24
24
  } from './retry-classifier.mjs';
25
25
  import { _classifyMidstreamError, _midstreamSleepWithAbort, stampAnthropicStreamOutcome } from './anthropic-sse.mjs';
26
26
  import { notifyCurrentAnthropicRateLimit } from './admission-scheduler.mjs';
27
+ import { noteAbandonedUsage } from '../../../shared/llm/usage-context.mjs';
27
28
 
28
29
  export function createAnthropicMidState(attemptIndex) {
29
30
  return {
@@ -144,7 +145,7 @@ export function createAnthropicMidstreamRecovery({
144
145
  * `{ retry: false, value }` when a non-streaming replay produced the turn,
145
146
  * and throws the (stamped) error when the turn is over.
146
147
  */
147
- const onStreamError = async ({ err, midState, controller, response, attemptIndex }) => {
148
+ const decideStreamError = async ({ err, midState, controller, response, attemptIndex }) => {
148
149
  // Canonical stream-outcome contract: stamp before ANY safety
149
150
  // decision below. The parser's stamped verdict (when present)
150
151
  // is authoritative — coarse midState.partialToolCall must not
@@ -309,6 +310,14 @@ export function createAnthropicMidstreamRecovery({
309
310
  throw err;
310
311
  };
311
312
 
313
+ // A thrown error carries its partial usage to the send's accounting; an
314
+ // attempt replaced by a retry or a non-streaming replay is noted here.
315
+ const onStreamError = async (args) => {
316
+ const decision = await decideStreamError(args);
317
+ noteAbandonedUsage(args.err?.partialUsage, args.err?.partialModel);
318
+ return decision;
319
+ };
320
+
312
321
  const exhaustedError = () => firstAttemptError || new Error(unreachableMessage);
313
322
 
314
323
  return { onStreamError, exhaustedError };
@@ -20,9 +20,6 @@ export function scopedCacheGeneration() {
20
20
  // sessionId -> Map<key, { content, ts, firstToolUseId, depRoots }>
21
21
  const _scopedBySession = new Map();
22
22
 
23
- // sessionId -> Map<absPath, Set<cacheKey>> — reverse index for O(1) path-targeted invalidation
24
- const _scopedReverseIdx = new Map();
25
-
26
23
  function _canonicalArgs(args) {
27
24
  if (args === null || args === undefined) return '';
28
25
  if (typeof args !== 'object') return String(args);
@@ -243,16 +240,6 @@ function _pathTouchesRoot(absPath, root) {
243
240
  );
244
241
  }
245
242
 
246
- function _dropScopedEntry(sessionId, key) {
247
- _scopedBySession.get(sessionId)?.delete(key);
248
- const index = _scopedReverseIdx.get(sessionId);
249
- if (!index) return;
250
- for (const [path, keys] of index) {
251
- keys.delete(key);
252
- if (keys.size === 0) index.delete(path);
253
- }
254
- }
255
-
256
243
  /**
257
244
  * Look up a cached result for a deterministic multi-file-scope tool. Returns
258
245
  * null on miss. On hit returns the full entry
@@ -265,7 +252,7 @@ export function tryScopedToolCached({ sessionId, toolName, args, cwd, touch = tr
265
252
  const key = _scopedKey(toolName, args, cwd);
266
253
  const entry = map.get(key);
267
254
  if (!entry || Date.now() - entry.ts >= SCOPED_CACHE_TTL_MS) {
268
- if (entry) _dropScopedEntry(sessionId, key);
255
+ if (entry) map.delete(key);
269
256
  return null;
270
257
  }
271
258
  if (touch) {
@@ -301,33 +288,12 @@ export function setScopedToolCached({
301
288
  _scopedBySession.set(sessionId, map);
302
289
  }
303
290
  const depRoots = _scopedDependencyRoots(toolName, args, cwd);
304
- if (
305
- !setBoundedTextCacheEntry(
306
- map,
307
- key,
308
- { content, ts: Date.now(), firstToolUseId: toolUseId || null, depRoots },
309
- { maxEntries: MAX_PER_SESSION, onEvict: (evictedKey) => _dropScopedEntry(sessionId, evictedKey) }
310
- )
311
- )
312
- return;
313
- // Register key in reverse index for dependency roots. Exact root hits use
314
- // O(1) lookup; touched files under a root are caught by the small prefix scan
315
- // in clearScopedToolsForSessionPaths (MAX_PER_SESSION is 100).
316
- let ridx = _scopedReverseIdx.get(sessionId);
317
- if (!ridx) {
318
- ridx = new Map();
319
- _scopedReverseIdx.set(sessionId, ridx);
320
- }
321
- const _registerAbs = (abs) => {
322
- if (!abs || typeof abs !== 'string') return;
323
- let s = ridx.get(abs);
324
- if (!s) {
325
- s = new Set();
326
- ridx.set(abs, s);
327
- }
328
- s.add(key);
329
- };
330
- for (const dep of depRoots) _registerAbs(dep);
291
+ setBoundedTextCacheEntry(
292
+ map,
293
+ key,
294
+ { content, ts: Date.now(), firstToolUseId: toolUseId || null, depRoots },
295
+ { maxEntries: MAX_PER_SESSION }
296
+ );
331
297
  }
332
298
 
333
299
  /**
@@ -338,78 +304,51 @@ export function clearScopedToolsForSession(sessionId) {
338
304
  if (!sessionId) return;
339
305
  mutationGeneration += 1;
340
306
  _scopedBySession.delete(sessionId);
341
- _scopedReverseIdx.delete(sessionId);
342
307
  }
343
308
 
344
309
  /**
345
- * Targeted scoped-cache invalidation: evict only entries whose cache
346
- * key is associated with at least one of the given touched paths. Uses a
347
- * reverse index (absPath → Set<cacheKey>) for exact root hits plus a bounded
348
- * root-prefix scan for files nested under cached directories/globs. Full wipe
310
+ * Targeted scoped-cache invalidation: evict only entries whose registered
311
+ * dependency roots contain, or are contained by, at least one of the given
312
+ * touched paths (single bounded pass; MAX_PER_SESSION entries). Full wipe
349
313
  * when paths cannot be resolved.
350
314
  */
351
315
  export function clearScopedToolsForSessionPaths(sessionId, touchedPaths, cwd) {
352
316
  if (!sessionId || !Array.isArray(touchedPaths) || touchedPaths.length === 0) return;
353
317
  mutationGeneration += 1;
354
- const map = _scopedBySession.get(sessionId);
355
- if (!map) return;
356
- const absPaths = touchedCacheKeys(touchedPaths, cwd);
357
- if (absPaths.length === 0) {
358
- // Fallback: can't resolve — full wipe.
359
- _scopedBySession.delete(sessionId);
360
- _scopedReverseIdx.delete(sessionId);
361
- return;
362
- }
363
- const ridx = _scopedReverseIdx.get(sessionId);
364
- const evictedKeys = new Set();
365
- for (const abs of absPaths) evictScopedEntriesForPath(map, ridx, abs, evictedKeys);
366
- // Remove evicted keys from any other reverse-index sets they appeared in; prune empty Sets.
367
- if (ridx && evictedKeys.size > 0) {
368
- for (const [absKey, keySet] of ridx) {
369
- for (const k of evictedKeys) keySet.delete(k);
370
- if (keySet.size === 0) ridx.delete(absKey);
371
- }
372
- }
318
+ if (!_scopedBySession.has(sessionId)) return;
319
+ clearSessionForAbsPaths(sessionId, touchedCacheKeys(touchedPaths, cwd));
373
320
  }
374
321
 
375
- // The normalized absolute cache keys of the touched paths; unresolvable
376
- // entries are dropped.
322
+ // Normalized, deduplicated absolute cache keys of the touched paths;
323
+ // unresolvable entries are dropped.
377
324
  function touchedCacheKeys(touchedPaths, cwd) {
378
325
  const base = cwd && typeof cwd === 'string' ? cwd : process.cwd();
379
- return touchedPaths
380
- .map((p) => {
381
- if (typeof p !== 'string' || p.length === 0) return null;
382
- try {
383
- return _normalizeCacheKey(_pathNorm(_pathIsAbs(p) ? p : _pathResolve(base, p)));
384
- } catch {
385
- return null;
386
- }
387
- })
388
- .filter(Boolean);
326
+ const out = new Set();
327
+ for (const p of touchedPaths) {
328
+ if (typeof p !== 'string' || p.length === 0) continue;
329
+ try {
330
+ out.add(_normalizeCacheKey(_pathNorm(_pathIsAbs(p) ? p : _pathResolve(base, p))));
331
+ } catch {
332
+ /* unresolvable: dropped */
333
+ }
334
+ }
335
+ out.delete('');
336
+ return [...out];
389
337
  }
390
338
 
391
- // Evicts every entry keyed to `abs` in the reverse index, then every entry
392
- // whose dependency roots cover it. An index miss may still touch a cached
393
- // directory/root dependency (e.g. grep path:"src" then edit src/a.mjs); the
394
- // prefix scan is bounded by MAX_PER_SESSION and prevents stale scoped hits.
395
- function evictScopedEntriesForPath(map, ridx, abs, evictedKeys) {
396
- const keys = ridx ? ridx.get(abs) : null;
397
- if (keys && keys.size > 0) {
398
- for (const key of keys) {
399
- if (map.has(key)) {
400
- map.delete(key);
401
- evictedKeys.add(key);
402
- }
403
- }
404
- keys.clear();
405
- ridx.delete(abs);
339
+ // Evicts the session's entries whose dependency roots touch any of the
340
+ // already-normalized `absPaths` in a single pass. Empty `absPaths` means the
341
+ // paths were unresolvable: full wipe.
342
+ function clearSessionForAbsPaths(sessionId, absPaths) {
343
+ if (absPaths.length === 0) {
344
+ _scopedBySession.delete(sessionId);
345
+ return;
406
346
  }
347
+ const map = _scopedBySession.get(sessionId);
348
+ if (!map) return;
407
349
  for (const [key, entry] of map) {
408
350
  const roots = Array.isArray(entry?.depRoots) ? entry.depRoots : [];
409
- if (roots.some((root) => _pathTouchesRoot(abs, root))) {
410
- map.delete(key);
411
- evictedKeys.add(key);
412
- }
351
+ if (roots.some((root) => absPaths.some((abs) => _pathTouchesRoot(abs, root)))) map.delete(key);
413
352
  }
414
353
  }
415
354
 
@@ -418,8 +357,10 @@ function evictScopedEntriesForPath(map, ridx, abs, evictedKeys) {
418
357
  // that no watcher reports.
419
358
  registerCacheInvalidationListener((paths) => {
420
359
  mutationGeneration += 1;
421
- for (const sessionId of _scopedBySession.keys()) {
422
- if (paths?.length) clearScopedToolsForSessionPaths(sessionId, paths);
423
- else clearScopedToolsForSession(sessionId);
360
+ if (paths?.length) {
361
+ const absPaths = touchedCacheKeys(paths);
362
+ for (const sessionId of [..._scopedBySession.keys()]) clearSessionForAbsPaths(sessionId, absPaths);
363
+ } else {
364
+ for (const sessionId of [..._scopedBySession.keys()]) clearScopedToolsForSession(sessionId);
424
365
  }
425
366
  });
@@ -133,7 +133,12 @@ function summarySendOpts(opts) {
133
133
  onSteerMessage: undefined,
134
134
  signal: combinedSignal(opts.signal || opts.sendOpts?.signal || null, opts.timeoutMs || HANDOFF_TIMEOUT_MAX_MS),
135
135
  };
136
- if (opts.sessionId) sendOpts.sessionId = `${opts.sessionId}:compact`;
136
+ // The `:compact` id isolates provider state (cache, socket pool); the
137
+ // spend still belongs to the source session.
138
+ if (opts.sessionId) {
139
+ sendOpts.sessionId = `${opts.sessionId}:compact`;
140
+ sendOpts.usageSessionId = opts.sessionId;
141
+ }
137
142
  if (opts.promptCacheKey || opts.sendOpts?.promptCacheKey) {
138
143
  sendOpts.promptCacheKey = `${opts.promptCacheKey || opts.sendOpts.promptCacheKey}:compact`;
139
144
  }
@@ -78,6 +78,8 @@ function summarizeConversation(target, ctx) {
78
78
  );
79
79
  const summarySession = {
80
80
  id: `${sessionId || 'unknown'}:compact`,
81
+ // Usage attribution: background sessions stay background.
82
+ sourceType: sessionRef?.sourceType,
81
83
  provider: target.providerName,
82
84
  model: target.model,
83
85
  cwd: sessionRef?.cwd,
@@ -3,7 +3,7 @@
3
3
  * ask for approval before the tool runs.
4
4
  */
5
5
  import { resolvePreToolAskApproval } from '../tool-helpers.mjs';
6
- import { runAbortable } from '../../../../../shared/abort-race.mjs';
6
+ import { runAbortable, throwIfAborted } from '../../../../../shared/abort-race.mjs';
7
7
 
8
8
  const isArgsObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
9
9
 
@@ -67,8 +67,12 @@ export async function applyBeforeToolHook({
67
67
  const denied = await askApproval(call, { callerSessionId, executeOpts, toolApprovalHook, decision });
68
68
  if (denied) return denied;
69
69
  }
70
- } catch {
71
- // Hooks are policy extensions. A broken hook must not wedge the agent loop.
70
+ } catch (error) {
71
+ throwIfAborted(executeOpts.signal);
72
+ // A failed policy check fails closed: the tool must not run.
73
+ return {
74
+ denial: `Error: tool "${name}" denied: PreToolUse policy check failed: ${error?.message || String(error)}`,
75
+ };
72
76
  }
73
77
  return call;
74
78
  }
@@ -86,7 +86,7 @@ export function composeSessionSystem(opts, { profile, providerName, modelName, s
86
86
  const languageContext = leadOnly ? _buildLeadLanguageContext() : '';
87
87
  const shellEnvironmentContext = buildShellEnvironmentContext(opts, ownerIsAgent, surface.toolsForRouting);
88
88
  // Persisted env tail: refreshSessionBp3Environment rebuilds the env block
89
- // as [session, project, bp3EnvironmentContext], so the language block must
89
+ // as [session, bp3EnvironmentContext], so the language block must
90
90
  // already sit at the end of this string to stay last after a refresh.
91
91
  const environmentTailContext = [shellEnvironmentContext, languageContext].filter(Boolean).join('\n\n---\n\n');
92
92
  const composed = composeSystemPrompt({
@@ -46,16 +46,13 @@ const STAT_CACHE = new Map(); // fullPath → { ts, stat }
46
46
  const STAT_CACHE_TTL_MS = 5_000;
47
47
  const STAT_CACHE_MAX_ENTRIES = 2_000;
48
48
  const RAW_CONTENT_CACHE = new Map(); // fullPath → { ts, mtimeMs, ctimeMs, size, rawBuf }
49
- const RAW_CONTENT_INFLIGHT = new Map(); // canonical path → { generation, promise }
49
+ const RAW_CONTENT_INFLIGHT = new Map(); // canonical path → { path, promise }
50
50
  const RAW_CONTENT_CACHE_TTL_MS = 30_000;
51
51
  // 64: a parallel read batch fanning out over a component directory easily
52
52
  // tops 16 files; at 16 the batch thrashed its own cache before re-reads hit.
53
53
  // The 64MB byte cap below still bounds memory.
54
54
  const RAW_CONTENT_CACHE_MAX_ENTRIES = 64;
55
- const PATH_MUTATION_GENERATIONS = new Map(); // canonical path/root → monotonic generation
56
- const PATH_MUTATION_GENERATION_MAX_ENTRIES = 4096;
57
- let PATH_MUTATION_GLOBAL_GENERATION = 0;
58
- const READ_ONLY_STAT_INFLIGHT = new Map(); // kind + canonical path → { generation, promise }
55
+ const READ_ONLY_STAT_INFLIGHT = new Map(); // kind + canonical path → { path, promise }
59
56
  const RAW_CONTENT_CACHE_MAX_BYTES = envByteBudget(
60
57
  'MIXDOG_RAW_CONTENT_CACHE_MAX_BYTES',
61
58
  'MIXDOG_RAW_CONTENT_CACHE_MAX_MB',
@@ -74,25 +71,6 @@ function cachePathsOverlap(a, b) {
74
71
  return a.startsWith(b.endsWith(sep) ? b : `${b}${sep}`) || b.startsWith(a.endsWith(sep) ? a : `${a}${sep}`);
75
72
  }
76
73
 
77
- function bumpPathMutationGeneration(path) {
78
- const key = canonicalCachePath(path);
79
- PATH_MUTATION_GENERATIONS.set(key, (PATH_MUTATION_GENERATIONS.get(key) || 0) + 1);
80
- while (PATH_MUTATION_GENERATIONS.size > PATH_MUTATION_GENERATION_MAX_ENTRIES) {
81
- const oldest = PATH_MUTATION_GENERATIONS.keys().next().value;
82
- if (!oldest) break;
83
- PATH_MUTATION_GENERATIONS.delete(oldest);
84
- }
85
- }
86
-
87
- function getPathMutationGeneration(path) {
88
- const key = canonicalCachePath(path);
89
- let generation = PATH_MUTATION_GLOBAL_GENERATION;
90
- for (const [changedPath, value] of PATH_MUTATION_GENERATIONS) {
91
- if (cachePathsOverlap(key, changedPath)) generation += value;
92
- }
93
- return generation;
94
- }
95
-
96
74
  function cacheEntryOverlapsPaths(entry, affectedPaths) {
97
75
  const entryPaths = Array.isArray(entry?.paths) ? entry.paths : [];
98
76
  const entryScopes = Array.isArray(entry?.scopes) ? entry.scopes : [];
@@ -237,12 +215,12 @@ export async function runResultCacheInFlight(key, compute, options = {}) {
237
215
  return subscribeResultCacheInFlight(entry, subscriberSignal);
238
216
  }
239
217
 
240
- // A concurrent caller joins the read already in flight for this key instead
241
- // of issuing a second syscall; a mutation bumps the path generation, so an
242
- // entry recorded before it is never joined.
243
- async function runInFlightForGeneration(inFlight, key, generation, start) {
218
+ // Invalidation detaches only affected in-flight entries. New readers never
219
+ // join a pre-mutation operation, while its existing subscribers still finish.
220
+ // Lookups need no scan of the project's historical mutations on every read.
221
+ async function runPathInFlight(inFlight, key, path, start) {
244
222
  const existing = inFlight.get(key);
245
- if (existing?.generation === generation) return await existing.promise;
223
+ if (existing) return await existing.promise;
246
224
  const promise = Promise.resolve()
247
225
  .then(start)
248
226
  .finally(() => {
@@ -250,23 +228,21 @@ async function runInFlightForGeneration(inFlight, key, generation, start) {
250
228
  inFlight.delete(key);
251
229
  }
252
230
  });
253
- inFlight.set(key, { generation, promise });
231
+ inFlight.set(key, { path, promise });
254
232
  return await promise;
255
233
  }
256
234
 
257
235
  export async function runRawContentInFlight(fullPath, loader = fsPromises.readFile) {
258
236
  const key = canonicalCachePath(fullPath);
259
- return await runInFlightForGeneration(RAW_CONTENT_INFLIGHT, key, getPathMutationGeneration(key), () =>
260
- loader(fullPath)
261
- );
237
+ return await runPathInFlight(RAW_CONTENT_INFLIGHT, key, key, () => loader(fullPath));
262
238
  }
263
239
 
264
240
  export async function runReadOnlyStatInFlight(fullPath, loader = fsPromises.stat, kind = 'stat') {
265
241
  const canonicalPath = canonicalCachePath(fullPath);
266
- return await runInFlightForGeneration(
242
+ return await runPathInFlight(
267
243
  READ_ONLY_STAT_INFLIGHT,
268
244
  `${kind}|${canonicalPath}`,
269
- getPathMutationGeneration(canonicalPath),
245
+ canonicalPath,
270
246
  () => loader(fullPath)
271
247
  );
272
248
  }
@@ -484,8 +460,8 @@ function cacheInvalidateAll() {
484
460
  STAT_CACHE.clear();
485
461
  RAW_CONTENT_CACHE.clear();
486
462
  RAW_CONTENT_CACHE_BYTES = 0;
487
- PATH_MUTATION_GENERATIONS.clear();
488
- PATH_MUTATION_GLOBAL_GENERATION += 1;
463
+ RAW_CONTENT_INFLIGHT.clear();
464
+ READ_ONLY_STAT_INFLIGHT.clear();
489
465
  runExtraInvalidationListeners();
490
466
  }
491
467
 
@@ -495,6 +471,11 @@ function cacheInvalidatePaths(paths) {
495
471
  cacheInvalidateAll();
496
472
  return;
497
473
  }
474
+ for (const inFlight of [RAW_CONTENT_INFLIGHT, READ_ONLY_STAT_INFLIGHT]) {
475
+ for (const [key, entry] of inFlight) {
476
+ if (affectedPaths.some((affected) => cachePathsOverlap(entry.path, affected))) inFlight.delete(key);
477
+ }
478
+ }
498
479
  for (const [key, entry] of RESULT_CACHE_INFLIGHT) {
499
480
  const scopes = Array.isArray(entry.scopes) ? entry.scopes : [];
500
481
  if (
@@ -522,7 +503,6 @@ function cacheInvalidatePaths(paths) {
522
503
  }
523
504
  for (const affected of affectedPaths) {
524
505
  deleteReadRangeIndexForPath(affected);
525
- bumpPathMutationGeneration(affected);
526
506
  }
527
507
  runExtraInvalidationListeners(affectedPaths);
528
508
  }