mixdog 0.9.96 → 0.9.99

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 (193) hide show
  1. package/README.md +0 -17
  2. package/package.json +16 -8
  3. package/scripts/build-tui.mjs +6 -6
  4. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  5. package/src/defaults/skills/setup/SKILL.md +1 -1
  6. package/src/lib/keychain-cjs.cjs +1 -1
  7. package/src/lib/mixdog-debug.cjs +2 -3
  8. package/src/rules/agent/30-explorer.md +43 -32
  9. package/src/rules/lead/01-general.md +1 -2
  10. package/src/rules/lead/lead-tool.md +0 -3
  11. package/src/rules/shared/01-tool.md +11 -10
  12. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  13. package/src/runtime/agent/orchestrator/agent-trace.mjs +19 -5
  14. package/src/runtime/agent/orchestrator/mcp/client.mjs +81 -33
  15. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -3
  17. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +5 -3
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  19. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +23 -1
  20. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  21. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -6
  22. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -4
  24. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +20 -2
  25. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +4 -3
  26. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +42 -26
  27. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  28. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +44 -6
  29. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  30. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -1
  31. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  32. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +3 -6
  33. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  34. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  35. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +59 -42
  36. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -2
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -22
  38. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  39. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +2 -0
  41. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +103 -12
  42. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +4 -4
  43. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -30
  44. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +10 -30
  45. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +41 -22
  46. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +6 -2
  47. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +55 -11
  48. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  49. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +55 -18
  50. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -0
  51. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  52. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -105
  55. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +2 -1
  56. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -1
  58. package/src/runtime/attachments/pdf-extract.mjs +83 -0
  59. package/src/runtime/attachments/store.mjs +515 -0
  60. package/src/runtime/attachments/store.test.mjs +188 -0
  61. package/src/runtime/channels/lib/config.mjs +33 -33
  62. package/src/runtime/channels/lib/inbound-handler.mjs +7 -7
  63. package/src/runtime/channels/lib/interaction-handlers.mjs +4 -194
  64. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  65. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  66. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  67. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  68. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  69. package/src/runtime/channels/lib/runtime-paths.mjs +5 -34
  70. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  71. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  72. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  73. package/src/runtime/channels/lib/worker-ipc.mjs +4 -70
  74. package/src/runtime/channels/lib/worker-main.mjs +39 -58
  75. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  76. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  77. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  78. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  79. package/src/runtime/channels/tool-defs.mjs +1 -1
  80. package/src/runtime/memory/index.mjs +72 -211
  81. package/src/runtime/memory/lib/agent-ipc.mjs +9 -8
  82. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +2 -2
  83. package/src/runtime/memory/lib/http-router.mjs +1 -6
  84. package/src/runtime/memory/lib/http-wire.mjs +39 -4
  85. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  86. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  87. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  88. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  89. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  90. package/src/runtime/memory/lib/memory.mjs +1 -1
  91. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  92. package/src/runtime/search/index.mjs +2 -2
  93. package/src/runtime/shared/abort-race.mjs +70 -0
  94. package/src/runtime/shared/child-spawn-gate.mjs +101 -63
  95. package/src/runtime/shared/config.mjs +11 -24
  96. package/src/runtime/shared/json-metrics.mjs +94 -0
  97. package/src/runtime/shared/llm/index.mjs +1 -1
  98. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  99. package/src/runtime/shared/resource-admission.mjs +6 -5
  100. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  101. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  102. package/src/runtime/shared/tool-surface.mjs +91 -22
  103. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  104. package/src/runtime/shared/turn-snapshot.mjs +30 -2
  105. package/src/runtime/shared/turn-worktree-snapshot.mjs +19 -0
  106. package/src/session-runtime/channel-config-api.mjs +5 -5
  107. package/src/session-runtime/config-lifecycle.mjs +27 -27
  108. package/src/session-runtime/lifecycle-api.mjs +22 -3
  109. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  110. package/src/session-runtime/prewarm.mjs +1 -1
  111. package/src/session-runtime/provider-models.mjs +12 -2
  112. package/src/session-runtime/quick-model-rows.mjs +38 -7
  113. package/src/session-runtime/quick-search-models.mjs +15 -17
  114. package/src/session-runtime/remote-control.mjs +6 -7
  115. package/src/session-runtime/runtime-core.mjs +61 -32
  116. package/src/session-runtime/runtime-tunables.mjs +9 -6
  117. package/src/session-runtime/self-update.mjs +1 -1
  118. package/src/session-runtime/session-lifecycle.mjs +36 -15
  119. package/src/session-runtime/session-title.mjs +28 -4
  120. package/src/session-runtime/session-turn-api.mjs +55 -15
  121. package/src/session-runtime/settings-api.mjs +7 -7
  122. package/src/standalone/agent-dispatch-broker.mjs +1 -1
  123. package/src/standalone/channel-admin.mjs +42 -53
  124. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +16 -18
  125. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +73 -58
  126. package/src/standalone/channel-worker.mjs +124 -647
  127. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +190 -132
  128. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  129. package/src/standalone/{engine-daemon-client.mjs → session-client.mjs} +151 -86
  130. package/src/standalone/session-protocol.mjs +9 -4
  131. package/src/standalone/session-runtime-pool.mjs +393 -0
  132. package/src/standalone/session-runtime-record.mjs +20 -0
  133. package/src/standalone/session-runtime-worker.mjs +203 -0
  134. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +192 -157
  135. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +142 -63
  136. package/src/standalone/session-wire.mjs +55 -0
  137. package/src/tui/App.jsx +4 -4
  138. package/src/tui/app/app-format.mjs +1 -1
  139. package/src/tui/app/channel-pickers.mjs +41 -41
  140. package/src/tui/app/input-parsers.mjs +2 -2
  141. package/src/tui/app/onboarding-steps.mjs +2 -2
  142. package/src/tui/app/project-picker.mjs +8 -8
  143. package/src/tui/app/prompt-submit.mjs +15 -17
  144. package/src/tui/app/settings-picker.mjs +25 -25
  145. package/src/tui/app/use-prompt-handlers.mjs +7 -3
  146. package/src/tui/app/use-prompt-queue-history.mjs +1 -1
  147. package/src/tui/app/use-transcript-scroll.mjs +1 -1
  148. package/src/tui/components/ToolExecution.jsx +1 -1
  149. package/src/tui/components/prompt-input/restore-policy.mjs +1 -1
  150. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  151. package/src/tui/dist/index.mjs +403 -170
  152. package/src/tui/hooks/useSession.mjs +16 -0
  153. package/src/tui/index.jsx +11 -11
  154. package/src/tui/paste-attachments.mjs +13 -7
  155. package/src/tui/prompt-history-store.mjs +52 -4
  156. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  157. package/src/tui/{engine → session}/agent-job-feed.mjs +9 -5
  158. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  159. package/src/tui/{engine → session}/context-state.mjs +3 -3
  160. package/src/tui/{engine → session}/labels.mjs +2 -2
  161. package/src/tui/{engine → session}/live-share.mjs +28 -14
  162. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  163. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  164. package/src/tui/session/oauth-flows.mjs +140 -0
  165. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  166. package/src/tui/{engine → session}/queue-helpers.mjs +8 -8
  167. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  168. package/src/tui/{engine → session}/session-api-ext.mjs +42 -21
  169. package/src/tui/{engine → session}/session-api.mjs +17 -61
  170. package/src/tui/{engine → session}/session-flow.mjs +5 -3
  171. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  172. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  173. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  174. package/src/tui/{engine → session}/tool-card-results.mjs +4 -4
  175. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  176. package/src/tui/{engine → session}/tool-result-text.mjs +3 -3
  177. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  178. package/src/tui/{engine → session}/turn.mjs +29 -107
  179. package/src/tui/{engine-local-session.mjs → session-local.mjs} +72 -78
  180. package/src/tui/session.mjs +22 -0
  181. package/src/ui/statusline-segments.mjs +1 -1
  182. package/src/ui/statusline.mjs +1 -1
  183. package/vendor/ink/build/components/App.js +10 -1
  184. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  185. package/src/standalone/engine-daemon-protocol.mjs +0 -32
  186. package/src/tui/engine/turn-watchdog.mjs +0 -92
  187. package/src/tui/engine.mjs +0 -22
  188. package/src/tui/hooks/useEngine.mjs +0 -16
  189. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  190. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  191. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  192. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  193. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -1,4 +1,3 @@
