mixdog 1.2.0 → 1.2.1

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 (201) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/src/defaults/shipped-history.json +4 -0
  4. package/src/defaults/skills/pptx/SKILL.md +6 -6
  5. package/src/defaults/skills/pptx/references/review.md +6 -4
  6. package/src/defaults/skills/pptx/scripts/source-extract.mjs +1 -1
  7. package/src/defaults/skills/setup/references/actions.md +13 -3
  8. package/src/defaults/skills/visualize/SKILL.md +12 -9
  9. package/src/rules/shared/60-verification.md +3 -1
  10. package/src/rules/shared/70-delivery.md +4 -2
  11. package/src/runtime/agent/orchestrator/cache-mtime.mjs +26 -5
  12. package/src/runtime/agent/orchestrator/config.mjs +10 -4
  13. package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
  14. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +11 -5
  15. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +74 -21
  16. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +86 -8
  17. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +5 -1
  18. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +65 -7
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +128 -44
  20. package/src/runtime/agent/orchestrator/mcp/lazy-output-validator.mjs +25 -0
  21. package/src/runtime/agent/orchestrator/mcp/plugin-placeholders.mjs +54 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +108 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +19 -11
  25. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +9 -0
  26. package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +337 -0
  27. package/src/runtime/agent/orchestrator/providers/command-code-oauth-tokens.mjs +159 -0
  28. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +444 -0
  29. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +7 -4
  31. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +9 -2
  32. package/src/runtime/agent/orchestrator/providers/gemini.mjs +41 -28
  33. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +7 -1
  34. package/src/runtime/agent/orchestrator/providers/model-proxy-bridge.mjs +1080 -0
  35. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +15 -0
  36. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +35 -0
  37. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -0
  38. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +6 -5
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +6 -2
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +11 -0
  42. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +2 -1
  43. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +9 -3
  44. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +4 -4
  45. package/src/runtime/agent/orchestrator/providers/registry.mjs +35 -9
  46. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +3 -0
  47. package/src/runtime/agent/orchestrator/runtime-core/extension-scopes.mjs +15 -55
  48. package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +15 -2
  49. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +267 -84
  50. package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +4 -1
  51. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +21 -0
  52. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +54 -3
  53. package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +284 -0
  54. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +268 -0
  55. package/src/runtime/agent/orchestrator/session/catalog-db/sqlite-gate.mjs +27 -0
  56. package/src/runtime/agent/orchestrator/session/catalog-db/worker.mjs +628 -0
  57. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +6 -2
  58. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +184 -0
  59. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +136 -0
  60. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +128 -0
  61. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +127 -0
  62. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +101 -0
  63. package/src/runtime/agent/orchestrator/session/explicit-skills.mjs +83 -5
  64. package/src/runtime/agent/orchestrator/session/imported-message.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +2 -1
  66. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -1
  67. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +3 -2
  68. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +6 -4
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +4 -3
  70. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +3 -0
  71. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +12 -3
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +3 -1
  73. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +20 -12
  74. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +9 -2
  75. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +4 -0
  76. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +17 -4
  77. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -1
  78. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -3
  79. package/src/runtime/agent/orchestrator/session/store/listing.mjs +6 -2
  80. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +6 -0
  81. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +817 -0
  82. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +19 -12
  84. package/src/runtime/agent/orchestrator/session/store-summary-reader-catalog-fixture.mjs +158 -0
  85. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +183 -374
  86. package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +3 -1
  87. package/src/runtime/agent/orchestrator/session/tool-batch/pre-dispatch.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +21 -2
  89. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +38 -17
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +17 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -0
  92. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +2 -2
  93. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +6 -6
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +173 -22
  95. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +8 -4
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +5 -4
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +15 -2
  98. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +13 -10
  99. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +1 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +17 -12
  101. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +2 -2
  102. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +4 -4
  104. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/route.mjs +2 -2
  105. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  106. package/src/runtime/agent/orchestrator/tools/patch/read-redirects.mjs +34 -30
  107. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +7 -7
  108. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +1 -1
  109. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -0
  110. package/src/runtime/attachments/store.d.mts +3 -0
  111. package/src/runtime/channels/lib/owned-runtime/owned-start.mjs +1 -1
  112. package/src/runtime/channels/lib/webhook/session-dispatch.mjs +16 -4
  113. package/src/runtime/channels/lib/worker-main.mjs +17 -14
  114. package/src/runtime/computer-bridge/linux-readiness.mjs +0 -2
  115. package/src/runtime/effort-judge/judge-client.mjs +56 -18
  116. package/src/runtime/memory/lib/memory.mjs +1 -1
  117. package/src/runtime/memory/lib/session-ingest.mjs +2 -2
  118. package/src/runtime/shared/atomic-file.mjs +25 -4
  119. package/src/runtime/shared/extension-scopes.mjs +5 -3
  120. package/src/runtime/shared/file-lock.mjs +37 -11
  121. package/src/runtime/shared/llm/anthropic-effort.mjs +14 -0
  122. package/src/runtime/shared/llm/model-catalog.mjs +0 -12
  123. package/src/runtime/shared/llm/provider-catalog-registry.mjs +6 -0
  124. package/src/runtime/shared/plugin-manifest.mjs +18 -9
  125. package/src/runtime/shared/pristine-execution-contract.json +9 -2
  126. package/src/runtime/shared/pristine-execution.mjs +28 -4
  127. package/src/runtime/shared/provider-accounts.mjs +124 -20
  128. package/src/runtime/shared/provider-api-key.mjs +12 -0
  129. package/src/runtime/shared/shell-display.mjs +9 -3
  130. package/src/runtime/shared/skill-mentions.d.mts +10 -0
  131. package/src/runtime/shared/skill-mentions.mjs +133 -0
  132. package/src/runtime/shared/skill-script-runtime.mjs +264 -0
  133. package/src/runtime/shared/tool-primitives.mjs +35 -2
  134. package/src/runtime/shared/turn-worktree-snapshot.mjs +25 -5
  135. package/src/runtime/shared/worker-requests.mjs +15 -2
  136. package/src/session-runtime/automation-agents.mjs +8 -2
  137. package/src/session-runtime/boot/apis.mjs +55 -0
  138. package/src/session-runtime/boot/config.mjs +5 -2
  139. package/src/session-runtime/boot/facade.mjs +1 -0
  140. package/src/session-runtime/boot/infrastructure.mjs +29 -18
  141. package/src/session-runtime/conversation-import/discovery.mjs +698 -0
  142. package/src/session-runtime/conversation-import/service.mjs +369 -0
  143. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +10 -5
  144. package/src/session-runtime/cwd-plugins/plugin-status.mjs +47 -42
  145. package/src/session-runtime/mcp-glue/connect.mjs +57 -10
  146. package/src/session-runtime/mcp-glue/plugin-servers.mjs +499 -0
  147. package/src/session-runtime/mcp-glue/server-catalog.mjs +115 -30
  148. package/src/session-runtime/mcp-glue.mjs +22 -2
  149. package/src/session-runtime/new-session-config.mjs +3 -2
  150. package/src/session-runtime/notification-bus.mjs +9 -2
  151. package/src/session-runtime/provider-auth/accounts.mjs +33 -15
  152. package/src/session-runtime/provider-models/web-search-catalog.mjs +5 -1
  153. package/src/session-runtime/provider-readiness.mjs +16 -10
  154. package/src/session-runtime/resource-extension-sync.mjs +20 -5
  155. package/src/session-runtime/resource-mcp-api.mjs +101 -77
  156. package/src/session-runtime/resource-plugins-api.mjs +29 -41
  157. package/src/session-runtime/resource-scope-decorate.mjs +6 -8
  158. package/src/session-runtime/resource-skills-api.mjs +23 -2
  159. package/src/session-runtime/schedule-session-run.mjs +1 -1
  160. package/src/session-runtime/services/channel-session-router.mjs +4 -1
  161. package/src/session-runtime/services/hook-bus/command-handler.mjs +4 -1
  162. package/src/session-runtime/services/hook-bus/handlers.mjs +7 -3
  163. package/src/session-runtime/services/provider-admin.mjs +64 -18
  164. package/src/session-runtime/services/usage-dashboard-model.mjs +3 -0
  165. package/src/session-runtime/setup-tool/executor.mjs +12 -1
  166. package/src/session-runtime/setup-tool/extended-actions.mjs +1 -0
  167. package/src/session-runtime/setup-tool/settings-contract.mjs +1 -0
  168. package/src/session-runtime/setup-tool/tool-defs.mjs +7 -1
  169. package/src/session-runtime/skills-api.mjs +7 -3
  170. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +1 -0
  171. package/src/session-runtime/webhook-session-run.mjs +16 -4
  172. package/src/skill-script-entry.mjs +36 -0
  173. package/src/standalone/daemon-stored-session-views.mjs +11 -3
  174. package/src/standalone/daemon.mjs +41 -4
  175. package/src/standalone/session-protocol.mjs +5 -0
  176. package/src/standalone/session-service/session-calls/session-action.mjs +6 -1
  177. package/src/tui/App.jsx +9 -1
  178. package/src/tui/app/app-view/floating-panel.jsx +9 -1
  179. package/src/tui/app/app-view.jsx +6 -0
  180. package/src/tui/app/extension-pickers/plugins-pickers.mjs +35 -16
  181. package/src/tui/app/model-options.mjs +2 -0
  182. package/src/tui/app/skill-mention-palette.mjs +88 -0
  183. package/src/tui/app/slash-palette.mjs +23 -7
  184. package/src/tui/app/transcript-row-estimate.mjs +11 -3
  185. package/src/tui/app/use-prompt-draft-flow.mjs +7 -2
  186. package/src/tui/app/use-prompt-state.mjs +5 -0
  187. package/src/tui/components/Message.jsx +22 -1
  188. package/src/tui/components/PromptInput.jsx +21 -3
  189. package/src/tui/components/SlashCommandPalette.jsx +12 -3
  190. package/src/tui/components/TranscriptItem.jsx +9 -1
  191. package/src/tui/components/prompt-input/key-handler.mjs +21 -8
  192. package/src/tui/components/user-message-skills.mjs +38 -0
  193. package/src/tui/dist/index.mjs +507 -171
  194. package/src/tui/session/queue-helpers.mjs +7 -2
  195. package/src/tui/session/session-api/extensions.mjs +4 -2
  196. package/src/tui/session/session-api/settings.mjs +7 -0
  197. package/src/tui/session/session-flow/drain.mjs +14 -4
  198. package/src/tui/session/tui-steering-persist.mjs +25 -9
  199. package/src/tui/session/turn.mjs +17 -0
  200. package/src/runtime/effort-judge/previous-human-request.mjs +0 -162
  201. package/src/runtime/effort-judge/verified-context-input.mjs +0 -10
