mixdog 0.9.147 → 0.9.148

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 (255) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +2 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  49. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  50. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +17 -11
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +20 -7
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +132 -42
  55. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -3
  59. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  60. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  61. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  62. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  64. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  65. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  66. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  67. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  68. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  69. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  70. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  71. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  72. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  74. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  75. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  76. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  77. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  78. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  79. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  80. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  81. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +45 -8
  82. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  84. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  85. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  87. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  88. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  90. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +178 -29
  92. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  94. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  95. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  101. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  102. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  103. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  104. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  105. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  106. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  107. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  108. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  109. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  110. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  111. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  112. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  113. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  114. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  116. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  117. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  118. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  119. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  120. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  121. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  122. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  123. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  124. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  125. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  126. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  127. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  128. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  129. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  130. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  131. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  132. package/src/runtime/attachments/store.mjs +11 -3
  133. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  134. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  135. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  136. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  137. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  138. package/src/runtime/channels/lib/webhook.mjs +39 -20
  139. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  140. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  141. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  142. package/src/runtime/media/jobs.mjs +13 -0
  143. package/src/runtime/media/renditions.mjs +7 -1
  144. package/src/runtime/media/store.mjs +42 -22
  145. package/src/runtime/memory/index.mjs +4 -9
  146. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  147. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  148. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  149. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  150. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  151. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  152. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  153. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  154. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  155. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  156. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  157. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  158. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  159. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  160. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  161. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  162. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  163. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  164. package/src/runtime/memory/lib/memory.mjs +50 -12
  165. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  166. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  167. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  168. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  169. package/src/runtime/shared/background-tasks.mjs +15 -0
  170. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  171. package/src/runtime/shared/child-guardian.mjs +2 -10
  172. package/src/runtime/shared/pid-liveness.mjs +27 -0
  173. package/src/runtime/shared/runtime-root.mjs +1 -11
  174. package/src/runtime/shared/service-discovery.mjs +1 -17
  175. package/src/runtime/shared/singleton-owner.mjs +1 -11
  176. package/src/runtime/shared/staged-update.mjs +2 -12
  177. package/src/runtime/shared/tool-status.mjs +5 -1
  178. package/src/runtime/shared/tool-surface.mjs +15 -7
  179. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  180. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  181. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  182. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  183. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  184. package/src/runtime/web-search/lib/state.mjs +15 -15
  185. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  186. package/src/session-runtime/context-status.mjs +4 -12
  187. package/src/session-runtime/runtime-core.mjs +20 -1
  188. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  189. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  190. package/src/standalone/agent-tool/notify.mjs +3 -1
  191. package/src/standalone/agent-tool/spawn-flow.mjs +48 -66
  192. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  193. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  194. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  195. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  196. package/src/standalone/agent-tool.mjs +20 -55
  197. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  198. package/src/standalone/channel-client.mjs +47 -15
  199. package/src/standalone/channel-transport.mjs +44 -25
  200. package/src/standalone/daemon.mjs +31 -2
  201. package/src/standalone/hook-bus/config.mjs +25 -9
  202. package/src/standalone/hook-bus/handlers.mjs +16 -52
  203. package/src/standalone/hook-bus.mjs +29 -23
  204. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  205. package/src/standalone/session-client.mjs +86 -23
  206. package/src/standalone/session-protocol.mjs +0 -2
  207. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  208. package/src/standalone/session-runtime-host.mjs +101 -6
  209. package/src/standalone/session-runtime-record.mjs +1 -5
  210. package/src/standalone/session-runtime-worker.mjs +52 -57
  211. package/src/standalone/session-service.mjs +4 -1
  212. package/src/standalone/session-transport.mjs +56 -25
  213. package/src/tui/App.jsx +40 -49
  214. package/src/tui/app/app-view.jsx +14 -21
  215. package/src/tui/app/core-memory-picker.mjs +29 -16
  216. package/src/tui/app/create-app-pickers.mjs +15 -36
  217. package/src/tui/app/extension-pickers.mjs +60 -49
  218. package/src/tui/app/maintenance-pickers.mjs +100 -57
  219. package/src/tui/app/message-selector.mjs +6 -5
  220. package/src/tui/app/model-picker.mjs +30 -13
  221. package/src/tui/app/onboarding-steps.mjs +21 -12
  222. package/src/tui/app/panel-epoch.mjs +56 -0
  223. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  224. package/src/tui/app/panel-surface.mjs +98 -0
  225. package/src/tui/app/project-picker.mjs +30 -29
  226. package/src/tui/app/prompt-submit.mjs +192 -97
  227. package/src/tui/app/prompt-submit.test.mjs +206 -0
  228. package/src/tui/app/provider-setup-picker.mjs +74 -43
  229. package/src/tui/app/resume-picker.mjs +11 -8
  230. package/src/tui/app/route-pickers.mjs +45 -28
  231. package/src/tui/app/settings-picker.mjs +69 -18
  232. package/src/tui/app/shell-layout.mjs +1 -3
  233. package/src/tui/app/slash-commands.mjs +0 -2
  234. package/src/tui/app/slash-dispatch.mjs +4 -2
  235. package/src/tui/app/text-entry-policy.mjs +25 -0
  236. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  237. package/src/tui/app/usage-context-panels.mjs +21 -25
  238. package/src/tui/app/use-global-key-input.mjs +3 -2
  239. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  240. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  241. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  242. package/src/tui/components/Picker.jsx +1 -1
  243. package/src/tui/components/TextEntryPanel.jsx +7 -2
  244. package/src/tui/dist/index.mjs +652 -561
  245. package/src/tui/lib/voice-setup.mjs +1 -2
  246. package/src/tui/session/agent-job-feed.mjs +73 -11
  247. package/src/tui/session/session-api-ext.mjs +5 -6
  248. package/src/tui/session/session-api.mjs +1 -0
  249. package/src/tui/session/tool-approval.mjs +15 -5
  250. package/src/tui/session/tui-steering-persist.mjs +33 -19
  251. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  252. package/src/tui/session-local.mjs +0 -22
  253. package/scripts/.tmp-coverage-report.mjs +0 -96
  254. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  255. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -365,7 +365,7 @@ export function toolLoadingTargets(name, args = {}) {
365
365
 
366
366
  const CATEGORY_ORDER = [
367
367
  'Read', 'Search', 'Load', 'MCP', 'Skill', 'Web Research', 'Memory',
368
- 'Patch', 'Git', 'Shell', 'Agent', 'Task', 'Schedule', 'Channel', 'Setup', 'Other',
368
+ 'Patch', 'Git', 'Shell', 'Agent', 'Task', 'Setup', 'Other',
369
369
  ];
370
370
 
371
371
  const TOOL_CATEGORY = new Map([
@@ -443,8 +443,6 @@ const CATEGORY_COPY = new Map([
443
443
  ['Shell', { active: 'Running', done: 'Ran', noun: 'command' }],
444
444
  ['Agent', { active: 'Calling', done: 'Called', noun: 'agent' }],
445
445
  ['Task', { active: 'Checking', done: 'Checked', noun: 'task' }],
446
- ['Schedule', { active: 'Running', done: 'Ran', noun: 'schedule' }],
447
- ['Channel', { active: 'Sending', done: 'Sent', noun: 'message' }],
448
446
  ['Setup', { active: 'Setting up', done: 'Set up', noun: 'item' }],
449
447
  ['Other', { active: 'Calling', done: 'Called', noun: 'tool' }],
450
448
  ]);
@@ -584,8 +582,11 @@ export function toolWorkUnit(name, args = {}, category = '') {
584
582
  case 'job_wait':
585
583
  return unitDescriptor('Shell', { count: queryCount(a, 'command', 'commands', 'cmd') || 1, noun: 'command' });
586
584
  case 'git':
587
- case 'git_stage':
588
585
  return unitDescriptor('Git', { count: queryCount(a, 'command', 'commands') || 1, noun: 'Git command' });
586
+ // Staging is not "running a Git command": it selects change_ids out of an
587
+ // existing diff. Its own work unit keeps the two apart on the activity row.
588
+ case 'git_stage':
589
+ return unitDescriptor('Git', { count: queryCount(a, 'change_ids', 'change_id') || 1, active: 'Staging', done: 'Staged', noun: 'change' });
589
590
  case 'agent':
590
591
  case 'bridge': {
591
592
  const type = String(a.type || a.action || '').toLowerCase();
@@ -604,8 +605,13 @@ export function toolWorkUnit(name, args = {}, category = '') {
604
605
  }
605
606
  case 'task': {
606
607
  const action = String(a.action || '').toLowerCase();
607
- if (action === 'cancel') return unitDescriptor('Task', { count: queryCount(a, 'task_id', 'task_ids', 'id', 'ids') || 1, active: 'Cancelling', done: 'Cancelled', noun: 'task' });
608
- return unitDescriptor('Task', { count: queryCount(a, 'task_id', 'task_ids', 'id', 'ids') || 1, noun: 'task' });
608
+ const taskCount = queryCount(a, 'task_id', 'task_ids', 'id', 'ids') || 1;
609
+ // Waiting on a task, enumerating tasks, and cancelling one are distinct
610
+ // work; only `read`/`status` falls through to the neutral check verb.
611
+ if (action === 'cancel') return unitDescriptor('Task', { count: taskCount, active: 'Cancelling', done: 'Cancelled', noun: 'task' });
612
+ if (action === 'wait') return unitDescriptor('Task', { count: taskCount, active: 'Waiting for', done: 'Waited for', noun: 'task' });
613
+ if (action === 'list') return unitDescriptor('Task', { count: taskCount, active: 'Listing', done: 'Listed', noun: 'task' });
614
+ return unitDescriptor('Task', { count: taskCount, noun: 'task' });
609
615
  }
610
616
  case 'skill':
611
617
  case 'skill_execute':
@@ -620,7 +626,9 @@ export function toolWorkUnit(name, args = {}, category = '') {
620
626
  count: queryCount(a, 'symbols', 'symbol', 'query', 'file', 'path') || 1,
621
627
  active: searching ? 'Mapping' : 'Reading',
622
628
  done: searching ? 'Mapped' : 'Read',
623
- noun: searching ? 'symbol' : 'file',
629
+ // "code map", not "file": an overview/imports/impact pass reads
630
+ // structure, and sharing the plain read unit hid it behind file reads.
631
+ noun: searching ? 'symbol' : 'code map',
624
632
  });
625
633
  }
626
634
  case 'request_user_input':
@@ -0,0 +1,168 @@
1
+ // Durable turn-snapshot index.
2
+ //
3
+ // The shadow repository already writes every turn baseline to disk as a Git
4
+ // tree object, but the map from a session to that tree lived only in runtime
5
+ // memory. Beginning the next turn, evicting the 32-entry turn cache or
6
+ // restarting the daemon therefore stripped a finished review of its revert
7
+ // source while the tree itself was still sitting on disk (user: 실행취소는 왜
8
+ // 안 먹는거).
9
+ //
10
+ // Persist the three values a revert actually needs — the worktree root, the
11
+ // baseline tree hash, and the paths this session itself mutated — so a review
12
+ // stays revertible for as long as the objects survive. The session-owned path
13
+ // list is what lets a SHARED worktree still revert: the baseline describes the
14
+ // whole tree, but only these paths may be restored from it.
15
+ import { createHash } from 'node:crypto';
16
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ // Same resolution as session-runtime/runtime-paths.mjs. Shared runtime code
21
+ // cannot import the session layer, so the rule is repeated instead of inverted.
22
+ const DATA_DIR = process.env.MIXDOG_DATA_DIR
23
+ || join(process.env.MIXDOG_HOME || join(homedir(), '.mixdog'), 'data');
24
+ const RECORD_VERSION = 1;
25
+ // Matches the shadow repository's own `gc --prune` window: once the tree object
26
+ // is collected the record cannot restore anything anyway.
27
+ const RECORD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
28
+ // Enough to cover the reviews a user can still act on; the newest turn wins.
29
+ const MAX_TURNS_PER_SESSION = 8;
30
+ // A bounded directory: one file per session, oldest swept first.
31
+ const MAX_RECORD_FILES = 512;
32
+
33
+ let recordRoot = join(DATA_DIR, 'turn-snapshots');
34
+ // Per-session write chain. Two turns of one session never race their own file,
35
+ // and a read never observes a partially written record (writes land by rename).
36
+ const writeChains = new Map();
37
+ let sweepDone = false;
38
+
39
+ function clean(value) {
40
+ return typeof value === 'string' ? value.trim() : '';
41
+ }
42
+
43
+ function recordPath(sessionId) {
44
+ const hash = createHash('sha256').update(sessionId).digest('hex').slice(0, 24);
45
+ return join(recordRoot, `${hash}.json`);
46
+ }
47
+
48
+ function chain(sessionId, task) {
49
+ const previous = writeChains.get(sessionId) || Promise.resolve();
50
+ const next = previous.then(task, task);
51
+ writeChains.set(sessionId, next.then(() => undefined, () => undefined));
52
+ return next;
53
+ }
54
+
55
+ async function readRecordFile(path) {
56
+ try {
57
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
58
+ if (!parsed || parsed.version !== RECORD_VERSION) return null;
59
+ if (!Array.isArray(parsed.turns)) return null;
60
+ return parsed;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** Oldest-first sweep so a long-lived install cannot accumulate records. */
67
+ async function sweepRecords() {
68
+ if (sweepDone) return;
69
+ sweepDone = true;
70
+ try {
71
+ const names = await readdir(recordRoot);
72
+ const now = Date.now();
73
+ const kept = [];
74
+ for (const name of names) {
75
+ if (!name.endsWith('.json')) continue;
76
+ const path = join(recordRoot, name);
77
+ try {
78
+ const info = await stat(path);
79
+ if (now - info.mtimeMs > RECORD_TTL_MS) {
80
+ await rm(path, { force: true });
81
+ continue;
82
+ }
83
+ kept.push({ path, mtimeMs: info.mtimeMs });
84
+ } catch { /* a record that cannot be read is not worth keeping */ }
85
+ }
86
+ kept.sort((left, right) => left.mtimeMs - right.mtimeMs);
87
+ for (const entry of kept.slice(0, Math.max(0, kept.length - MAX_RECORD_FILES))) {
88
+ await rm(entry.path, { force: true });
89
+ }
90
+ } catch { /* the store is best-effort: a revert falls back to memory */ }
91
+ }
92
+
93
+ /**
94
+ * Fold one turn into a session's record. `generation` identifies the turn, so
95
+ * re-recording the same turn updates it in place rather than appending.
96
+ */
97
+ export async function saveTurnSnapshotRecord(sessionId, turn) {
98
+ const id = clean(sessionId);
99
+ const baselineTree = clean(turn?.baselineTree);
100
+ const root = clean(turn?.root);
101
+ if (!id || !baselineTree || !root) return false;
102
+ return await chain(id, async () => {
103
+ try {
104
+ await mkdir(recordRoot, { recursive: true });
105
+ void sweepRecords();
106
+ const path = recordPath(id);
107
+ const existing = await readRecordFile(path);
108
+ const generation = Number(turn?.generation) || 0;
109
+ const turns = (existing?.turns || []).filter((entry) => entry?.generation !== generation);
110
+ turns.push({
111
+ generation,
112
+ root,
113
+ baselineTree,
114
+ // Relative, forward-slashed paths this session's own tools mutated.
115
+ toolFiles: [...new Set((Array.isArray(turn?.toolFiles) ? turn.toolFiles : [])
116
+ .map((value) => clean(value))
117
+ .filter(Boolean))],
118
+ sealed: turn?.sealed === true,
119
+ updatedAt: Date.now(),
120
+ });
121
+ turns.sort((left, right) => (left.generation || 0) - (right.generation || 0));
122
+ const payload = {
123
+ version: RECORD_VERSION,
124
+ sessionId: id,
125
+ turns: turns.slice(-MAX_TURNS_PER_SESSION),
126
+ };
127
+ const temporary = `${path}.${process.pid}.tmp`;
128
+ await writeFile(temporary, JSON.stringify(payload), 'utf8');
129
+ await rename(temporary, path);
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ });
135
+ }
136
+
137
+ /**
138
+ * The newest recorded turn that actually owns files. A turn that mutated
139
+ * nothing has nothing to revert, and returning it would hide the completed
140
+ * turn behind it — which is exactly the review the user is looking at.
141
+ */
142
+ export async function loadTurnSnapshotRecord(sessionId) {
143
+ const id = clean(sessionId);
144
+ if (!id) return null;
145
+ const record = await readRecordFile(recordPath(id));
146
+ if (!record) return null;
147
+ for (let index = record.turns.length - 1; index >= 0; index -= 1) {
148
+ const turn = record.turns[index];
149
+ if (!turn?.baselineTree || !turn?.root) continue;
150
+ if (!Array.isArray(turn.toolFiles) || turn.toolFiles.length === 0) continue;
151
+ return turn;
152
+ }
153
+ return null;
154
+ }
155
+
156
+ export async function deleteTurnSnapshotRecords(sessionId) {
157
+ const id = clean(sessionId);
158
+ if (!id) return;
159
+ await chain(id, async () => {
160
+ try { await rm(recordPath(id), { force: true }); } catch { /* best effort */ }
161
+ });
162
+ }
163
+
164
+ export function _setTurnSnapshotStoreRootForTest(directory) {
165
+ recordRoot = clean(directory) || join(DATA_DIR, 'turn-snapshots');
166
+ writeChains.clear();
167
+ sweepDone = false;
168
+ }
@@ -5,9 +5,12 @@ import {
5
5
  _resetTurnWorktreeSnapshotsForTest,
6
6
  createTurnWorktreeSnapshot,
7
7
  refreshTurnWorktreeSnapshot,
8
+ resumeTurnWorktreeSnapshot,
8
9
  revertTurnWorktreeFile,
10
+ revertTurnWorktreePaths,
9
11
  revertTurnWorktreeSnapshot,
10
12
  } from './turn-worktree-snapshot.mjs';
13
+ import { loadTurnSnapshotRecord, saveTurnSnapshotRecord } from './turn-snapshot-store.mjs';
11
14
 
12
15
  // Turn-scoped review registry.
13
16
  //
@@ -96,6 +99,9 @@ function createDiffTracker(meta = {}) {
96
99
  originByCurrentPath: new Map(),
97
100
  unifiedDiff: '',
98
101
  worktreeSnapshot: null,
102
+ // Session-owned relative paths. These outlive the before/after buffers on
103
+ // purpose: they are what scopes a revert once the exact content is gone.
104
+ ownedPaths: new Set(),
99
105
  };
100
106
  }
101
107
 
@@ -346,6 +352,13 @@ export function recordTurnDiffChanges(sessionId, changes = []) {
346
352
  const before = contentBuffer(raw?.before);
347
353
  const after = contentBuffer(raw?.after);
348
354
  const destinationPath = clean(raw?.newPath);
355
+ // WHICH files this session wrote has to survive releasing their content —
356
+ // on turn completion, and on the tracked-size ceiling. That list is the
357
+ // attribution a shared worktree cannot otherwise reconstruct.
358
+ tracker.ownedPaths.add(sourceDisplay);
359
+ if (destinationPath) {
360
+ tracker.ownedPaths.add(displayPath(raw?.newDisplayPath || destinationPath));
361
+ }
349
362
  if (!destinationPath) {
350
363
  if (before === null && after !== null) {
351
364
  tracker.originByCurrentPath.delete(sourceKey);
@@ -549,14 +562,37 @@ export function completeAgentTurnReview(handle, patches = []) {
549
562
  }
550
563
  }
551
564
 
565
+ /** Persist the durable half of a finished review: the baseline tree hash plus
566
+ * the paths this session owns. Memory keeps the fast path; the record is what
567
+ * survives the next turn, a cache eviction and a runtime restart. A CONTENDED
568
+ * worktree is recorded too — the baseline is still exact, and the owned paths
569
+ * are precisely the attribution that contention destroys. */
570
+ async function persistTurnSnapshotRecord(ownerSessionId, tracker) {
571
+ const snapshot = tracker?.worktreeSnapshot;
572
+ if (!ownerSessionId || !snapshot?.baselineTree || !snapshot.root) return;
573
+ const owned = [...tracker.ownedPaths];
574
+ if (owned.length === 0) return;
575
+ try {
576
+ await saveTurnSnapshotRecord(ownerSessionId, {
577
+ generation: _turnsBySession.get(ownerSessionId)?.generation || 0,
578
+ root: snapshot.root,
579
+ baselineTree: snapshot.baselineTree,
580
+ toolFiles: owned,
581
+ sealed: true,
582
+ });
583
+ } catch { /* the record adds to a review, it is never required by one */ }
584
+ }
585
+
552
586
  /** Freeze a completed Lead turn while retaining only its bounded review data. */
553
587
  export async function completeTurnSnapshot(sessionId) {
554
- const tracker = _diffTrackersBySession.get(clean(sessionId));
588
+ const ownerSessionId = clean(sessionId);
589
+ const tracker = _diffTrackersBySession.get(ownerSessionId);
555
590
  if (!tracker) return false;
556
591
  if (worktreeSnapshotUsable(tracker)) {
557
592
  try { await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot); } catch {}
558
593
  }
559
594
  tracker.sealed = true;
595
+ await persistTurnSnapshotRecord(ownerSessionId, tracker);
560
596
  // Only a usable Git baseline can replace the exact apply_patch buffers. A
561
597
  // contended worktree — and a worktree with no Git baseline at all — retains
562
598
  // this session's bounded before/after content so Review can still undo its
@@ -590,6 +626,71 @@ function worktreeSnapshotUsable(tracker) {
590
626
  return Boolean(tracker && !tracker.worktreeContended && tracker.worktreeSnapshot);
591
627
  }
592
628
 
629
+ /** Match a user-supplied file against a recorded session-owned path. */
630
+ function recordedPathMatchesFile(owned, root, file) {
631
+ const target = clean(file);
632
+ if (!target) return false;
633
+ const normalizedRoot = pathKey(root);
634
+ const normalizedTarget = pathKey(target);
635
+ const relativeTarget = normalizedRoot && normalizedTarget.startsWith(`${normalizedRoot}/`)
636
+ ? normalizedTarget.slice(normalizedRoot.length + 1)
637
+ : normalizedTarget;
638
+ const normalizedOwned = pathKey(owned);
639
+ return normalizedOwned === relativeTarget || normalizedOwned === normalizedTarget;
640
+ }
641
+
642
+ /** Re-open the recorded baseline for a session whose tracker is gone. Returns
643
+ * null when the record is missing or its tree object has been collected. */
644
+ async function resumeRecordedSnapshot(ownerSessionId) {
645
+ if (!ownerSessionId) return null;
646
+ let record = null;
647
+ try {
648
+ record = await loadTurnSnapshotRecord(ownerSessionId);
649
+ } catch {
650
+ return null;
651
+ }
652
+ if (!record) return null;
653
+ const snapshot = await resumeTurnWorktreeSnapshot(record.root, record.baselineTree, {
654
+ paths: record.toolFiles,
655
+ }).catch(() => null);
656
+ if (!snapshot) return null;
657
+ return { record, snapshot };
658
+ }
659
+
660
+ /** A review rebuilt from the durable record. Same shape as the live one, but
661
+ * scoped to the recording session's own paths so a worktree shared with other
662
+ * sessions stays attributable instead of losing its revert entirely. */
663
+ async function scopedReviewFromRecord(ownerSessionId) {
664
+ const resumed = await resumeRecordedSnapshot(ownerSessionId);
665
+ if (!resumed) return null;
666
+ return {
667
+ supported: true,
668
+ files: resumed.snapshot.files || [],
669
+ patch: resumed.snapshot.patch || '',
670
+ snapshotKind: 'scoped',
671
+ revertMode: 'scoped',
672
+ patchTruncated: resumed.snapshot.patchTruncated === true,
673
+ authoritative: true,
674
+ agents: publicAgentReviews(ownerSessionId),
675
+ reason: 'recorded',
676
+ };
677
+ }
678
+
679
+ /** Restore recorded paths — every one of them, or just the file the user picked. */
680
+ async function revertFromRecord(worktree, ownerSessionId, file) {
681
+ const resumed = await resumeRecordedSnapshot(ownerSessionId);
682
+ if (!resumed) return null;
683
+ const owned = resumed.record.toolFiles;
684
+ const targets = file
685
+ ? owned.filter((path) => recordedPathMatchesFile(path, resumed.record.root, file))
686
+ : owned;
687
+ if (targets.length === 0) {
688
+ throw new Error('turn review revert cannot attribute this file to the session');
689
+ }
690
+ await revertTurnWorktreePaths(resumed.snapshot, targets);
691
+ return await getTurnReviewDiff(worktree, ownerSessionId);
692
+ }
693
+
593
694
  /** Return the authoritative Lead net diff plus attributed child reviews. */
594
695
  export async function getTurnReviewDiff(_worktree, sessionId, options = {}) {
595
696
  if (DISABLED) return { supported: false, files: [], patch: '', agents: [] };
@@ -609,6 +710,13 @@ export async function getTurnReviewDiff(_worktree, sessionId, options = {}) {
609
710
  const trackedRevertAvailable = !snapshot
610
711
  && tracker?.valid === true
611
712
  && trackedPairs(tracker).length > 0;
713
+ if (!snapshot && !trackedRevertAvailable) {
714
+ // Both in-memory sources are gone: the next turn replaced the tracker, the
715
+ // turn cache evicted it, or the runtime restarted. The recorded baseline is
716
+ // still on disk and still exact.
717
+ const recorded = await scopedReviewFromRecord(ownerSessionId);
718
+ if (recorded) return recorded;
719
+ }
612
720
  return {
613
721
  supported: true,
614
722
  files: snapshot?.files || [],
@@ -635,15 +743,23 @@ export async function revertTurnReviewFile(_worktree, sessionId, file) {
635
743
  const tracker = _diffTrackersBySession.get(ownerSessionId);
636
744
  if (tracker && !worktreeSnapshotUsable(tracker)) {
637
745
  const pairs = trackedPairs(tracker);
638
- if (pairs.length === 0) throw new Error(unavailableTrackedRevertReason(tracker));
639
- const pair = pairs.find((entry) => pairMatchesFile(entry, _worktree, file));
640
- if (!pair) throw new Error('turn review tracked file snapshot is unavailable');
641
- await revertTrackedPairs(tracker, _worktree, [pair]);
746
+ if (pairs.length > 0) {
747
+ const pair = pairs.find((entry) => pairMatchesFile(entry, _worktree, file));
748
+ if (!pair) throw new Error('turn review tracked file snapshot is unavailable');
749
+ await revertTrackedPairs(tracker, _worktree, [pair]);
750
+ return await getTurnReviewDiff(_worktree, ownerSessionId);
751
+ }
752
+ const recorded = await revertFromRecord(_worktree, ownerSessionId, file);
753
+ if (recorded) return recorded;
754
+ throw new Error(unavailableTrackedRevertReason(tracker));
755
+ }
756
+ if (tracker?.worktreeSnapshot) {
757
+ await revertTurnWorktreeFile(tracker.worktreeSnapshot, file);
642
758
  return await getTurnReviewDiff(_worktree, ownerSessionId);
643
759
  }
644
- if (!tracker?.worktreeSnapshot) throw new Error('turn worktree snapshot is unavailable');
645
- await revertTurnWorktreeFile(tracker.worktreeSnapshot, file);
646
- return await getTurnReviewDiff(_worktree, ownerSessionId);
760
+ const recorded = await revertFromRecord(_worktree, ownerSessionId, file);
761
+ if (recorded) return recorded;
762
+ throw new Error('turn worktree snapshot is unavailable');
647
763
  }
648
764
 
649
765
  /** Restore every file in the reviewed turn to its turn-start state. */
@@ -652,13 +768,21 @@ export async function revertTurnReview(_worktree, sessionId) {
652
768
  const tracker = _diffTrackersBySession.get(ownerSessionId);
653
769
  if (tracker && !worktreeSnapshotUsable(tracker)) {
654
770
  const pairs = trackedPairs(tracker);
655
- if (pairs.length === 0) throw new Error(unavailableTrackedRevertReason(tracker));
656
- await revertTrackedPairs(tracker, _worktree, pairs);
771
+ if (pairs.length > 0) {
772
+ await revertTrackedPairs(tracker, _worktree, pairs);
773
+ return await getTurnReviewDiff(_worktree, ownerSessionId);
774
+ }
775
+ const recorded = await revertFromRecord(_worktree, ownerSessionId, null);
776
+ if (recorded) return recorded;
777
+ throw new Error(unavailableTrackedRevertReason(tracker));
778
+ }
779
+ if (tracker?.worktreeSnapshot) {
780
+ await revertTurnWorktreeSnapshot(tracker.worktreeSnapshot);
657
781
  return await getTurnReviewDiff(_worktree, ownerSessionId);
658
782
  }
659
- if (!tracker?.worktreeSnapshot) throw new Error('turn worktree snapshot is unavailable');
660
- await revertTurnWorktreeSnapshot(tracker.worktreeSnapshot);
661
- return await getTurnReviewDiff(_worktree, ownerSessionId);
783
+ const recorded = await revertFromRecord(_worktree, ownerSessionId, null);
784
+ if (recorded) return recorded;
785
+ throw new Error('turn worktree snapshot is unavailable');
662
786
  }
663
787
 
664
788
  export function _resetTurnSnapshotForTest() {
@@ -10,14 +10,23 @@ import {
10
10
  rm,
11
11
  writeFile,
12
12
  } from 'node:fs/promises';
13
- import { tmpdir } from 'node:os';
13
+ import { homedir } from 'node:os';
14
14
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
15
15
 
16
16
  const COMMAND_TIMEOUT_MS = 30_000;
17
17
  const COMMAND_MAX_BYTES = 8 * 1024 * 1024;
18
18
  const PATCH_MAX_BYTES = 2 * 1024 * 1024;
19
19
  const MAX_UNTRACKED_FILE_BYTES = 2 * 1024 * 1024;
20
- const SNAPSHOT_ROOT = join(tmpdir(), 'mixdog-turn-worktree-snapshots-v1');
20
+ // Baselines used to live in the OS temp directory, where a reboot or a disk
21
+ // cleaner could remove the only copy of a turn's revert source. Keep them with
22
+ // the rest of the runtime data (same resolution as
23
+ // session-runtime/runtime-paths.mjs; shared code cannot import that layer).
24
+ const DATA_DIR = process.env.MIXDOG_DATA_DIR
25
+ || join(process.env.MIXDOG_HOME || join(homedir(), '.mixdog'), 'data');
26
+ const SNAPSHOT_ROOT = join(DATA_DIR, 'turn-worktree-snapshots-v1');
27
+ // A baseline tree is unreachable by design (no commit, no ref), so collection
28
+ // is what bounds how long a review stays revertible.
29
+ const SHADOW_GC_PRUNE = '7.days.ago';
21
30
  const states = new Map();
22
31
 
23
32
  function clean(value) {
@@ -417,11 +426,14 @@ function parseNumstat(text) {
417
426
  return out;
418
427
  }
419
428
 
420
- async function diffTreesUnlocked(state, baselineTree, currentTree) {
429
+ async function diffTreesUnlocked(state, baselineTree, currentTree, paths = null) {
421
430
  if (!baselineTree || !currentTree || baselineTree === currentTree) {
422
431
  return { patch: '', files: [], patchTruncated: false, currentTree };
423
432
  }
424
- const rangeArgs = ['--find-renames', baselineTree, currentTree, '--', '.'];
433
+ // A scoped review restricts the range to the paths its session owns: the
434
+ // baseline describes the whole worktree, concurrent sessions included.
435
+ const pathspec = Array.isArray(paths) && paths.length > 0 ? paths : ['.'];
436
+ const rangeArgs = ['--find-renames', baselineTree, currentTree, '--', ...pathspec];
425
437
  const [nameStatus, numstat] = await Promise.all([
426
438
  runGit(shadowArgs(state, ['diff', '--name-status', '-z', ...rangeArgs]), { cwd: state.root }),
427
439
  runGit(shadowArgs(state, ['diff', '--numstat', '-z', ...rangeArgs]), { cwd: state.root }),
@@ -462,6 +474,26 @@ async function diffTreesUnlocked(state, baselineTree, currentTree) {
462
474
  return { patch, files, patchTruncated, currentTree };
463
475
  }
464
476
 
477
+ // One maintenance pass per shadow repository per process, off the turn's
478
+ // critical path. `--auto` does nothing until the repository actually needs it,
479
+ // so the common case costs a process spawn and nothing else.
480
+ const maintainedShadowRepositories = new Set();
481
+
482
+ function scheduleShadowGc(state) {
483
+ if (maintainedShadowRepositories.has(state.gitDir)) return;
484
+ maintainedShadowRepositories.add(state.gitDir);
485
+ const timer = setTimeout(() => {
486
+ // Deliberately outside the state lock: collection can run for minutes and
487
+ // git serializes itself through gc.lock, so a clash just retries later.
488
+ void runGit(shadowArgs(state, ['gc', '--auto', `--prune=${SHADOW_GC_PRUNE}`, '--quiet']), {
489
+ cwd: state.root,
490
+ allowFailure: true,
491
+ timeoutMs: 300_000,
492
+ }).catch(() => {});
493
+ }, 60_000);
494
+ timer.unref?.();
495
+ }
496
+
465
497
  export async function createTurnWorktreeSnapshot(worktree) {
466
498
  const root = await repositoryRoot(worktree).catch(() => null);
467
499
  if (!root) return null;
@@ -469,6 +501,7 @@ export async function createTurnWorktreeSnapshot(worktree) {
469
501
  return await withStateLock(state, async () => {
470
502
  const baselineTree = await captureTreeUnlocked(state);
471
503
  if (!baselineTree) return null;
504
+ scheduleShadowGc(state);
472
505
  return {
473
506
  state,
474
507
  root,
@@ -477,16 +510,57 @@ export async function createTurnWorktreeSnapshot(worktree) {
477
510
  patch: '',
478
511
  files: [],
479
512
  patchTruncated: false,
513
+ scopePaths: null,
480
514
  };
481
515
  });
482
516
  }
483
517
 
518
+ /** Re-open a recorded baseline after the runtime lost its in-memory tracker.
519
+ * The tree object is the durable half of a review; `paths` scopes both this
520
+ * diff and any later revert to what the recording session actually owns. */
521
+ export async function resumeTurnWorktreeSnapshot(worktree, baselineTree, { paths = null } = {}) {
522
+ const tree = clean(baselineTree);
523
+ if (!tree) return null;
524
+ const root = await repositoryRoot(worktree).catch(() => null);
525
+ if (!root) return null;
526
+ const state = stateForRoot(root);
527
+ return await withStateLock(state, async () => {
528
+ await ensureState(state);
529
+ // A collected or never-written baseline restores nothing. Report that as
530
+ // "no snapshot" instead of failing every later call made against it.
531
+ const kind = await runGit(shadowArgs(state, ['cat-file', '-t', tree]), {
532
+ cwd: state.root,
533
+ allowFailure: true,
534
+ });
535
+ if (kind.code !== 0 || clean(kind.stdout) !== 'tree') return null;
536
+ const scopePaths = Array.isArray(paths) && paths.length > 0 ? [...new Set(paths)] : null;
537
+ const snapshot = {
538
+ state,
539
+ root,
540
+ baselineTree: tree,
541
+ currentTree: tree,
542
+ patch: '',
543
+ files: [],
544
+ patchTruncated: false,
545
+ scopePaths,
546
+ };
547
+ const currentTree = await captureTreeUnlocked(state);
548
+ Object.assign(snapshot, await diffTreesUnlocked(state, tree, currentTree, scopePaths));
549
+ return snapshot;
550
+ });
551
+ }
552
+
484
553
  export async function refreshTurnWorktreeSnapshot(snapshot) {
485
554
  if (!snapshot?.state || !snapshot.baselineTree) return null;
486
555
  return await withStateLock(snapshot.state, async () => {
487
556
  const currentTree = await captureTreeUnlocked(snapshot.state);
488
557
  if (currentTree === snapshot.currentTree) return snapshot;
489
- const review = await diffTreesUnlocked(snapshot.state, snapshot.baselineTree, currentTree);
558
+ const review = await diffTreesUnlocked(
559
+ snapshot.state,
560
+ snapshot.baselineTree,
561
+ currentTree,
562
+ snapshot.scopePaths,
563
+ );
490
564
  Object.assign(snapshot, review);
491
565
  return snapshot;
492
566
  });
@@ -531,19 +605,29 @@ async function restorePathFromTree(snapshot, rel) {
531
605
  }
532
606
  }
533
607
 
608
+ async function revertPathsUnlocked(snapshot, targets) {
609
+ for (const target of targets) {
610
+ await restorePathFromTree(snapshot, target);
611
+ }
612
+ const currentTree = await captureTreeUnlocked(snapshot.state);
613
+ Object.assign(snapshot, await diffTreesUnlocked(
614
+ snapshot.state,
615
+ snapshot.baselineTree,
616
+ currentTree,
617
+ snapshot.scopePaths,
618
+ ));
619
+ return snapshot;
620
+ }
621
+
534
622
  export async function revertTurnWorktreeFile(snapshot, value) {
535
623
  if (!snapshot?.state || !snapshot.baselineTree) throw new Error('turn worktree snapshot is unavailable');
536
624
  return await withStateLock(snapshot.state, async () => {
537
625
  const rel = safeRelativePath(snapshot.root, value);
538
626
  const entry = snapshot.files.find((file) =>
539
627
  pathKey(file.path) === pathKey(rel) || pathKey(file.oldPath) === pathKey(rel));
540
- const targets = [...new Set([entry?.path || rel, entry?.oldPath].filter(Boolean))];
541
- for (const target of targets) {
542
- await restorePathFromTree(snapshot, safeRelativePath(snapshot.root, target));
543
- }
544
- const currentTree = await captureTreeUnlocked(snapshot.state);
545
- Object.assign(snapshot, await diffTreesUnlocked(snapshot.state, snapshot.baselineTree, currentTree));
546
- return snapshot;
628
+ const targets = [...new Set([entry?.path || rel, entry?.oldPath].filter(Boolean))]
629
+ .map((target) => safeRelativePath(snapshot.root, target));
630
+ return await revertPathsUnlocked(snapshot, targets);
547
631
  });
548
632
  }
549
633
 
@@ -557,12 +641,20 @@ export async function revertTurnWorktreeSnapshot(snapshot) {
557
641
  .flatMap((file) => [file.path, file.oldPath])
558
642
  .filter(Boolean))]
559
643
  .map((target) => safeRelativePath(snapshot.root, target));
560
- for (const target of targets) {
561
- await restorePathFromTree(snapshot, target);
562
- }
563
- const currentTree = await captureTreeUnlocked(snapshot.state);
564
- Object.assign(snapshot, await diffTreesUnlocked(snapshot.state, snapshot.baselineTree, currentTree));
565
- return snapshot;
644
+ return await revertPathsUnlocked(snapshot, targets);
645
+ });
646
+ }
647
+
648
+ /** Restore only the listed paths. This is the session-owned revert: a shared
649
+ * worktree cannot attribute the whole-tree diff, but the paths this session's
650
+ * own tools wrote are exactly attributable. */
651
+ export async function revertTurnWorktreePaths(snapshot, values) {
652
+ if (!snapshot?.state || !snapshot.baselineTree) throw new Error('turn worktree snapshot is unavailable');
653
+ const requested = (Array.isArray(values) ? values : []).filter(Boolean);
654
+ if (requested.length === 0) throw new Error('turn review revert has no restorable files');
655
+ return await withStateLock(snapshot.state, async () => {
656
+ const targets = [...new Set(requested.map((value) => safeRelativePath(snapshot.root, value)))];
657
+ return await revertPathsUnlocked(snapshot, targets);
566
658
  });
567
659
  }
568
660