mixdog 0.9.50 → 0.9.52

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 (134) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -11,7 +11,12 @@
11
11
  // no module imports its own path. The URL below is resolved against THIS
12
12
  // module's dir (tools/code-graph/), so it walks one level up to reach the
13
13
  // worker at tools/.
14
- import { resolve as pathResolve } from 'node:path';
14
+ import {
15
+ isAbsolute,
16
+ relative as pathRelative,
17
+ resolve as pathResolve,
18
+ win32 as pathWin32,
19
+ } from 'node:path';
15
20
  import { Worker } from 'node:worker_threads';
16
21
  import { acquire as acquireChildSpawnSlot, hasSpareCapacity as childSpawnHasSpareCapacity } from '../../../../shared/child-spawn-gate.mjs';
17
22
  import {
@@ -56,6 +61,46 @@ import { _findDirProjectRoot } from './project-root.mjs';
56
61
  // spawn instead of fanning out. Entry removed on settle so the next caller
57
62
  // after a failure can retry.
58
63
  const _inflightAsyncBuilds = new Map();
64
+ const CODE_GRAPH_FILES_ARG_MAX_CHARS = 16_000;
65
+
66
+ function _usesWindowsPathSemantics(value) {
67
+ return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value);
68
+ }
69
+
70
+ function _normalizedExcludedPrefixes(cwd, excludedProjectRoots) {
71
+ const windows = _usesWindowsPathSemantics(cwd);
72
+ const relative = windows ? pathWin32.relative : pathRelative;
73
+ const resolve = windows ? pathWin32.resolve : pathResolve;
74
+ return [...new Set((excludedProjectRoots || []).map((root) => {
75
+ let rel = relative(cwd, resolve(root)).replace(/\\/g, '/').replace(/\/+$/, '');
76
+ if (windows) rel = rel.toLowerCase();
77
+ const absolute = windows ? pathWin32.isAbsolute(rel) : isAbsolute(rel);
78
+ return rel && !rel.startsWith('../') && !absolute ? rel : null;
79
+ }).filter(Boolean))].sort();
80
+ }
81
+
82
+ // Exported for the focused pre-cap exclusion regression.
83
+ export function _scopeCodeGraphManifest(manifest, cwd, {
84
+ excludedProjectRoots = [],
85
+ maxFiles = CODE_GRAPH_MAX_FILES,
86
+ } = {}) {
87
+ const prefixes = _normalizedExcludedPrefixes(cwd, excludedProjectRoots);
88
+ const windows = _usesWindowsPathSemantics(cwd);
89
+ const excluded = (rel) => {
90
+ const normalized = rel.replace(/\\/g, '/');
91
+ const comparable = windows ? normalized.toLowerCase() : normalized;
92
+ return prefixes.some((prefix) => comparable === prefix || comparable.startsWith(`${prefix}/`));
93
+ };
94
+ const scoped = (Array.isArray(manifest) ? manifest : [])
95
+ .filter((meta) => meta && typeof meta.rel === 'string' && !excluded(meta.rel));
96
+ const cap = Math.max(1, Math.floor(Number(maxFiles) || CODE_GRAPH_MAX_FILES));
97
+ return {
98
+ manifest: scoped,
99
+ indexed: scoped.length > cap ? scoped.slice(0, cap) : scoped,
100
+ truncated: scoped.length > cap,
101
+ prefixes,
102
+ };
103
+ }
59
104
 