@@ -16,6 +16,7 @@
16
16
  import { isActualUserInstructionMessage, isInjectedSkillBodyMessage, isSummaryMessage } from './compact/messages.mjs';
17
17
  import { ACTIVE_TURN_CONTINUATION_ANCHOR, ACTIVE_TURN_CONTINUATION_SOURCE } from './compact/continuation.mjs';
18
18
  import { parseTaskNotification } from '../../../shared/task-notification-envelope.mjs';
19
+ import { isImportedMessage } from './imported-message.mjs';
19
20
 
20
21
  export const SYNTHETIC_USER_ENVELOPE_TAG = 'mixdog-runtime';
21
22
 
@@ -62,7 +63,8 @@ function isAlreadyEnveloped(text) {
62
63
 
63
64
  // null => a real user instruction (or nothing to wrap); otherwise the kind.
64
65
  export function classifySyntheticUserMessage(message) {
65
- if (message?.role !== 'user') return null;
66
+ // Imported prose stays the user's own (unwrapped) voice.
67
+ if (message?.role !== 'user' || isImportedMessage(message)) return null;
66
68
  const text = messageText(message);
67
69
  if (!text.trim()) return null;
68
70
  if (isAlreadyEnveloped(text)) return null;
@@ -92,7 +92,7 @@ function crossTurnStub(batch, call, ctSig) {
92
92
  // (which spans ONE assistant turn): when the model re-issues an IDENTICAL
93
93
  // normalized call that has already failed repeatFailLimit times in a row
94
94
  // across iterations, stop re-executing — the result will not change, and
95
- // each retry burns a full LLM round-trip until the hard iteration cap. The
95
+ // each retry burns a full LLM round-trip with no iteration cap to end it. The
96
96
  // call stays unresolved; the guard only skips re-execution.
97
97
  function repeatFailureSkip(batch, call, sigs) {
98
98
  const { sessionRef, repeatFailLimit } = batch;
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { contextMeasurementStats } from '../../../../shared/context-measurement.mjs';
8
8
  import { toolResultText } from './tool-result-text.mjs';
9
- import { restoredUserText } from './user-text.mjs';
9
+ import { restoredUserImageRefs, restoredUserText } from './user-text.mjs';
10
10
  import {
11
11
  completionCardFromExecution,
12
12
  parseModelVisibleCompletionWrapper,
@@ -38,6 +38,7 @@ import {
38
38
  } from '../../../../shared/tool-execution-contract.mjs';
39
39
  import { toolResultTerminalStatus } from '../../../../shared/tool-status.mjs';
40
40
  import { transcriptRouteMetadataFields } from '../../../../shared/transcript-metadata.mjs';
41
+ import { isImportedMessage } from '../imported-message.mjs';
41
42
 
42
43
  function restoredTranscriptMetadata(message) {
43
44
  const value = message?.meta?.transcript;
@@ -62,6 +63,10 @@ function restoredTranscriptMetadata(message) {
62
63
  ...(typeof value.sender === 'string' && value.sender ? { sender: value.sender } : {}),
63
64
  ...(typeof value.device === 'string' && value.device ? { device: value.device } : {}),
64
65
  ...(typeof value.checkpointId === 'string' && value.checkpointId ? { checkpointId: value.checkpointId } : {}),
66
+ // Skills this user turn explicitly loaded (header selection or `$name`).
67
+ ...(Array.isArray(value.appliedSkills) && value.appliedSkills.length
68
+ ? { appliedSkills: value.appliedSkills.filter((name) => typeof name === 'string' && name) }
69
+ : {}),
65
70
  ...(completion ? { completion } : {}),
66
71
  };
67
72
  }
@@ -322,6 +327,11 @@ function restoredUserTranscriptItems(message, nextId) {
322
327
  // skill bodies (meta:'skill'), hook/system reminders, and tag-wrapped context
323
328
  // blocks must not restore as user bubbles in any client.
324
329
  if (message?.meta === 'skill' || message?.meta === 'hook') return [];
330
+ // Imported prose renders verbatim: never a control row, card or display rewrite.
331
+ if (isImportedMessage(message)) {
332
+ const text = (typeof message.content === 'string' ? message.content : toolResultText(message.content)).trim();
333
+ return text ? [{ kind: 'user', id: nextId(), text, ...restoredTranscriptMetadata(message) }] : [];
334
+ }
325
335
  const text = restoredUserText(message?.content).trim();
326
336
  // Persisted async-completion wrappers ("Async shell task ... finished. Result:
327
337
  // > ...") are the only durable record of a background completion — the live
@@ -353,7 +363,16 @@ function restoredUserTranscriptItems(message, nextId) {
353
363
  }
354
364
  const synthetic = parseSyntheticAgentMessage(text);
355
365
  if (!synthetic) {
356
- return [{ kind: 'user', id: nextId(), text, ...restoredTranscriptMetadata(message) }];
366
+ const imageRefs = restoredUserImageRefs(message?.content);
367
+ return [
368
+ {
369
+ kind: 'user',
370
+ id: nextId(),
371
+ text,
372
+ ...restoredTranscriptMetadata(message),
373
+ ...(imageRefs.length ? { imageRefs } : {}),
374
+ },
375
+ ];
357
376
  }
358
377
  return [restoredNotificationToolItem(synthetic, text, message, nextId, /^(failed|error|killed|cancelled)$/i)];
359
378
  }
@@ -21,9 +21,7 @@ const FEEDBACK_BLOCK = /<visualize_feedback>\n([\s\S]*?)\n?<\/visualize_feedback
21
21
  // on the blob itself: the reference's claimed sizeBytes is not trusted.
22
22
  const FEEDBACK_MAX_BYTES = 128 * 1024;
23
23
  const QUOTED = '("(?:[^"\\\\]|\\\\.)*")';
24
- const CHOICE_LINE = new RegExp(
25
- `· group: (?:${QUOTED}|\\(none\\)) → choice: ${QUOTED}(?: \\(${QUOTED}\\))?$`
26
- );
24
+ const CHOICE_LINE = new RegExp(`· group: (?:${QUOTED}|\\(none\\)) → choice: ${QUOTED}(?: \\(${QUOTED}\\))?$`);
27
25
 
28
26
  function unquote(value) {
29
27
  if (!value) return '';
@@ -53,7 +51,7 @@ function feedbackDisplay(body) {
53
51
  continue;
54
52
  }
55
53
  if (tweaks) {
56
- const change = /^ (.+?) \([^)]*\): (.*) \(was (.*)\)$/.exec(line);
54
+ const change = /^ {4}(.+?) \([^)]*\): (.*) \(was (.*)\)$/.exec(line);
57
55
  if (change) tweaks.push(`${change[1]}: ${change[2]} (was ${change[3]})`);
58
56
  else if (line.startsWith(' - tab: ')) {
59
57
  tweaks = [];
@@ -118,23 +116,46 @@ function referencedFeedbackText(part) {
118
116
  }
119
117
  }
120
118
 
121
- export function restoredUserText(content) {
122
- if (!Array.isArray(content)) {
123
- return withVisualizeFeedbackDisplay(typeof content === 'string' ? content : toolResultText(content));
124
- }
119
+ /** The display lines of a stored prompt's parts, and for each image part the
120
+ * line that draws it: the composer's metadata line right before it, else a
121
+ * bare line of its own (a bare line never stands for the next image). */
122
+ function restoredUserParts(content) {
125
123
  const texts = [];
126
- let previous = '';
124
+ const images = [];
125
+ let pendingMetadata = '';
127
126
  for (const part of content) {
128
- let text;
129
127
  if (part?.type === 'image' || part?.type === 'input_image') {
130
- text = IMAGE_METADATA_LINE.test(previous) ? '' : `[Image: ${part.mimeType || part.mediaType || 'image'}]`;
131
- } else if (isAttachmentReference(part)) {
132
- text = referencedFeedbackText(part);
133
- } else {
134
- text = toolResultText([part]);
128
+ let line = pendingMetadata;
129
+ if (!line) {
130
+ line = `[Image: ${part.mimeType || part.mediaType || 'image'}]`;
131
+ texts.push(line);
132
+ }
133
+ pendingMetadata = '';
134
+ images.push({ part, line });
135
+ continue;
135
136
  }
136
- previous = text;
137
+ const text = isAttachmentReference(part) ? referencedFeedbackText(part) : toolResultText([part]);
137
138
  if (text) texts.push(text);
139
+ pendingMetadata = IMAGE_METADATA_LINE.test(text) ? text.trimEnd().split(/\r?\n/).pop().trim() : '';
138
140
  }
139
- return withVisualizeFeedbackDisplay(texts.join('\n'));
141
+ return { texts, images };
142
+ }
143
+
144
+ export function restoredUserText(content) {
145
+ if (!Array.isArray(content)) {
146
+ return withVisualizeFeedbackDisplay(typeof content === 'string' ? content : toolResultText(content));
147
+ }
148
+ return withVisualizeFeedbackDisplay(restoredUserParts(content).texts.join('\n'));
149
+ }
150
+
151
+ /** The stored original of each image the restored bubble draws, by the
152
+ * `[Image: …]` line that draws it. Every image keeps its slot (one without a
153
+ * stored original has no `ref`) so later images do not shift onto it; empty
154
+ * when no image has a stored original. */
155
+ export function restoredUserImageRefs(content) {
156
+ if (!Array.isArray(content)) return [];
157
+ const slots = restoredUserParts(content).images.map(({ part, line }) =>
158
+ isAttachmentReference(part) ? { line, ref: part.attachmentRef } : { line }
159
+ );
160
+ return slots.some((slot) => slot.ref) ? slots : [];
140
161
  }
@@ -3,13 +3,25 @@ import { buildPowerShellFilterTeePlan } from '../shell-analysis.mjs';
3
3
  import { normalizeErrorMessage } from '../path-diagnostics.mjs';
4
4
  import { planDirectExeSpawn } from '../shell-direct-exe.mjs';
5
5
  import { POWERSHELL_UTF8_PREFIX } from '../../../../../shared/shell-display.mjs';
6
+ import { renderPowerShellSkillFunction, skillScriptRuntime } from '../../../../../shared/skill-script-runtime.mjs';
6
7
  import { buildShellSpawnEnv } from './spawn-env.mjs';
7
8
  import { formatShellToolFailure } from './result-format.mjs';
8
9
 
10
+ // Every PowerShell model command defines `mixdog-skill-node` first, so the
11
+ // skill launcher runs without the .cmd's cmd.exe argument re-parsing.
12
+ // Injected whenever absent, independent of the UTF-8 prefix deduplication.
13
+ function prefixPowerShellSkillFunction(command) {
14
+ const fn = renderPowerShellSkillFunction(skillScriptRuntime());
15
+ const text = String(command || '');
16
+ return text.trimStart().startsWith(`${fn}\n`) ? text : `${fn}\n${text}`;
17
+ }
18
+
9
19
  function prefixPowerShellUtf8(command) {
10
20
  const prefix = POWERSHELL_UTF8_PREFIX;
11
21
  const text = String(command || '');
12
- return text.trimStart().startsWith(prefix) ? text : `${prefix}\n${text}`;
22
+ const trimmed = text.trimStart();
23
+ const body = trimmed.startsWith(prefix) ? trimmed.slice(prefix.length).replace(/^[ \t]*\r?\n/, '') : text;
24
+ return `${prefix}\n${prefixPowerShellSkillFunction(body)}`;
13
25
  }
14
26
 
15
27
  // How the command reaches the child: a direct exe spawn, PowerShell with the
@@ -54,6 +66,10 @@ export async function planShellInvocation({ command, resolvedSpec, cwd }) {
54
66
  plan.wrappedCommand = prefixPowerShellUtf8(plan.teePlan ? plan.teePlan.command : command);
55
67
  return plan;
56
68
  }
69
+ if (shellType === 'powershell') {
70
+ plan.wrappedCommand = prefixPowerShellSkillFunction(command);
71
+ return plan;
72
+ }
57
73
  if (process.platform !== 'win32' && (shell.includes('bash') || shell.includes('zsh'))) {
58
74
  try {
59
75
  plan.wrappedCommand = await wrapCommandWithSnapshot(shell, command);
@@ -1,4 +1,5 @@
1
1
  import { resourceAdmission } from '../../../../../shared/resource-admission.mjs';
2
+ import { withSkillLauncherPath } from '../../../../../shared/skill-script-runtime.mjs';
2
3
  import {
3
4
  applyShellEgressPolicy,
4
5
  scrubLoaderVars,
@@ -24,5 +25,8 @@ export function buildShellSpawnEnv(cwd, baseEnv = process.env) {
24
25
  scrubLoaderVars(spawnEnv);
25
26
  scrubRuntimeRootVars(spawnEnv);
26
27
  applyShellEgressPolicy(spawnEnv);
28
+ // `mixdog-skill-node` (a skill's ${MIXDOG_SKILL_NODE}) resolves in every
29
+ // model shell; only this env gets the entry, never the daemon's own.
30
+ withSkillLauncherPath(spawnEnv);
27
31
  return spawnEnv;
28
32
  }
@@ -8,7 +8,7 @@ import { normalizeOutputPath, resolveAgainstCwd } from '../path-utils.mjs';
8
8
  /** Report the requested path's result; suggestions never retarget the operation. */
9
9
  export async function readFamilyPathEnoentOrError(workDir, fullPath, inputPath, err) {
10
10
  const safeMsg = normalizeErrorMessage(err instanceof Error ? err.message : String(err));
11
- const hint = buildNotFoundHint(workDir, fullPath, 'List', err?.code);
11
+ const hint = await buildNotFoundHint(workDir, fullPath, 'List', err?.code);
12
12
  const tail = finalizeReadFamilyEnoentTail(hint, inputPath, err?.code);
13
13
  // Conclusive absence is the list's ANSWER, not a tool failure (matches
14
14
  // read's `[path absent]` and git's `repo:false` policy). Genuine
@@ -78,4 +78,4 @@ export async function countTextFileLines(fullPath, size) {
78
78
  } finally {
79
79
  await handle?.close().catch(() => {});
80
80
  }
81
- }
81
+ }
@@ -97,7 +97,7 @@ const BASENAME_SCAN_SKIP_DIRS = new Set([
97
97
  // directories scanned and wall-clock. `visitEntry` decides what counts as a
98
98
  // match and which directories to queue, and returns true to stop reading the
99
99
  // current directory (its own limit break).
100
- function scanByBasename(searchRoot, { limit, maxDirs, deadlineMs }, visitEntry) {
100
+ async function scanByBasename(searchRoot, { limit, maxDirs, deadlineMs }, visitEntry) {
101
101
  const matches = [];
102
102
  const queue = [searchRoot];
103
103
  const stopAt = Date.now() + Math.max(1, deadlineMs);
@@ -112,7 +112,7 @@ function scanByBasename(searchRoot, { limit, maxDirs, deadlineMs }, visitEntry)
112
112
  scanned++;
113
113
  let entries;
114
114
  try {
115
- entries = readdirSync(dir, { withFileTypes: true });
115
+ entries = await readdir(dir, { withFileTypes: true });
116
116
  } catch {
117
117
  continue;
118
118
  }
@@ -127,7 +127,7 @@ function scanByBasename(searchRoot, { limit, maxDirs, deadlineMs }, visitEntry)
127
127
  return matches;
128
128
  }
129
129
 
130
- export function findFileByBasename(
130
+ export async function findFileByBasename(
131
131
  searchRoot,
132
132
  fullPath,
133
133
  { limit = 3, maxDirs = 6000, deadlineMs = BASENAME_SCAN_DEADLINE_MS } = {}
@@ -136,7 +136,7 @@ export function findFileByBasename(
136
136
  if (typeof searchRoot !== 'string' || !searchRoot) return [];
137
137
  const target = basename(fullPath).toLowerCase();
138
138
  if (!target) return [];
139
- return scanByBasename(searchRoot, { limit, maxDirs, deadlineMs }, (dir, ent, matches, queue) => {
139
+ return await scanByBasename(searchRoot, { limit, maxDirs, deadlineMs }, (dir, ent, matches, queue) => {
140
140
  const name = ent.name;
141
141
  if (ent.isDirectory()) {
142
142
  if (name.startsWith('.') || BASENAME_SCAN_SKIP_DIRS.has(name)) return false;
@@ -205,7 +205,7 @@ export function findBySuffixStrip(searchRoot, fullPath, { maxIterations = 12 } =
205
205
 
206
206
  // Same as findFileByBasename but for directories sharing the missing path's
207
207
  // final segment name (e.g. guessed `src/shared` → the real `lib/shared`).
208
- export function findDirectoryByBasename(
208
+ export async function findDirectoryByBasename(
209
209
  searchRoot,
210
210
  fullPath,
211
211
  { limit = 3, maxDirs = 6000, deadlineMs = BASENAME_SCAN_DEADLINE_MS } = {}
@@ -214,7 +214,7 @@ export function findDirectoryByBasename(
214
214
  if (typeof searchRoot !== 'string' || !searchRoot) return [];
215
215
  const target = basename(fullPath).toLowerCase();
216
216
  if (!target) return [];
217
- return scanByBasename(searchRoot, { limit, maxDirs, deadlineMs }, (dir, ent, matches, queue) => {
217
+ return await scanByBasename(searchRoot, { limit, maxDirs, deadlineMs }, (dir, ent, matches, queue) => {
218
218
  const name = ent.name;
219
219
  if (!ent.isDirectory()) return false;
220
220
  if (name.toLowerCase() === target) {
@@ -19,6 +19,15 @@ const READ_RANGE_INDEX_SWEEP_CONCURRENCY = 8;
19
19
  const READ_RANGE_INDEX_CACHE = new Map();
20
20
  const READ_RANGE_INDEX_DRAFTS = new WeakMap();
21
21
  const READ_RANGE_INDEX_PERSIST_PENDING = new Map();
22
+ const READ_RANGE_INDEX_INFLIGHT = new Map();
23
+ const READ_RANGE_INDEX_LAST_PERSISTED = new Map();
24
+ // key -> outstanding deletes whose unlink / cancelled write has not settled.
25
+ const READ_RANGE_INDEX_DELETING = new Map();
26
+ let readRangeIndexDeleteSeq = 0;
27
+ const READ_RANGE_INDEX_LAST_PERSISTED_MAX = 256;
28
+ const READ_RANGE_INDEX_WRITE_CONCURRENCY = 2;
29
+ const READ_RANGE_INDEX_WRITE_WAITERS = [];
30
+ let readRangeIndexWritesActive = 0;
22
31
  let readRangeIndexDiskSwept = false;
23
32
 
24
33
  let traceReadRangeIndex = () => {};
@@ -99,7 +108,9 @@ async function computePrefixHashForIndex(fullPath, st, handle = null, prefixBuff
99
108
  if (!handle) {
100
109
  try {
101
110
  await fh.close();
102
- } catch {}
111
+ } catch {
112
+ /* close failure after a completed read is irrelevant */
113
+ }
103
114
  }
104
115
  }
105
116
  } catch {
@@ -123,14 +134,21 @@ async function loadReadRangeIndexFromDisk(fullPath, st, handle, prefixBuffer) {
123
134
  // No existsSync preflight: a missing file surfaces as an ENOENT from
124
135
  // readFile below, caught by the same try/catch — one FS pass, not two.
125
136
  if (!file || !st) return null;
137
+ // A delete wins over any disk content: while a cancelled write may still be
138
+ // landing the file is invisible, and a read that straddled a delete is void.
139
+ const key = canonicalCachePath(fullPath);
140
+ const deleteSeq = readRangeIndexDeleteSeq;
141
+ const deleted = () => READ_RANGE_INDEX_DELETING.has(key) || readRangeIndexDeleteSeq !== deleteSeq;
142
+ if (deleted()) return null;
126
143
  try {
127
144
  const row = JSON.parse(await readFile(file, 'utf-8'));
128
- if (!readRangeIndexMatches(row, fullPath, st)) return null;
145
+ if (deleted() || !readRangeIndexMatches(row, fullPath, st)) return null;
129
146
  // The prefix is an extra check, not proof that later byte offsets survived.
130
147
  if (typeof row.prefixHash === 'string' && row.prefixHash) {
131
148
  const cur = await computePrefixHashForIndex(fullPath, st, handle, prefixBuffer);
132
149
  if (!cur || cur !== row.prefixHash) return null;
133
150
  }
151
+ if (deleted()) return null;
134
152
  const anchors = new Map([[0, 0]]);
135
153
  for (const entry of row.anchors) {
136
154
  if (!Array.isArray(entry) || entry.length < 2) continue;
@@ -161,35 +179,133 @@ function currentReadRangeIndex(index) {
161
179
  return !!index?.published && READ_RANGE_INDEX_CACHE.get(canonicalCachePath(index.fullPath)) === index;
162
180
  }
163
181
 
164
- function persistReadRangeIndexSync(index) {
182
+ function rememberPersisted(key, text) {
183
+ READ_RANGE_INDEX_LAST_PERSISTED.delete(key);
184
+ READ_RANGE_INDEX_LAST_PERSISTED.set(key, text);
185
+ if (READ_RANGE_INDEX_LAST_PERSISTED.size > READ_RANGE_INDEX_LAST_PERSISTED_MAX) {
186
+ READ_RANGE_INDEX_LAST_PERSISTED.delete(READ_RANGE_INDEX_LAST_PERSISTED.keys().next().value);
187
+ }
188
+ }
189
+
190
+ // job = { index, row }: row is a pre-serialised evicted row (index no longer cached).
191
+ // Returns false only when a write was needed but failed (e.g. lock contended).
192
+ function persistReadRangeIndexSync(job) {
193
+ const { index, row: evictedRow } = job;
165
194
  const file = readRangeIndexFilePath(index?.fullPath);
166
- if (!file || !currentReadRangeIndex(index)) return;
195
+ if (!file || (!evictedRow && !currentReadRangeIndex(index))) return true;
167
196
  try {
168
- if (!sameFileVersion(index.fileVersion, statSync(index.fullPath))) return;
169
- const row = serialiseReadRangeIndex(index);
170
- if (row) writeJsonAtomicSync(file, row, { compact: true, lock: true, fsync: false });
171
- } catch {}
197
+ if (!sameFileVersion(evictedRow?.fileVersion || index.fileVersion, statSync(index.fullPath))) return true;
198
+ const row = evictedRow || serialiseReadRangeIndex(index);
199
+ if (!row) return true;
200
+ const key = canonicalCachePath(index.fullPath);
201
+ const text = JSON.stringify(row);
202
+ if (READ_RANGE_INDEX_LAST_PERSISTED.get(key) === text) return true;
203
+ writeJsonAtomicSync(file, row, { compact: true, lock: true, fsync: false });
204
+ rememberPersisted(key, text);
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
172
209
  }
173
210
 
174
- async function persistReadRangeIndex(index, evictedRow = null) {
211
+ function acquireReadRangeIndexWriteSlot() {
212
+ if (readRangeIndexWritesActive < READ_RANGE_INDEX_WRITE_CONCURRENCY) {
213
+ readRangeIndexWritesActive += 1;
214
+ return Promise.resolve();
215
+ }
216
+ return new Promise((resolve) => READ_RANGE_INDEX_WRITE_WAITERS.push(resolve));
217
+ }
218
+
219
+ function releaseReadRangeIndexWriteSlot() {
220
+ const next = READ_RANGE_INDEX_WRITE_WAITERS.shift();
221
+ if (next) next();
222
+ else readRangeIndexWritesActive -= 1;
223
+ }
224
+
225
+ // One write attempt. `state.epoch` changes when the path is deleted: a write
226
+ // that straddles a delete removes what it just wrote instead of resurrecting it.
227
+ async function persistReadRangeIndexOnce(key, job, state) {
228
+ const { index, row: evictedRow } = job;
175
229
  const file = readRangeIndexFilePath(index?.fullPath);
176
230
  if (!file || (!evictedRow && !currentReadRangeIndex(index))) return;
231
+ const epoch = state.epoch;
232
+ // Admission precedes every stat/lock so waiting writers hold no file lock.
233
+ await acquireReadRangeIndexWriteSlot();
177
234
  try {
235
+ if (state.epoch !== epoch || (!evictedRow && !currentReadRangeIndex(index))) return;
178
236
  if (!sameFileVersion(evictedRow?.fileVersion || index.fileVersion, await stat(index.fullPath))) return;
179
237
  await mkdir(READ_RANGE_INDEX_DISK_DIR, { recursive: true });
180
- if (!evictedRow && !currentReadRangeIndex(index)) return;
238
+ if (state.epoch !== epoch || (!evictedRow && !currentReadRangeIndex(index))) return;
181
239
  const row = evictedRow || serialiseReadRangeIndex(index);
182
- if (row) await writeJsonAtomicAsync(file, row, { compact: true, lock: true, fsync: false });
183
- } catch {}
240
+ if (!row) return;
241
+ const text = JSON.stringify(row);
242
+ if (READ_RANGE_INDEX_LAST_PERSISTED.get(key) === text) return;
243
+ readRangeIndexPersistStats.concurrent += 1;
244
+ readRangeIndexPersistStats.maxConcurrent = Math.max(
245
+ readRangeIndexPersistStats.maxConcurrent,
246
+ readRangeIndexPersistStats.concurrent
247
+ );
248
+ try {
249
+ await writeJsonAtomicAsync(file, row, { compact: true, lock: true, fsync: false });
250
+ readRangeIndexPersistStats.writes += 1;
251
+ } finally {
252
+ readRangeIndexPersistStats.concurrent -= 1;
253
+ }
254
+ if (state.epoch !== epoch) {
255
+ await unlink(file).catch(() => {});
256
+ return;
257
+ }
258
+ rememberPersisted(key, text);
259
+ } catch {
260
+ /* persisting the index is best-effort */
261
+ } finally {
262
+ releaseReadRangeIndexWriteSlot();
263
+ }
264
+ }
265
+
266
+ // Single-flight per cache file: a request during an in-flight write only
267
+ // replaces the pending job; one follow-up write persists the latest state.
268
+ function requestReadRangeIndexPersist(key, job) {
269
+ const state = READ_RANGE_INDEX_INFLIGHT.get(key);
270
+ if (state) {
271
+ state.next = job;
272
+ return;
273
+ }
274
+ const fresh = { epoch: 0, next: null, cancelWaiters: [] };
275
+ READ_RANGE_INDEX_INFLIGHT.set(key, fresh);
276
+ const settleCancelled = () => {
277
+ for (const resolve of fresh.cancelWaiters.splice(0)) resolve();
278
+ };
279
+ void (async () => {
280
+ try {
281
+ let cur = job;
282
+ while (cur) {
283
+ await persistReadRangeIndexOnce(key, cur, fresh);
284
+ // A cancelled write has finished (and cleaned up) once Once returns.
285
+ settleCancelled();
286
+ cur = fresh.next;
287
+ fresh.next = null;
288
+ }
289
+ } finally {
290
+ settleCancelled();
291
+ READ_RANGE_INDEX_INFLIGHT.delete(key);
292
+ }
293
+ })();
184
294
  }
185
295
 
186
296
  export function scheduleReadRangeIndexPersist(index) {
187
297
  if (!READ_RANGE_INDEX_DISK_DIR || !currentReadRangeIndex(index)) return;
188
298
  const key = canonicalCachePath(index.fullPath);
299
+ const inflight = READ_RANGE_INDEX_INFLIGHT.get(key);
300
+ if (inflight) {
301
+ inflight.next = { index, row: null };
302
+ return;
303
+ }
189
304
  if (READ_RANGE_INDEX_PERSIST_PENDING.has(key)) return;
190
305
  const t = setTimeout(() => {
191
306
  READ_RANGE_INDEX_PERSIST_PENDING.delete(key);
192
- void persistReadRangeIndex(index);
307
+ const latest = READ_RANGE_INDEX_CACHE.get(key);
308
+ if (latest) requestReadRangeIndexPersist(key, { index: latest, row: null });
193
309
  }, READ_RANGE_INDEX_PERSIST_DEBOUNCE_MS);
194
310
  t.unref();
195
311
  READ_RANGE_INDEX_PERSIST_PENDING.set(key, t);
@@ -199,10 +315,19 @@ export function flushReadRangeIndexesSync() {
199
315
  for (const [key, timer] of READ_RANGE_INDEX_PERSIST_PENDING) {
200
316
  try {
201
317
  clearTimeout(timer);
202
- } catch {}
318
+ } catch {
319
+ /* a timer that cannot be cleared must not block the flush */
320
+ }
203
321
  READ_RANGE_INDEX_PERSIST_PENDING.delete(key);
204
322
  const index = READ_RANGE_INDEX_CACHE.get(key);
205
- if (index) persistReadRangeIndexSync(index);
323
+ if (index) persistReadRangeIndexSync({ index, row: null });
324
+ }
325
+ // Writes queued behind an in-flight one are still pending state at exit.
326
+ for (const state of READ_RANGE_INDEX_INFLIGHT.values()) {
327
+ // Keep the job unless it was durably handled: when the async writer holds
328
+ // the lock, its follow-up still persists it if the process keeps running.
329
+ const job = state.next;
330
+ if (job && persistReadRangeIndexSync(job) && state.next === job) state.next = null;
206
331
  }
207
332
  }
208
333
 
@@ -213,11 +338,32 @@ function dropReadRangeIndexSlot(key) {
213
338
  }
214
339
 
215
340
  export function deleteReadRangeIndexForPath(fullPath) {
216
- dropReadRangeIndexSlot(canonicalCachePath(fullPath));
341
+ const key = canonicalCachePath(fullPath);
342
+ dropReadRangeIndexSlot(key);
343
+ READ_RANGE_INDEX_LAST_PERSISTED.delete(key);
344
+ readRangeIndexDeleteSeq += 1;
345
+ // Disk loads for this path are void until the unlink and any cancelled
346
+ // in-flight write (which removes its own output) have settled.
347
+ READ_RANGE_INDEX_DELETING.set(key, (READ_RANGE_INDEX_DELETING.get(key) || 0) + 1);
348
+ const inflight = READ_RANGE_INDEX_INFLIGHT.get(key);
349
+ let cancelled = Promise.resolve();
350
+ if (inflight) {
351
+ inflight.epoch += 1;
352
+ inflight.next = null;
353
+ cancelled = new Promise((resolve) => inflight.cancelWaiters.push(resolve));
354
+ }
217
355
  const file = readRangeIndexFilePath(fullPath);
218
- if (file) void unlink(file).catch(() => {});
356
+ const unlinked = file ? unlink(file).catch(() => {}) : Promise.resolve();
357
+ void Promise.all([unlinked, cancelled]).then(() => {
358
+ const left = (READ_RANGE_INDEX_DELETING.get(key) || 1) - 1;
359
+ if (left > 0) READ_RANGE_INDEX_DELETING.set(key, left);
360
+ else READ_RANGE_INDEX_DELETING.delete(key);
361
+ });
219
362
  }
220
363
 
364
+ // Test observability for write coalescing and the concurrency cap.
365
+ export const readRangeIndexPersistStats = { writes: 0, concurrent: 0, maxConcurrent: 0 };
366
+
221
367
  export async function sweepStaleReadRangeIndexes(directory = READ_RANGE_INDEX_DISK_DIR) {
222
368
  if (!directory) return;
223
369
  let entries;
@@ -235,8 +381,14 @@ export async function sweepStaleReadRangeIndexes(directory = READ_RANGE_INDEX_DI
235
381
  const full = join(directory, name);
236
382
  try {
237
383
  const st = await stat(full);
238
- if (now - st.mtimeMs > READ_RANGE_INDEX_DISK_STALE_MS) await unlink(full);
239
- } catch {}
384
+ if (now - st.mtimeMs > READ_RANGE_INDEX_DISK_STALE_MS) {
385
+ await unlink(full);
386
+ // A removed file invalidates the unchanged-content skip.
387
+ READ_RANGE_INDEX_LAST_PERSISTED.clear();
388
+ }
389
+ } catch {
390
+ /* best-effort sweep: a file that vanished or cannot be removed is skipped */
391
+ }
240
392
  }
241
393
  }
242
394
  await Promise.all(Array.from({ length: Math.min(READ_RANGE_INDEX_SWEEP_CONCURRENCY, entries.length) }, worker));
@@ -255,7 +407,7 @@ function refreshReadRangeIndexSlot(key, index) {
255
407
  // their slot immediately. Disk IO must not delay the foreground lookup.
256
408
  const row = serialiseReadRangeIndex(evicted);
257
409
  dropReadRangeIndexSlot(oldest);
258
- if (row) void persistReadRangeIndex(evicted, row);
410
+ if (row) requestReadRangeIndexPersist(oldest, { index: evicted, row });
259
411
  }
260
412
  return index;
261
413
  }
@@ -346,8 +498,7 @@ export async function publishReadRangeIndex(index, handle, signal = null, before
346
498
  // result requesting producer certification still owes its terminal stats.
347
499
  if (slot && READ_RANGE_INDEX_CACHE.get(key) !== slot && !onValidated) return false;
348
500
  try {
349
- if (!sameFileVersion(version, await handle.stat()) || !sameFileVersion(version, await stat(fullPath)))
350
- return false;
501
+ if (!sameFileVersion(version, await handle.stat()) || !sameFileVersion(version, await stat(fullPath))) return false;
351
502
  } catch {
352
503
  return false;
353
504
  }
@@ -108,7 +108,9 @@ async function symlinkTargetGuard(fullPath, filePath, helpers) {
108
108
  if (typeof isSpecialFileStat === 'function' && isSpecialFileStat(targetStat)) {
109
109
  return guardResponse('special', helpers, filePath, realTarget);
110
110
  }
111
- } catch {}
111
+ } catch {
112
+ /* vanished target: see above */
113
+ }
112
114
  return null;
113
115
  }
114
116
 
@@ -138,7 +140,9 @@ export async function statReadTarget(fullPath, filePath, options, helpers) {
138
140
  const error = await symlinkTargetGuard(fullPath, filePath, helpers);
139
141
  if (error) return { st, error };
140
142
  }
141
- } catch {}
143
+ } catch {
144
+ /* lstat failure is non-fatal: see above */
145
+ }
142
146
  return { st };
143
147
  }
144
148
 
@@ -148,12 +152,12 @@ export async function statReadTarget(fullPath, filePath, options, helpers) {
148
152
  * recovery mode for an ordinary absence probe. Genuine not-found only —
149
153
  * EACCES/EPERM etc. keep failure semantics.
150
154
  */
151
- export function readNotFoundResponse(err, { fullPath, filePath, workDir }, helpers) {
155
+ export async function readNotFoundResponse(err, { fullPath, filePath, workDir }, helpers) {
152
156
  const { findSimilarFile, normalizeErrorMessage, normalizeOutputPath } = helpers;
153
157
  const similar = findSimilarFile(fullPath);
154
158
  let hint = similar
155
159
  ? appendNotFoundCwdNote(` Did you mean "${normalizeOutputPath(similar)}"?`, workDir, err?.code)
156
- : buildNotFoundHint(workDir, fullPath, 'Read', err?.code);
160
+ : await buildNotFoundHint(workDir, fullPath, 'Read', err?.code);
157
161
  hint = finalizeReadFamilyEnoentTail(hint, filePath, err?.code);
158
162
  const safeMsg = normalizeErrorMessage(err instanceof Error ? err.message : String(err), workDir);
159
163
  if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return `[path absent] ${safeMsg}${hint}`;
@@ -30,9 +30,10 @@ async function prefetchRawBody(fullPath, st, helpers, forceFresh = false) {
30
30
  const cachedRaw = !forceFresh && _rawContentCacheGet ? _rawContentCacheGet(fullPath, st) : null;
31
31
  if (cachedRaw) return { buf: cachedRaw, fromCache: true };
32
32
  try {
33
- const buf = !forceFresh && _runRawContentInFlight
34
- ? await _runRawContentInFlight(fullPath, readFile, st)
35
- : await readFile(fullPath);
33
+ const buf =
34
+ !forceFresh && _runRawContentInFlight
35
+ ? await _runRawContentInFlight(fullPath, readFile, st)
36
+ : await readFile(fullPath);
36
37
  return { buf, fromCache: false };
37
38
  } catch {
38
39
  return { buf: null, fromCache: false };
@@ -84,7 +85,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
84
85
  let widenNote = '';
85
86
  const target = await statReadTarget(fullPath, filePath, options, helpers);
86
87
  if (target.error) return target.error;
87
- if (!target.st) return readNotFoundResponse(target.statErr, { fullPath, filePath, workDir }, helpers);
88
+ if (!target.st) return await readNotFoundResponse(target.statErr, { fullPath, filePath, workDir }, helpers);
88
89
  const st = target.st;
89
90
  // Reactive widen + window-history bookkeeping (ranged text reads only).
90
91
  if (