mixdog 1.0.12 → 1.1.0

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 (256) hide show
  1. package/README.md +75 -181
  2. package/package.json +4 -2
  3. package/scripts/build-shipped-history.mjs +131 -0
  4. package/scripts/lib/run-node-tests.mjs +235 -13
  5. package/scripts/lib/test-failure-records.mjs +5 -0
  6. package/scripts/lib/test-failure-summary.mjs +9 -3
  7. package/scripts/lib/test-spawn-ledger.mjs +19 -0
  8. package/scripts/prune-embedding-runtime.mjs +9 -5
  9. package/scripts/test-direct.mjs +16 -1
  10. package/src/agents/advisor/AGENT.md +38 -0
  11. package/src/agents/advisor/agent.json +6 -0
  12. package/src/agents/reviewer/AGENT.md +5 -2
  13. package/src/agents/reviewer/agent.json +1 -1
  14. package/src/defaults/shipped-history.json +381 -0
  15. package/src/defaults/skills/goal-management/SKILL.md +11 -5
  16. package/src/defaults/skills/setup/SKILL.md +5 -8
  17. package/src/defaults/skills/setup/references/actions.md +61 -31
  18. package/src/defaults/skills/setup/references/surfaces.md +12 -10
  19. package/src/lib/rules-builder.cjs +1 -1
  20. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +3 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +4 -0
  22. package/src/runtime/agent/orchestrator/config-presets.d.mts +1 -0
  23. package/src/runtime/agent/orchestrator/config-presets.mjs +44 -0
  24. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +9 -1
  25. package/src/runtime/agent/orchestrator/config.mjs +102 -21
  26. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +77 -26
  27. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +44 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +2 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +11 -2
  30. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-login.mjs +2 -0
  31. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +3 -3
  32. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +388 -0
  33. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +92 -109
  34. package/src/runtime/agent/orchestrator/providers/custom-reasoning.mjs +107 -0
  35. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +51 -9
  36. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +2 -1
  37. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +14 -9
  38. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +51 -11
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +6 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +7 -5
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +12 -0
  42. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +27 -7
  43. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +12 -2
  44. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/quota-reading.mjs +70 -0
  47. package/src/runtime/agent/orchestrator/providers/registry.mjs +38 -3
  48. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +19 -35
  49. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +5 -0
  50. package/src/runtime/agent/orchestrator/runtime-core/goal-mutations.mjs +0 -17
  51. package/src/runtime/agent/orchestrator/runtime-core/goal-state.mjs +0 -3
  52. package/src/runtime/agent/orchestrator/runtime-core/goal-store.mjs +0 -1
  53. package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +1 -1
  55. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +1 -2
  56. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +18 -4
  57. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +7 -1
  58. package/src/runtime/agent/orchestrator/runtime-core/session-route-policy.mjs +16 -0
  59. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +7 -7
  62. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +6 -0
  63. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +28 -3
  64. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +24 -11
  65. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +2 -6
  66. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +63 -3
  67. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +4 -4
  68. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +1 -0
  69. package/src/runtime/agent/orchestrator/stall-policy.mjs +0 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +19 -20
  72. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -11
  73. package/src/runtime/agent/orchestrator/tools/builtin/file-version-test-helpers.mjs +11 -0
  74. package/src/runtime/agent/orchestrator/tools/builtin/file-version.mjs +13 -0
  75. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +8 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-task-output.mjs +25 -12
  77. package/src/runtime/agent/orchestrator/tools/builtin/raw-content-publication.mjs +17 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +136 -76
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +15 -17
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -2
  81. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +11 -10
  82. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -3
  83. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +30 -16
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +4 -11
  85. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +7 -3
  86. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-validation.mjs +16 -19
  87. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  88. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +4 -1
  89. package/src/runtime/agent/orchestrator/tools/lib/shell-run-settle.mjs +14 -4
  90. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +58 -8
  91. package/src/runtime/agent/orchestrator/tools/patch/content-guard.mjs +47 -0
  92. package/src/runtime/agent/orchestrator/tools/patch/delete-quarantine.mjs +155 -0
  93. package/src/runtime/agent/orchestrator/tools/patch/dispatch/native-proofs.mjs +6 -2
  94. package/src/runtime/agent/orchestrator/tools/patch/dispatch/native-snapshots.mjs +6 -2
  95. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +19 -16
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +13 -44
  97. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  98. package/src/runtime/agent/orchestrator/tools/patch/post-patch-excerpts.mjs +5 -2
  99. package/src/runtime/agent/orchestrator/tools/patch/replay-capture.mjs +47 -7
  100. package/src/runtime/agent/orchestrator/tools/patch/rollback-state.mjs +72 -8
  101. package/src/runtime/agent/orchestrator/tools/patch/section-coalesce.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/patch/sequence/run.mjs +9 -1
  103. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +9 -1
  104. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +6 -6
  105. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +16 -4
  106. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -14
  107. package/src/runtime/agent/orchestrator/tools/patch/wave.mjs +42 -4
  108. package/src/runtime/agent/orchestrator/tools/shell-codepage.mjs +296 -0
  109. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +75 -24
  110. package/src/runtime/channels/lib/config.mjs +3 -8
  111. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +7 -3
  112. package/src/{tui → runtime/channels}/lib/voice-setup.mjs +38 -22
  113. package/src/runtime/channels/lib/webhook/request-handler.mjs +42 -8
  114. package/src/runtime/computer-bridge/actions.mjs +2 -0
  115. package/src/runtime/effort-judge/judge-client.mjs +12 -3
  116. package/src/runtime/effort-judge/model-manifest.json +7 -7
  117. package/src/runtime/effort-judge/previous-human-request.mjs +162 -0
  118. package/src/runtime/effort-judge/verified-context-input.mjs +10 -0
  119. package/src/runtime/memory/lib/embedding-provider.mjs +12 -0
  120. package/src/runtime/memory/lib/http-router/session-routes.mjs +2 -1
  121. package/src/runtime/memory/lib/memory-config-flags.mjs +3 -2
  122. package/src/runtime/memory/lib/memory-embed.mjs +5 -0
  123. package/src/runtime/shared/agent-route-config.mjs +50 -2
  124. package/src/runtime/shared/bounded-file-read.mjs +166 -0
  125. package/src/runtime/shared/builtin-grandfathering.mjs +37 -0
  126. package/src/runtime/shared/config.mjs +24 -4
  127. package/src/runtime/shared/defaults-separation.mjs +263 -0
  128. package/src/runtime/shared/llm/anthropic-effort.mjs +4 -0
  129. package/src/runtime/shared/llm/model-catalog.mjs +23 -0
  130. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +105 -1
  131. package/src/runtime/shared/llm/usage-ledger-worker.mjs +2 -1
  132. package/src/runtime/shared/llm/usage-ledger.mjs +72 -5
  133. package/src/runtime/shared/onnx-runtime-support.mjs +6 -0
  134. package/src/runtime/shared/orchestration.mjs +14 -6
  135. package/src/runtime/shared/projects.mjs +65 -48
  136. package/src/runtime/shared/provider-accounts.mjs +47 -6
  137. package/src/runtime/shared/quota-resume-store.mjs +255 -0
  138. package/src/runtime/shared/shipped-definitions.mjs +275 -0
  139. package/src/runtime/shared/tool-card-model.golden.json +3 -3
  140. package/src/runtime/shared/tool-result-summary.mjs +86 -15
  141. package/src/runtime/shared/transcript-metadata.mjs +2 -0
  142. package/src/runtime/shared/turn-review-reply.mjs +2 -0
  143. package/src/runtime/shared/turn-snapshot.mjs +65 -6
  144. package/src/runtime/shared/turn-worktree-snapshot.mjs +202 -22
  145. package/src/session-runtime/boot/apis.mjs +19 -0
  146. package/src/session-runtime/boot/config.mjs +4 -0
  147. package/src/session-runtime/boot/facade.mjs +4 -1
  148. package/src/session-runtime/boot/infrastructure.mjs +21 -2
  149. package/src/session-runtime/boot/session.mjs +3 -0
  150. package/src/session-runtime/channel-config-api.mjs +5 -0
  151. package/src/session-runtime/config-lifecycle/config-writers.mjs +3 -0
  152. package/src/session-runtime/config-lifecycle.mjs +31 -6
  153. package/src/session-runtime/goal-facade-api.mjs +4 -0
  154. package/src/session-runtime/lifecycle/inheritance.mjs +28 -6
  155. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +3 -0
  156. package/src/session-runtime/lifecycle/session-switch/resume.mjs +7 -1
  157. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +6 -0
  158. package/src/session-runtime/lifecycle/teardown/session-close.mjs +14 -2
  159. package/src/session-runtime/media-api.mjs +32 -22
  160. package/src/session-runtime/model-recency.mjs +14 -0
  161. package/src/session-runtime/model-route/route-tuning.mjs +3 -5
  162. package/src/session-runtime/model-route/set-route.mjs +49 -21
  163. package/src/session-runtime/model-route-api.mjs +2 -1
  164. package/src/session-runtime/notification-bus.mjs +31 -6
  165. package/src/session-runtime/provider-auth/login.mjs +6 -0
  166. package/src/session-runtime/provider-models/catalog-load.mjs +3 -1
  167. package/src/session-runtime/provider-models/row-hydration.mjs +15 -14
  168. package/src/session-runtime/quick-model-rows/model-meta.mjs +26 -0
  169. package/src/session-runtime/quick-model-rows/provider-rows.mjs +40 -14
  170. package/src/session-runtime/resource-api.mjs +8 -1
  171. package/src/session-runtime/runtime-core.mjs +4 -0
  172. package/src/session-runtime/runtime-facade.mjs +20 -1
  173. package/src/session-runtime/runtime-review-api.mjs +12 -1
  174. package/src/session-runtime/services/agent-tool/helpers.mjs +4 -0
  175. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +10 -15
  176. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +11 -3
  177. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +2 -0
  178. package/src/session-runtime/services/channel-admin.mjs +87 -13
  179. package/src/session-runtime/services/provider-admin.mjs +256 -63
  180. package/src/session-runtime/services/seeds.mjs +73 -142
  181. package/src/session-runtime/services/usage-dashboard-model.mjs +29 -0
  182. package/src/session-runtime/services/usage-dashboard.mjs +7 -0
  183. package/src/session-runtime/session-address.mjs +125 -0
  184. package/src/session-runtime/session-create.mjs +57 -2
  185. package/src/session-runtime/settings-builtin-tools-api.mjs +8 -0
  186. package/src/session-runtime/settings-compaction-api.mjs +13 -0
  187. package/src/session-runtime/setup-tool/executor.mjs +92 -40
  188. package/src/session-runtime/setup-tool/extended-actions.mjs +64 -5
  189. package/src/session-runtime/setup-tool/settings-contract.mjs +44 -1
  190. package/src/session-runtime/setup-tool/tool-defs.mjs +12 -7
  191. package/src/session-runtime/tool-surface.mjs +16 -9
  192. package/src/session-runtime/turn/session-ops.mjs +3 -0
  193. package/src/session-runtime/turn/share-ops.mjs +4 -1
  194. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +6 -0
  195. package/src/session-runtime/usage-stats-api.mjs +15 -0
  196. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +31 -5
  197. package/src/session-runtime/workflow-agents-api/agent-route.mjs +4 -0
  198. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +27 -7
  199. package/src/standalone/daemon-boot-coordinator.mjs +10 -0
  200. package/src/standalone/daemon-drain-policy.mjs +9 -0
  201. package/src/standalone/daemon-stored-session-views.mjs +7 -8
  202. package/src/standalone/daemon.mjs +21 -5
  203. package/src/standalone/local-session-runtime.mjs +11 -0
  204. package/src/standalone/session-client.mjs +52 -7
  205. package/src/standalone/session-protocol.mjs +8 -0
  206. package/src/standalone/session-service/agent-tree/agent-child.mjs +2 -0
  207. package/src/standalone/session-service/entries/lifecycle.mjs +2 -0
  208. package/src/standalone/session-service/entries/session-loads.mjs +9 -0
  209. package/src/standalone/session-service/projection/revisions.mjs +28 -4
  210. package/src/standalone/session-service/projection.mjs +1 -0
  211. package/src/standalone/session-service/quota-resume.mjs +140 -0
  212. package/src/standalone/session-service/session-calls/session-action.mjs +2 -2
  213. package/src/standalone/session-service/session-calls.mjs +10 -2
  214. package/src/standalone/session-service/stored-reader.mjs +26 -1
  215. package/src/standalone/session-service.mjs +21 -0
  216. package/src/standalone/session-transport/routes.mjs +2 -0
  217. package/src/tui/app/doctor.mjs +7 -11
  218. package/src/tui/app/maintenance-pickers/auto-clear-picker.mjs +3 -5
  219. package/src/tui/app/settings-picker/settings-rows.mjs +4 -6
  220. package/src/tui/app/settings-picker/settings-toggles/store-toggles.mjs +1 -1
  221. package/src/tui/app/slash-dispatch/route-commands.mjs +3 -2
  222. package/src/tui/components/ContextPanel.jsx +5 -1
  223. package/src/tui/components/Spinner.jsx +34 -319
  224. package/src/tui/components/SpinnerByline.jsx +256 -0
  225. package/src/tui/components/use-spinner-progress.mjs +113 -0
  226. package/src/tui/dist/index.mjs +15540 -14452
  227. package/src/tui/session/agent-job-feed/execution-delivery.mjs +13 -1
  228. package/src/tui/session/agent-job-feed/notification-router.mjs +3 -0
  229. package/src/tui/session/agent-job-feed/pending-resume.mjs +14 -1
  230. package/src/tui/session/agent-job-feed.mjs +4 -0
  231. package/src/tui/session/context-state.mjs +13 -1
  232. package/src/tui/session/initial-state.mjs +6 -0
  233. package/src/tui/session/quota-resume.mjs +263 -0
  234. package/src/tui/session/session-api/intake/abort.mjs +39 -35
  235. package/src/tui/session/session-api/intake/submission.mjs +13 -21
  236. package/src/tui/session/session-api/intake.mjs +3 -3
  237. package/src/tui/session/session-api/integrations.mjs +5 -1
  238. package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
  239. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -2
  240. package/src/tui/session/session-api/lifecycle/session-reset.mjs +4 -0
  241. package/src/tui/session/session-api/lifecycle.mjs +71 -2
  242. package/src/tui/session/session-api/media.mjs +2 -2
  243. package/src/tui/session/session-api/quota-resume.mjs +21 -0
  244. package/src/tui/session/session-api/routes.mjs +2 -0
  245. package/src/tui/session/session-api/settings.mjs +1 -0
  246. package/src/tui/session/session-api.mjs +2 -0
  247. package/src/tui/session/session-flow/auto-clear.mjs +14 -6
  248. package/src/tui/session/session-flow/drain.mjs +52 -5
  249. package/src/tui/session/session-flow/queue/take-entries.mjs +2 -3
  250. package/src/tui/session/session-flow/queue.mjs +3 -0
  251. package/src/tui/session/session-flow/steering.mjs +11 -0
  252. package/src/tui/session/session-flow.mjs +1 -0
  253. package/src/tui/session/tool-approval.mjs +7 -2
  254. package/src/tui/session/transcript-intake.mjs +1 -0
  255. package/src/tui/session/turn.mjs +40 -3
  256. package/src/vendor/statusline/bin/statusline-route.mjs +3 -2