1
- import { lstatSync, realpathSync, readdirSync, statSync } from 'fs';
2
1
  import * as fsPromises from 'fs/promises';
3
2
  import { readFile } from 'fs/promises';
4
3
  import { extname } from 'path';
@@ -7,7 +6,11 @@ import { buildNotFoundHint, finalizeReadFamilyEnoentTail, tryReadFamilyEnoentRed
7
6
  import { getReadSnapshot } from './read-snapshot-runtime.mjs';
8
7
  import { recordReadPathRedirect } from './snapshot-store.mjs';
9
8
  import { snapshotCoversFullFile, statMatchesSnapshot } from './snapshot-helpers.mjs';
10
- import { formatBinaryReadPreview } from './binary-file.mjs';
9
+ import {
10
+ formatBinaryReadPreviewFromBuffer,
11
+ inspectBinaryFile,
12
+ isBinaryBuffer,
13
+ } from './binary-file.mjs';
11
14
 
12
15
  function snapshotBodyWasReturnedByRead(snapshot) {
13
16
  return String(snapshot?.source || '').startsWith('read');
@@ -27,22 +30,27 @@ function withSymbolReadNote(text, args) {
27
30
  // decoding. utf8-with-BOM (EF BB BF) keeps the utf-8 decoder; its leading
28
31
  // U+FEFF is stripped for display downstream, so bomLen is reported but not
29
32
  // applied for utf8.
33
+ function detectReadEncodingFromBuffer(head) {
34
+ const n = head?.length || 0;
35
+ if (n >= 2 && head[0] === 0xff && head[1] === 0xfe) {
36
+ return { encoding: 'utf16le', bomLen: 2 };
37
+ }
38
+ if (n >= 2 && head[0] === 0xfe && head[1] === 0xff) {
39
+ return { encoding: 'utf16be', bomLen: 2 };
40
+ }
41
+ if (n >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) {
42
+ return { encoding: 'utf8', bomLen: 3 };
43
+ }
44
+ return { encoding: 'utf8', bomLen: 0 };
45
+ }
46
+
30
47
  async function detectReadEncoding(fullPath) {
31
48
  let fh;
32
49
  try {
33
50
  fh = await fsPromises.open(fullPath, 'r');
34
51
  const head = Buffer.alloc(3);
35
52
  const { bytesRead: n } = await fh.read(head, 0, 3, 0);
36
- if (n >= 2 && head[0] === 0xff && head[1] === 0xfe) {
37
- return { encoding: 'utf16le', bomLen: 2 };
38
- }
39
- if (n >= 2 && head[0] === 0xfe && head[1] === 0xff) {
40
- return { encoding: 'utf16be', bomLen: 2 };
41
- }
42
- if (n >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) {
43
- return { encoding: 'utf8', bomLen: 3 };
44
- }
45
- return { encoding: 'utf8', bomLen: 0 };
53
+ return detectReadEncodingFromBuffer(head.subarray(0, n));
46
54
  } catch {
47
55
  return { encoding: 'utf8', bomLen: 0 };
48
56
  } finally {
@@ -164,7 +172,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
164
172
  let st;
165
173
  let _statErr;
166
174
  try {
167
- st = statSync(fullPath);
175
+ st = options?._preflightStats?.get?.(fullPath) || await fsPromises.stat(fullPath);
168
176
  } catch (err) {
169
177
  // Fall through to the existing similar-file recovery path below.
170
178
  st = null;
@@ -174,7 +182,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
174
182
  if (st.isDirectory()) {
175
183
  let entries = [];
176
184
  try {
177
- entries = readdirSync(fullPath, { withFileTypes: true })
185
+ entries = (await fsPromises.readdir(fullPath, { withFileTypes: true }))
178
186
  .slice(0, 20)
179
187
  .map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`);
180
188
  } catch { /* best-effort preview */ }
@@ -191,14 +199,14 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
191
199
  return `Error: cannot read special file (FIFO / character / block device / socket): ${normalizeOutputPath(filePath)}`;
192
200
  // R1+R2: realpath the resolved path so a symlink → /dev/zero (or any
193
201
  // other blocked device, UNC, or Windows reserved name) is caught on
194
- // the REAL target, not the symlink name. lstatSync detects whether
195
- // the entry IS a symlink first so realpathSync is only called when
202
+ // the REAL target, not the symlink name. lstat detects whether
203
+ // the entry IS a symlink first so realpath is only called when
196
204
  // it would actually differ — saves a syscall on the common case.
197
205
  try {
198
- const _lst = lstatSync(fullPath);
206
+ const _lst = await fsPromises.lstat(fullPath);
199
207
  if (_lst && typeof _lst.isSymbolicLink === 'function' && _lst.isSymbolicLink()) {
200
208
  let _realTarget = null;
201
- try { _realTarget = realpathSync(fullPath); } catch { _realTarget = null; }
209
+ try { _realTarget = await fsPromises.realpath(fullPath); } catch { _realTarget = null; }
202
210
  if (_realTarget && _realTarget !== fullPath) {
203
211
  if (isBlockedDevicePath(_realTarget))
204
212
  return `Error: cannot read device file via symlink (would block or produce infinite output): ${normalizeOutputPath(filePath)} → ${normalizeOutputPath(_realTarget)}`;
@@ -211,7 +219,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
211
219
  // the target stat could differ from the link stat in
212
220
  // pathological cases (replaced under us).
213
221
  try {
214
- const _rst = statSync(_realTarget);
222
+ const _rst = await fsPromises.stat(_realTarget);
215
223
  if (typeof isSpecialFileStat === 'function' && isSpecialFileStat(_rst))
216
224
  return `Error: cannot read special file via symlink (FIFO / character / block device / socket): ${normalizeOutputPath(filePath)} → ${normalizeOutputPath(_realTarget)}`;
217
225
  } catch { /* if the target is gone, let the normal path surface ENOENT */ }
@@ -430,11 +438,42 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
430
438
  // large UTF-16LE+BOM file is recognized as text up front and routed to
431
439
  // the bounded in-memory utf16le path below, never mis-decoded as utf-8
432
440
  // or rejected by the utf-8 streaming/binary branch (Bug 1).
433
- const _readEnc = await detectReadEncoding(fullPath);
441
+ const _preferRangeStream = hasRangeArgs && !wantFull
442
+ && (options?.forceReadRangeStream === true || st.size > READ_STREAM_RANGE_MIN_BYTES);
443
+ const _preferSmartStream = !hasRangeArgs && !wantFull
444
+ && st.size >= READ_SMART_STREAM_MIN_BYTES;
445
+ const _preferBufferedRead = st.size <= READ_MAX_SIZE_BYTES
446
+ && !_preferRangeStream
447
+ && !_preferSmartStream;
448
+ let _prefetchedRawBuf = null;
449
+ let _prefetchedRawFromCache = false;
450
+ if (_preferBufferedRead) {
451
+ const _cachedRaw = _rawContentCacheGet ? _rawContentCacheGet(fullPath, st) : null;
452
+ if (_cachedRaw) {
453
+ _prefetchedRawBuf = _cachedRaw;
454
+ _prefetchedRawFromCache = true;
455
+ } else {
456
+ try { _prefetchedRawBuf = await readFile(fullPath); } catch { _prefetchedRawBuf = null; }
457
+ }
458
+ }
459
+ const _readEnc = _prefetchedRawBuf
460
+ ? detectReadEncodingFromBuffer(_prefetchedRawBuf.subarray(0, Math.min(3, _prefetchedRawBuf.length)))
461
+ : await detectReadEncoding(fullPath);
434
462
  // UTF-16 (LE or BE) reads share one constraint: the streaming/binary
435
463
  // paths decode chunks as utf-8, so a BOM-flagged UTF-16 file must route
436
464
  // to the bounded in-memory decode below regardless of byte order.
437
465
  const _isUtf16 = _readEnc.encoding === 'utf16le' || _readEnc.encoding === 'utf16be';
466
+ let _binaryInspection = null;
467
+ const _inspectBinary = async () => {
468
+ if (_binaryInspection) return _binaryInspection;
469
+ _binaryInspection = _prefetchedRawBuf
470
+ ? {
471
+ isBinary: isBinaryBuffer(_prefetchedRawBuf, st.size),
472
+ preview: _prefetchedRawBuf.subarray(0, Math.min(256, _prefetchedRawBuf.length)),
473
+ }
474
+ : await inspectBinaryFile(fullPath, st.size);
475
+ return _binaryInspection;
476
+ };
438
477
  if (st.size > READ_MAX_SIZE_BYTES) {
439
478
  // .pdf/.ipynb were already dispatched up front (MEDIA-WINS), so this
440
479
  // >10MiB branch only handles the non-media text path from here on.
@@ -450,8 +489,13 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
450
489
  if (!hasRangeArgs) {
451
490
  return `Error: file size ${st.size} bytes exceeds ${READ_MAX_SIZE_BYTES}-byte cap.`;
452
491
  }
453
- if (isBinaryFile(fullPath, st.size)) {
454
- const { text, snapshotMeta } = formatBinaryReadPreview(fullPath, normalizeOutputPath(filePath), st.size);
492
+ const _binary = await _inspectBinary();
493
+ if (_binary.isBinary) {
494
+ const { text, snapshotMeta } = formatBinaryReadPreviewFromBuffer(
495
+ _binary.preview,
496
+ normalizeOutputPath(filePath),
497
+ st.size,
498
+ );
455
499
  _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
456
500
  _cacheSet(cacheKey, text, { paths: [fullPath], readSnapshotMeta: snapshotMeta });
457
501
  return withSymbolReadNote(text, args);
@@ -510,11 +554,18 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
510
554
  // unambiguous TEXT signal, so classify it as utf16le up front and skip
511
555
  // NUL rejection. (_readEnc was detected above, before the >10MiB size
512
556
  // branch, so the large-file path can classify utf16le.)
513
- if (!_isUtf16 && isBinaryFile(fullPath, st.size)) {
514
- const { text, snapshotMeta } = formatBinaryReadPreview(fullPath, normalizeOutputPath(filePath), st.size);
515
- _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
516
- _cacheSet(cacheKey, text, { paths: [fullPath], readSnapshotMeta: snapshotMeta });
517
- return withSymbolReadNote(text, args);
557
+ if (!_isUtf16) {
558
+ const _binary = await _inspectBinary();
559
+ if (_binary.isBinary) {
560
+ const { text, snapshotMeta } = formatBinaryReadPreviewFromBuffer(
561
+ _binary.preview,
562
+ normalizeOutputPath(filePath),
563
+ st.size,
564
+ );
565
+ _recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
566
+ _cacheSet(cacheKey, text, { paths: [fullPath], readSnapshotMeta: snapshotMeta });
567
+ return withSymbolReadNote(text, args);
568
+ }
518
569
  }
519
570
  // Whole-file reads above READ_WHOLE_FILE_MAX_BYTES use stream smart-elide
520
571
  // (then READ_MAX_OUTPUT_BYTES truncation) instead of refusing. Absolute
@@ -588,8 +639,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
588
639
  }
589
640
  }
590
641
  try {
591
- const cachedRawBuf = _rawContentCacheGet ? _rawContentCacheGet(fullPath, st) : null;
592
- const rawBuf = cachedRawBuf || await readFile(fullPath);
642
+ const rawBuf = _prefetchedRawBuf || await readFile(fullPath);
593
643
  // Encoding-aware decode (fresh read AND raw-content cache hit both
594
644
  // flow through here). For a BOM-flagged UTF-16LE file, strip the
595
645
  // 2-byte FF FE BOM and decode as utf16le so it reverses the write
@@ -614,7 +664,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
614
664
  // the cache + snapshot record stale bytes.
615
665
  let _stPostRead;
616
666
  let _readStableForRawCache = true;
617
- if (cachedRawBuf) {
667
+ if (_prefetchedRawFromCache) {
618
668
  _stPostRead = st;
619
669
  } else {
620
670
  try { _stPostRead = await fsPromises.stat(fullPath); } catch { _stPostRead = st; }
@@ -1,16 +1,15 @@
1
1
  import { readFile, stat } from 'fs/promises';
2
2
  import { open } from 'fs/promises';
3
- import { createRequire } from 'module';
4
3
  import { READ_MAX_SIZE_BYTES } from './read-constants.mjs';
5
4
  import { imageBlocksFromBuffer } from './read-image-resize.mjs';
5
+ import { inspectPdfBuffer } from '../../../../attachments/pdf-extract.mjs';
6
6
 
7
- const requireCjs = createRequire(import.meta.url);
8
7
  const DEFAULT_READ_MAX_OUTPUT_BYTES = 100 * 1024;
9
8
 
10
9
  // PDFs at or under this size are emitted as an Anthropic base64 document
11
10
  // block (the model reads the rendered PDF directly): 20MB raw → ~27MB
12
11
  // base64, which stays under the 32MB request cap.
13
- // Larger PDFs fall back to pdf-parse TEXT extraction.
12
+ // Larger PDFs fall back to bounded PDF.js text extraction.
14
13
  const PDF_DOCUMENT_MAX_BYTES = 20 * 1024 * 1024;
15
14
 
16
15
  // %PDF- magic bytes (0x25 0x50 0x44 0x46 0x2D). A document block must only be
@@ -66,37 +65,18 @@ function parsePagesArg(pagesArg) {
66
65
  return { filter: { from, to } };
67
66
  }
68
67
 
69
- // pdf-parse TEXT extraction. Fallback path for PDFs over the document-block
68
+ // PDF.js text extraction. Fallback path for PDFs over the document-block
70
69
  // size cap, and the always-path when a page range is requested (a base64
71
70
  // document block can't be page-filtered, so a narrowed read keeps using text).
72
71
  async function extractPdfTextBody(fullPath, pageFilter, maxOutputBytes) {
73
- const pdfParse = requireCjs('pdf-parse');
74
72
  const buf = await readFile(fullPath);
75
- const pageTexts = [];
76
- const data = await pdfParse(buf, {
77
- pagerender: (pageData) => {
78
- // pdf-parse exposes either `pageNumber` (1-based, pdf.js) or
79
- // `_pageIndex` (0-based, internal); preferring pageIndex+1 over
80
- // pageNumber dropped/duplicated pages when pdf.js renumbered with
81
- // annotations or oddball page trees. Use pageNumber first.
82
- const pageNum = (typeof pageData.pageNumber === 'number')
83
- ? pageData.pageNumber
84
- : ((pageData._pageIndex ?? pageData.pageIndex ?? 0) + 1);
85
- if (pageFilter && (pageNum < pageFilter.from || pageNum > pageFilter.to)) return Promise.resolve('');
86
- return pageData.getTextContent().then((tc) => {
87
- const text = tc.items.map((i) => i.str).join(' ');
88
- pageTexts.push({ page: pageNum, text });
89
- return text;
90
- });
91
- },
73
+ const result = await inspectPdfBuffer(buf, {
74
+ extractText: true,
75
+ maxPages: Infinity,
76
+ maxOutputBytes,
77
+ pageRange: pageFilter,
92
78
  });
93
- let out = pageFilter
94
- ? pageTexts.map((p) => `--- Page ${p.page} ---\n${p.text}`).join('\n\n')
95
- : (data.text || '');
96
- if (out.length > maxOutputBytes) {
97
- out = out.slice(0, maxOutputBytes) + `\n\n... [PDF output truncated at ${Math.round(maxOutputBytes / 1024)} KB; use pages param to narrow]`;
98
- }
99
- return out || '(no text content extracted from PDF)';
79
+ return result.text || '(no text content extracted from PDF)';
100
80
  }
101
81
 
102
82
  export async function extractPdfText(fullPath, pagesArg, { maxOutputBytes = DEFAULT_READ_MAX_OUTPUT_BYTES, textOnly = false } = {}) {
@@ -136,7 +116,7 @@ export async function extractPdfText(fullPath, pagesArg, { maxOutputBytes = DEFA
136
116
  // TEXT fallback: >20MB PDFs, page-filtered reads, or non-magic files.
137
117
  return await extractPdfTextBody(fullPath, pages.filter, maxOutputBytes);
138
118
  } catch (err) {
139
- return `Error: pdf-parse failed — ${err instanceof Error ? err.message : String(err)}`;
119
+ return `Error: PDF extraction failed — ${err instanceof Error ? err.message : String(err)}`;
140
120
  }
141
121
  }
142
122
 
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'fs';
1
+ import { readFile } from 'fs/promises';
2
2
  import { executeSingleReadTool } from './read-single-tool.mjs';
3
3
  import { imageMimeForPath, readImageAsContent } from './read-image.mjs';
4
4
  import { readEntryCoalescedDiskWindow } from './read-batch.mjs';
@@ -6,6 +6,8 @@ import { readPathStringGuardError } from './read-open.mjs';
6
6
  import { parseReadLineNumberArg } from './read-args.mjs';
7
7
  import { assertPathsReachable } from './fs-reachability.mjs';
8
8
  import { coerceReadFamilyPathArg } from './path-utils.mjs';
9
+ import { readIoAdmission } from '../../../../shared/tool-workload-gates.mjs';
10
+ import { currentToolExecutionOwner } from '../../../../shared/tool-execution-owner.mjs';
9
11
 
10
12
  function hasLineCoordinate(path) {
11
13
  return typeof path === 'string' && /(?:#L\d+|:\d+(?:-\d+)?(?::|$))/i.test(path);
@@ -50,15 +52,14 @@ function _guardedReadError(p, helpers) {
50
52
  }
51
53
 
52
54
  // Reachability preflight for EVERY read shape (scalar / array / reads[]). MUST
53
- // run before any sync FS — including path normalization and the image
54
- // stat/read. A dead mount would otherwise freeze the event loop, defeating even
55
- // the 630s dispatch ceiling.
55
+ // run before any filesystem access. Besides bounding a dead mount, its Stats
56
+ // objects seed the real read so the common path does not immediately re-stat.
56
57
  async function _readReachPreflight(rawPath, workDir, helpers) {
57
58
  const {
58
59
  normalizeInputPath, resolveAgainstCwd,
59
60
  } = helpers;
60
61
  // A guarded path (UNC/SMB, Windows device, ADS, /dev/* block) must be
61
- // REJECTED here, not skipped: skipping would let the later sync guard/open
62
+ // REJECTED here, not skipped: skipping would let the later guard/open
62
63
  // path touch it and trigger NTLM/raw-device access or hang. Reject up front
63
64
  // with the same message the inline guards emit.
64
65
  // normalizeInputPath FIRST (FS-pure) so we stat the same path the real read
@@ -71,7 +72,7 @@ async function _readReachPreflight(rawPath, workDir, helpers) {
71
72
  const stripped = _stripLineCoordForReach(normalizeInputPath(raw));
72
73
  const full = resolveAgainstCwd(stripped, workDir);
73
74
  const guardMsg = _guardedReadError(stripped, helpers) || _guardedReadError(full, helpers);
74
- if (guardMsg) return guardMsg;
75
+ if (guardMsg) return { error: guardMsg, statsByPath: null };
75
76
  // Dedup by resolved path so a batch repeating the same file (or the
76
77
  // same union window) issues one stat probe, not one per entry —
77
78
  // bounding the preflight's FS work to the distinct target set.
@@ -79,9 +80,17 @@ async function _readReachPreflight(rawPath, workDir, helpers) {
79
80
  seenFull.add(full);
80
81
  candidates.push(full);
81
82
  }
82
- if (candidates.length === 0) return null;
83
- try { await assertPathsReachable(candidates); return null; }
84
- catch (e) { return `Error: ${e?.message || e}`; }
83
+ if (candidates.length === 0) return { error: null, statsByPath: new Map() };
84
+ try {
85
+ const stats = await assertPathsReachable(candidates);
86
+ const statsByPath = new Map();
87
+ for (let i = 0; i < candidates.length; i++) {
88
+ if (stats[i]) statsByPath.set(candidates[i], stats[i]);
89
+ }
90
+ return { error: null, statsByPath };
91
+ } catch (e) {
92
+ return { error: `Error: ${e?.message || e}`, statsByPath: null };
93
+ }
85
94
  }
86
95
 
87
96
  export async function executeReadTool(args, workDir, readStateScope, executeChildBuiltinTool, options = {}, helpers = {}) {
@@ -118,8 +127,8 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
118
127
  }
119
128
  }
120
129
  args.path = coerceReadFamilyPathArg(args.path, workDir);
121
- // Reachability preflight up front (all shapes) — before readPathStringGuardError /
122
- // image stat, all of which can touch sync FS.
130
+ // Reachability preflight up front (all shapes) — before
131
+ // readPathStringGuardError / image stat.
123
132
  // options._skipReachPreflight: set only by the batch dispatcher on its
124
133
  // child reads (below). The parent batch call already ran this exact
125
134
  // preflight over EVERY candidate path in the array (_collectReachCandidates
@@ -128,8 +137,9 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
128
137
  // child (readPathStringGuardError / image fast-path) — only the async
129
138
  // reachability stat is skipped, never the security guards.
130
139
  if (options?._skipReachPreflight !== true) {
131
- const _reErr = await _readReachPreflight(args.path, workDir, helpers);
132
- if (_reErr) return _reErr;
140
+ const _preflight = await _readReachPreflight(args.path, workDir, helpers);
141
+ if (_preflight.error) return _preflight.error;
142
+ options = { ...options, _preflightStats: _preflight.statsByPath };
133
143
  }
134
144
  // Image files (png/jpg/jpeg/gif/webp): return an MCP image block so the
135
145
  // model can actually SEE the image. native Read does this, but mixdog's
@@ -140,7 +150,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
140
150
  if (options?.mediaTextOnly !== true && typeof args.path === 'string' && imageMimeForPath(args.path)) {
141
151
  const _imgNorm = normalizeInputPath(args.path);
142
152
  // W1 H: device-file / UNC / Windows-device / ADS guards must run
143
- // BEFORE the image fast-path so statSync/readFileSync of a
153
+ // BEFORE the image fast-path so stat/readFile of a
144
154
  // UNC/device path can't bypass the checks the normal read path
145
155
  // enforces (NTLM hash leak, raw-device access, ADS).
146
156
  if (typeof isUncPath === 'function' && isUncPath(_imgNorm))
@@ -158,7 +168,11 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
158
168
  return `Error: cannot read Windows device path (reserved name or raw-device namespace): ${normalizeOutputPath(_imgFull)}`;
159
169
  if (typeof hasUnsafeWin32Component === 'function' && hasUnsafeWin32Component(_imgFull))
160
170
  return `Error: cannot read Windows path with trailing dot/space or NTFS ADS suffix (bypasses device guard): ${normalizeOutputPath(_imgFull)}`;
161
- const _imgResult = await readImageAsContent(_imgFull, normalizeOutputPath(_imgNorm));
171
+ const _imgResult = await readImageAsContent(
172
+ _imgFull,
173
+ normalizeOutputPath(_imgNorm),
174
+ options?._preflightStats?.get?.(_imgFull) || null,
175
+ );
162
176
  if (_imgResult) return _imgResult;
163
177
  }
164
178
  // Unified-read dispatch (v0.6.283+):
@@ -399,7 +413,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
399
413
  let rangeHashes = rangeHashesByPath.get(fullPath) || [];
400
414
  if (rangeHashes.length === 0 && mergedRanges.length > 0) {
401
415
  try {
402
- const rawLines = readFileSync(fullPath, 'utf-8').split('\n');
416
+ const rawLines = (await readFile(fullPath, 'utf-8')).split('\n');
403
417
  rangeHashes = mergedRanges.map((range) => {
404
418
  const startIdx = Math.max(0, range.startLine - 1);
405
419
  const endIdx = Math.min(rawLines.length, range.endLine);
@@ -608,11 +622,16 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
608
622
  // Mode routing. A window already dropped any conflicting head/tail/summary
609
623
  // glance above (so the window is served by executeSingleReadTool); what
610
624
  // remains here is a mode-only read, or count/hex which are not text windows.
611
- if (args.mode === 'head') return executeChildBuiltinTool('head', { path: args.path, n: args.n }, workDir);
612
- if (args.mode === 'tail') return executeChildBuiltinTool('tail', { path: args.path, n: args.n }, workDir);
613
- if (args.mode === 'count') return executeChildBuiltinTool('wc', { path: args.path }, workDir);
614
- if (args.mode === 'summary') return executeChildBuiltinTool('summary', { path: args.path, n: args.n, limit: args.limit }, workDir);
615
- if (args.mode === 'hex') return executeChildBuiltinTool('hex', { path: args.path, n: args.n, offset: args.offset }, workDir);
616
- return executeSingleReadTool(args, workDir, readStateScope, options, helpers);
625
+ const runReadIo = (task) => readIoAdmission.run(
626
+ options?.callerSessionId || options?.sessionId || currentToolExecutionOwner(),
627
+ task,
628
+ { signal: options?.signal || options?.abortSignal || null },
629
+ );
630
+ if (args.mode === 'head') return runReadIo(() => executeChildBuiltinTool('head', { path: args.path, n: args.n }, workDir));
631
+ if (args.mode === 'tail') return runReadIo(() => executeChildBuiltinTool('tail', { path: args.path, n: args.n }, workDir));
632
+ if (args.mode === 'count') return runReadIo(() => executeChildBuiltinTool('wc', { path: args.path }, workDir));
633
+ if (args.mode === 'summary') return runReadIo(() => executeChildBuiltinTool('summary', { path: args.path, n: args.n, limit: args.limit }, workDir));
634
+ if (args.mode === 'hex') return runReadIo(() => executeChildBuiltinTool('hex', { path: args.path, n: args.n, offset: args.offset }, workDir));
635
+ return runReadIo(() => executeSingleReadTool(args, workDir, readStateScope, options, helpers));
617
636
 
618
637
  }
@@ -423,7 +423,9 @@ function spawnRg(argsList, execOptions) {
423
423
  // spawn below, so queue-wait time is excluded from the 20s deadline. No
424
424
  // node guardian is started: rg owns its full SIGTERM→grace→force-kill +
425
425
  // force-settle teardown, so the 1:1 guardian process was pure overhead.
426
- return acquireChildSpawnSlot(gateSignal, 'search').then((releaseSlot) => new Promise((resolve, reject) => {
426
+ return acquireChildSpawnSlot(gateSignal, 'search', {
427
+ ownerKey: execOptions?.ownerKey || execOptions?.callerSessionId || execOptions?.sessionId,
428
+ }).then((releaseSlot) => new Promise((resolve, reject) => {
427
429
  // Probe after acquisition: this spawn counts toward inflight. The
428
430
  // answer is best-effort and only selects the per-process rg cap.
429
431
  const proc = spawn(rgExecutable(), _withRgThreads(argsList, activeSearchSpawnCount()), {
@@ -605,7 +607,9 @@ function spawnRgWindowedLines(argsList, execOptions, opts = {}) {
605
607
  // Same gate + thread-cap + no-guardian treatment as spawnRg; queue-wait is
606
608
  // excluded from the rg timeout (armed after spawn). releaseSlot is fired
607
609
  // once via .finally on every settle path.
608
- return acquireChildSpawnSlot(gateSignal, 'search').then((releaseSlot) => new Promise((resolve, reject) => {
610
+ return acquireChildSpawnSlot(gateSignal, 'search', {
611
+ ownerKey: execOptions?.ownerKey || execOptions?.callerSessionId || execOptions?.sessionId,
612
+ }).then((releaseSlot) => new Promise((resolve, reject) => {
609
613
  // As above, probe only after this spawn has acquired its gate slot.
610
614
  const proc = spawn(rgExecutable(), _withRgThreads(argsList, activeSearchSpawnCount()), {
611
615
  cwd: execOptions?.cwd,
@@ -4,7 +4,7 @@
4
4
  // code-graph.mjs.
5
5
  import { resolve as pathResolve, isAbsolute, relative as pathRelative, basename as pathBasename } from 'node:path';
6
6
  import { homedir as osHomedir } from 'node:os';
7
- import { readFileSync, existsSync, statSync } from 'node:fs';
7
+ import { existsSync, statSync } from 'node:fs';
8
8
  import { normalizeInputPath, toDisplayPath } from '../builtin.mjs';
9
9
  import { findFileByBasename } from '../builtin/path-diagnostics.mjs';
10
10
  import { markScopedCacheIncomplete } from '../../session/cache/scoped-cache-outcome.mjs';
@@ -40,7 +40,9 @@ import {
40
40
  _CALLEES_BRACE_LANGS,
41
41
  _formatRelated,
42
42
  _formatImpact,
43
+ _impactSourceNodes,
43
44
  _resolveReferenceLanguageNode,
45
+ _prewarmSourceTextNodes,
44
46
  _prewarmReferenceSourceText,
45
47
  _cheapReferenceSearch,
46
48
  _formatCallerReferences,
@@ -375,13 +377,19 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
375
377
  const depsAll = [...(graph.reverse.get(depRel) || [])].sort();
376
378
  if (!depsAll.length) return '(no dependents)';
377
379
  const deps = depsAll.slice(0, GRAPH_LIST_CAP);
380
+ await _prewarmSourceTextNodes(
381
+ graph,
382
+ deps.map((dep) => graph.nodes.get(dep)).filter(Boolean),
383
+ { signal },
384
+ );
378
385
  const basename = depRel.split('/').pop();
379
386
  const stem = basename.replace(/\.[^/.]+$/, '');
380
387
  const enriched = deps.map((dep) => {
381
388
  const depNode = graph.nodes.get(dep);
382
389
  if (!depNode) return dep;
383
- let text;
384
- try { text = readFileSync(depNode.abs, 'utf8'); } catch { return dep; }
390
+ const cached = graph._sourceTextCache?.get(depNode.rel);
391
+ if (!cached || cached.fingerprint !== (depNode.fingerprint || '')) return dep;
392
+ const text = cached.text;
385
393
  const linesArr = text.split(/\r?\n/);
386
394
  for (let i = 0; i < linesArr.length; i++) {
387
395
  const ln = linesArr[i];
@@ -407,6 +415,8 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
407
415
  if (mode === 'impact') {
408
416
  if (!node) return _appendSameBasenameHint(`Error: code_graph impact: file not found in graph: ${normFile || '(missing file)'}`, normFile, graph);
409
417
  const targetSymbol = String(args?.symbol || '').trim();
418
+ await _prewarmSourceTextNodes(graph, [node], { signal });
419
+ await _prewarmSourceTextNodes(graph, _impactSourceNodes(node, graph, targetSymbol), { signal });
410
420
  return _formatImpact(node, graph, cwd, targetSymbol);
411
421
  }
412
422
 
@@ -425,6 +435,7 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
425
435
  if (!_CALLEES_BRACE_LANGS.has(declHit.lang)) {
426
436
  return `(callees unsupported for ${declHit.lang})`;
427
437
  }
438
+ await _prewarmSourceTextNodes(graph, [graph.nodes.get(declHit.rel)].filter(Boolean), { signal });
428
439
  const rows = _extractCallees(graph, declHit, cwd, {
429
440
  cap: 200,
430
441
  callerSymbol: symbol,
@@ -438,9 +449,11 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
438
449
 
439
450
  if (mode === 'symbols') {
440
451
  if (!node) return _appendSameBasenameHint(`Error: code_graph symbols: file not found in graph: ${normFile || '(missing file)'}`, normFile, graph);
441
- let text = '';
442
- try { text = readFileSync(node.abs, 'utf8'); } catch { return '(no symbols)'; }
443
- return _extractSymbolsCheap(text, node.lang);
452
+ await _prewarmSourceTextNodes(graph, [node], { signal });
453
+ const cached = graph._sourceTextCache?.get(node.rel);
454
+ return cached && cached.fingerprint === (node.fingerprint || '')
455
+ ? _extractSymbolsCheap(cached.text, node.lang)
456
+ : '(no symbols)';
444
457
  }
445
458
 
446
459
  if (mode === 'find_symbol') {
@@ -449,6 +462,15 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
449
462
  const language = String(args?.language || '').trim() || null;
450
463
  const limit = Math.max(1, Math.min(50, Number(args?.limit || 20)));
451
464
  if (rel && !node) return _appendSameBasenameHint(`Error: code_graph find_symbol: file not found in graph: ${normFile || '(missing file)'}`, normFile, graph);
465
+ if (args?.body !== false) {
466
+ const hits = _findSymbolHits(graph, symbol, { language });
467
+ const primary = hits.find((hit) => hit.declarationLike) || hits[0];
468
+ await _prewarmSourceTextNodes(
469
+ graph,
470
+ [primary?.rel ? graph.nodes.get(primary.rel) : null].filter(Boolean),
471
+ { signal },
472
+ );
473
+ }
452
474
  return _findSymbolAcrossGraph(graph, symbol, cwd, { language, limit, fileRel: rel, body: args?.body !== false });
453
475
  }
454
476
 
@@ -461,6 +483,14 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
461
483
  const keyword = String(args?.symbol || '').trim();
462
484
  const keywords = symbolsList.length ? symbolsList : (keyword ? [keyword] : []);
463
485
  if (!keywords.length) throw new Error('code_graph symbol_search: "symbol" (or "symbols[]") is required.');
486
+ // Native graph symbols answer without source text. Nodes lacking native
487
+ // symbols fall back to cheap text extraction, so warm exactly that subset
488
+ // asynchronously before the synchronous formatter scans it.
489
+ await _prewarmSourceTextNodes(
490
+ graph,
491
+ [...graph.nodes.values()].filter((candidate) => !Array.isArray(candidate?.symbols) || candidate.symbols.length === 0),
492
+ { signal },
493
+ );
464
494
  if (keywords.length === 1) {
465
495
  return _searchSymbolsByKeyword(graph, keywords[0], cwd, { language, limit });
466
496
  }
@@ -487,6 +517,7 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
487
517
  }
488
518
  }
489
519
  const narrowedByCaller = Boolean(rel || scopeRelPrefix || explicitLanguage);
520
+ if (node) await _prewarmSourceTextNodes(graph, [node], { signal });
490
521
  const resolved = _resolveReferenceLanguageNode(graph, symbol, rel, cwd, explicitLanguage);
491
522
  if (rel && resolved.kind === 'file-not-found') {
492
523
  return _appendSameBasenameHint(`Error: code_graph references: file not found in graph: ${normFile || '(missing file)'}`, normFile, graph);
@@ -501,7 +532,7 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
501
532
  const userLimit = Number.isFinite(rawLimit) && rawLimit > 0
502
533
  ? Math.min(500, Math.floor(rawLimit))
503
534
  : null;
504
- const _refNodes = await _prewarmReferenceSourceText(graph, symbol, lang);
535
+ const _refNodes = await _prewarmReferenceSourceText(graph, symbol, lang, { signal });
505
536
  const refResult = _cheapReferenceSearch(graph, symbol, cwd, { language: lang, limit: userLimit, fileRel: rel, scopeRelPrefix, nodes: _refNodes });
506
537
  return narrowedByCaller ? refResult : _augmentNoHitDiagnostic(refResult, '(no references)', graph, cwd, symbol);
507
538
  }
@@ -517,6 +548,7 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
517
548
  }
518
549
  }
519
550
  const narrowedByCaller = Boolean(rel || scopeRelPrefix || explicitLanguage);
551
+ if (node) await _prewarmSourceTextNodes(graph, [node], { signal });
520
552
  const resolved = _resolveReferenceLanguageNode(graph, symbol, rel, cwd, explicitLanguage);
521
553
  if (rel && resolved.kind === 'file-not-found') {
522
554
  return _appendSameBasenameHint(`Error: code_graph callers: file not found in graph: ${normFile || '(missing file)'}`, normFile, graph);
@@ -531,7 +563,7 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
531
563
  const userLimit = Number.isFinite(rawLimit) && rawLimit > 0
532
564
  ? Math.min(500, Math.floor(rawLimit))
533
565
  : null;
534
- const _callerNodes = await _prewarmReferenceSourceText(graph, symbol, lang);
566
+ const _callerNodes = await _prewarmReferenceSourceText(graph, symbol, lang, { signal });
535
567
  const depth = Math.max(1, Math.min(5, Math.floor(Number(args?.depth) || 1)));
536
568
  if (depth > 1) {
537
569
  return _formatTransitiveCallers(graph, symbol, cwd, { language: lang, depth, page: args?.page });
@@ -576,12 +608,23 @@ async function findSymbolTool(args, cwd, signal = null, options = {}) {
576
608
  if (!symbol) {
577
609
  if (fileRel) {
578
610
  const node = graph.nodes.get(fileRel);
579
- let text = '';
580
- try { text = readFileSync(node.abs, 'utf8'); } catch { return '(no symbols)'; }
581
- return _extractSymbolsCheap(text, node.lang);
611
+ await _prewarmSourceTextNodes(graph, [node], { signal });
612
+ const cached = graph._sourceTextCache?.get(node.rel);
613
+ return cached && cached.fingerprint === (node.fingerprint || '')
614
+ ? _extractSymbolsCheap(cached.text, node.lang)
615
+ : '(no symbols)';
582
616
  }
583
617
  throw new Error('find_symbol: provide "symbol" (to locate) or "file" (to list its symbols).');
584
618
  }
619
+ if (args?.body !== false) {
620
+ const hits = _findSymbolHits(graph, symbol, { language });
621
+ const primary = hits.find((hit) => hit.declarationLike) || hits[0];
622
+ await _prewarmSourceTextNodes(
623
+ graph,
624
+ [primary?.rel ? graph.nodes.get(primary.rel) : null].filter(Boolean),
625
+ { signal },
626
+ );
627
+ }
585
628
  return _findSymbolAcrossGraph(graph, symbol, cwd, { language, limit, fileRel, body: args?.body !== false });
586
629
  }
587
630
 
@@ -632,6 +675,7 @@ export async function resolveSymbolReadSpan(cwd, { symbol, path = null, language
632
675
  }
633
676
  if (!Number.isFinite(endLine) || endLine < startLine) {
634
677
  const node = graph.nodes.get(primary.rel);
678
+ await _prewarmSourceTextNodes(graph, [node].filter(Boolean));
635
679
  const srcText = node ? _getSourceTextForNode(graph, node) : null;
636
680
  const inferred = srcText ? _inferSpanEndByIndent(srcText.split('\n'), startLine) : null;
637
681
  if (inferred) {
@@ -2,7 +2,6 @@
2
2
  // Symbol search / callers / callees / references / impact query layer over a
3
3
  // built graph. Pure over {graph,cwd,args}; owns no cache state. Extracted
4
4
  // verbatim from code-graph.mjs.
5
- import { readFileSync } from 'node:fs';
6
5
  import { _isJsLike } from './lang-predicates.mjs';
7
6
  import { _maskNonCodeText } from './text-mask.mjs';
8
7
  import {
@@ -61,6 +60,18 @@ function _collectImpactSymbols(node, graph) {
61
60
  return [...names];
62
61
  }
63
62
 
63
+ export function _impactSourceNodes(node, graph, targetSymbol = '') {
64
+ const symbols = targetSymbol ? [targetSymbol] : _collectImpactSymbols(node, graph).slice(0, 8);
65
+ const out = [];
66
+ const seen = new Set();
67
+ for (const candidate of [node, ...symbols.flatMap((symbol) => _lookupCandidateNodes(graph, symbol, node?.lang || null))]) {
68
+ if (!candidate?.rel || seen.has(candidate.rel)) continue;
69
+ seen.add(candidate.rel);
70
+ out.push(candidate);
71
+ }
72
+ return out;
73
+ }
74
+
64
75
  function _buildImpactSummary(node, graph, cwd, targetSymbol = '') {
65
76
  const imports = node.resolvedImports.map((p) => _graphRel(p, cwd));
66
77
  const dependents = [...(graph.reverse.get(node.rel) || [])].sort();