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
@@ -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,
@@ -3,10 +3,11 @@
3
3
  // readImageWithTokenBudget) so a `read` on an image returns a viewable,
4
4
  // budget-bounded image block instead of refusing oversized originals.
5
5
  //
6
- // sharp is an OPTIONAL dependency. Every entry point degrades to a `null`
7
- // return when sharp can't be loaded (not installed, native binding missing,
8
- // dlopen failure); callers fall back to the legacy pass-through-with-cap
9
- // behaviour. No code path throws on a missing sharp.
6
+ // sharp is a direct runtime dependency. Entry points still degrade to `null`
7
+ // when a platform-native binding cannot load so a damaged install reports the
8
+ // existing bounded fallback instead of crashing the whole daemon.
9
+
10
+ import { createHash } from 'node:crypto';
10
11
 
11
12
  // Anthropic inline-image input is capped near 5MB base64 (API rejects on the
12
13
  // base64 LENGTH, not raw bytes). IMAGE_TARGET_RAW_SIZE is the raw-byte target
@@ -20,6 +21,70 @@ const IMAGE_MAX_HEIGHT = 2000;
20
21
  // dimension/raw-size resize governs the common case and the token gate only
21
22
  // fires on pathologically dense images.
22
23
  const DEFAULT_IMAGE_MAX_TOKENS = Math.ceil(API_IMAGE_MAX_BASE64_SIZE * 0.125);
24
+ export const OPENAI_IMAGE_MAX_DIMENSION = 2048;
25
+ export const OPENAI_IMAGE_PATCH_SIZE = 32;
26
+ export const OPENAI_IMAGE_MAX_PATCHES = 1536;
27
+ const IMAGE_RESIZE_CACHE_MAX_BYTES = 64 * 1024 * 1024;
28
+ const imageResizeCache = new Map();
29
+ let imageResizeCacheBytes = 0;
30
+ let imageResizeCacheHits = 0;
31
+ let imageResizeCacheMisses = 0;
32
+
33
+ export function imageProfileForProvider(provider) {
34
+ const value = String(provider || '').trim().toLowerCase();
35
+ return /^(?:openai|xai|grok|deepseek|opencode-go|ollama|lmstudio)(?:-|$)/.test(value)
36
+ ? 'openai'
37
+ : 'anthropic';
38
+ }
39
+
40
+ export function openAIImagePatchCount(width, height) {
41
+ return Math.ceil(Math.max(1, Number(width) || 1) / OPENAI_IMAGE_PATCH_SIZE)
42
+ * Math.ceil(Math.max(1, Number(height) || 1) / OPENAI_IMAGE_PATCH_SIZE);
43
+ }
44
+
45
+ function resizeCacheKey(buffer, ext, maxTokens, profile) {
46
+ return createHash('sha256')
47
+ .update(String(ext || '')).update('\0')
48
+ .update(String(maxTokens || 0)).update('\0')
49
+ .update(String(profile || '')).update('\0')
50
+ .update(buffer)
51
+ .digest('hex');
52
+ }
53
+
54
+ function cloneResizeResult(result) {
55
+ return {
56
+ ...result,
57
+ ...(result?.dimensions ? { dimensions: { ...result.dimensions } } : {}),
58
+ };
59
+ }
60
+
61
+ function rememberResizeResult(key, result) {
62
+ const bytes = Buffer.byteLength(String(result?.data || ''), 'base64');
63
+ if (bytes <= 0 || bytes > IMAGE_RESIZE_CACHE_MAX_BYTES) return;
64
+ const existing = imageResizeCache.get(key);
65
+ if (existing) {
66
+ imageResizeCacheBytes -= existing.bytes;
67
+ imageResizeCache.delete(key);
68
+ }
69
+ imageResizeCache.set(key, { result: cloneResizeResult(result), bytes });
70
+ imageResizeCacheBytes += bytes;
71
+ while (imageResizeCacheBytes > IMAGE_RESIZE_CACHE_MAX_BYTES && imageResizeCache.size > 0) {
72
+ const oldest = imageResizeCache.keys().next().value;
73
+ const evicted = imageResizeCache.get(oldest);
74
+ imageResizeCache.delete(oldest);
75
+ imageResizeCacheBytes -= evicted?.bytes || 0;
76
+ }
77
+ }
78
+
79
+ export function imageResizeCacheStats() {
80
+ return {
81
+ entries: imageResizeCache.size,
82
+ bytes: imageResizeCacheBytes,
83
+ maxBytes: IMAGE_RESIZE_CACHE_MAX_BYTES,
84
+ hits: imageResizeCacheHits,
85
+ misses: imageResizeCacheMisses,
86
+ };
87
+ }
23
88
 