@@ -19,6 +19,22 @@ export function sessionHasRouteHistory(session) {
19
19
  return hasRouteHistoryMessage(session?.messages) || hasRouteHistoryMessage(session?.liveTurnMessages);
20
20
  }
21
21
 
22
+ // Whether a model change must leave the current conversation on its model:
23
+ // a model reply or a compaction summary ties it to its provider cache and
24
+ // replay state, and a first turn in flight owns its model. Prompts that were
25
+ // all refused before any reply leave nothing to protect.
26
+ export function sessionRouteCommitted(session) {
27
+ const messages = Array.isArray(session?.messages) ? session.messages : [];
28
+ return (
29
+ hasRouteHistoryMessage(session?.liveTurnMessages) ||
30
+ messages.some(
31
+ (message) =>
32
+ message?.role === 'assistant' ||
33
+ (message?.role === 'user' && typeof message.content === 'string' && message.content.startsWith(SUMMARY_PREFIX))
34
+ )
35
+ );
36
+ }
37
+
22
38
  export function sessionUsesRoute(session, route) {
23
39
  return !!session && session.provider === route?.provider && session.model === route?.model;
24
40
  }
@@ -305,6 +305,8 @@ export function applyDeferredToolSurface(session, mode, extraTools = [], options
305
305
  }
