mixdog 0.9.96 → 0.9.97

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 (170) hide show
  1. package/README.md +0 -17
  2. package/package.json +8 -7
  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/lead/01-general.md +1 -2
  9. package/src/rules/lead/lead-tool.md +0 -3
  10. package/src/rules/shared/01-tool.md +11 -10
  11. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  12. package/src/runtime/agent/orchestrator/mcp/client.mjs +81 -33
  13. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  14. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  15. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -6
  16. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -4
  18. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +20 -2
  19. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +4 -3
  20. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +40 -25
  21. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  22. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  23. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  24. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  25. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  26. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +59 -42
  27. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -22
  29. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  30. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +2 -0
  32. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +4 -4
  33. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -30
  34. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +41 -22
  35. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +6 -2
  36. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +55 -11
  37. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  38. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +55 -18
  39. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -0
  40. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  41. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +67 -21
  44. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +2 -1
  45. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  46. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -1
  47. package/src/runtime/channels/lib/config.mjs +33 -33
  48. package/src/runtime/channels/lib/inbound-handler.mjs +7 -7
  49. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  50. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  51. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  52. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  53. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  54. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  55. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  56. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  57. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  58. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  59. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  60. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  61. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  62. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  63. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  64. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  65. package/src/runtime/channels/tool-defs.mjs +1 -1
  66. package/src/runtime/memory/index.mjs +72 -211
  67. package/src/runtime/memory/lib/agent-ipc.mjs +9 -8
  68. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +2 -2
  69. package/src/runtime/memory/lib/http-router.mjs +1 -6
  70. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  71. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  72. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  73. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  74. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  75. package/src/runtime/memory/lib/memory.mjs +1 -1
  76. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  77. package/src/runtime/search/index.mjs +2 -2
  78. package/src/runtime/shared/abort-race.mjs +70 -0
  79. package/src/runtime/shared/child-spawn-gate.mjs +101 -63
  80. package/src/runtime/shared/config.mjs +11 -24
  81. package/src/runtime/shared/llm/index.mjs +1 -1
  82. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  83. package/src/runtime/shared/resource-admission.mjs +6 -5
  84. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  85. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  86. package/src/runtime/shared/tool-surface.mjs +91 -22
  87. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  88. package/src/runtime/shared/turn-snapshot.mjs +17 -2
  89. package/src/session-runtime/channel-config-api.mjs +5 -5
  90. package/src/session-runtime/config-lifecycle.mjs +27 -27
  91. package/src/session-runtime/lifecycle-api.mjs +22 -3
  92. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  93. package/src/session-runtime/prewarm.mjs +1 -1
  94. package/src/session-runtime/provider-models.mjs +1 -1
  95. package/src/session-runtime/remote-control.mjs +6 -7
  96. package/src/session-runtime/runtime-core.mjs +52 -28
  97. package/src/session-runtime/self-update.mjs +1 -1
  98. package/src/session-runtime/session-lifecycle.mjs +36 -15
  99. package/src/session-runtime/session-title.mjs +1 -1
  100. package/src/session-runtime/session-turn-api.mjs +55 -15
  101. package/src/session-runtime/settings-api.mjs +7 -7
  102. package/src/standalone/agent-dispatch-broker.mjs +1 -1
  103. package/src/standalone/channel-admin.mjs +42 -53
  104. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +16 -18
  105. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +45 -46
  106. package/src/standalone/channel-worker.mjs +124 -647
  107. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +187 -132
  108. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  109. package/src/standalone/{engine-daemon-client.mjs → session-client.mjs} +151 -86
  110. package/src/standalone/session-protocol.mjs +8 -4
  111. package/src/standalone/session-runtime-pool.mjs +393 -0
  112. package/src/standalone/session-runtime-record.mjs +20 -0
  113. package/src/standalone/session-runtime-worker.mjs +203 -0
  114. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +170 -157
  115. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +139 -61
  116. package/src/standalone/session-wire.mjs +55 -0
  117. package/src/tui/App.jsx +4 -4
  118. package/src/tui/app/app-format.mjs +1 -1
  119. package/src/tui/app/channel-pickers.mjs +41 -41
  120. package/src/tui/app/input-parsers.mjs +2 -2
  121. package/src/tui/app/onboarding-steps.mjs +2 -2
  122. package/src/tui/app/project-picker.mjs +8 -8
  123. package/src/tui/app/prompt-submit.mjs +8 -8
  124. package/src/tui/app/settings-picker.mjs +25 -25
  125. package/src/tui/app/use-prompt-queue-history.mjs +1 -1
  126. package/src/tui/app/use-transcript-scroll.mjs +1 -1
  127. package/src/tui/components/ToolExecution.jsx +1 -1
  128. package/src/tui/components/prompt-input/restore-policy.mjs +1 -1
  129. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  130. package/src/tui/dist/index.mjs +154 -127
  131. package/src/tui/hooks/useSession.mjs +16 -0
  132. package/src/tui/index.jsx +11 -11
  133. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  134. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  135. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  136. package/src/tui/{engine → session}/context-state.mjs +3 -3
  137. package/src/tui/{engine → session}/labels.mjs +2 -2
  138. package/src/tui/{engine → session}/live-share.mjs +7 -7
  139. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  140. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  141. package/src/tui/session/oauth-flows.mjs +140 -0
  142. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  143. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  144. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  145. package/src/tui/{engine → session}/session-api-ext.mjs +30 -17
  146. package/src/tui/{engine → session}/session-api.mjs +7 -60
  147. package/src/tui/{engine → session}/session-flow.mjs +1 -1
  148. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  149. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  150. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  151. package/src/tui/{engine → session}/tool-card-results.mjs +4 -4
  152. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  153. package/src/tui/{engine → session}/tool-result-text.mjs +3 -3
  154. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  155. package/src/tui/{engine → session}/turn.mjs +29 -107
  156. package/src/tui/{engine-local-session.mjs → session-local.mjs} +59 -75
  157. package/src/tui/session.mjs +22 -0
  158. package/src/ui/statusline-segments.mjs +1 -1
  159. package/src/ui/statusline.mjs +1 -1
  160. package/vendor/ink/build/components/App.js +10 -1
  161. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  162. package/src/standalone/engine-daemon-protocol.mjs +0 -32
  163. package/src/tui/engine/turn-watchdog.mjs +0 -92
  164. package/src/tui/engine.mjs +0 -22
  165. package/src/tui/hooks/useEngine.mjs +0 -16
  166. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  167. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  168. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  169. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  170. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -345,6 +345,8 @@ function normalizedRow(row, heartbeatAt = 0) {
345
345
  preview: cleanText(row.preview),
346
346
  generation: typeof row.generation === 'number' ? row.generation : 0,
347
347
  implicitBashSessionId: row.implicitBashSessionId || null,
348
+ storageMtimeMs: positiveNumber(row.storageMtimeMs, 0),
349
+ storageSize: positiveNumber(row.storageSize, 0),
348
350
  detachedReason: row.detachedReason || null,
349
351
  };
