mixdog 1.0.2 → 1.0.4

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 (46) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/defaults/skills/browser-use/SKILL.md +14 -6
  4. package/src/defaults/skills/goal-management/SKILL.md +2 -2
  5. package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +10 -1
  6. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +1 -1
  7. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +40 -99
  8. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +6 -1
  9. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +2 -0
  10. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +7 -3
  11. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +18 -38
  12. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-path-fanout.mjs +59 -32
  13. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +15 -10
  14. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-request.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/tools/builtin/lib/shared-native-scan.mjs +12 -0
  16. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +14 -0
  17. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  18. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +3 -1
  19. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +17 -9
  20. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -0
  21. package/src/runtime/agent/orchestrator/tools/patch/wave.mjs +4 -0
  22. package/src/runtime/browser-bridge/input-fields.mjs +1 -1
  23. package/src/runtime/browser-bridge/tool-defs.mjs +1 -1
  24. package/src/runtime/computer-bridge/actions.mjs +0 -13
  25. package/src/runtime/media/adapters/antigravity-image.mjs +7 -3
  26. package/src/runtime/media/adapters/codex-image.mjs +12 -1
  27. package/src/runtime/media/adapters/gemini-image.mjs +17 -6
  28. package/src/runtime/media/adapters/gemini-video.mjs +19 -9
  29. package/src/runtime/media/adapters/xai-media.mjs +20 -7
  30. package/src/runtime/media/jobs.mjs +35 -3
  31. package/src/runtime/media/media-usage.mjs +126 -0
  32. package/src/runtime/media/tool.mjs +16 -4
  33. package/src/runtime/shared/llm/cost.mjs +50 -3
  34. package/src/runtime/shared/llm/model-catalog-projection.mjs +7 -2
  35. package/src/runtime/shared/llm/model-catalog.mjs +2 -1
  36. package/src/runtime/shared/llm/model-pricing-rates.mjs +21 -0
  37. package/src/runtime/shared/llm/usage-accounting.mjs +25 -5
  38. package/src/runtime/shared/llm/usage-context.mjs +6 -0
  39. package/src/runtime/shared/llm/usage-ledger-import.mjs +1 -0
  40. package/src/session-runtime/internal-tool-executor/feature-tools.mjs +9 -1
  41. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +38 -6
  42. package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +9 -2
  43. package/src/session-runtime/services/agent-tool/worker-index.mjs +6 -3
  44. package/src/session-runtime/services/hook-bus/event-runner.mjs +10 -2
  45. package/src/session-runtime/services/hook-bus/tool-gate.mjs +5 -2
  46. package/src/tui/dist/index.mjs +7 -3
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.2",
3
+ "version": "1.0.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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,
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: goal-management
3
- description: Manage durable goals, task progress, approvals, and completion.
4
- when_to_use: 'Explicit Goals, approved multi-round work (migrations, improvement passes); not single-turn tasks.'
3
+ description: Manage the task list of approved work carried across turns, its progress, approvals, and completion.
4
+ when_to_use: 'Explicit Goal (`goal` tool) requests, approved multi-turn work in progress (migrations, improvement passes); not single-turn tasks or recurring schedules.'
5
5
  dependencies:
6
6
  tools:
7
7
  - type: tool