24
89
  // Cached dynamic import. Resolves to the sharp factory or null (absent /
25
90
  // failed). Cached so repeated reads don't re-attempt a failing import.
@@ -38,6 +103,10 @@ async function loadSharp() {
38
103
  return _sharpPromise;
39
104
  }
40
105
 
106
+ export function prewarmImageResizer() {
107
+ return loadSharp();
108
+ }
109
+
41
110
  // True when sharp resolved; used for the per-file change summary / fallback note.
42
111
  async function sharpAvailable() {
43
112
  return (await loadSharp()) !== null;
@@ -88,8 +157,21 @@ export function imageMetadataText(dims, sourcePath) {
88
157
  // Returns { data (base64), mimeType ("image/..."), dimensions } on success,
89
158
  // or null when sharp is unavailable OR any sharp op threw (caller falls back
90
159
  // to legacy pass-through-with-cap).
91
- export async function resizeImageBuffer(buffer, ext, { maxTokens = DEFAULT_IMAGE_MAX_TOKENS } = {}) {
160
+ export async function resizeImageBuffer(buffer, ext, {
161
+ maxTokens = DEFAULT_IMAGE_MAX_TOKENS,
162
+ profile = 'anthropic',
163
+ } = {}) {
92
164
  if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null;
165
+ const normalizedProfile = profile === 'openai' ? 'openai' : 'anthropic';
166
+ const cacheKey = resizeCacheKey(buffer, ext, maxTokens, normalizedProfile);
167
+ const cached = imageResizeCache.get(cacheKey);
168
+ if (cached) {
169
+ imageResizeCacheHits += 1;
170
+ imageResizeCache.delete(cacheKey);
171
+ imageResizeCache.set(cacheKey, cached);
172
+ return cloneResizeResult(cached.result);
173
+ }
174
+ imageResizeCacheMisses += 1;
93
175
  const sharp = await loadSharp();
94
176
  if (!sharp) return null;
95
177
  try {
@@ -108,13 +190,20 @@ export async function resizeImageBuffer(buffer, ext, { maxTokens = DEFAULT_IMAGE
108
190
  // Constrain dimensions while preserving aspect ratio.
109
191
  let width = originalWidth;
110
192
  let height = originalHeight;
111
- if (width > IMAGE_MAX_WIDTH) {
112
- height = Math.round((height * IMAGE_MAX_WIDTH) / width);
113
- width = IMAGE_MAX_WIDTH;
193
+ const maxWidth = normalizedProfile === 'openai' ? OPENAI_IMAGE_MAX_DIMENSION : IMAGE_MAX_WIDTH;
194
+ const maxHeight = normalizedProfile === 'openai' ? OPENAI_IMAGE_MAX_DIMENSION : IMAGE_MAX_HEIGHT;
195
+ let scale = Math.min(1, maxWidth / width, maxHeight / height);
196
+ if (normalizedProfile === 'openai') {
197
+ scale = Math.min(
198
+ scale,
199
+ Math.sqrt((OPENAI_IMAGE_MAX_PATCHES * OPENAI_IMAGE_PATCH_SIZE ** 2) / (width * height)),
200
+ );
114
201
  }
115
- if (height > IMAGE_MAX_HEIGHT) {
116
- width = Math.round((width * IMAGE_MAX_HEIGHT) / height);
117
- height = IMAGE_MAX_HEIGHT;
202
+ width = Math.max(1, Math.floor(width * scale));
203
+ height = Math.max(1, Math.floor(height * scale));
204
+ while (normalizedProfile === 'openai' && openAIImagePatchCount(width, height) > OPENAI_IMAGE_MAX_PATCHES) {
205
+ if (width >= height) width -= 1;
206
+ else height -= 1;
118
207
  }
119
208
  const needsResize = width !== originalWidth || height !== originalHeight;
120
209
  if (needsResize || originalSize > IMAGE_TARGET_RAW_SIZE) {
@@ -162,11 +251,13 @@ export async function resizeImageBuffer(buffer, ext, { maxTokens = DEFAULT_IMAGE
162
251
  }
163
252
  }
164
253
 
165
- return {
254
+ const result = {
166
255
  data: base64,
167
256
  mimeType: `image/${mediaType}`,
168
257
  dimensions: { originalWidth, originalHeight, displayWidth, displayHeight },
169
258
  };
259
+ rememberResizeResult(cacheKey, result);
260
+ return result;
170
261
  } catch {
171
262
  // sharp present but processing failed (corrupt header, unsupported
172
263
  // format, OOM). Signal fallback rather than throwing.
@@ -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).` }],