350
352
  }
@@ -412,10 +414,15 @@ function sessionHeartbeatMtimes() {
412
414
  * Returns `null` when the directory itself is NOT enumerable (unreadable
413
415
  * stat / readdir): that is not "no sessions", and the caller must keep the
414
416
  * authority it already has (the index rows) instead of publishing an empty
415
- * catalog. `indexRowsById` carries those rows so a single UNREADABLE session
416
- * file also retains its last known row instead of vanishing.
417
+ * catalog. `indexRowsById` carries those rows so unchanged files can reuse the
418
+ * durable summary and a single UNREADABLE file retains its last known row
419
+ * instead of vanishing.
417
420
  */
418
- function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsById = new Map()) {
421
+ function scanSessionFiles(
422
+ heartbeatMtimes = sessionHeartbeatMtimes(),
423
+ indexRowsById = new Map(),
424
+ { indexMtimeMs = 0, forceRead = false } = {},
425
+ ) {
419
426
  const directory = join(dataDir(), 'sessions');
420
427
  const dirProbe = probePath(directory);
421
428
  if (dirProbe.state === PROBE_ABSENT) return [];
@@ -426,13 +433,37 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsB
426
433
  for (const filename of entries) {
427
434
  if (!filename.endsWith('.json')) continue;
428
435
  const storageId = filename.slice(0, -5);
429
- const read = readTextFile(join(directory, filename));
436
+ const path = join(directory, filename);
437
+ const retained = indexRowsById.get(storageId);
438
+ const probe = probePath(path);
439
+ // Provably gone: the row legitimately disappears with the file.
440
+ if (probe.state === PROBE_ABSENT) continue;
441
+ if (probe.state !== PROBE_PRESENT) {
442
+ if (retained) rows.push(retained);
443
+ continue;
444
+ }
445
+ if (!forceRead && retained) {
446
+ const exactFingerprint = retained.storageMtimeMs > 0
447
+ && retained.storageSize > 0
448
+ && retained.storageMtimeMs === probe.mtimeMs
449
+ && retained.storageSize === probe.size;
450
+ // Older v2 rows predate per-file fingerprints. Their global index
451
+ // timestamp remains a one-release compatibility fallback; every
452
+ // later save/rebuild stamps the exact (mtimeMs,size) pair.
453
+ const legacyUnchanged = retained.storageMtimeMs === 0
454
+ && indexMtimeMs > 0
455
+ && (probe.mtimeMs || 0) <= indexMtimeMs;
456
+ if (exactFingerprint || legacyUnchanged) {
457
+ rows.push(retained);
458
+ continue;
459
+ }
460
+ }
461
+ const read = readTextFile(path);
430
462
  // Provably gone: the row legitimately disappears with the file.
431
463
  if (read.state === PROBE_ABSENT) continue;
432
464
  if (read.state !== PROBE_PRESENT) {
433
465
  // Exists and could not be read (EACCES/EIO): retain the last known
434
466
  // row rather than let a transient IO error delete it from view.
435
- const retained = indexRowsById.get(storageId);
436
467
  if (retained) rows.push(retained);
437
468
  continue;
438
469
  }
@@ -440,36 +471,16 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsB
440
471
  // record or a foreign identity is not this file's session.
441
472
  const record = readTopLevelLifecycleRecord(read.text);
442
473
  if (isLifecycleUnreadable(record) || record.id !== storageId) continue;
443
- const row = rowFromSession(record.doc, heartbeatMtimes.get(record.id) || 0);
474
+ const summary = rowFromSession(record.doc, heartbeatMtimes.get(record.id) || 0);
475
+ const row = summary
476
+ ? { ...summary, storageMtimeMs: probe.mtimeMs, storageSize: probe.size }
477
+ : null;
444
478
  if (row) rows.push(row);
445
479
  }
446
480
  return leadRowsWithAgentHeartbeat(rows).sort((left, right) =>
447
481
  (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
448
482
  }
449
483
 
450
- function sessionFilesAreNewerThan(indexPath) {
451
- const directory = join(dataDir(), 'sessions');
452
- if (probePath(directory).state !== PROBE_PRESENT) return false;
453
- const indexProbe = probePath(indexPath);
454
- // Only a PROVABLY absent index makes the files newer by definition. An
455
- // index that exists but cannot be stat'ed (EACCES/EIO) is still the
456
- // authority we failed to consult — claiming "files are newer" there would
457
- // replace the whole catalog on the strength of an IO error.
458
- if (indexProbe.state === PROBE_ABSENT) return true;
459
- if (indexProbe.state !== PROBE_PRESENT) return false;
460
- const indexMtime = indexProbe.mtimeMs || 0;
461
- let entries;
462
- try { entries = readdirSync(directory); } catch { return false; }
463
- for (const filename of entries) {
464
- if (!filename.endsWith('.json')) continue;
465
- // Absent (raced unlink) and unreadable both prove nothing about
466
- // freshness: only an observed newer mtime does.
467
- const probe = probePath(join(directory, filename));
468
- if (probe.state === PROBE_PRESENT && (probe.mtimeMs || 0) > indexMtime) return true;
469
- }
470
- return false;
471
- }
472
-
473
484
  export function listStoredSessionSummaries(options = {}) {
474
485
  const heartbeatMtimes = sessionHeartbeatMtimes();
475
486
  const indexPath = join(dataDir(), 'session-summaries.json');
@@ -478,6 +489,7 @@ export function listStoredSessionSummaries(options = {}) {
478
489
  // an individual session file cannot be read.
479
490
  const indexRowsById = new Map();
480
491
  let indexRows = null;
492
+ let indexMtimeMs = 0;
481
493
  const indexRead = readTextFile(indexPath);
482
494
  if (indexRead.state === PROBE_PRESENT) {
483
495
  try {
@@ -490,25 +502,30 @@ export function listStoredSessionSummaries(options = {}) {
490
502
  indexRows = leadRowsWithAgentHeartbeat(normalizedRows)
491
503
  .sort((left, right) =>
492
504
  (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
505
+ const indexProbe = probePath(indexPath);
506
+ if (indexProbe.state === PROBE_PRESENT) indexMtimeMs = indexProbe.mtimeMs || 0;
493
507
  }
494
508
  } catch { /* malformed sidecar: the files below are the authority */ }
495
509
  }
496
510
  // An index that EXISTS but is unreadable (EACCES/EIO) is neither missing
497
511
  // nor empty: the scan below still runs, and when IT cannot enumerate
498
512
  // either, the catalog reports nothing rather than inventing an empty truth.
499
- const scan = () => scanSessionFiles(heartbeatMtimes, indexRowsById);
500
- if (options.refreshFromStorage === true) return scan() ?? indexRows ?? [];
513
+ const scan = (forceRead = false) => scanSessionFiles(
514
+ heartbeatMtimes,
515
+ indexRowsById,
516
+ { indexMtimeMs, forceRead },
517
+ );
518
+ if (options.refreshFromStorage === true) return scan(true) ?? indexRows ?? [];
501
519
  if (indexRows) {
502
- // A crashed/non-blocking sidecar writer can leave a valid but old,
503
- // non-empty index indefinitely. Detect only newer session JSONs
504
- // (heartbeat churn does not count), then recover read-only from the
505
- // authoritative files without trying to rewrite the stuck index.
506
- if (options.rebuildIfMissing !== false && sessionFilesAreNewerThan(indexPath)) {
507
- return scan() ?? indexRows;
508
- }
509
- if (indexRows.length > 0 || options.rebuildIfMissing === false) return indexRows;
520
+ if (options.rebuildIfMissing === false) return indexRows;
521
+ // Enumerate identities to detect deletions, stat known files, and parse
522
+ // only rows newer than the index. Cold-start cost therefore scales with
523
+ // changed transcripts rather than total transcript bytes.
524
+ return scan(false) ?? indexRows;
510
525
  }
511
- return options.rebuildIfMissing === false ? [] : (scan() ?? indexRows ?? []);
526
+ // Missing/malformed index is the sole cold path that must rebuild every
527
+ // summary from canonical session bytes.
528
+ return options.rebuildIfMissing === false ? [] : (scan(true) ?? indexRows ?? []);
512
529
  }
513
530
 
514
531
  /** Exact, fail-closed existence check for a durable session address.
@@ -567,7 +584,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
567
584
  restoreTranscriptItems,
568
585
  sessionContextSnapshotProjection,
569
586
  } = await import(
570
- '../../../../tui/engine/session-api-ext.mjs'
587
+ '../../../../tui/session/session-api-ext.mjs'
571
588
  );
572
589
  let preparedContextProjection = null;
573
590
  try {
@@ -724,7 +741,7 @@ export async function createStoredSessionLiveViewer(id, options = {}) {
724
741
  clearStreamingTail: () => commit({ streamingTail: null }),
725
742
  };
726
743
  const { createLiveShare, liveSharePipePath } = await import(
727
- '../../../../tui/engine/live-share.mjs'
744
+ '../../../../tui/session/live-share.mjs'
728
745
  );
729
746
  const share = createLiveShare({
730
747
  ownerSessionId: () => '',
@@ -257,8 +257,9 @@ export async function processToolBatch(ctx) {
257
257
  _executeOk = true;
258
258
  } else {
259
259
  // Fallback for providers that don't stream tool calls early:
260
- // start the whole remaining batch in parallel, including
261
- // apply_patch and shell.
260
+ // dispatch the whole remaining batch. The eager dispatcher
261
+ // keeps independent calls parallel while making shell wait
262
+ // for earlier apply_patch calls.
262
263
  if (isParallelDispatchable(call.name)) {
263
264
  startEagerRun(calls, callIndex, _duplicateCallIds);
264
265
  }
@@ -506,38 +506,24 @@ export async function executeBashTool(args, workDir, options = {}) {
506
506
  { backgroundTasksDisabled: _bgTasksDisabled },
507
507
  );
508
508
  if (longForegroundHint) return formatShellToolFailure(longForegroundHint);
509
- // Auto-background threshold. Reference-CLI parity: sync commands run to
510
- // their timeout without any default auto-promotion, so the default is 0
511
- // (disabled) for ALL callers. It is an explicit opt-in only: set
512
- // MIXDOG_SHELL_AUTO_BACKGROUND_MS (positive ms) to re-enable detaching a
513
- // still-running foreground one-shot into a tracked shell-job. When enabled,
514
- // the value stays a soft hint clamped below `timeout` so the hard ceiling
515
- // remains a separate, later bound. Never applies to run_in_background
516
- // (already detached) or persistent sessions (handled far above).
517
- // Promotion threshold (Codex `yield_time_ms` / CC startBackgrounding
518
- // analogue). Previously 0 = OFF, so every long command held the turn open
519
- // to the full foreground timeout and, at the cap, produced a wasted wait.
520
- // 30 s keeps ordinary builds/tests inline (measured shell p90 ~ 18 s) while
521
- // detaching the long tail (measured: 111 calls/day above 30 s, 30 of them
522
- // burning the entire 120 s cap) as a tracked job with partial output.
509
+ // Main-agent blocking budget. A timeout is the command's total deadline,
510
+ // not permission to hold the conversation open for that whole duration:
511
+ // after 15 s a still-running command becomes a tracked background task and
512
+ // completion is pushed to the owner. Explicit timeouts keep their remaining
513
+ // deadline after promotion. Never applies to run_in_background (already
514
+ // detached), persistent sessions, or commands barred from backgrounding.
523
515
  // MIXDOG_SHELL_AUTO_BACKGROUND_MS overrides; an explicit 0 disables.
524
516
  const _autoBgEnvRaw = process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS;
525
517
  const _autoBgEnvMs = Number(_autoBgEnvRaw);
526
518
  const DEFAULT_AUTO_BACKGROUND_MS = (_autoBgEnvRaw != null && String(_autoBgEnvRaw).trim() !== ''
527
519
  && Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs >= 0)
528
520
  ? Math.floor(_autoBgEnvMs)
529
- : 30_000;
521
+ : 15_000;
530
522
  // Gate on backgroundOnTimeout rather than just `runInBackground`: it already
531
523
  // encodes "promotion is available for this command" (not detached, background
532
524
  // tasks enabled, command shape allows it), so the soft threshold can never
533
525
  // promote something the hard timeout would refuse to.
534
- // An EXPLICIT caller timeout is a deliberate "I intend to wait this long"
535
- // (a long build/test/install), so the soft threshold never overrides it —
536
- // the call stays inline until its own deadline, where backgroundOnTimeout
537
- // still promotes instead of killing. Auto-promotion therefore only applies
538
- // to the OMITTED-timeout default, which is exactly the case where nobody
539
- // declared how long the command should be allowed to hold the turn.
540
- const autoBackgroundMs = (!backgroundOnTimeout || hasExplicitTimeout || DEFAULT_AUTO_BACKGROUND_MS <= 0)
526
+ const autoBackgroundMs = (!backgroundOnTimeout || DEFAULT_AUTO_BACKGROUND_MS <= 0)
541
527
  ? 0
542
528
  : Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
543
529
 
@@ -712,6 +698,10 @@ export async function executeBashTool(args, workDir, options = {}) {
712
698
  // tracked background job (unlimited) instead of killing it.
713
699
  backgroundOnTimeout,
714
700
  promotedTimeoutMs,
701
+ // Soft/interrupt promotion happens before the foreground cap.
702
+ // Preserve an explicit total deadline by passing it separately;
703
+ // omitted-timeout promotions retain the existing unlimited job.
704
+ backgroundDeadlineMs: hasExplicitTimeout ? totalTimeout : 0,
715
705
  // Threaded so an auto-backgrounded foreground job is stamped with
716
706
  // the dispatching terminal's claude.exe pid (per-terminal scope).
717
707
  clientHostPid: options?.clientHostPid,
@@ -1,17 +1,33 @@
1
1
  import { closeSync, openSync, readSync } from 'fs';
2
+ import { open } from 'fs/promises';
3
+
4
+ const HEAD_CAP = 64 * 1024;
5
+ const TAIL_SIZE = 4 * 1024;
6
+
7
+ function hasUtf16Bom(buf, length = buf?.length || 0) {
8
+ return length >= 2
9
+ && ((buf[0] === 0xff && buf[1] === 0xfe)
10
+ || (buf[0] === 0xfe && buf[1] === 0xff));
11
+ }
12
+
13
+ function containsNull(buf, length = buf?.length || 0) {
14
+ for (let i = 0; i < length; i++) {
15
+ if (buf[i] === 0) return true;
16
+ }
17
+ return false;
18
+ }
2
19
 
3
20
  // Binary detection: reading a PNG / ELF / zip / compressed blob as utf-8
4
21
  // pollutes the context with U+FFFD characters and wastes tokens. Sample the
5
22
  // head and tail of the file and look for a null byte — the canonical signal
6
23
  // that the file is not plain text. Head window scales with file size:
7
24
  // min(fileSize, 64KB) head + 4KB tail, so a 250KB file with a null byte at
8
- // 9KB or 249KB is caught equally. The sampling is synchronous and cheap
9
- // relative to the 256KB read budget it guards.
25
+ // 9KB or 249KB is caught equally. This synchronous form remains for legacy
26
+ // mode readers. The main read path uses inspectBinaryFile(), which performs
27
+ // the same probes asynchronously.
10
28
  // Callers inside the ≤READ_MAX_SIZE_BYTES branch should pass st.size so the
11
29
  // tail probe fires; callers above the cap pass the real size from err.size.
12
30
  export function isBinaryFile(fullPath, fileSize = 0) {
13
- const HEAD_CAP = 64 * 1024; // 64 KB max head window
14
- const TAIL_SIZE = 4 * 1024; // 4 KB tail probe
15
31
  const headBytes = fileSize > 0 ? Math.min(fileSize, HEAD_CAP) : HEAD_CAP;
16
32
  let fd = null;
17
33
  try {
@@ -25,20 +41,14 @@ export function isBinaryFile(fullPath, fileSize = 0) {
25
41
  // (detectReadEncodingFromBuffer/decodeReadBuffer support utf16le and
26
42
  // utf16be), so exempt it rather than reject the file as binary.
27
43
  // FF FE = UTF-16LE, FE FF = UTF-16BE.
28
- if (nHead >= 2
29
- && ((headBuf[0] === 0xff && headBuf[1] === 0xfe)
30
- || (headBuf[0] === 0xfe && headBuf[1] === 0xff))) return false;
31
- for (let i = 0; i < nHead; i++) {
32
- if (headBuf[i] === 0) return true;
33
- }
44
+ if (hasUtf16Bom(headBuf, nHead)) return false;
45
+ if (containsNull(headBuf, nHead)) return true;
34
46
  // Tail probe (only when file is larger than head window)
35
47
  if (fileSize > headBytes && fileSize > TAIL_SIZE) {
36
48
  const tailOffset = fileSize - TAIL_SIZE;
37
49
  const tailBuf = Buffer.allocUnsafe(TAIL_SIZE);
38
50
  const nTail = readSync(fd, tailBuf, 0, TAIL_SIZE, tailOffset);
39
- for (let i = 0; i < nTail; i++) {
40
- if (tailBuf[i] === 0) return true;
41
- }
51
+ if (containsNull(tailBuf, nTail)) return true;
42
52
  }
43
53
  return false;
44
54
  } catch {
@@ -48,8 +58,71 @@ export function isBinaryFile(fullPath, fileSize = 0) {
48
58
  }
49
59
  }
50
60
 
61
+ /** Classify an already-loaded whole file without another open/read cycle. */
62
+ export function isBinaryBuffer(buf, fileSize = buf?.length || 0) {
63
+ if (!Buffer.isBuffer(buf) || buf.length === 0) return false;
64
+ const size = fileSize > 0 ? Math.min(fileSize, buf.length) : buf.length;
65
+ const headBytes = Math.min(size, HEAD_CAP);
66
+ if (hasUtf16Bom(buf, headBytes)) return false;
67
+ if (containsNull(buf, headBytes)) return true;
68
+ if (size > headBytes && size > TAIL_SIZE) {
69
+ const tail = buf.subarray(Math.max(0, size - TAIL_SIZE), size);
70
+ if (containsNull(tail)) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Async binary probe that keeps one file handle for head, tail, and preview.
77
+ * Returns the head bytes even when the null marker is in the tail so callers
78
+ * can render a hex preview without reopening the file.
79
+ */
80
+ export async function inspectBinaryFile(fullPath, fileSize = 0, { previewBytes = 256 } = {}) {
81
+ const headBytes = fileSize > 0 ? Math.min(fileSize, HEAD_CAP) : HEAD_CAP;
82
+ let fh;
83
+ try {
84
+ fh = await open(fullPath, 'r');
85
+ const headBuf = Buffer.allocUnsafe(Math.max(0, headBytes));
86
+ const { bytesRead: nHead } = await fh.read(headBuf, 0, headBytes, 0);
87
+ const head = headBuf.subarray(0, nHead);
88
+ const preview = head.subarray(0, Math.min(previewBytes, nHead));
89
+ if (nHead === 0 || hasUtf16Bom(head, nHead)) return { isBinary: false, preview };
90
+ if (containsNull(head, nHead)) return { isBinary: true, preview };
91
+ if (fileSize > headBytes && fileSize > TAIL_SIZE) {
92
+ const tailBuf = Buffer.allocUnsafe(TAIL_SIZE);
93
+ const { bytesRead: nTail } = await fh.read(
94
+ tailBuf,
95
+ 0,
96
+ TAIL_SIZE,
97
+ fileSize - TAIL_SIZE,
98
+ );
99
+ if (containsNull(tailBuf, nTail)) return { isBinary: true, preview };
100
+ }
101
+ return { isBinary: false, preview };
102
+ } catch {
103
+ return { isBinary: false, preview: Buffer.alloc(0) };
104
+ } finally {
105
+ if (fh) { try { await fh.close(); } catch {} }
106
+ }
107
+ }
108
+
51
109
  const BINARY_PREVIEW_BYTES = 256;
52
110
 
111
+ export function formatBinaryReadPreviewFromBuffer(
112
+ buffer,
113
+ displayPath,
114
+ fileSize,
115
+ { previewBytes = BINARY_PREVIEW_BYTES } = {},
116
+ ) {
117
+ const source = Buffer.isBuffer(buffer) ? buffer : Buffer.alloc(0);
118
+ const slice = source.subarray(0, Math.min(previewBytes, source.length));
119
+ const hex = Array.from(slice).map((b) => b.toString(16).padStart(2, '0')).join(' ');
120
+ const disp = displayPath || '(binary file)';
121
+ const note = `binary, ${fileSize} byte${fileSize === 1 ? '' : 's'}`;
122
+ const text = `${disp}\n${note}\n${hex || '(empty)'}`;
123
+ return { text, snapshotMeta: { source: 'read_hex', ranges: [] } };
124
+ }
125
+
53
126
  /** Short hex preview for read when null bytes mark the file as binary. */
54
127
  export function formatBinaryReadPreview(fullPath, displayPath, fileSize, { previewBytes = BINARY_PREVIEW_BYTES } = {}) {
55
128
  const n = Math.max(0, Math.min(previewBytes, fileSize > 0 ? fileSize : previewBytes));
@@ -58,12 +131,12 @@ export function formatBinaryReadPreview(fullPath, displayPath, fileSize, { previ
58
131
  fd = openSync(fullPath, 'r');
59
132
  const buf = Buffer.alloc(n);
60
133
  const bytesRead = readSync(fd, buf, 0, n, 0);
61
- const slice = buf.subarray(0, bytesRead);
62
- const hex = Array.from(slice).map((b) => b.toString(16).padStart(2, '0')).join(' ');
63
- const disp = displayPath || fullPath;
64
- const note = `binary, ${fileSize} byte${fileSize === 1 ? '' : 's'}`;
65
- const text = `${disp}\n${note}\n${hex || '(empty)'}`;
66
- return { text, snapshotMeta: { source: 'read_hex', ranges: [] } };
134
+ return formatBinaryReadPreviewFromBuffer(
135
+ buf.subarray(0, bytesRead),
136
+ displayPath || fullPath,
137
+ fileSize,
138
+ { previewBytes },
139
+ );
67
140
  } catch {
68
141
  const disp = displayPath || fullPath;
69
142
  return {
@@ -47,6 +47,6 @@ export async function assertPathReachable(path, deadlineMs = FS_REACHABILITY_DEA
47
47
  // the wall-clock cost is one deadline, not N.
48
48
  export async function assertPathsReachable(paths, deadlineMs = FS_REACHABILITY_DEADLINE_MS) {
49
49
  const list = Array.isArray(paths) ? paths.filter((p) => typeof p === 'string' && p.length) : [];
50
- if (list.length === 0) return;
51
- await Promise.all(list.map((p) => assertPathReachable(p, deadlineMs)));
50
+ if (list.length === 0) return [];
51
+ return await Promise.all(list.map((p) => assertPathReachable(p, deadlineMs)));
52
52
  }
@@ -18,10 +18,12 @@ import {
18
18
  cacheGet,
19
19
  cacheSet,
20
20
  getCachedReadOnlyStat,
21
+ lstatPathsForMtime,
21
22
  statCacheSet,
22
23
  statPathsForMtime,
23
24
  registerCacheInvalidationListener,
24
25
  } from './cache-layers.mjs';
26
+ import { formatListSize, formatMtime } from './list-formatting.mjs';
25
27
  import {
26
28
  compileSimpleGlob,
27
29
  NOISE_DIR_NAMES,
@@ -1,4 +1,4 @@
1
- import { readFileSync, statSync } from 'fs';
1
+ import { readFile, stat } from 'fs/promises';
2
2
  import { extname } from 'path';
3
3
  import {
4
4
  API_IMAGE_MAX_BASE64_SIZE,
@@ -36,14 +36,14 @@ export function imageMimeForPath(p) {
36
36
  // sharp absent / resize failed: GRACEFUL fallback to the legacy
37
37
  // pass-through-with-cap behaviour (refuse over-cap originals with an isError
38
38
  // text result, else emit the raw base64 image block). No throw on missing sharp.
39
- export async function readImageAsContent(fullPath, displayPath) {
39
+ export async function readImageAsContent(fullPath, displayPath, preflightStat = null) {
40
40
  const mimeType = imageMimeForPath(fullPath);
41
41
  if (!mimeType) return null;
42
42
  let st;
43
- try { st = statSync(fullPath); } catch { return null; }
43
+ try { st = preflightStat || await stat(fullPath); } catch { return null; }
44
44
 
45
45
  let buf;
46
- try { buf = readFileSync(fullPath); } catch { return null; }
46
+ try { buf = await readFile(fullPath); } catch { return null; }
47
47
  if (buf.length === 0) {
48
48
  return {
49
49
  content: [{ type: 'text', text: `Error: image "${displayPath}" is empty (0 bytes).` }],
@@ -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; }