@@ -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 };
@@ -31,7 +31,7 @@ export const GOAL_TOOL_DEFS = Object.freeze([
31
31
  name: 'goal',
32
32
  title: 'Goal',
33
33
  description: [
34
- 'Durable tasks with an idle reminder for unfinished work. Create for an explicit Goal request, or approved work spanning several rounds or turns: staged refactors and migrations, repeated improvement passes, open-ended objectives that must be finished, with or without a time budget. Single-turn tasks, planning or estimates alone, and unaccepted proposals do not qualify.',
34
+ 'Task list kept across turns, with an idle reminder for unfinished work. Use for an explicit Goal request or approved multi-turn work (staged refactors, migrations, improvement passes, open-ended objectives), optionally time-boxed. Not for single-turn tasks, plans or estimates alone, unaccepted proposals, recurring schedules, or work waiting weeks on external events.',
35
35
  'Obtain required approval, load the goal-management skill for lifecycle and completion policy, then create or reconcile the Goal before starting approved work. Mutations need the latest revision.',
36
36
  ].join(' '),
37
37
  annotations: {
@@ -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
  }
@@ -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
  }
@@ -14,11 +14,13 @@ import { isUncOrSmbPath, resolveSearchScope } from '../search-path-diagnostics.m
14
14
  import { buildGrepRgArgs } from '../search-builders.mjs';
15
15
  import { runRgWindowedLines } from '../native-search-runner.mjs';
16
16
  import { statReachable } from '../fs-reachability.mjs';
17
+ import { runSharedNativeScan } from './shared-native-scan.mjs';
17
18
  import { markScopedCacheIncomplete } from '../../../session/cache/scoped-cache-outcome.mjs';
18
19
  import { GREP_CONTEXT_MAX, hasUnsupportedRipgrepRegex } from '../arg-guard.mjs';
19
20
  import { resolveSearchWindow } from './search-input-helpers.mjs';
20
21
  import { formatGrepFanoutSections, formatGrepOutput, grepNoMatchesBody } from './grep-output.mjs';
21
22
  import { expandGrepAnchorContextOutput } from './grep-context-expander.mjs';
23
+ import { resolveGrepScope } from './grep-request.mjs';
22
24
 
23
25
  // Case-insensitive path keys on Windows, where rg echoes the operand path
24
26
  // in whatever case the caller wrote it.
@@ -30,12 +32,11 @@ function pathKey(value) {
30
32
  // The single-pattern request the combined path[] pass can answer, parsed
31
33
  // the way the canonical single-path parsing in search-grep-tool.mjs does
32
34
  // (keep in sync); null when the arguments fall outside its narrow
33
- // eligibility: single pattern, no glob/type, default flags, content-ish or
35
+ // eligibility: single pattern, no type, default flags, content-ish or
34
36
  // files_with_matches mode. MIXDOG_GREP_PATH_COMBINED=0 disables.
35
37
  function combinedPathRequest(args, defaultHeadLimit) {
36
38
  if (
37
39
  process.env.MIXDOG_GREP_PATH_COMBINED === '0' ||
38
- args.glob ||
39
40
  args.type ||
40
41
  args.multiline === true ||
41
42
  args['-o'] === true ||
@@ -81,8 +82,7 @@ function combinedPathRequest(args, defaultHeadLimit) {
81
82
  // order; null when one is a glob, unreachable, or on a share — the legacy
82
83
  // fan-out owns those diagnostics.
83
84
  async function resolvePathRoots(list, workDir) {
84
- const roots = [];
85
- for (const p of list) {
85
+ const roots = await Promise.all(list.map(async (p) => {
86
86
  const cleaned = normalizeInputPath(p);
87
87
  if (hasGlobMagic(cleaned)) return null;
88
88
  const resolved = resolveSearchScope(cleaned, workDir);
@@ -93,11 +93,21 @@ async function resolvePathRoots(list, workDir) {
93
93
  } catch {
94
94
  return null;
95
95
  }
96
- roots.push({
96
+ return {
97
97
  arg: p,
98
98
  abs: normalizeOutputPath(isAbsolute(resolved) ? await trueCasePath(resolved) : resolved),
99
99
  isDir: st.isDirectory(),
100
- });
100
+ };
101
+ }));
102
+ if (roots.some((root) => !root)) return null;
103
+ // An overlapping operand would scan a file twice and then attribute both
104
+ // copies to each containing section. Keep independent scope semantics.
105
+ for (let i = 0; i < roots.length; i++) {
106
+ const left = pathKey(roots[i].abs).replace(/\/+$/, '');
107
+ for (let j = i + 1; j < roots.length; j++) {
108
+ const right = pathKey(roots[j].abs).replace(/\/+$/, '');
109
+ if (left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)) return null;
110
+ }
101
111
  }
102
112
  return roots;
103
113
  }
@@ -190,17 +200,23 @@ async function runCombinedPathFanout({ args, list, workDir, options, callContext
190
200
  const roots = await resolvePathRoots(list, workDir);
191
201
  if (!roots) return null;
192
202
  const { headLimit, offset } = request;
193
- const rgArgs = combinedPathRgArgs(args, request, roots, workDir);
203
+ const rgArgs = await combinedPathRgArgs(args, request, roots, workDir);
204
+ if (!rgArgs) return null;
194
205
  const perWindow = headLimit === Infinity ? 300 : offset + headLimit + 4;
195
206
  const cap = Math.min(4000, Math.max(400, perWindow * roots.length));
196
207
  let streamed;
197
208
  try {
198
- streamed = await runRgWindowedLines(
199
- rgArgs,
200
- { cwd: workDir, signal: options.signal },
201
- { offset: 0, limit: cap, summaryLimit: 0 }
202
- );
209
+ const runWindowedLines =
210
+ typeof options.__runRgWindowedLines === 'function' ? options.__runRgWindowedLines : runRgWindowedLines;
211
+ streamed = await runSharedNativeScan(runWindowedLines, {
212
+ cwd: workDir,
213
+ argv: rgArgs,
214
+ window: { offset: 0, limit: cap, summaryLimit: 0 },
215
+ scopes: roots.map((root) => root.abs),
216
+ signal: options.signal,
217
+ });
203
218
  } catch {
219
+ options.signal?.throwIfAborted();
204
220
  return null;
205
221
  }
206
222
  if (!streamed.complete || streamed.partial) return null;
@@ -211,26 +227,36 @@ async function runCombinedPathFanout({ args, list, workDir, options, callContext
211
227
  }
212
228
 
213
229
  // One rg invocation over every root's files at once.
214
- function combinedPathRgArgs(args, request, roots, workDir) {
215
- return buildGrepRgArgs({
216
- patterns: [request.pattern],
217
- includeNoise: args.include_noise === true,
218
- text: args.text === true,
219
- searchPath: workDir,
220
- globPatterns: [],
221
- outputMode: request.outMode,
222
- caseInsensitive: request.caseInsensitive,
223
- showLineNumbers: true,
224
- beforeN: null,
225
- afterN: null,
226
- contextN: null,
227
- multilineMode: false,
228
- fileType: '',
229
- onlyMatching: false,
230
- pcre2: false,
231
- withFilename: true,
232
- candidateFiles: roots.map((r) => r.abs),
233
- });
230
+ async function combinedPathRgArgs(args, request, roots, workDir) {
231
+ const perRoot = await Promise.all(roots.map(async (root) => {
232
+ const scope = await resolveGrepScope({ ...args, path: root.arg }, workDir);
233
+ if (scope.result !== undefined) return null;
234
+ return buildGrepRgArgs({
235
+ patterns: [request.pattern],
236
+ includeNoise: args.include_noise === true,
237
+ text: args.text === true,
238
+ searchPath: isAbsolute(scope.searchPath) ? root.abs : scope.searchPath,
239
+ // Match buildGrepScope: an explicit file operand wins over its glob.
240
+ globPatterns: root.isDir ? scope.normalizedGlobPatterns : [],
241
+ outputMode: request.outMode,
242
+ caseInsensitive: request.caseInsensitive,
243
+ showLineNumbers: true,
244
+ beforeN: null,
245
+ afterN: null,
246
+ contextN: null,
247
+ multilineMode: false,
248
+ fileType: '',
249
+ onlyMatching: false,
250
+ pcre2: false,
251
+ withFilename: true,
252
+ }).slice(0, -1);
253
+ }));
254
+ if (perRoot.some((argv) => !argv)) return null;
255
+ const first = JSON.stringify(perRoot[0]);
256
+ // Native filters are rooted at each operand. Only identical normalized
257
+ // rules (including explicit noise-directory exceptions) may share a scan.
258
+ if (perRoot.some((argv) => JSON.stringify(argv) !== first)) return null;
259
+ return [...perRoot[0], ...roots.map((root) => root.abs)];
234
260
  }
235
261
 
236
262
  // The per-root sections in argument order; roots without a hit collapse
@@ -252,6 +278,7 @@ async function renderCombinedPathSections(request, roots, byRoot, bodyContext) {
252
278
  // recursion bottoms out after one level. Results retain input order even
253
279
  // though every path starts immediately.
254
280
  export async function runGrepPathFanout(input) {
281
+ input.options.signal?.throwIfAborted();
255
282
  const combined = await runCombinedPathFanout(input);
256
283
  if (combined !== null) return combined;
257
284
  const { args, list, workDir, executeChildBuiltinTool, readStateScope, options, callContextCharBudget } = input;