60
105
  export function _isCompatibleDiskCodeGraphEntry(entry, maxFiles = CODE_GRAPH_MAX_FILES) {
61
106
  return Number.isFinite(entry?.maxFiles) && entry.maxFiles === maxFiles;
@@ -150,11 +195,80 @@ export function _prepareDiskCodeGraphFastPath({
150
195
  // Worker disk writes are debounced with an unref'd timer. Drain before sending
151
196
  // success so legacy migration persists every loaded cwd before this Worker can
152
197
  // exit; a drain failure is intentionally thrown to the worker's failure path.
153
- export function _postCodeGraphWorkerSuccess(graph, postMessage, drainCache = drainCodeGraphCacheStrict) {
154
- drainCache();
198
+ export function _postCodeGraphWorkerSuccess(
199
+ graph,
200
+ postMessage,
201
+ drainCache = drainCodeGraphCacheStrict,
202
+ { cache = true } = {},
203
+ ) {
204
+ if (cache) drainCache();
155
205
  postMessage({ ok: true, signature: graph.signature, graph });
156
206
  }
157
207
 
208
+ // Structured keys avoid both separator collisions inside roots/prefixes and
209
+ // collisions between scoped and ordinary builds.
210
+ export function _codeGraphInflightKey(graphCwd, {
211
+ scoped = false,
212
+ maxFiles = CODE_GRAPH_MAX_FILES,
213
+ prefixes = [],
214
+ } = {}) {
215
+ return scoped
216
+ ? JSON.stringify(['scope', graphCwd, maxFiles, prefixes])
217
+ : JSON.stringify(['root', graphCwd]);
218
+ }
219
+
220
+ // Keep the existing binary protocol while bounding Windows command-line size.
221
+ // Each invocation still resolves against the whole tree. Duplicate lightweight
222
+ // reused records are merged so relationship fields survive every chunk.
223
+ export async function _runGraphFilesChunked(
224
+ absRoot,
225
+ rels,
226
+ reusedMetas,
227
+ {
228
+ maxArgChars = CODE_GRAPH_FILES_ARG_MAX_CHARS,
229
+ runGraphFiles = _runGraphFiles,
230
+ } = {},
231
+ ) {
232
+ const budget = Math.max(1, Math.floor(Number(maxArgChars) || CODE_GRAPH_FILES_ARG_MAX_CHARS));
233
+ const chunks = [];
234
+ let chunk = [];
235
+ let chars = 0;
236
+ for (const rel of Array.isArray(rels) ? rels : []) {
237
+ const cost = String(rel).length + 3; // separator plus conservative quoting margin
238
+ if (cost > budget) throw new Error(`code-graph relative path exceeds --files argument budget: ${rel}`);
239
+ if (chunk.length && chars + cost > budget) {
240
+ chunks.push(chunk);
241
+ chunk = [];
242
+ chars = 0;
243
+ }
244
+ chunk.push(rel);
245
+ chars += cost;
246
+ }
247
+ if (chunk.length) chunks.push(chunk);
248
+
249
+ const merged = new Map();
250
+ for (const relChunk of chunks) {
251
+ const records = await runGraphFiles(absRoot, relChunk, reusedMetas);
252
+ for (const rec of Array.isArray(records) ? records : []) {
253
+ if (!rec || typeof rec.rel !== 'string') continue;
254
+ const previous = merged.get(rec.rel);
255
+ if (!previous) {
256
+ merged.set(rec.rel, rec);
257
+ continue;
258
+ }
259
+ const next = { ...previous, ...rec };
260
+ for (const field of ['resolvedImports', 'importedBy']) {
261
+ next[field] = [...new Set([
262
+ ...(Array.isArray(previous[field]) ? previous[field] : []),
263
+ ...(Array.isArray(rec[field]) ? rec[field] : []),
264
+ ])];
265
+ }
266
+ merged.set(rec.rel, next);
267
+ }
268
+ }
269
+ return [...merged.values()];
270
+ }
271
+
158
272
  // Exported for the focused worker-protocol regression test.
159
273
  export function _codeGraphWorkerFailure(message) {
160
274
  const error = typeof message?.error === 'string' ? message.error.trim() : '';
@@ -195,15 +309,23 @@ export function prewarmCodeGraphIfProject(cwd) {
195
309
  return true;
196
310
  }
197
311
 
198
- export async function buildCodeGraphAsync(cwd, signal = null, { bestEffort = false } = {}) {
312
+ export async function buildCodeGraphAsync(cwd, signal = null, {
313
+ bestEffort = false,
314
+ excludedProjectRoots = [],
315
+ maxFiles = CODE_GRAPH_MAX_FILES,
316
+ } = {}) {
199
317
  if (signal?.aborted) throw new Error('aborted');
200
318
  const graphCwd = _canonicalGraphCwd(cwd);
319
+ const prefixes = _normalizedExcludedPrefixes(graphCwd, excludedProjectRoots);
320
+ const scoped = prefixes.length > 0 || maxFiles !== CODE_GRAPH_MAX_FILES;
321
+ const scopeOptions = scoped ? { excludedProjectRoots, maxFiles, cache: false } : null;
322
+ const inflightKey = _codeGraphInflightKey(graphCwd, { scoped, maxFiles, prefixes });
201
323
  const cached = _codeGraphCache.get(graphCwd);
202
- if (cached?.graph && Date.now() - cached.ts < CODE_GRAPH_TTL_MS) {
324
+ if (!scoped && cached?.graph && Date.now() - cached.ts < CODE_GRAPH_TTL_MS) {
203
325
  _touchCodeGraphCache(graphCwd);
204
326
  return cached.graph;
205
327
  }
206
- const existing = _inflightAsyncBuilds.get(graphCwd);
328
+ const existing = _inflightAsyncBuilds.get(inflightKey);
207
329
  if (existing) {
208
330
  if (!signal) return existing;
209
331
  let onAbort = null;
@@ -230,6 +352,12 @@ export async function buildCodeGraphAsync(cwd, signal = null, { bestEffort = fal
230
352
  // Loading the compact disk manifest/one candidate entry is synchronous but
231
353
  // bounded I/O. Do not run a manifest at all when no disk candidate exists:
232
354
  // cold/dirty misses remain entirely on the existing Worker path.
355
+ if (scoped) {
356
+ return _spawnCodeGraphWorker(
357
+ cwd, graphCwd, _genAtStart, signal, null, null, null,
358
+ { buildOptions: scopeOptions, cacheResult: false },
359
+ );
360
+ }
233
361
  return _prepareDiskCodeGraphFastPath({
234
362
  graphCwd,
235
363
  runFastPath: (diskProbe) => _runDiskCodeGraphFastPath({
@@ -251,11 +379,11 @@ export async function buildCodeGraphAsync(cwd, signal = null, { bestEffort = fal
251
379
  }),
252
380
  });
253
381
  })();
254
- _inflightAsyncBuilds.set(graphCwd, promise);
382
+ _inflightAsyncBuilds.set(inflightKey, promise);
255
383
  try {
256
384
  return await promise;
257
385
  } finally {
258
- _inflightAsyncBuilds.delete(graphCwd);
386
+ _inflightAsyncBuilds.delete(inflightKey);
259
387
  }
260
388
  }
261
389
 
@@ -272,6 +400,8 @@ export function _spawnCodeGraphWorker(
272
400
  getGeneration = _getCodeGraphGen,
273
401
  setMemoryCache = _setCodeGraphCache,
274
402
  setDiskCache = _setDiskCodeGraphEntry,
403
+ buildOptions = null,
404
+ cacheResult = true,
275
405
  } = {},
276
406
  ) {
277
407
  let _worker = null;
@@ -299,7 +429,7 @@ export function _spawnCodeGraphWorker(
299
429
  const workerUrl = new URL('../code-graph-prewarm-worker.mjs', import.meta.url);
300
430
  try {
301
431
  _worker = createWorker(workerUrl, {
302
- workerData: { cwd, manifest, signature },
432
+ workerData: { cwd, manifest, signature, buildOptions },
303
433
  execArgv: [],
304
434
  });
305
435
  } catch (e) {
@@ -323,7 +453,7 @@ export function _spawnCodeGraphWorker(
323
453
  try {
324
454
  if (msg && msg.ok && msg.graph && typeof msg.signature === 'string') {
325
455
  const genStillCurrent = getGeneration(graphCwd) === genAtStart;
326
- if (genStillCurrent) {
456
+ if (genStillCurrent && cacheResult) {
327
457
  setMemoryCache(graphCwd, { ts: Date.now(), signature: msg.signature, graph: msg.graph });
328
458
  setDiskCache(graphCwd, msg.graph);
329
459
  }
@@ -344,27 +474,34 @@ export function _spawnCodeGraphWorker(
344
474
  * buildCodeGraphAsync (worker-thread isolated) or the code_graph / find_symbol
345
475
  * tools, never this synchronous form on the main event loop.
346
476
  */
347
- export async function _buildCodeGraph(cwd, { manifest: suppliedManifest = null, signature: suppliedSignature = null } = {}) {
477
+ export async function _buildCodeGraph(cwd, {
478
+ manifest: suppliedManifest = null,
479
+ signature: suppliedSignature = null,
480
+ excludedProjectRoots = [],
481
+ maxFiles = CODE_GRAPH_MAX_FILES,
482
+ cache = true,
483
+ } = {}) {
348
484
  const now = Date.now();
349
485
  let _tp = performance.now();
350
486
  const _trace = (label) => { if (process.env.MIXDOG_GRAPH_TRACE) { const n = performance.now(); process.stderr.write(`[cg-trace] ${label}=${(n - _tp).toFixed(0)}ms\n`); _tp = n; } };
351
487
  const graphCwd = _canonicalGraphCwd(cwd);
352
488
  const absRoot = graphCwd;
353
489
  const _genAtStart = _getCodeGraphGen(graphCwd);
354
- const cached = _codeGraphCache.get(graphCwd);
490
+ const cached = cache ? _codeGraphCache.get(graphCwd) : null;
355
491
  let previousGraph = cached?.graph || null;
356
- _consumeCodeGraphDirtyPaths(graphCwd);
492
+ if (cache) _consumeCodeGraphDirtyPaths(graphCwd);
357
493
 
358
494
  // 1. Change-detect via Rust --manifest (fp/rel/size only, no parse). A
359
495
  // main-thread disk-cache validation may hand its just-computed manifest to
360
496
  // this Worker after a miss, avoiding a duplicate native process.
361
- const manifest = Array.isArray(suppliedManifest) ? suppliedManifest : await _runGraphManifest(absRoot);
497
+ const unscopedManifest = Array.isArray(suppliedManifest) ? suppliedManifest : await _runGraphManifest(absRoot);
498
+ const scoped = _scopeCodeGraphManifest(unscopedManifest, absRoot, { excludedProjectRoots, maxFiles });
499
+ const manifest = scoped.manifest;
362
500
  const signature = typeof suppliedSignature === 'string'
363
501
  ? suppliedSignature
364
502
  : _computeGraphSignature(manifest);
365
503
  if (!Array.isArray(suppliedManifest)) _trace('manifest+sig');
366
- const truncated = manifest.length > CODE_GRAPH_MAX_FILES;
367
- const indexed = truncated ? manifest.slice(0, CODE_GRAPH_MAX_FILES) : manifest;
504
+ const { truncated, indexed } = scoped;
368
505
 
369
506
  // 2. Memory cache hit.
370
507
  if (cached && cached.signature === signature && now - cached.ts < CODE_GRAPH_TTL_MS) {
@@ -373,8 +510,8 @@ export async function _buildCodeGraph(cwd, { manifest: suppliedManifest = null,
373
510
  }
374
511
 
375
512
  // 3. Disk cache hit.
376
- ensureDiskCodeGraphLoaded(now);
377
- const diskEntry = getDiskCodeGraphEntry(graphCwd);
513
+ if (cache) ensureDiskCodeGraphLoaded(now);
514
+ const diskEntry = cache ? getDiskCodeGraphEntry(graphCwd) : null;
378
515
  if (_isCompatibleDiskCodeGraphEntry(diskEntry) && diskEntry.signature === signature) {
379
516
  const graph = _deserializeGraph(graphCwd, diskEntry);
380
517
  if (graph) {
@@ -406,7 +543,7 @@ export async function _buildCodeGraph(cwd, { manifest: suppliedManifest = null,
406
543
  if (freshRels.length === 0) {
407
544
  fileInfos = reusable;
408
545
  } else if (reusable.length > 0 && freshRels.length <= 256) {
409
- const recs = await _runGraphFiles(absRoot, freshRels, reusable);
546
+ const recs = await _runGraphFilesChunked(absRoot, freshRels, reusable);
410
547
  const reusedByRel = new Map(reusable.map((info) => [info.rel, info]));
411
548
  const freshSet = new Set(freshRels);
412
549
  fileInfos = [...reusable];
@@ -425,11 +562,29 @@ export async function _buildCodeGraph(cwd, { manifest: suppliedManifest = null,
425
562
  }
426
563
  }
427
564
  }
565
+ } else if (scoped.prefixes.length) {
566
+ const recs = await _runGraphFilesChunked(absRoot, freshRels, reusable);
567
+ fileInfos = recs.map((rec) => _fileInfoFromRustRecord(rec, absRoot));
428
568
  } else {
429
569
  let recs = await _runGraphWalk(absRoot);
430
- if (recs.length > CODE_GRAPH_MAX_FILES) recs = recs.slice(0, CODE_GRAPH_MAX_FILES);
570
+ if (recs.length > maxFiles) recs = recs.slice(0, maxFiles);
431
571
  fileInfos = recs.map((rec) => _fileInfoFromRustRecord(rec, absRoot));
432
572
  }
573
+ const allowedRels = new Set(indexed.map((meta) => meta.rel));
574
+ for (const info of fileInfos) {
575
+ info.resolvedImports = (info.resolvedImports || []).filter((rel) => allowedRels.has(rel));
576
+ info.importedBy = [];
577
+ }
578
+ const importedBy = new Map(fileInfos.map((info) => [info.rel, []]));
579
+ for (const info of fileInfos) {
580
+ for (const targetRel of info.resolvedImports) {
581
+ const sources = importedBy.get(targetRel);
582
+ if (sources) sources.push(info.rel);
583
+ }
584
+ }
585
+ for (const info of fileInfos) {
586
+ info.importedBy = [...new Set(importedBy.get(info.rel) || [])];
587
+ }
433
588
  _trace('walk+parse');
434
589
  const nodes = new Map();
435
590
  const reverse = new Map();
@@ -470,7 +625,7 @@ export async function _buildCodeGraph(cwd, { manifest: suppliedManifest = null,
470
625
  }
471
626
  }
472
627
  graph._symbolTokenIndexDirty = true;
473
- if (_getCodeGraphGen(graphCwd) === _genAtStart) {
628
+ if (cache && _getCodeGraphGen(graphCwd) === _genAtStart) {
474
629
  _setCodeGraphCache(graphCwd, { ts: now, signature, graph });
475
630
  _setDiskCodeGraphEntry(graphCwd, graph);
476
631
  _trace('cache+disk');
@@ -437,6 +437,20 @@ export function ensureDiskCodeGraphLoaded(now = Date.now()) {
437
437
  _loadDiskCodeGraphCache(now);
438
438
  }
439
439
 
440
+ // Read-only inventory used by filesystem-root federation. This deliberately
441
+ // does not initialize, migrate, prune, or load graph payloads: manifest keys
442
+ // are sufficient to identify roots the user has already indexed.
443
+ export function listCachedCodeGraphRoots() {
444
+ try {
445
+ const file = join(_codeGraphDiskDir(), 'manifest.json');
446
+ if (!existsSync(file)) return [];
447
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
448
+ return parsed && typeof parsed === 'object' ? Object.keys(parsed) : [];
449
+ } catch {
450
+ return [];
451
+ }
452
+ }
453
+
440
454
  export function _setDiskCodeGraphEntry(cwd, graph) {
441
455
  _loadDiskCodeGraphCache();
442
456
  // Stamp the cache entry with the persistence timestamp (not the build
@@ -19,6 +19,12 @@ import {
19
19
  _stripEmptyArgs,
20
20
  } from './project-root.mjs';
21
21
  import { buildCodeGraphAsync, prewarmCodeGraph, prewarmCodeGraphSymbols } from './build.mjs';
22
+ import {
23
+ _isFilesystemRootPath,
24
+ collectTrustedCodeGraphRoots,
25
+ formatFederatedProjectLabel,
26
+ owningTrustedCodeGraphRoot,
27
+ } from './trusted-roots.mjs';
22
28
  import {
23
29
  _findSymbolHits,
24
30
  _findSymbolAcrossGraph,
@@ -51,6 +57,10 @@ function _collectGraphSymbolList(args) {
51
57
 
52
58
  const CODE_GRAPH_FILE_BATCH_CAP = 20;
53
59
  const _AGGREGATE_FILE_WILDCARD_RE = /[*?[\]{}]/;
60
+ const ROOT_FEDERATED_MODES = new Set([
61
+ 'overview', 'symbol', 'find_symbol', 'symbol_search', 'search',
62
+ 'references', 'callers', 'callees', 'symbols', 'prewarm',
63
+ ]);
54
64
 
55
65
  // Absorb: file/files arriving as a JSON-stringified array
56
66
  // (file:"[\"a.mjs\",\"b.mjs\"]") — parse to a real array so the graph lookup
@@ -181,7 +191,9 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
181
191
  return `prewarm scheduled: cwd=${cwd} symbols=${symbols.length}${symbols.length ? ` (${symbols.slice(0, 5).join(',')}${symbols.length > 5 ? `,+${symbols.length - 5}` : ''})` : ''}`;
182
192
  }
183
193
 
184
- const graph = await buildCodeGraphAsync(cwd, signal);
194
+ const graph = await buildCodeGraphAsync(cwd, signal, {
195
+ excludedProjectRoots: options?.excludedProjectRoots,
196
+ });
185
197
  if (!graph || graph.nodes.size === 0) {
186
198
  throw new Error(`code_graph: cwd '${cwd}' is not an indexed/known project root or contains zero eligible files`);
187
199
  }
@@ -476,7 +488,9 @@ async function findSymbolTool(args, cwd, signal = null, options = {}) {
476
488
  else prewarmCodeGraph(cwd);
477
489
  return `prewarm scheduled: cwd=${cwd} symbols=${symbols.length}${symbols.length ? ` (${symbols.slice(0, 5).join(',')}${symbols.length > 5 ? `,+${symbols.length - 5}` : ''})` : ''}`;
478
490
  }
479
- const graph = await buildCodeGraphAsync(cwd, signal);
491
+ const graph = await buildCodeGraphAsync(cwd, signal, {
492
+ excludedProjectRoots: options?.excludedProjectRoots,
493
+ });
480
494
  if (!graph) throw new Error(`find_symbol: cwd '${cwd}' is not an indexed/known project root or contains zero eligible files`);
481
495
  if (options?.scopedCacheOutcome && graph.truncated) {
482
496
  markScopedCacheIncomplete(options.scopedCacheOutcome);
@@ -594,6 +608,98 @@ export async function executeCodeGraphTool(name, args, cwd, signal = null, optio
594
608
  const hasAggregateFileArgs = _hasAggregateFileArgs(args);
595
609
  let effectiveCwd = baseCwd;
596
610
  const baseProjectRoot = _findDirProjectRoot(baseCwd);
611
+ const filesystemRootCwd = !baseProjectRoot && _isFilesystemRootPath(baseCwd);
612
+ if (filesystemRootCwd) {
613
+ const trustedRoots = collectTrustedCodeGraphRoots(baseCwd);
614
+ const files = _collectGraphFileList(args, { cap: false });
615
+ const rawMode = String(args?.mode || '').trim();
616
+ const canFederate = files.length > 0 || ROOT_FEDERATED_MODES.has(rawMode);
617
+ if (files.length) {
618
+ if (files.some((file) => _AGGREGATE_FILE_WILDCARD_RE.test(file))) {
619
+ return `Error: ${name}: wildcard-shaped file anchors are not allowed at a filesystem root`;
620
+ }
621
+ if (files.length > CODE_GRAPH_FILE_BATCH_CAP) {
622
+ return `Error: ${name}: file list exceeds cap of ${CODE_GRAPH_FILE_BATCH_CAP}`;
623
+ }
624
+ const routed = files.map((file) => {
625
+ const abs = isAbsolute(file) ? pathResolve(file) : pathResolve(baseCwd, file);
626
+ return {
627
+ file,
628
+ abs,
629
+ exists: existsSync(abs),
630
+ root: owningTrustedCodeGraphRoot(abs, trustedRoots),
631
+ };
632
+ });
633
+ const missing = routed.find((row) => !row.exists);
634
+ if (missing) return `Error: ${name}: file not found: ${missing.file}`;
635
+ const untrusted = routed.find((row) => !row.root);
636
+ if (untrusted) {
637
+ return `Error: ${name}: file anchor is not owned by a trusted project: ${untrusted.file}`;
638
+ }
639
+ if (!canFederate) {
640
+ return `Error: ${name}: mode '${rawMode}' cannot be routed from a filesystem root`;
641
+ }
642
+ const projectArgs = { ...args };
643
+ delete projectArgs.cwd;
644
+ const sections = [];
645
+ for (const { file, abs, root } of routed) {
646
+ const excludedProjectRoots = trustedRoots.filter((candidate) => candidate !== root
647
+ && owningTrustedCodeGraphRoot(candidate, [root]) === root);
648
+ let body;
649
+ try {
650
+ body = await executeCodeGraphTool(
651
+ name,
652
+ { ...projectArgs, file: abs, files: undefined },
653
+ root,
654
+ signal,
655
+ { ...options, excludedProjectRoots },
656
+ );
657
+ } catch (err) {
658
+ body = `Error: ${err?.message || String(err)}`;
659
+ }
660
+ sections.push(`# ${rawMode} ${file}\n# project ${formatFederatedProjectLabel(root)}\n${body}`);
661
+ }
662
+ return sections.join('\n\n');
663
+ }
664
+ if (trustedRoots.length && canFederate) {
665
+ const projectArgs = { ...args };
666
+ delete projectArgs.cwd;
667
+ const runOne = async (root, nextArgs) => executeCodeGraphTool(
668
+ name,
669
+ nextArgs,
670
+ root,
671
+ signal,
672
+ {
673
+ ...options,
674
+ excludedProjectRoots: trustedRoots.filter((candidate) => candidate !== root
675
+ && owningTrustedCodeGraphRoot(candidate, [root]) === root),
676
+ },
677
+ );
678
+ let federationWork;
679
+ federationWork = (async () => {
680
+ const sections = [];
681
+ for (const root of trustedRoots) {
682
+ let body;
683
+ try { body = await runOne(root, projectArgs); }
684
+ catch (err) { body = `Error: ${err?.message || String(err)}`; }
685
+ sections.push(`# project ${formatFederatedProjectLabel(root)}\n${body}`);
686
+ }
687
+ return sections.join('\n\n');
688
+ })();
689
+ if (federationWork) {
690
+ if (!signal) return federationWork;
691
+ let onAbort = null;
692
+ const abortP = new Promise((_, reject) => {
693
+ if (signal.aborted) { reject(new Error('aborted')); return; }
694
+ onAbort = () => reject(new Error('aborted'));
695
+ signal.addEventListener('abort', onAbort, { once: true });
696
+ });
697
+ return Promise.race([federationWork, abortP]).finally(() => {
698
+ if (onAbort) signal.removeEventListener('abort', onAbort);
699
+ });
700
+ }
701
+ }
702
+ }
597
703
  if (hasAggregateFileArgs && !baseProjectRoot) {
598
704
  const aggregateRoot = _resolveAggregateFileProjectRoot(args, baseCwd);
599
705
  if (!aggregateRoot) {
@@ -0,0 +1,93 @@
1
+ import { statSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
3
+ import { listProjects } from '../../../../../standalone/projects.mjs';
4
+ import {
5
+ explicitSessionCwd,
6
+ readLastSessionCwd,
7
+ } from '../../../../shared/user-cwd.mjs';
8
+ import { listCachedCodeGraphRoots } from './disk-cache.mjs';
9
+ import { _findDirProjectRoot } from './project-root.mjs';
10
+
11
+ function isDirectory(path) {
12
+ try { return statSync(path).isDirectory(); } catch { return false; }
13
+ }
14
+
15
+ export function _isFilesystemRootPath(value) {
16
+ const raw = String(value || '').trim();
17
+ if (!raw) return false;
18
+ // A bare drive designator is drive-relative on Windows. Guard it before
19
+ // resolve(), which may expand C: to that drive's active directory (even C:\).
20
+ if (/^[a-zA-Z]:$/.test(raw)) return false;
21
+ // Keep this platform-neutral so Windows drive-root semantics can also be
22
+ // regression-tested on Unix hosts.
23
+ if (/^[a-zA-Z]:[\\/]$/.test(raw)) return true;
24
+ try {
25
+ const abs = resolve(raw);
26
+ return dirname(abs) === abs;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ function pathKey(path) {
33
+ const abs = resolve(path);
34
+ return process.platform === 'win32' ? abs.toLowerCase() : abs;
35
+ }
36
+
37
+ export function _pathIsWithin(root, candidate) {
38
+ try {
39
+ const rel = relative(resolve(root), resolve(candidate));
40
+ return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel));
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Return live, non-root graph targets from explicit registrations, current /
48
+ * recent session selection, and cache manifests. Dependencies are injectable
49
+ * to keep trust-source and Windows/Unix semantics tests hermetic.
50
+ */
51
+ export function collectTrustedCodeGraphRoots(filesystemRoot, {
52
+ registered = () => listProjects().map((entry) => entry.path),
53
+ selected = () => [
54
+ explicitSessionCwd(),
55
+ readLastSessionCwd(),
56
+ ],
57
+ cached = listCachedCodeGraphRoots,
58
+ directory = isDirectory,
59
+ projectRoot = _findDirProjectRoot,
60
+ } = {}) {
61
+ const root = resolve(filesystemRoot);
62
+ const rows = [
63
+ ...registered().map((path) => ({ path, detect: false })),
64
+ ...selected().map((path) => ({ path, detect: true })),
65
+ ...cached().map((path) => ({ path, detect: false })),
66
+ ];
67
+ const found = new Map();
68
+ for (const row of rows) {
69
+ if (!row.path) continue;
70
+ let candidate;
71
+ try { candidate = resolve(row.path); } catch { continue; }
72
+ if (!directory(candidate) || _isFilesystemRootPath(candidate) || !_pathIsWithin(root, candidate)) continue;
73
+ // A selected directory inside a sentinel project belongs to the nearest
74
+ // project graph. Explicit registrations and cache keys remain exact roots
75
+ // so registered non-sentinel projects are retained.
76
+ const detected = row.detect ? projectRoot(candidate) : null;
77
+ if (detected) candidate = resolve(detected);
78
+ if (_isFilesystemRootPath(candidate) || !directory(candidate) || !_pathIsWithin(root, candidate)) continue;
79
+ const key = pathKey(candidate);
80
+ if (!found.has(key)) found.set(key, candidate);
81
+ }
82
+ return [...found.values()].sort((a, b) => a.localeCompare(b));
83
+ }
84
+
85
+ export function owningTrustedCodeGraphRoot(file, roots) {
86
+ const matches = (roots || []).filter((root) => _pathIsWithin(root, file));
87
+ matches.sort((a, b) => resolve(b).length - resolve(a).length);
88
+ return matches[0] || null;
89
+ }
90
+
91
+ export function formatFederatedProjectLabel(root) {
92
+ return `${basename(root) || root} [${root}]`;
93
+ }
@@ -27,12 +27,21 @@ try {
27
27
  if (!cwd) {
28
28
  parentPort.postMessage({ ok: false });
29
29
  } else {
30
- const graph = await _buildCodeGraph(cwd, {
30
+ const buildOptions = {
31
31
  manifest: Array.isArray(workerData.manifest) ? workerData.manifest : null,
32
32
  signature: typeof workerData.signature === 'string' ? workerData.signature : null,
33
- });
33
+ ...(workerData.buildOptions && typeof workerData.buildOptions === 'object'
34
+ ? workerData.buildOptions
35
+ : {}),
36
+ };
37
+ const graph = await _buildCodeGraph(cwd, buildOptions);
34
38
  if (graph && typeof graph.signature === 'string') {
35
- _postCodeGraphWorkerSuccess(graph, (message) => parentPort.postMessage(message));
39
+ _postCodeGraphWorkerSuccess(
40
+ graph,
41
+ (message) => parentPort.postMessage(message),
42
+ undefined,
43
+ { cache: buildOptions.cache },
44
+ );
36
45
  } else {
37
46
  parentPort.postMessage({ ok: false });
38
47
  }