306
306
  const catalog = sortedCatalogByMeasuredUsage([...byName.values()]);
307
307
  const defaultNames = defaultDeferredToolNames(catalog, mode);
308
+ // Solo keeps `agent` in the deferred pool: loadable on request, never eager.
309
+ if (session.orchestrationMode === 'none') defaultNames.delete('agent');
308
310
  const storedNames = providerMode === 'native' ? [] : storedDeferredToolNames(session);
309
311
  const fixedSurface = FIXED_SURFACE_MODES.has(providerMode);
310
312
  let selectedNames = fixedSurface
@@ -144,8 +144,8 @@ export function collectStandaloneToolDefs({
144
144
  return {
145
145
  standaloneTools,
146
146
  internalToolDefs: [...standaloneTools, ...webSearchRuntimeTools.filter((tool) => tool?.public === false)],
147
- // Workflow-aware model surface: a pack that declares an EMPTY agents list
148
- // (Solo) must not advertise the agent tool at all — the model calling a
147
+ // Workflow-aware model surface: a session with no delegatable agents
148
+ // must not advertise the agent tool at all — the model calling a
149
149
  // schema-visible tool that policy always rejects is a guaranteed error turn
150
150
  // (user-reported in Solo). Names derive from the live agent tool defs.
151
151
  agentToolNames: new Set(agentTools.map((tool) => String(tool?.name || '')).filter(Boolean)),
@@ -6,7 +6,7 @@ import { orchestrationInstructions } from '../orchestration.mjs';
6
6
  import { DEFAULT_WORKFLOW_ID, normalizeWorkflowId } from '../workflow-ids.mjs';
7
7
 
8
8
  export function createWorkflowContext({ packs, agents }) {
9
- function workflowSummary(pack, { hasAgents = true, orchestrationMode = 'none' } = {}) {
9
+ function workflowSummary(pack, { hasAgents = true } = {}) {
10
10
  const id = normalizeWorkflowId(pack?.id, DEFAULT_WORKFLOW_ID);
11
11
  return {
12
12
  id,
@@ -14,7 +14,8 @@ export function createWorkflowContext({ packs, agents }) {
14
14
  description: clean(pack?.description),
15
15
  source: clean(pack?.source),
16
16
  // Effective session capability, not an editable workflow property.
17
- delegatesAgents: orchestrationMode !== 'none' && hasAgents !== false,
17
+ // Solo keeps the agent tool deferred for explicit user requests.
18
+ delegatesAgents: hasAgents !== false,
18
19
  };
19
20
  }
20
21
 
@@ -23,7 +24,6 @@ export function createWorkflowContext({ packs, agents }) {
23
24
  function activeWorkflowSummary(config, dir) {
24
25
  return workflowSummary(packs.sharedWorkflowPack(dir, packs.activeWorkflowId(config)), {
25
26
  hasAgents: agents.delegatableAgentIds(config, dir).length > 0,
26
- orchestrationMode: configuredOrchestrationMode(config),
27
27
  });
28
28
  }
29
29
 
@@ -44,10 +44,11 @@ export function createWorkflowContext({ packs, agents }) {
44
44
  }
45
45
 
46
46
  function orchestrationContextBlock(config, dir) {
47
- const instructions = orchestrationInstructions(configuredOrchestrationMode(config));
48
- if (!instructions) return '';
49
- const lines = [instructions];
47
+ const mode = configuredOrchestrationMode(config);
50
48
  const agentIds = agents.delegatableAgentIds(config, dir);
49
+ // Solo without agents has nothing to delegate to on request either.
50
+ if (mode === 'none' && !agentIds.length) return '';
51
+ const lines = [orchestrationInstructions(mode)];
51
52
  const agentBlocks = agentIds.map((id) => agents.loadAgentDefinition(dir, id)).filter(Boolean);
52
53
  if (agentBlocks.length) {
53
54
  lines.push('# Available Agents');
@@ -74,7 +75,6 @@ export function createWorkflowContext({ packs, agents }) {
74
75
  return {
75
76
  summary: workflowSummary(pack, {
76
77
  hasAgents: agents.delegatableAgentIds(config, dir).length > 0,
77
- orchestrationMode,
78
78
  }),
79
79
  orchestrationMode,
80
80
  // Pack block alone: agent-owned sessions never get the Lead-only
@@ -187,12 +187,18 @@ export function normalizeWorkflowRoute(routeLike, fallback = {}) {
187
187
  const effort = normalizeEffortInput(routeLike?.effort ?? fallback.effort);
188
188
  const fast = routeLike?.fast ?? fallback.fast;
189
189
  const modelParameters = routeLike?.modelParameters ?? fallback.modelParameters;
190
+ const requestedPercent = Number(routeLike?.contextPercent ?? fallback.contextPercent);
191
+ const contextPercent =
192
+ Number.isFinite(requestedPercent) && requestedPercent >= 10 && requestedPercent <= 100
193
+ ? Math.round(requestedPercent / 10) * 10
194
+ : null;
190
195
  return {
191
196
  provider,
192
197
  model,
193
198
  ...(effort ? { effort } : {}),
194
199
  ...(typeof fast === 'boolean' ? { fast } : {}),
195
200
  ...(modelParameters && typeof modelParameters === 'object' ? { modelParameters: { ...modelParameters } } : {}),
201
+ ...(contextPercent ? { contextPercent } : {}),
196
202
  };
197
203
  }
198
204
 
@@ -8,9 +8,13 @@
8
8
  * later request rebuilds the same history and the prompt cache keeps hitting.
9
9
  * A judge that is missing, slow or failing leaves the step at the effort in
10
10
  * force (the turn's or the previous step's).
11
+ * A custom route (snapshot mode 'custom') has no mid-conversation update: the
12
+ * judged effort, snapped onto the model's levels, becomes the effort of the
13
+ * turn's following requests.
11
14
  */
12
15
  import { resolveAutoEffort } from '../../providers/auto-effort.mjs';
13
- import { stepEffortConfiguration } from '../../providers/effort-configuration.mjs';
16
+ import { resolveCustomReasoning } from '../../providers/custom-reasoning.mjs';
17
+ import { autoEffortModel, stepEffortConfiguration } from '../../providers/effort-configuration.mjs';
14
18
  import { effortOptionsFor } from '../../runtime-core/effort.mjs';
15
19
  import { judgeTurn, recordEffortDecision } from '../../../../effort-judge/judge-client.mjs';
16
20
  import { stepResultWindow } from '../../../../effort-judge/judge-input.mjs';
@@ -68,14 +72,22 @@ export async function markStepEffort(state, assistantTurnMsg) {
68
72
  },
69
73
  });
70
74
  // `atStep` numbers the tool-result step; `step` in a resolved decision is the ladder move.
71
- const record = { at: new Date().toISOString(), sessionId: state.sessionId, provider, model, mode: 'on', atStep: step };
75
+ const record = {
76
+ at: new Date().toISOString(),
77
+ sessionId: state.sessionId,
78
+ provider,
79
+ model,
80
+ mode: 'on',
81
+ atStep: step,
82
+ };
72
83
  if (!judged.probs) {
73
84
  recordEffortDecision({ ...record, skipped: judged.skipped });
74
85
  return;
75
86
  }
87
+ const modelMeta = autoEffortModel(provider, state.provider, model);
76
88
  const resolved = resolveAutoEffort({
77
89
  base: auto.base,
78
- options: effortOptionsFor(provider, { id: model }),
90
+ options: effortOptionsFor(provider, modelMeta),
79
91
  probs: judged.probs,
80
92
  });
81
93
  recordEffortDecision({
@@ -84,6 +96,19 @@ export async function markStepEffort(state, assistantTurnMsg) {
84
96
  ms: judged.ms,
85
97
  probs: judged.probs.map((value) => Number(value.toFixed(3))),
86
98
  });
99
+ if (snapshot.mode === 'custom') {
100
+ const level =
101
+ resolved &&
102
+ resolveCustomReasoning({
103
+ protocol: state.provider?.customConfig?.protocol,
104
+ model: modelMeta,
105
+ effort: resolved.effort,
106
+ }).level;
107
+ if (!level) return;
108
+ state.opts.effort = level;
109
+ last.meta = { ...last.meta, effortConfiguration: { ...snapshot, effort: level } };
110
+ return;
111
+ }
87
112
  const marked = resolved && stepEffortConfiguration(snapshot, provider, model, resolved.effort);
88
113
  if (marked) last.meta = { ...last.meta, effortConfiguration: marked };
89
114
  }
@@ -1,15 +1,17 @@
1
1
  // Auto effort for one user turn. The Auto effort built-in (Extensions →
2
2
  // Built-in) turns it `on`: the judged effort applies to this turn. The
3
3
  // MIXDOG_AUTO_EFFORT env value (off/observe/on) overrides it for diagnostics;
4
- // `observe` judges and logs without applying. Applies only to user-written
5
- // prompts on models whose effort can change mid-conversation without
6
- // invalidating the prompt cache.
4
+ // `observe` judges and logs without applying. Applies to user-written prompts
5
+ // and to agent sessions' briefs and Lead follow-ups (not runtime-injected
6
+ // turns) on models whose effort can change mid-conversation without
7
+ // invalidating the prompt cache, and on custom Chat/Responses routes whose
8
+ // model declares effort levels (the effort is sent with each request).
7
9
  import { readSection } from '../../../../shared/config.mjs';
8
- import { effortConfigurationMode } from '../../providers/effort-configuration.mjs';
10
+ import { autoEffortModel, turnEffortConfigurationMode } from '../../providers/effort-configuration.mjs';
9
11
  import { autoEffortBase, normalizeAutoEffortMode, resolveAutoEffort } from '../../providers/auto-effort.mjs';
10
12
  import { builtinFeatureActive } from '../../runtime-core/builtin-features.mjs';
11
13
  import { effortOptionsFor } from '../../runtime-core/effort.mjs';
12
- import { judgeTurn, recordEffortDecision } from '../../../../effort-judge/judge-client.mjs';
14
+ import { effortJudgeSupportsSteps, judgeTurn, recordEffortDecision } from '../../../../effort-judge/judge-client.mjs';
13
15
  import { promptContentText } from './prompt-utils.mjs';
14
16
 
15
17
  // Every cache-safe route judges its tool-result steps the same way. Measured
@@ -26,6 +28,17 @@ export function autoEffortStepsEnabled() {
26
28
  );
27
29
  }
28
30
 
31
+ /** `{ request, base }` when each tool-result step of this turn is judged
32
+ * (session/loop/step-auto-effort.mjs): auto effort is on for the turn, the
33
+ * route has an effort configuration (a mid-conversation update route, or a
34
+ * custom Chat/Responses route whose model declares levels), and the installed
35
+ * judge was trained on steps. Otherwise null. */
36
+ export function turnStepAutoEffort(autoEffort, effortConfiguration) {
37
+ return autoEffort?.mode === 'on' && effortConfiguration && autoEffortStepsEnabled() && effortJudgeSupportsSteps()
38
+ ? { request: autoEffort.request, base: autoEffort.base }
39
+ : null;
40
+ }
41
+
29
42
  export function autoEffortMode() {
30
43
  if (process.env.MIXDOG_AUTO_EFFORT) return normalizeAutoEffortMode(process.env.MIXDOG_AUTO_EFFORT);
31
44
  try {
@@ -53,14 +66,14 @@ function lastText(messages, role) {
53
66
  */
54
67
  export async function resolveTurnAutoEffort({ sessionId, session, provider, input }) {
55
68
  const mode = autoEffortMode();
56
- // promptSource marks runtime-injected turns (completion notices, queued work);
57
- // agent-owned sessions (workers, maintenance) receive runtime-written briefs.
58
- if (mode === 'off' || input?.promptSource || session?.owner === 'agent') return null;
69
+ // promptSource marks runtime-injected turns (completion notices, queued work).
70
+ // Agent sessions are judged like any other: their spawn brief and later Lead
71
+ // `send` messages arrive as plain prompts.
72
+ if (mode === 'off' || input?.promptSource) return null;
59
73
  const request = promptContentText(input?.prompt).trim();
60
74
  // Skill bodies and runtime blocks arrive as tagged text, not as a request.
61
75
  if (!request || request.startsWith('<')) return null;
62
- const opts = { ...(provider?.config || {}), modelParameters: session.modelParameters || {} };
63
- if (!effortConfigurationMode(session.provider, session.model, opts)) return null;
76
+ if (!turnEffortConfigurationMode(session, provider)) return null;
64
77
  const chosen = String(session.effort || '').toLowerCase();
65
78
  const base = autoEffortBase(chosen);
66
79
  // The previous user request shows the work a short follow-up continues.
@@ -85,7 +98,7 @@ export async function resolveTurnAutoEffort({ sessionId, session, provider, inpu
85
98
  }
86
99
  const resolved = resolveAutoEffort({
87
100
  base,
88
- options: effortOptionsFor(session.provider, { id: session.model }),
101
+ options: effortOptionsFor(session.provider, autoEffortModel(session.provider, provider, session.model)),
89
102
  probs: judged.probs,
90
103
  });
91
104
  recordEffortDecision({
@@ -7,8 +7,7 @@
7
7
  import { getProvider } from '../../providers/registry.mjs';
8
8
  import { prepareExplicitSkills } from '../explicit-skills.mjs';
9
9
  import { prepareTurnEffortConfiguration } from '../../providers/effort-configuration.mjs';
10
- import { autoEffortStepsEnabled, resolveTurnAutoEffort } from './ask-turn-auto-effort.mjs';
11
- import { effortJudgeSupportsSteps } from '../../../../effort-judge/judge-client.mjs';
10
+ import { resolveTurnAutoEffort, turnStepAutoEffort } from './ask-turn-auto-effort.mjs';
12
11
  import { saveSessionAsync } from '../store.mjs';
13
12
  import { hasUserConversationMessage, refreshSessionBp3Environment } from './prompt-utils.mjs';
14
13
  import { filterModelVisibleSessionMessages } from './message-sanitize.mjs';
@@ -37,10 +36,7 @@ export async function prepareAskTurn({ sessionId, opened, input, cwdOverride, tr
37
36
  );
38
37
  // Auto effort also judges every tool-result step of the turn when the
39
38
  // installed judge was trained on steps (session/loop/step-auto-effort.mjs).
40
- const stepAutoEffort =
41
- autoEffort?.mode === 'on' && effortConfiguration && autoEffortStepsEnabled() && effortJudgeSupportsSteps()
42
- ? { request: autoEffort.request, base: autoEffort.base }
43
- : null;
39
+ const stepAutoEffort = turnStepAutoEffort(autoEffort, effortConfiguration);
44
40
  applyTurnContextMeta(session, provider);
45
41
  const effectiveCwd = cwdOverride || session.cwd;
46
42
  // A failed-turn retry resubmits the prompt the failure left unanswered;
@@ -4,7 +4,9 @@
4
4
  // resumeSession (reload tools for a stored session) all share the same tool
5
5
  // resolution + context-meta + agent-runtime resolution helpers.
6
6
  import { getProvider } from '../../providers/registry.mjs';
7
+ import { invalidateContextUsageSnapshot, invalidateProviderContextBaseline } from '../loop/compact-policy.mjs';
7
8
  import { saveSession, saveSessionAsync, saveSessionAsyncDeferred, loadSession, setLiveSession } from '../store.mjs';
9
+ import { _liveSessions } from '../store/live-state.mjs';
8
10
  import { _isActivelyOwnedElsewhere, _recoverTurnCheckpointDurably } from './session-owner-liveness.mjs';
9
11
  import { isAgentOwner } from '../../agent-owner.mjs';
10
12
  import { getHiddenAgent } from '../../internal-agents.mjs';
@@ -44,7 +46,7 @@ export { isSessionOwnerGone, recoverSessionAfterProcessRestart } from './session
44
46
  // re-exported so prior importers of this module stay unchanged.
45
47
  export { prefetchSession, prepareSessionProjection } from './prepared-resume-cache.mjs';
46
48
 
47
- function buildSessionProviderCacheOpts(providerName, sessionId, agent = null) {
49
+ function buildSessionProviderCacheOpts(providerName, sessionId, agent = null, config = null) {
48
50
  // Keep this in sync with createSession's provider-cache policy: only
49
51
  // explicit-breakpoint providers get BP cache opts here; OpenAI/key-prefix
50
52
  // providers use promptCacheKey and request-time strategy instead, plus the
@@ -55,7 +57,7 @@ function buildSessionProviderCacheOpts(providerName, sessionId, agent = null) {
55
57
  try {
56
58
  let autoClear = null;
57
59
  if (!agent || agent === 'lead') {
58
- const loadedConfig = loadConfig({ secrets: false });
60
+ const loadedConfig = config || loadConfig({ secrets: false });
59
61
  const normalizedAutoClear = normalizeAutoClearConfig(loadedConfig?.autoClear);
60
62
  autoClear = {
61
63
  ...normalizedAutoClear,
@@ -68,6 +70,29 @@ function buildSessionProviderCacheOpts(providerName, sessionId, agent = null) {
68
70
  }
69
71
  }
70
72
 
73
+ // Re-derive a Lead session's builder-made cache policy (messages TTL follows
74
+ // the auto-clear idle window). Explicit caller/profile policies, non-Lead
75
+ // sessions and non-explicit-breakpoint providers are left untouched.
76
+ export function refreshSessionProviderCacheOpts(session, config = null) {
77
+ if (!session || session.providerCacheOptsOverride === true) return false;
78
+ if (session.agent && session.agent !== 'lead') return false;
79
+ if (cacheCapabilityForProvider(session.provider) !== 'explicit-breakpoint') return false;
80
+ const next = buildSessionProviderCacheOpts(session.provider, session.id, session.agent, config);
81
+ if (!next) return false;
82
+ session.providerCacheOpts = next;
83
+ return true;
84
+ }
85
+
86
+ // An auto-clear settings change reaches every Lead session held in this
87
+ // process, not only the one on screen; persisted ones refresh on resume.
88
+ export function refreshLiveSessionsProviderCacheOpts(config = null) {
89
+ let refreshed = 0;
90
+ for (const session of _liveSessions.values()) {
91
+ if (refreshSessionProviderCacheOpts(session, config)) refreshed += 1;
92
+ }
93
+ return refreshed;
94
+ }
95
+
71
96
  // --- agent spawn (createSession) ---
72
97
  // opts can pass either a `preset` object (from config.presets) or raw provider/model.
73
98
  // Preset shape: { name, provider, model, effort?, fast?, tools? }
@@ -199,6 +224,7 @@ export function createSession(opts) {
199
224
  contextMeta,
200
225
  origin: sessionOriginFields(opts, { profile, presetObj, providerCacheOpts, surface }),
201
226
  });
227
+ session.providerCacheOptsOverride = Boolean(resolved.providerCacheOpts);
202
228
  refreshSessionBp3Environment(session, opts.cwd);
203
229
  // In-process registry + async debounced save: same-process create → load
204
230
  // reads live memory; disk flush is for cross-process / restart durability.
@@ -308,6 +334,7 @@ function applyRouteContextWindow(session, routeChanged, selectedContextWindowPro
308
334
  function resetSessionForRouteChange(id, session, previousModel, previousProvider) {
309
335
  session.promptCacheKey = providerCacheKey(session.provider);
310
336
  session.providerCacheOpts = buildSessionProviderCacheOpts(session.provider, session.id, session.agent) || null;
337
+ session.providerCacheOptsOverride = false;
311
338
  session.lastInputTokens = 0;
312
339
  session.lastOutputTokens = 0;
313
340
  session.lastCachedReadTokens = 0;
@@ -337,7 +364,34 @@ function resetSessionForRouteChange(id, session, previousModel, previousProvider
337
364
  _forgetPreparedResume(id);
338
365
  }
339
366
 
340
- export function updateSessionRoute(id, route = {}) {
367
+ // Every message field a provider adapter replays or re-reads as native state.
368
+ const PROVIDER_REPLAY_FIELDS = [
369
+ 'providerReplay',
370
+ 'reasoning',
371
+ 'thinkingBlocks',
372
+ 'assistantBlocks',
373
+ 'reasoningItems',
374
+ 'reasoningContent',
375
+ 'providerMetadata',
376
+ ];
377
+
378
+ // Provider-native replay (reasoning items, thinking blocks) belongs to the
379
+ // provider that produced it. Replace, never mutate, so delta saves rewrite.
380
+ function stripProviderReplay(session) {
381
+ if (!Array.isArray(session.messages)) return;
382
+ session.messages = session.messages.map((message) => {
383
+ if (!message || !PROVIDER_REPLAY_FIELDS.some((field) => field in message)) return message;
384
+ const plain = { ...message };
385
+ for (const field of PROVIDER_REPLAY_FIELDS) delete plain[field];
386
+ return plain;
387
+ });
388
+ }
389
+
390
+ // `options.crossProvider` is the explicit in-place continuation of an
391
+ // established session on another model: the previous provider's replay state
392
+ // and context readings are dropped so the next turn re-measures (and
393
+ // auto-compacts) against the new model's window.
394
+ export function updateSessionRoute(id, route = {}, options = {}) {
341
395
  if (!id) return null;
342
396
  const session = loadSession(id);
343
397
  if (!session || session.closed === true) return null;
@@ -348,6 +402,11 @@ export function updateSessionRoute(id, route = {}) {
348
402
  (route.provider && route.provider !== previousProvider) || (route.model && route.model !== previousModel);
349
403
  applyRouteContextWindow(session, routeChanged, Object.hasOwn(route, 'selectedContextWindow'));
350
404
  if (routeChanged) resetSessionForRouteChange(id, session, previousModel, previousProvider);
405
+ if (routeChanged && options.crossProvider === true) {
406
+ stripProviderReplay(session);
407
+ invalidateContextUsageSnapshot(session);
408
+ invalidateProviderContextBaseline(session);
409
+ }
351
410
  // The model feeds the `# Session` prompt block (`Model: …`).
352
411
  // Rebuild it here: createSession stamped the block with the creation-time
353
412
  // route and set sessionStartMetaInjected, so the ask-time refresh guard
@@ -449,6 +508,7 @@ export async function resumeSession(sessionId, preset, options = {}) {
449
508
  if (_isActivelyOwnedElsewhere(session, sessionId)) return _attachViewerSession(session, sessionId);
450
509
  _recoverTurnCheckpointDurably(session, sessionId);
451
510
  _refreshResumedTools(session, sessionId, preset);
511
+ refreshSessionProviderCacheOpts(session);
452
512
  // The live session already owns the refreshed tools and desktop scope.
453
513
  // Defer the structured clone + worker round-trip so opening a conversation
454
514
  // is not blocked on persisting the same in-memory state back to disk.
@@ -9,15 +9,15 @@ import { collectPromptSkillsCached } from '../../context/collect.mjs';
9
9
  import { isAgentOwner } from '../../agent-owner.mjs';
10
10
  import { getHiddenAgent } from '../../internal-agents.mjs';
11
11
  import { workflowDisallowsAgentTool } from '../../runtime-core/workflow.mjs';
12
- import { sessionOrchestrationMode } from '../../../../shared/orchestration.mjs';
13
12
  import { finalizeSessionToolList, resolveSessionTools, permissionFromToolSpec } from './tool-resolution.mjs';
14
13
  import { unusedModelEditToolName } from '../../../../shared/edit-tool-dialect.mjs';
15
14
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
16
15
 
17
- // A lead session may not delegate when orchestration is off or its workflow
18
- // forbids the agent tool; an agent-owned session never gets the tool anyway.
16
+ // A lead session may not delegate when its workflow forbids the agent tool
17
+ // (no delegatable agents); Solo keeps it deferred for explicit user requests.
18
+ // An agent-owned session never gets the tool anyway.
19
19
  export function delegationDisabled(source, ownerIsAgent) {
20
- return !ownerIsAgent && (sessionOrchestrationMode(source) === 'none' || workflowDisallowsAgentTool(source?.workflow));
20
+ return !ownerIsAgent && workflowDisallowsAgentTool(source?.workflow);
21
21
  }
22
22
 
23
23
  // Exactly two schema surfaces exist: Lead and Agent. Every Agent role gets
@@ -52,6 +52,7 @@ function restoredTranscriptMetadata(message) {
52
52
  ...(Number.isFinite(Number(value.at)) ? { at: Number(value.at) } : {}),
53
53
  ...transcriptRouteMetadataFields(value),
54
54
  ...(typeof value.sender === 'string' && value.sender ? { sender: value.sender } : {}),
55
+ ...(typeof value.device === 'string' && value.device ? { device: value.device } : {}),
55
56
  ...(completion ? { completion } : {}),
56
57
  };
57
58
  }
@@ -283,7 +283,6 @@ const WORKFLOW_AGENT_STALL_ABORT_S = {
283
283
  reviewer: 300,
284
284
  'heavy-worker': 420,
285
285
  maintainer: 300,
286
- 'web-researcher': 300,
287
286
  };
288
287
 
289
288
  export function resolveAgentStallThresholds(agent, env = process.env) {
@@ -6,6 +6,7 @@ import { Readable } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { getAbortSignalForSession } from '../../session/abort-lookup.mjs';
8
8
  import { hashText } from './hash-utils.mjs';
9
+ import { fileVersion, sameFileVersion } from './file-version.mjs';
9
10
  import { sleep } from '../../../../shared/sleep.mjs';
10
11
  import { ioTraceDone, ioTraceStart } from '../io-trace.mjs';
11
12
 
@@ -20,13 +21,7 @@ function expectedTargetSnapshotChanged(currentStat, expected) {
20
21
  const currentExists = !!currentStat;
21
22
  if (snapshotExists !== currentExists) return true;
22
23
  if (!snapshotExists || !currentExists) return false;
23
- if (currentStat.size !== expected.size) return true;
24
- if (Math.abs(Number(currentStat.mtimeMs) - Number(expected.mtimeMs)) > 1) return true;
25
- if (Number.isFinite(expected.ctimeMs)) {
26
- if (Math.abs(Number(currentStat.ctimeMs) - Number(expected.ctimeMs)) > 1) return true;
27
- }
28
- if (Number.isFinite(expected.ino) && Number(currentStat.ino) !== Number(expected.ino)) return true;
29
- return false;
24
+ return !sameFileVersion(currentStat, expected);
30
25
  }
31
26
 
32
27
  function atomicWriteShouldFsync(value) {
@@ -180,7 +175,17 @@ async function assertExclusiveCreate(writeTarget, targetPath, tmp) {
180
175
  }
181
176
 
182
177
  async function assertTargetUnchanged(writeTarget, expectedTargetSnapshot, tmp) {
183
- if (!expectedTargetSnapshotChanged(statOrNull(writeTarget), expectedTargetSnapshot)) return;
178
+ let currentStat = null;
179
+ try {
180
+ currentStat = statSync(writeTarget);
181
+ } catch (error) {
182
+ // A failed lookup is not proof of absence, even for create-only writes.
183
+ if (error?.code !== 'ENOENT') {
184
+ await unlinkQuietly(tmp);
185
+ throw error;
186
+ }
187
+ }
188
+ if (!expectedTargetSnapshotChanged(currentStat, expectedTargetSnapshot)) return;
184
189
  await unlinkQuietly(tmp);
185
190
  const err = new Error(`target changed between preflight and rename (TOCTOU): ${writeTarget}`);
186
191
  err.code = 'ESTALE_TARGET';
@@ -236,6 +241,11 @@ export async function atomicWrite(
236
241
  content,
237
242
  { mode, signal, sessionId, flags, fsync, preserveMetadata = false, expectedTargetSnapshot } = {}
238
243
  ) {
244
+ // Own the caller's expectation before the first await. Missing fields on
245
+ // an existing target remain incomplete evidence, not an unguarded write.
246
+ expectedTargetSnapshot = expectedTargetSnapshot
247
+ ? { exists: expectedTargetSnapshot.exists !== false, ...fileVersion(expectedTargetSnapshot) }
248
+ : null;
239
249
  const traceStart = ioTraceStart();
240
250
  const resolvedSignal = await resolveWriteSignal(signal, sessionId);
241
251
  if (resolvedSignal?.aborted) throw abortReasonOf(resolvedSignal);
@@ -4,6 +4,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
4
4
  import { isAbsolute, sep } from 'node:path';
5
5
  import { canonicalCachePath, deleteReadRangeIndexForPath } from './read-range-index.mjs';
6
6
  import { resolveAgainstCwd } from './path-utils.mjs';
7
+ import { fileVersion, sameFileVersion } from './file-version.mjs';
7
8
 
8
9
  const RESULT_CACHE = new Map(); // key → { ts, value, paths, scopes, readSnapshotMeta, contentPrefixHash, bytes }
9
10
  const RESULT_CACHE_INFLIGHT = new Map(); // key → { promise, controller, subscribers, settled }
@@ -45,8 +46,8 @@ function resultCacheDelete(key) {
45
46
  const STAT_CACHE = new Map(); // fullPath → { ts, stat }
46
47
  const STAT_CACHE_TTL_MS = 5_000;
47
48
  const STAT_CACHE_MAX_ENTRIES = 2_000;
48
- const RAW_CONTENT_CACHE = new Map(); // fullPath → { ts, mtimeMs, ctimeMs, size, rawBuf }
49
- const RAW_CONTENT_INFLIGHT = new Map(); // canonical path → { path, promise }
49
+ const RAW_CONTENT_CACHE = new Map(); // fullPath → { ts, version, rawBuf }
50
+ const RAW_CONTENT_INFLIGHT = new Map(); // path + observed version → { path, promise }
50
51
  const RAW_CONTENT_CACHE_TTL_MS = 30_000;
51
52
  // 64: a parallel read batch fanning out over a component directory easily
52
53
  // tops 16 files; at 16 the batch thrashed its own cache before re-reads hit.
@@ -232,18 +233,21 @@ async function runPathInFlight(inFlight, key, path, start) {
232
233
  return await promise;
233
234
  }
234
235
 
235
- export async function runRawContentInFlight(fullPath, loader = fsPromises.readFile) {
236
- const key = canonicalCachePath(fullPath);
237
- return await runPathInFlight(RAW_CONTENT_INFLIGHT, key, key, () => loader(fullPath));
236
+ export async function runRawContentInFlight(fullPath, loader = fsPromises.readFile, stat = undefined) {
237
+ const path = canonicalCachePath(fullPath);
238
+ const version = fileVersion(stat);
239
+ // Buffered reads provide their observed version: a later read must not
240
+ // join older bytes merely because a watcher has not fired yet. Preserve
241
+ // the unversioned helper contract for callers that supply no stat.
242
+ if (stat !== undefined && !version) return await loader(fullPath);
243
+ const key = version ? JSON.stringify([path, version]) : path;
244
+ return await runPathInFlight(RAW_CONTENT_INFLIGHT, key, path, () => loader(fullPath));
238
245
  }
239
246
 
240
247
  export async function runReadOnlyStatInFlight(fullPath, loader = fsPromises.stat, kind = 'stat') {
241
248
  const canonicalPath = canonicalCachePath(fullPath);
242
- return await runPathInFlight(
243
- READ_ONLY_STAT_INFLIGHT,
244
- `${kind}|${canonicalPath}`,
245
- canonicalPath,
246
- () => loader(fullPath)
249
+ return await runPathInFlight(READ_ONLY_STAT_INFLIGHT, `${kind}|${canonicalPath}`, canonicalPath, () =>
250
+ loader(fullPath)
247
251
  );
248
252
  }
249
253
 
@@ -262,11 +266,7 @@ export function rawContentCacheGet(fullPath, stat, now = Date.now()) {
262
266
  rawContentCacheDelete(key);
263
267
  return null;
264
268
  }
265
- if (
266
- entry.size !== stat.size ||
267
- Math.abs(entry.mtimeMs - stat.mtimeMs) > 1 ||
268
- Math.abs(entry.ctimeMs - stat.ctimeMs) > 1
269
- ) {
269
+ if (!sameFileVersion(entry.version, stat)) {
270
270
  rawContentCacheDelete(key);
271
271
  return null;
272
272
  }
@@ -276,15 +276,14 @@ export function rawContentCacheGet(fullPath, stat, now = Date.now()) {
276
276
  }
277
277
 
278
278
  export function rawContentCacheSet(fullPath, stat, rawBuf, now = Date.now()) {
279
- if (!fullPath || !stat || !Buffer.isBuffer(rawBuf)) return;
280
- if (rawBuf.length > RAW_CONTENT_CACHE_MAX_BYTES) return;
279
+ if (!fullPath || !Buffer.isBuffer(rawBuf)) return;
280
+ const version = fileVersion(stat);
281
+ if (!version || rawBuf.length !== version.size || rawBuf.length > RAW_CONTENT_CACHE_MAX_BYTES) return;
281
282
  const key = canonicalCachePath(fullPath);
282
283
  rawContentCacheDelete(key);
283
284
  RAW_CONTENT_CACHE.set(key, {
284
285
  ts: now,
285
- mtimeMs: stat.mtimeMs,
286
- ctimeMs: stat.ctimeMs,
287
- size: stat.size,
286
+ version,
288
287
  rawBuf,
289
288
  });
290
289
  RAW_CONTENT_CACHE_BYTES += rawBuf.length;
@@ -14,6 +14,7 @@
14
14
  import { readFileSync, mkdirSync, existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
15
15
  import { dirname } from 'node:path';
16
16
  import { atomicWrite, symlinkWriteTarget } from './atomic-write.mjs';
17
+ import { fileVersion } from './file-version.mjs';
17
18
  import { assertPathsReachable } from './fs-reachability.mjs';
18
19
  import { normalizeInputPath, resolveAgainstCwd, normalizeOutputPath, toDisplayPath } from './path-utils.mjs';
19
20
  import {
@@ -476,15 +477,7 @@ function readEditTextForDisplay(fullPath, enc) {
476
477
  const SAME_STRINGS_ERROR = 'Error: old_string and new_string are exactly the same';
477
478
 
478
479
  function targetSnapshot(statBefore) {
479
- return statBefore
480
- ? {
481
- exists: true,
482
- size: statBefore.size,
483
- mtimeMs: statBefore.mtimeMs,
484
- ctimeMs: statBefore.ctimeMs,
485
- ino: statBefore.ino,
486
- }
487
- : { exists: false };
480
+ return statBefore ? { exists: true, ...fileVersion(statBefore) } : { exists: false };
488
481
  }
489
482
 
490
483
  /**
@@ -493,12 +486,14 @@ function targetSnapshot(statBefore) {
493
486
  * Returns `{ error }` when the bytes cannot be read or are not decidable text.
494
487
  */
495
488
  function readEditTarget(fullPath) {
496
- let statBefore = null;
489
+ // An unreadable pre-stat is incomplete existing-file evidence, not the
490
+ // explicit absence used by createEditTarget. Never resurrect a removed file.
491
+ let statBefore = {};
497
492
  let content;
498
493
  let fileEnc;
499
494
  try {
500
495
  try {
501
- statBefore = statSync(fullPath);
496
+ statBefore = { ...fileVersion(statSync(fullPath)) };
502
497
  } catch {
503
498
  /* readFileSync below surfaces the real error */
504
499
  }
@@ -0,0 +1,11 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ // NTFS identities may exceed Number.MAX_SAFE_INTEGER, where +/- 1 can round
4
+ // back to the original number. Perturb identity, not its precision.
5
+ export function differentFieldValue(current, field, direction = 1) {
6
+ let value;
7
+ if (field === 'ino' || field === 'dev') value = current === 0 ? 1 : 0;
8
+ else value = current + direction * (field.endsWith('Ms') ? 0.5 : 1);
9
+ assert.notEqual(value, current, `${field} fixture must actually differ`);
10
+ return value;
11
+ }
@@ -0,0 +1,13 @@
1
+ // The exact metadata version of a buffered file body. Missing/non-finite
2
+ // evidence is not equality; never round away a detected sub-ms change.
3
+ const FILE_VERSION_FIELDS = ['mtimeMs', 'ctimeMs', 'size', 'ino', 'dev'];
4
+
5
+ export function sameFileVersion(a, b) {
6
+ return !!a && !!b && FILE_VERSION_FIELDS.every((key) => Number.isFinite(a[key]) && a[key] === b[key]);
7
+ }
8
+
9
+ // Own the fields rather than retaining a mutable Stats/fixture object.
10
+ export function fileVersion(stat) {
11
+ if (!sameFileVersion(stat, stat)) return null;
12
+ return Object.fromEntries(FILE_VERSION_FIELDS.map((key) => [key, stat[key]]));
13
+ }