mixdog 1.4.2 → 1.4.3

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 (417) hide show
  1. package/package.json +2 -2
  2. package/scripts/build-shipped-history.mjs +15 -5
  3. package/scripts/gate-local.mjs +65 -19
  4. package/scripts/graph-parity.mjs +8 -3
  5. package/scripts/lib/gate-scheduler.mjs +2 -1
  6. package/scripts/lib/run-node-tests.mjs +17 -5
  7. package/scripts/lib/test-failure-summary.mjs +1 -8
  8. package/scripts/lib/test-spawn-ledger.mjs +33 -2
  9. package/scripts/post-release-check.mjs +26 -16
  10. package/scripts/provider-pingpong-live.mjs +1 -1
  11. package/scripts/prune-embedding-runtime.mjs +3 -1
  12. package/scripts/release-paths.mjs +7 -1
  13. package/src/defaults/shipped-history.json +20 -0
  14. package/src/defaults/skills/setup/SKILL.md +2 -2
  15. package/src/defaults/skills/setup/references/actions.md +16 -3
  16. package/src/defaults/skills/setup/references/surfaces.md +3 -0
  17. package/src/defaults/skills/setup-guide/SKILL.md +6 -1
  18. package/src/lib/instruction-text.cjs +36 -0
  19. package/src/lib/rules-builder.cjs +34 -20
  20. package/src/orchestration/balanced.md +23 -0
  21. package/src/orchestration/focused.md +22 -0
  22. package/src/orchestration/none.md +5 -0
  23. package/src/orchestration/swarm.md +24 -0
  24. package/src/rules/agent-mode/AGENT_MODE.md +3 -1
  25. package/src/rules/agent-mode/MIXY.md +2 -2
  26. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/session-prep.mjs +3 -3
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -2
  29. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +0 -9
  30. package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +2 -2
  31. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +67 -0
  32. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +1 -0
  33. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +4 -4
  34. package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +3 -1
  35. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +199 -46
  36. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +3 -5
  37. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +9 -3
  38. package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -5
  39. package/src/runtime/agent/orchestrator/mcp/oauth-client.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +8 -2
  41. package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +1 -1
  42. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +14 -0
  43. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-stop.mjs +3 -3
  46. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +4 -6
  49. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-head-parity.json +2784 -2872
  50. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +5 -8
  51. package/src/runtime/agent/orchestrator/providers/openai-responses-payload-parity.fixture.json +288 -300
  52. package/src/runtime/agent/orchestrator/providers/vertex-ai.mjs +10 -1
  53. package/src/runtime/agent/orchestrator/providers/xai-tool-search-head-parity.json +10 -30
  54. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +30 -0
  55. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +0 -11
  56. package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-availability.mjs +8 -0
  57. package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-delta.mjs +17 -3
  58. package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +47 -27
  59. package/src/runtime/agent/orchestrator/runtime-core/goal-deadlines.mjs +141 -10
  60. package/src/runtime/agent/orchestrator/runtime-core/goal-mutations.mjs +26 -63
  61. package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +28 -3
  62. package/src/runtime/agent/orchestrator/runtime-core/goal-runtime.mjs +36 -11
  63. package/src/runtime/agent/orchestrator/runtime-core/goal-state.mjs +88 -56
  64. package/src/runtime/agent/orchestrator/runtime-core/goal-storage.mjs +4 -1
  65. package/src/runtime/agent/orchestrator/runtime-core/goal-store.mjs +51 -32
  66. package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +45 -145
  67. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +15 -98
  68. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +32 -99
  69. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +23 -78
  70. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +69 -48
  71. package/src/runtime/agent/orchestrator/runtime-core/todo-list.mjs +367 -0
  72. package/src/runtime/agent/orchestrator/runtime-core/todo-tool-defs.mjs +47 -0
  73. package/src/runtime/agent/orchestrator/runtime-core/tool-availability.mjs +155 -0
  74. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +9 -0
  75. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +119 -35
  76. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +1 -1
  77. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +1 -1
  78. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -22
  79. package/src/runtime/agent/orchestrator/session/agent-pool-progress.mjs +30 -0
  80. package/src/runtime/agent/orchestrator/session/attachment-preflight.mjs +1 -1
  81. package/src/runtime/agent/orchestrator/session/cache-warm.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +2 -2
  83. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +1 -1
  84. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +1 -1
  85. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +3 -0
  86. package/src/runtime/agent/orchestrator/session/compact/summary-route.mjs +1 -1
  87. package/src/runtime/agent/orchestrator/session/context-utils.mjs +8 -12
  88. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +1 -1
  89. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +7 -3
  92. package/src/runtime/agent/orchestrator/session/human-gate/held-message.mjs +1 -1
  93. package/src/runtime/agent/orchestrator/session/human-gate/requests.mjs +1 -2
  94. package/src/runtime/agent/orchestrator/session/human-gate/run-policy.mjs +1 -1
  95. package/src/runtime/agent/orchestrator/session/image-strip-recovery.mjs +1 -1
  96. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +11 -1
  97. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +8 -1
  99. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/todo-check.mjs +196 -0
  100. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +116 -50
  101. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +8 -1
  102. package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +1 -1
  104. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +3 -1
  105. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +5 -6
  106. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +6 -1
  107. package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +16 -2
  108. package/src/runtime/agent/orchestrator/session/loop/turn-report.mjs +35 -0
  109. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +1 -1
  110. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +57 -26
  111. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +9 -7
  112. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +21 -3
  113. package/src/runtime/agent/orchestrator/session/manager/ask-turn-failure.mjs +27 -11
  114. package/src/runtime/agent/orchestrator/session/manager/ask-turn-open.mjs +37 -34
  115. package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +15 -0
  116. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +25 -15
  117. package/src/runtime/agent/orchestrator/session/manager/ask-turn-state.mjs +79 -24
  118. package/src/runtime/agent/orchestrator/session/manager/ask-turn-steering-drain.mjs +3 -0
  119. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +12 -1
  120. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  121. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +6 -1
  122. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +9 -1
  123. package/src/runtime/agent/orchestrator/session/manager/pending-spool-path.mjs +2 -2
  124. package/src/runtime/agent/orchestrator/session/manager/pending-spool-shard.mjs +1 -1
  125. package/src/runtime/agent/orchestrator/session/manager/prompt-settings.mjs +598 -0
  126. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +19 -10
  128. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +5 -0
  129. package/src/runtime/agent/orchestrator/session/manager/session-fallback-title.mjs +1 -1
  130. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +31 -0
  131. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +31 -0
  132. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +42 -6
  133. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +2 -0
  134. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +11 -1
  135. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-context.mjs +8 -9
  136. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +339 -88
  137. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-recovery.mjs +135 -11
  138. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +377 -104
  139. package/src/runtime/agent/orchestrator/session/manager/turn-durability.mjs +144 -0
  140. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +1 -1
  141. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +4 -0
  142. package/src/runtime/agent/orchestrator/session/manager.mjs +6 -0
  143. package/src/runtime/agent/orchestrator/session/mixy-name.mjs +1 -1
  144. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +8 -1
  145. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +5 -1
  146. package/src/runtime/agent/orchestrator/session/store/canonical-reader.mjs +1 -1
  147. package/src/runtime/agent/orchestrator/session/store/liveness-veto.mjs +2 -2
  148. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +23 -2
  149. package/src/runtime/agent/orchestrator/session/store/sweep/sub-agent-retention.mjs +3 -3
  150. package/src/runtime/agent/orchestrator/session/store/transcript-durability.mjs +88 -0
  151. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +5 -0
  152. package/src/runtime/agent/orchestrator/session/store-summary-log.mjs +1 -1
  153. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +14 -5
  154. package/src/runtime/agent/orchestrator/session/store-transcript-worker.mjs +2 -2
  155. package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
  156. package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +7 -1
  157. package/src/runtime/agent/orchestrator/session/tail-reminder-rules.mjs +9 -1
  158. package/src/runtime/agent/orchestrator/session/transcript-restore/agent-envelope.mjs +1 -1
  159. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +3 -1
  160. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +2 -2
  161. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -5
  162. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/backgrounded-result.mjs +4 -1
  163. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/misuse-guard.mjs +21 -3
  164. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/result-format.mjs +1 -1
  165. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/worktree-add-guard.mjs +77 -0
  166. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +0 -1
  167. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +7 -3
  168. package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +27 -9
  169. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +2 -2
  170. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +1 -1
  171. package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +0 -1
  172. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +0 -10
  173. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +1 -2
  174. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -24
  175. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary/child-kill.mjs +4 -3
  176. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +1 -2
  177. package/src/runtime/agent/orchestrator/tools/code-graph/modes/calls.mjs +1 -1
  178. package/src/runtime/agent/orchestrator/tools/code-graph/modes/symbols.mjs +2 -2
  179. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +4 -5
  180. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +3 -4
  181. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4 -22
  182. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +2 -2
  183. package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +1 -1
  184. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +0 -4
  185. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +0 -1
  186. package/src/runtime/attachments/attachment-reference.mjs +1 -1
  187. package/src/runtime/attachments/pdf-extract.mjs +1 -1
  188. package/src/runtime/attachments/store.d.mts +1 -0
  189. package/src/runtime/attachments/store.mjs +2 -2
  190. package/src/runtime/channels/lib/config-reload-watch.mjs +42 -0
  191. package/src/runtime/channels/lib/owned-runtime/config-reload.mjs +29 -9
  192. package/src/runtime/channels/lib/scheduler.mjs +33 -0
  193. package/src/runtime/channels/lib/schedules-db.mjs +11 -4
  194. package/src/runtime/channels/lib/webhook/session-dispatch.mjs +4 -1
  195. package/src/runtime/channels/lib/webhooks-db.mjs +9 -3
  196. package/src/runtime/channels/lib/whisper-server.mjs +12 -12
  197. package/src/runtime/channels/lib/worker-main.mjs +9 -31
  198. package/src/runtime/computer-bridge/linux-readiness.mjs +10 -14
  199. package/src/runtime/connectors/connector-accounts-mcp.mjs +169 -33
  200. package/src/runtime/connectors/gmail-app-password.mjs +2 -2
  201. package/src/runtime/connectors/google-mcp.mjs +1 -1
  202. package/src/runtime/effort-judge/judge-client.mjs +2 -2
  203. package/src/runtime/effort-judge/judge-input.mjs +4 -4
  204. package/src/runtime/explore/explorer.mjs +0 -5
  205. package/src/runtime/explore/tool.mjs +3 -3
  206. package/src/runtime/local-provider/hardware.mjs +1 -1
  207. package/src/runtime/media/adapters/codex-image.mjs +3 -3
  208. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  209. package/src/runtime/media/media-usage.mjs +1 -1
  210. package/src/runtime/media/store.mjs +1 -1
  211. package/src/runtime/memory/index.mjs +11 -1
  212. package/src/runtime/memory/lib/embedding-reindex.mjs +27 -0
  213. package/src/runtime/memory/lib/memory-embed.mjs +83 -14
  214. package/src/runtime/memory/lib/memory-process-lock.mjs +159 -36
  215. package/src/runtime/memory/lib/memory-text-utils.mjs +6 -1
  216. package/src/runtime/memory/lib/memory.mjs +17 -0
  217. package/src/runtime/memory/lib/pg/process.mjs +1 -1
  218. package/src/runtime/memory/lib/pg/supervisor.mjs +16 -96
  219. package/src/runtime/memory/lib/recall-hybrid/hybrid-sql.mjs +12 -3
  220. package/src/runtime/office/authoring/docx-html-runner.mjs +1 -1
  221. package/src/runtime/office/authoring/pptx-author-action.mjs +1 -1
  222. package/src/runtime/office/authoring/xlsx-html-build.mjs +1 -1
  223. package/src/runtime/office/authoring/xlsx-html-runner.mjs +1 -1
  224. package/src/runtime/office/capabilities.mjs +1 -1
  225. package/src/runtime/office/core/office-core.mjs +1 -1
  226. package/src/runtime/office/design/content-model.mjs +3 -3
  227. package/src/runtime/office/design/docx/design-docx/section.mjs +6 -6
  228. package/src/runtime/office/design/xlsx/design-xlsx-components.mjs +1 -1
  229. package/src/runtime/office/design/xlsx/design-xlsx.mjs +1 -1
  230. package/src/runtime/office/pdf/ocr-engine.mjs +1 -1
  231. package/src/runtime/office/pdf/pdf-writer.mjs +4 -4
  232. package/src/runtime/office/portable/portable-docx-operations.mjs +2 -2
  233. package/src/runtime/office/portable/portable-soffice.mjs +8 -6
  234. package/src/runtime/office/portable/xlsx-contract.mjs +1 -1
  235. package/src/runtime/shared/atomic-file.d.mts +8 -0
  236. package/src/runtime/shared/background-tasks.mjs +22 -4
  237. package/src/runtime/shared/background-wait.mjs +66 -0
  238. package/src/runtime/shared/child-guardian.mjs +17 -7
  239. package/src/runtime/shared/config.mjs +313 -16
  240. package/src/runtime/shared/connector-catalog.d.mts +4 -5
  241. package/src/runtime/shared/connector-catalog.mjs +11 -1
  242. package/src/runtime/shared/file-lock.mjs +8 -3
  243. package/src/runtime/shared/git-runner.mjs +7 -2
  244. package/src/runtime/shared/idle-gc.mjs +30 -7
  245. package/src/runtime/shared/llm/http-agent.mjs +8 -5
  246. package/src/runtime/shared/llm/system-proxy.mjs +2 -1
  247. package/src/runtime/shared/llm/usage-ledger-quota.mjs +31 -12
  248. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +84 -8
  249. package/src/runtime/shared/llm/usage-ledger.mjs +3 -0
  250. package/src/runtime/shared/llm/usage-rollup.mjs +48 -0
  251. package/src/runtime/shared/maintenance-schedule.mjs +109 -0
  252. package/src/runtime/shared/process-runner.mjs +8 -4
  253. package/src/runtime/shared/routine-trigger.d.mts +13 -0
  254. package/src/runtime/shared/routine-trigger.mjs +34 -0
  255. package/src/runtime/shared/run-child.d.mts +2 -1
  256. package/src/runtime/shared/run-child.mjs +70 -18
  257. package/src/runtime/shared/session-scratch.mjs +14 -2
  258. package/src/runtime/shared/shipped-definitions.mjs +7 -1
  259. package/src/runtime/shared/tool-card-model.golden.json +3 -12
  260. package/src/runtime/shared/tool-execution-contract.mjs +2 -0
  261. package/src/runtime/shared/tool-work-units.mjs +31 -6
  262. package/src/runtime/shared/turn-snapshot.mjs +26 -4
  263. package/src/runtime/shared/turn-worktree-snapshot.mjs +6 -11
  264. package/src/runtime/shared/worker-requests.mjs +17 -3
  265. package/src/runtime/shared/worktrees/branch-management.mjs +1 -5
  266. package/src/runtime/shared/worktrees/contract.mjs +16 -6
  267. package/src/runtime/shared/worktrees/dependencies.mjs +127 -89
  268. package/src/runtime/shared/worktrees/disk.mjs +79 -2
  269. package/src/runtime/shared/worktrees/git.mjs +44 -11
  270. package/src/runtime/shared/worktrees/include.mjs +6 -4
  271. package/src/runtime/shared/worktrees/maintenance.mjs +422 -30
  272. package/src/runtime/shared/worktrees/manager.mjs +772 -75
  273. package/src/runtime/shared/worktrees/settings.mjs +33 -7
  274. package/src/runtime/shared/worktrees/test-support.mjs +5 -1
  275. package/src/runtime/shared/worktrees/worker-snapshots.mjs +82 -0
  276. package/src/runtime/tidy/process.mjs +2 -1
  277. package/src/runtime/web-search/index.mjs +3 -22
  278. package/src/runtime/web-search/lib/web-tools.mjs +2 -2
  279. package/src/runtime/web-search/tool-defs.mjs +17 -26
  280. package/src/session-runtime/agent-profile-changes.mjs +55 -4
  281. package/src/session-runtime/boot/apis.mjs +27 -4
  282. package/src/session-runtime/boot/begin.mjs +2 -0
  283. package/src/session-runtime/boot/config.mjs +10 -1
  284. package/src/session-runtime/boot/facade.mjs +1 -0
  285. package/src/session-runtime/boot/providers.mjs +70 -3
  286. package/src/session-runtime/boot/session.mjs +2 -0
  287. package/src/session-runtime/boot/tools.mjs +69 -25
  288. package/src/session-runtime/channel-config-api.mjs +1 -0
  289. package/src/session-runtime/config-change-effects.mjs +179 -0
  290. package/src/session-runtime/config-lifecycle/config-writers.mjs +1 -0
  291. package/src/session-runtime/config-lifecycle.mjs +160 -24
  292. package/src/session-runtime/conversation-import/discovery.mjs +86 -7
  293. package/src/session-runtime/conversation-import/service.mjs +3 -2
  294. package/src/session-runtime/goal-facade-api.mjs +49 -3
  295. package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +3 -0
  296. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +3 -2
  297. package/src/session-runtime/internal-tool-executor/feature-tools.mjs +3 -11
  298. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +18 -4
  299. package/src/session-runtime/internal-tool-executor.mjs +12 -19
  300. package/src/session-runtime/lifecycle/cold-heir.mjs +7 -0
  301. package/src/session-runtime/lifecycle/memory-ingest.mjs +3 -3
  302. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +53 -5
  303. package/src/session-runtime/lifecycle/session-switch.mjs +13 -2
  304. package/src/session-runtime/lifecycle/teardown/session-close.mjs +4 -4
  305. package/src/session-runtime/lifecycle-api.mjs +1 -0
  306. package/src/session-runtime/mcp-glue/server-catalog.mjs +14 -3
  307. package/src/session-runtime/mcp-glue/server-connection.mjs +34 -8
  308. package/src/session-runtime/model-route/set-route.mjs +40 -7
  309. package/src/session-runtime/native-web-search/routes.mjs +3 -3
  310. package/src/session-runtime/provider-auth/refresh.mjs +6 -3
  311. package/src/session-runtime/provider-auth/setup.mjs +1 -1
  312. package/src/session-runtime/provider-readiness.mjs +53 -4
  313. package/src/session-runtime/resource-api.mjs +1 -1
  314. package/src/session-runtime/resource-extension-sync.mjs +4 -8
  315. package/src/session-runtime/resource-mcp-connector-accounts.mjs +5 -1
  316. package/src/session-runtime/resource-mcp-secrets.mjs +3 -3
  317. package/src/session-runtime/resource-plugins-api.mjs +4 -0
  318. package/src/session-runtime/routine-executor.mjs +152 -0
  319. package/src/session-runtime/routine-home-turn.mjs +46 -5
  320. package/src/session-runtime/runtime-facade.mjs +5 -0
  321. package/src/session-runtime/runtime-feature-gates.mjs +2 -11
  322. package/src/session-runtime/schedule-session-run.mjs +1 -0
  323. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +6 -1
  324. package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +7 -0
  325. package/src/session-runtime/services/agent-tool/send-flow.mjs +110 -6
  326. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +12 -0
  327. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +5 -0
  328. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +24 -6
  329. package/src/session-runtime/services/agent-tool.mjs +51 -1
  330. package/src/session-runtime/services/channel-admin.mjs +18 -0
  331. package/src/session-runtime/services/hook-bus/command-handler.mjs +7 -11
  332. package/src/session-runtime/services/plugin-admin.mjs +4 -0
  333. package/src/session-runtime/services/seeds.mjs +7 -0
  334. package/src/session-runtime/services/usage-stats-model.mjs +32 -11
  335. package/src/session-runtime/session-create/materialize.mjs +4 -2
  336. package/src/session-runtime/session-create/session-options.mjs +2 -0
  337. package/src/session-runtime/settings-builtin-tools-api.mjs +1 -9
  338. package/src/session-runtime/setup-tool/connector-widen-approval.mjs +1 -1
  339. package/src/session-runtime/setup-tool/executor.mjs +8 -1
  340. package/src/session-runtime/setup-tool/extended-actions.mjs +2 -0
  341. package/src/session-runtime/setup-tool/settings-contract.mjs +28 -0
  342. package/src/session-runtime/setup-tool/tool-defs.mjs +26 -1
  343. package/src/session-runtime/tool-policy/agent-profile-fold.mjs +40 -43
  344. package/src/session-runtime/tool-policy/mode-switch.mjs +1 -1
  345. package/src/session-runtime/tool-policy/policy-stage.mjs +106 -8
  346. package/src/session-runtime/tool-policy/workflow-overlay.mjs +2 -1
  347. package/src/session-runtime/tool-policy-refresh.mjs +182 -5
  348. package/src/session-runtime/tool-surface.mjs +38 -1
  349. package/src/session-runtime/turn/agent-ops.mjs +10 -2
  350. package/src/session-runtime/turn/session-ops.mjs +2 -0
  351. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +5 -0
  352. package/src/session-runtime/turn/turn-run.mjs +1 -1
  353. package/src/session-runtime/webhook-session-run.mjs +2 -0
  354. package/src/session-runtime/workflow-agents-api/agent-connectors.mjs +1 -4
  355. package/src/session-runtime/workflow-agents-api/agent-route.mjs +3 -10
  356. package/src/session-runtime/workflow-agents-api/instruction-files.mjs +165 -0
  357. package/src/session-runtime/workflow-agents-api/style-and-mode.mjs +5 -4
  358. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +2 -3
  359. package/src/session-runtime/workflow-agents-api.mjs +2 -0
  360. package/src/session-runtime/workspace/project-branches.mjs +1 -1
  361. package/src/session-runtime/workspace/session-view.mjs +1 -1
  362. package/src/session-runtime/workspace/transitions.mjs +20 -4
  363. package/src/session-runtime/workspace/worktree-maintenance.mjs +126 -6
  364. package/src/session-runtime/workspace/worktree-setup.mjs +13 -1
  365. package/src/session-runtime/workspace/worktree-tool.mjs +16 -0
  366. package/src/standalone/daemon-agent-control.mjs +25 -8
  367. package/src/standalone/daemon-desktop-runtime.mjs +6 -2
  368. package/src/standalone/daemon-lag-profiler.mjs +7 -7
  369. package/src/standalone/daemon.mjs +54 -2
  370. package/src/standalone/local-session-runtime.mjs +16 -0
  371. package/src/standalone/session-client/daemon-unavailable.mjs +1 -1
  372. package/src/standalone/session-protocol.mjs +11 -0
  373. package/src/standalone/session-service/agent-route-follow.mjs +23 -20
  374. package/src/standalone/session-service/agent-tree/agent-turns.mjs +18 -6
  375. package/src/standalone/session-service/quota-resume.mjs +9 -0
  376. package/src/standalone/session-service/session-calls/session-action.mjs +51 -1
  377. package/src/standalone/session-service/session-calls/session-turns.mjs +37 -18
  378. package/src/standalone/session-service/session-calls/turn-gate.mjs +34 -0
  379. package/src/standalone/session-service/session-calls.mjs +2 -1
  380. package/src/tui/App.jsx +8 -0
  381. package/src/tui/app/clipboard.mjs +1 -1
  382. package/src/tui/app/create-app-pickers.mjs +2 -0
  383. package/src/tui/app/doctor.mjs +2 -2
  384. package/src/tui/app/panel-signature.mjs +1 -1
  385. package/src/tui/app/settings-picker.mjs +53 -8
  386. package/src/tui/app/shell-layout/row-budgets.mjs +2 -2
  387. package/src/tui/app/slash-commands.mjs +1 -1
  388. package/src/tui/app/slash-dispatch/route-commands.mjs +1 -1
  389. package/src/tui/app/slash-dispatch/session-commands.mjs +1 -1
  390. package/src/tui/app/use-prompt-queue-history.mjs +2 -2
  391. package/src/tui/app/use-transcript-activity.mjs +1 -1
  392. package/src/tui/app/use-transcript-window/geometry.mjs +1 -1
  393. package/src/tui/components/prompt-input/escape-policy.d.mts +0 -1
  394. package/src/tui/components/prompt-input/escape-policy.mjs +1 -1
  395. package/src/tui/components/question-panel-model.mjs +4 -4
  396. package/src/tui/components/tool-execution/standard-card.jsx +3 -4
  397. package/src/tui/components/user-message-skills.mjs +1 -1
  398. package/src/tui/dist/index.mjs +309 -30
  399. package/src/tui/markdown/render-ansi.mjs +1 -1
  400. package/src/tui/session/agent-job-feed/notification-router.mjs +10 -0
  401. package/src/tui/session/agent-job-feed.mjs +2 -0
  402. package/src/tui/session/context-state.mjs +3 -0
  403. package/src/tui/session/quota-resume.mjs +1 -1
  404. package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
  405. package/src/tui/session/session-api/lifecycle/session-reset.mjs +7 -0
  406. package/src/tui/session/session-api/routes-agent-definitions.mjs +5 -0
  407. package/src/tui/session/session-api/worktree.mjs +13 -1
  408. package/src/tui/session/session-flow/drain.mjs +11 -14
  409. package/src/tui/session/tool-card-results/grouped-result.mjs +2 -2
  410. package/src/tui/session/turn-quiet.mjs +1 -1
  411. package/src/tui/session/turn.mjs +3 -0
  412. package/src/tui/session/worktree-setup-notice.mjs +1 -1
  413. package/src/tui/spinner-meta.mjs +3 -3
  414. package/src/tui/spinner-verbs.mjs +2 -2
  415. package/src/defaults/skills/goal-management/SKILL.md +0 -169
  416. package/src/runtime/agent/orchestrator/runtime-core/goal-tasks.mjs +0 -156
  417. package/src/session-runtime/goal-tasks.mjs +0 -1
@@ -59,7 +59,7 @@ import {
59
59
  restoreFromRecoveryRef,
60
60
  uncommittedChanges,
61
61
  } from './carry.mjs';
62
- import { freeBytesAt, requiredBytes, trackedCheckoutBytes } from './disk.mjs';
62
+ import { cachedFolderBytes, forgetFolderSize, freeBytesAt, requiredBytes, trackedCheckoutBytes } from './disk.mjs';
63
63
  import {
64
64
  branchCommit,
65
65
  currentBranch,
@@ -68,7 +68,9 @@ import {
68
68
  inspectRepo,
69
69
  isDiskFullError,
70
70
  listWorktrees,
71
+ localBranchCommits,
71
72
  localBranches,
73
+ parseStatusEntries,
72
74
  parseStatusPaths,
73
75
  remoteDefaultBranches,
74
76
  runGit,
@@ -85,7 +87,7 @@ import {
85
87
  removeIncludeLinks,
86
88
  restoreIncludeLinks,
87
89
  } from './include.mjs';
88
- import { linkWorktreeDependencies } from './dependencies.mjs';
90
+ import { dependencyArtefacts, linkedArtefacts } from './dependencies.mjs';
89
91
  import { withRepoLock as withRepoMutationLock } from './lock.mjs';
90
92
  import { holdWorkspaceActivityAsync } from './activity-gate.mjs';
91
93
  import { withAdvisoryLocks } from '../../agent/orchestrator/tools/builtin/advisory-lock.mjs';
@@ -125,15 +127,23 @@ function sessionRootOf(record) {
125
127
  // ---------------------------------------------------------------------------
126
128
  // Host configuration (settings source, free-space probe); tests override it.
127
129
 
128
- const DEFAULT_CONFIG = Object.freeze({ settings: null, freeBytes: freeBytesAt, processAlive: isPidAlive });
130
+ const DEFAULT_CONFIG = Object.freeze({
131
+ settings: null,
132
+ freeBytes: freeBytesAt,
133
+ processAlive: isPidAlive,
134
+ // The automatic cleanup's one deleting step (removeCheckoutVerified).
135
+ gitWorktreeRemove: (cwd, path) => runGit(cwd, ['worktree', 'remove', '--force', path], { allowFailure: true }),
136
+ });
129
137
  let config = { ...DEFAULT_CONFIG };
130
138
 
131
139
  /**
132
140
  * Override host hooks. `settings`: stored worktree settings object or a
133
141
  * function returning it (default: agent config `worktree`). `freeBytes(dir)`:
134
- * free-space probe. `processAlive(pid)`: lease holder liveness. Returns a
135
- * restore function.
136
- * @param {{ settings?: unknown, freeBytes?: (dir: string) => Promise<number>, processAlive?: (pid: number) => boolean }} overrides
142
+ * free-space probe. `processAlive(pid)`: lease holder liveness.
143
+ * `gitWorktreeRemove(cwd, path)`: the automatic cleanup's `git worktree
144
+ * remove --force` ({ code, stderr }). Returns a restore function.
145
+ * @param {{ settings?: unknown, freeBytes?: (dir: string) => Promise<number>, processAlive?: (pid: number) => boolean,
146
+ * gitWorktreeRemove?: (cwd: string, path: string) => Promise<{ code: number, stderr: string }> }} overrides
137
147
  */
138
148
  export function configureWorktreeManager(overrides = {}) {
139
149
  const previous = config;
@@ -291,16 +301,22 @@ async function keepRemovedTip(record) {
291
301
  return { tip, recoveryRef: ref };
292
302
  }
293
303
 
294
- /** Managed checkouts whose registration is stale (directory missing). */
295
- async function staleManagedRegistrations(repoCommonDir) {
296
- const root = managedWorktreeRoot(repoCommonDir);
304
+ /**
305
+ * Repository-wide `git worktree prune`: every registration whose folder is
306
+ * gone (managed or not — a deleted scratch checkout, a crashed tool's) is
307
+ * dropped; git keeps locked ones. Only bookkeeping: no folder is touched.
308
+ * @returns {Promise<string[]>} the checkout paths whose registration went
309
+ */
310
+ async function pruneStaleRegistrations(repoCommonDir) {
297
311
  const stale = [];
298
- for (const { worktreePath } of await adminRegistrations(repoCommonDir)) {
299
- if (!(await isGone(worktreePath))) continue;
300
- const rel = relative(pathKey(root), pathKey(worktreePath));
301
- if (rel && !rel.startsWith('..') && !rel.includes(sep)) stale.push(worktreePath);
312
+ for (const registration of await adminRegistrations(repoCommonDir)) {
313
+ if (await isGone(registration.worktreePath)) stale.push(registration);
302
314
  }
303
- return stale;
315
+ if (!stale.length) return [];
316
+ await runGit(repoCommonDir, ['--git-dir', repoCommonDir, 'worktree', 'prune'], { allowFailure: true });
317
+ return stale
318
+ .filter((registration) => !existsSync(registration.admin))
319
+ .map((registration) => canonicalPath(registration.worktreePath));
304
320
  }
305
321
 
306
322
  async function setResource(record, name, status) {
@@ -326,23 +342,223 @@ async function unmergedCommitCount(record) {
326
342
  const reference = (record.mergeTarget && (await branchCommit(cwd, record.mergeTarget))) || record.baseCommit;
327
343
  if (!reference) return 0;
328
344
  const result = await runGit(cwd, ['rev-list', '--count', tip, '--not', reference]);
329
- return Number(firstLine(result.stdout)) || 0;
345
+ const count = Number(firstLine(result.stdout)) || 0;
346
+ if (count === 0 || (await changesContainedIn(cwd, tip, reference))) return 0;
347
+ return count;
348
+ }
349
+
350
+ /**
351
+ * A squash, rebase or cherry-pick merge leaves the branch's commits
352
+ * unreachable from `reference` although their changes are there. Merged only
353
+ * when merging `tip` into `reference` would leave `reference`'s tree exactly
354
+ * as it is: a change the target later reverted would come back, so it is
355
+ * unmerged work. A patch-equivalence test alone (`git cherry`) is not enough
356
+ * for that reason and is not used. An unreachable merge commit of the branch's
357
+ * own is unmerged as well (its resolution is work of its own), and anything
358
+ * git cannot decide (a conflict, an old git) counts as unmerged.
359
+ */
360
+ async function changesContainedIn(cwd, tip, reference) {
361
+ const merges = await runGit(cwd, ['rev-list', '--count', '--merges', tip, '--not', reference], {
362
+ allowFailure: true,
363
+ readOnly: true,
364
+ });
365
+ if (merges.code !== 0 || Number(firstLine(merges.stdout)) !== 0) return false;
366
+ const merged = await runGit(cwd, ['merge-tree', '--write-tree', reference, tip], { allowFailure: true });
367
+ if (merged.code !== 0) return false;
368
+ const target = await runGit(cwd, ['rev-parse', `${reference}^{tree}`], { allowFailure: true, readOnly: true });
369
+ return target.code === 0 && firstLine(merged.stdout) === firstLine(target.stdout);
370
+ }
371
+
372
+ /**
373
+ * A cheap fingerprint of what the cleanup's slow checks (dirtyPaths,
374
+ * unmergedCommitCount) looked at: the checkout's HEAD, its index file (any
375
+ * add, commit, checkout or refresh rewrites it), the folder's own mtime, the
376
+ * branch tip and the merge target's tip. One git call and two stats.
377
+ */
378
+ async function checkoutToken(record) {
379
+ const cwd = repoCwd(record);
380
+ const [paths, tip, target] = await Promise.all([
381
+ runGit(record.path, ['rev-parse', 'HEAD', '--git-path', 'index'], { allowFailure: true, readOnly: true }),
382
+ record.branch ? branchCommit(cwd, record.branch) : null,
383
+ record.mergeTarget ? branchCommit(cwd, record.mergeTarget) : null,
384
+ ]);
385
+ const [head = '', index = ''] = paths.stdout.split(/\r?\n/);
386
+ const stamp = (path) =>
387
+ stat(path).then(
388
+ (info) => `${info.mtimeMs}:${info.size}`,
389
+ () => 'none'
390
+ );
391
+ return JSON.stringify([
392
+ paths.code,
393
+ head.trim(),
394
+ index ? await stamp(resolve(record.path, index.trim())) : 'none',
395
+ await stamp(record.path),
396
+ tip,
397
+ target,
398
+ ]);
399
+ }
400
+
401
+ const DIRTY_STATUS_ARGS = ['status', '--porcelain=v1', '-z', '--untracked-files=all'];
402
+ // An automatic removal reads the ignored paths too; a folder an ignore
403
+ // pattern matches is listed once, not its contents.
404
+ const AUTOMATIC_STATUS_ARGS = [...DIRTY_STATUS_ARGS, '--ignored=matching'];
405
+ // The Cache Directory Tagging signature a CACHEDIR.TAG starts with.
406
+ const CACHEDIR_SIGNATURE = 'Signature: 8a477f597d28d172789f06886806bc55';
407
+
408
+ function identityPath(record) {
409
+ return record.projectSubdir ? `${record.projectSubdir}/${SESSION_PROJECT_ID_PATH}` : SESSION_PROJECT_ID_PATH;
410
+ }
411
+
412
+ /** The status paths that are user work: not the session identity file, not an include copy. */
413
+ function userStatusPaths(record, paths) {
414
+ const includes = record.resources?.includes || [];
415
+ const identity = identityPath(record);
416
+ return paths.filter((path) => path !== identity && !coveredByIncludes(includes, path));
330
417
  }
331
418
 
332
419
  /** Uncommitted/untracked paths plus modified include copies. */
333
420
  async function dirtyPaths(record) {
334
421
  if (!existsSync(record.path)) return [];
422
+ const status = await runGit(record.path, DIRTY_STATUS_ARGS, { readOnly: true });
423
+ return [
424
+ ...userStatusPaths(record, parseStatusPaths(status.stdout)),
425
+ ...(await modifiedIncludeCopies(record.resources?.includes)),
426
+ ];
427
+ }
428
+
429
+ /** AUTOMATIC_STATUS_ARGS output as the changed paths and the ignored entries. */
430
+ function splitIgnored(stdout) {
431
+ const entries = parseStatusEntries(stdout);
432
+ return {
433
+ changed: entries.filter((entry) => entry.code !== '!!').map((entry) => entry.path),
434
+ ignored: entries.filter((entry) => entry.code === '!!').map((entry) => entry.path),
435
+ };
436
+ }
437
+
438
+ async function cacheDirTagged(dir) {
439
+ try {
440
+ return (await readFile(join(dir, 'CACHEDIR.TAG'), 'latin1')).startsWith(CACHEDIR_SIGNATURE);
441
+ } catch {
442
+ return false;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Of the ignored status entries, the ones no install or rebuild restores:
448
+ * user data only this checkout holds (an env file, a dataset, build output).
449
+ * Left out as reproducible: the session identity file (or its folder holding
450
+ * nothing else), the recorded include items (links go with the removal, a
451
+ * changed copy is dirty already), the dependency artefacts a committed
452
+ * lockfile installs whole (`artefacts`, dependencyArtefacts: node_modules
453
+ * only, so hand edits inside node_modules are deliberately not protected —
454
+ * see the `reinstallable` flag there for why), `__pycache__`
455
+ * folders and folders a CACHEDIR.TAG marks as a cache.
456
+ */
457
+ async function unreproducibleIgnored(record, entries, artefacts) {
335
458
  const includes = record.resources?.includes || [];
336
- const status = await runGit(record.path, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], {
337
- readOnly: true,
338
- });
339
- const identity = record.projectSubdir
340
- ? `${record.projectSubdir}/${SESSION_PROJECT_ID_PATH}`
341
- : SESSION_PROJECT_ID_PATH;
342
- const dirty = parseStatusPaths(status.stdout).filter(
343
- (path) => path !== identity && !coveredByIncludes(includes, path)
344
- );
345
- return [...dirty, ...(await modifiedIncludeCopies(includes))];
459
+ const identity = identityPath(record);
460
+ const identityDir = identity.slice(0, identity.lastIndexOf('/'));
461
+ const tagged = new Map();
462
+ const isCache = (dir) => {
463
+ if (!tagged.has(dir)) tagged.set(dir, cacheDirTagged(join(record.path, ...dir.split('/'))));
464
+ return tagged.get(dir);
465
+ };
466
+ const kept = [];
467
+ for (const entry of entries) {
468
+ const path = entry.replace(/\/$/, '');
469
+ if (path === identity || coveredByIncludes(includes, path)) continue;
470
+ if (artefacts.some((artefact) => path === artefact || path.startsWith(`${artefact}/`))) continue;
471
+ if (path === identityDir) {
472
+ const names = await readdir(join(record.path, ...path.split('/'))).catch(() => null);
473
+ if (names?.length === 1 && names[0] === 'project.id') continue;
474
+ }
475
+ const parts = path.split('/');
476
+ const folders = entry.endsWith('/') ? parts : parts.slice(0, -1);
477
+ if (folders.includes('__pycache__')) continue;
478
+ let cache = false;
479
+ for (let depth = 1; depth <= folders.length && !cache; depth++) {
480
+ cache = await isCache(folders.slice(0, depth).join('/'));
481
+ }
482
+ if (!cache) kept.push(path);
483
+ }
484
+ return kept;
485
+ }
486
+
487
+ /** The keep reason of a worktree holding unreproducible ignored files (`files` capped to a preview). */
488
+ function ignoredKeep(paths) {
489
+ return { reason: 'ignored', files: paths.slice(0, DIRTY_CHOICE_PREVIEW), count: paths.length };
490
+ }
491
+
492
+ /**
493
+ * What keeps `record` from an automatic removal: `dirty` (dirtyPaths) and
494
+ * `ignored` (unreproducibleIgnored), with the `artefacts` that decided it.
495
+ */
496
+ async function automaticKeepPaths(record) {
497
+ if (!existsSync(record.path)) return { dirty: [], ignored: [], artefacts: [] };
498
+ const [status, artefacts] = await Promise.all([
499
+ runGit(record.path, AUTOMATIC_STATUS_ARGS, { readOnly: true }),
500
+ dependencyArtefacts(record.path),
501
+ ]);
502
+ const { changed, ignored } = splitIgnored(status.stdout);
503
+ return {
504
+ dirty: [...userStatusPaths(record, changed), ...(await modifiedIncludeCopies(record.resources?.includes))],
505
+ ignored: await unreproducibleIgnored(record, ignored, artefacts),
506
+ artefacts,
507
+ };
508
+ }
509
+
510
+ /** How long the cleanup's last dirty check under the folder hold may take. */
511
+ const FINAL_DIRTY_CHECK_TIMEOUT_MS = 1_500;
512
+ /** How long a timed-out final status is given to exit after its kill. */
513
+ const FINAL_CHECK_EXIT_WAIT_MS = 2_000;
514
+
515
+ /**
516
+ * The cleanup's last word before a removal, under the folder hold: the full
517
+ * status (untracked files at any depth, unstaged edits of tracked ones,
518
+ * ignored files) and the include-copy check again, bounded by `timeoutMs`;
519
+ * `artefacts` are the dependency artefacts read before the hold. null:
520
+ * clean, remove it. `{ reason: 'dirty' }`: something is uncommitted now.
521
+ * `{ reason: 'ignored', files, count }`: unreproducibleIgnored. `{ reason: 'busy' }`:
522
+ * the check timed out, failed or could not finish — never read as clean. A
523
+ * timed-out git is killed and awaited (bounded) before this returns, so none
524
+ * keeps running in the folder; `gitRunning: true` reports one that outlived
525
+ * the wait.
526
+ */
527
+ async function finalDirtyCheck(record, timeoutMs, artefacts) {
528
+ if (!existsSync(record.path)) return null;
529
+ const started = Date.now();
530
+ let status;
531
+ try {
532
+ status = await runGit(record.path, AUTOMATIC_STATUS_ARGS, {
533
+ readOnly: true,
534
+ timeoutMs,
535
+ waitForExitMs: FINAL_CHECK_EXIT_WAIT_MS,
536
+ });
537
+ } catch (error) {
538
+ return { reason: 'busy', ...(error?.childExited === false ? { gitRunning: true } : {}) };
539
+ }
540
+ const { changed, ignored } = splitIgnored(status.stdout);
541
+ if (userStatusPaths(record, changed).length) return { reason: 'dirty' };
542
+ // The include copies and the ignored entries: file reads only (no child), bounded by what is left.
543
+ let timer;
544
+ try {
545
+ const left = Math.max(0, timeoutMs - (Date.now() - started));
546
+ const [modified, unreproducible] = await Promise.race([
547
+ Promise.all([
548
+ modifiedIncludeCopies(record.resources?.includes),
549
+ unreproducibleIgnored(record, ignored, artefacts),
550
+ ]),
551
+ new Promise((_, reject) => {
552
+ timer = setTimeout(() => reject(new Error('include check timed out')), left);
553
+ }),
554
+ ]);
555
+ if (modified.length) return { reason: 'dirty' };
556
+ return unreproducible.length ? ignoredKeep(unreproducible) : null;
557
+ } catch {
558
+ return { reason: 'busy' };
559
+ } finally {
560
+ clearTimeout(timer);
561
+ }
346
562
  }
347
563
 
348
564
  async function deleteBranchIfFree(record) {
@@ -506,6 +722,96 @@ async function removeCheckoutSafely(record) {
506
722
  return [];
507
723
  }
508
724
 
725
+ /**
726
+ * Put back what an automatic removal attempt took apart in a checkout that
727
+ * survived (`undo` as recorded in `deleting.undo`): the recorded include
728
+ * links that are missing again, and Mixdog's worktree lock when the checkout
729
+ * is still registered but no longer locked. Never throws.
730
+ * @returns {Promise<object[]>} what could not be put back
731
+ */
732
+ async function undoAutomaticAttempt(record, undo) {
733
+ if (!undo || !existsSync(record.path)) return [];
734
+ const failures = [];
735
+ try {
736
+ if (undo.links) {
737
+ for (const failure of await restoreIncludeLinks(record.resources?.includes)) {
738
+ failures.push({ kind: 'link', path: failure.path, error: failure.error });
739
+ }
740
+ }
741
+ const registration = undo.lock !== null && undo.lock !== undefined ? await registrationFor(record) : null;
742
+ if (registration && registration.locked === null) {
743
+ const locked = await runGit(repoCwd(record), ['worktree', 'lock', '--reason', String(undo.lock), record.path], {
744
+ allowFailure: true,
745
+ });
746
+ if (locked.code !== 0) failures.push({ kind: 'lock', path: record.path, error: firstLine(locked.stderr) });
747
+ }
748
+ } catch (error) {
749
+ failures.push({ kind: 'undo', path: record.path, error: String(error?.message || error) });
750
+ }
751
+ return failures;
752
+ }
753
+
754
+ /**
755
+ * The automatic cleanup's checkout removal (`deleting.automatic`): the caller
756
+ * verified the worktree under its folder hold just before (lease, live
757
+ * sessions, jobs, a full status). Only `git worktree remove --force` deletes
758
+ * here — there is no recursive fallback. When git fails or anything is left
759
+ * (or the checkout is not registered, or carries a lock that is not
760
+ * Mixdog's), it stops: the recorded links it unlinked are put back, its own
761
+ * lock is restored, and what is left is returned for the record to keep.
762
+ * @returns {Promise<object[]>} the remainder (empty when everything is gone)
763
+ */
764
+ async function removeCheckoutVerified(record) {
765
+ await assertRecoveryStoresClear(record);
766
+ await assertManagedCheckoutPath(record);
767
+ await assertNoUnrecordedDirectoryLinks(record);
768
+ const cwd = repoCwd(record);
769
+ const registration = await registrationFor(record);
770
+ if (!registration) {
771
+ return existsSync(record.path) ? [{ kind: 'directory', path: record.path, error: 'not-registered' }] : [];
772
+ }
773
+ if (existsSync(record.path)) {
774
+ const lock = registration.locked ?? null;
775
+ if (lock !== null && !String(lock).startsWith('mixdog session=')) {
776
+ return [{ kind: 'lock', path: record.path, error: 'external-lock' }];
777
+ }
778
+ // What this attempt takes apart (the recorded links, Mixdog's lock) is
779
+ // written down first, so a failure here or a restart puts it back.
780
+ record.deleting = { ...record.deleting, undo: { links: true, lock } };
781
+ await putRecord(record);
782
+ let removed;
783
+ try {
784
+ const unlinked = await removeIncludeLinks(record.resources?.includes);
785
+ if (unlinked.length) {
786
+ return [
787
+ ...unlinked.map((path) => ({ kind: 'link', path })),
788
+ ...(await undoAutomaticAttempt(record, record.deleting.undo)),
789
+ ];
790
+ }
791
+ // A built-in fsmonitor daemon holds the folder open (Windows) until stopped.
792
+ await runGit(record.path, ['fsmonitor--daemon', 'stop'], { allowFailure: true });
793
+ if (lock !== null) await runGit(cwd, ['worktree', 'unlock', record.path], { allowFailure: true });
794
+ removed = await config.gitWorktreeRemove(cwd, record.path);
795
+ } catch (error) {
796
+ // A rejected git call (timeout, spawn failure) is a failed removal too.
797
+ removed = { code: null, stderr: String(error?.message || error) };
798
+ }
799
+ if (removed.code !== 0 || existsSync(record.path)) {
800
+ // Whatever git left stays as it is; put back what this attempt took apart.
801
+ return [
802
+ {
803
+ kind: 'git-remove',
804
+ path: record.path,
805
+ error: firstLine(removed.stderr) || (removed.code === 0 ? 'folder left behind' : `exit ${removed.code}`),
806
+ },
807
+ ...(await undoAutomaticAttempt(record, record.deleting.undo)),
808
+ ];
809
+ }
810
+ }
811
+ await pruneRegistration(record.repoCommonDir, record.path, { allowLocked: true });
812
+ return (await registrationFor(record)) ? [{ kind: 'registration', path: record.path }] : [];
813
+ }
814
+
509
815
  /** Report what a failed safe removal could not put back on its error (never swallowed). */
510
816
  function withRollbackFailures(error, failures) {
511
817
  if (!failures.length) return error;
@@ -593,6 +899,21 @@ async function rollbackCreation(record, cause) {
593
899
  return { complete: false, remaining };
594
900
  }
595
901
 
902
+ /**
903
+ * An automatic removal that did not finish (git refused, something was left,
904
+ * or it was interrupted) is not a decided deletion: the record goes back to
905
+ * `ready` with `removalIncomplete` ({ at, reason, remaining }), so only a
906
+ * later pass that verifies it afresh (lease, live sessions, jobs, the full
907
+ * dirty check) tries again — nothing resumes it blindly.
908
+ */
909
+ async function keepIncompleteRemoval(record, remaining, reason = 'git-remove-failed') {
910
+ record.state = 'ready';
911
+ record.deleting = null;
912
+ record.removalIncomplete = { at: Date.now(), reason, remaining };
913
+ await putRecord(record);
914
+ return { removed: false, retryable: true, incomplete: true, remaining };
915
+ }
916
+
596
917
  /** finishDeletion's folder step; what is left over goes to `remaining`. */
597
918
  async function removeFolder(record, remaining) {
598
919
  await removeOwnerLease(record);
@@ -604,7 +925,12 @@ async function removeFolder(record, remaining) {
604
925
  for (const result of results) {
605
926
  if (!result.ok) remaining.push({ kind: 'cleanup', name: result.name, error: result.error.message });
606
927
  }
607
- remaining.push(...(await (record.deleting?.safeOnly ? removeCheckoutSafely(record) : removeCheckout(record))));
928
+ const remove = record.deleting?.automatic
929
+ ? removeCheckoutVerified
930
+ : record.deleting?.safeOnly
931
+ ? removeCheckoutSafely
932
+ : removeCheckout;
933
+ remaining.push(...(await remove(record)));
608
934
  }
609
935
 
610
936
  /**
@@ -629,6 +955,7 @@ async function finishDeletion(record) {
629
955
  } catch (error) {
630
956
  remaining.push({ kind: 'error', error: String(error?.message || error) });
631
957
  }
958
+ if (remaining.length && record.deleting?.automatic) return keepIncompleteRemoval(record, remaining);
632
959
  if (remaining.length) {
633
960
  record.deleting = { ...record.deleting, remaining, lastAttemptAt: Date.now() };
634
961
  await putRecord(record);
@@ -636,14 +963,21 @@ async function finishDeletion(record) {
636
963
  }
637
964
  let branch = { deleted: false, reason: 'kept' };
638
965
  let kept = { tip: null, recoveryRef: null };
639
- const merged = (await unmergedCommitCount(record)) === 0;
966
+ // A cleanup decided the branch merged at `mergedTip`: no second commit walk
967
+ // while the tip is still that one.
968
+ const mergedTip = record.deleting?.mergedTip;
969
+ const merged =
970
+ (!!mergedTip && record.branch && (await branchCommit(repoCwd(record), record.branch)) === mergedTip) ||
971
+ (await unmergedCommitCount(record)) === 0;
640
972
  if (record.branch && !record.deleting?.preserveBranch && (record.deleting?.force || merged)) {
641
- // Forced past unmerged commits: the tip stays reachable before `branch -D`.
642
- if (!merged) kept = await keepRemovedTip(record);
973
+ // Forced past unmerged commits (or a cleanup that deletes branches): the
974
+ // tip stays reachable before `branch -D`.
975
+ if (!merged || record.deleting?.keepTip) kept = await keepRemovedTip(record);
643
976
  else kept.tip = await branchCommit(repoCwd(record), record.branch);
644
977
  branch = await deleteBranchIfFree(record);
645
978
  }
646
979
  await deleteRecord(record.id);
980
+ forgetFolderSize(record.path);
647
981
  await pruneEmptyRepoRoot(record.repoCommonDir);
648
982
  return {
649
983
  removed: true,
@@ -847,11 +1181,42 @@ export async function markWorktreeDisposable(entryId, disposable = true) {
847
1181
  const current = await getRecord(entryId);
848
1182
  if (!current) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
849
1183
  current.disposable = disposable === true;
1184
+ // A deleted session's mark: no archive rule applies to it.
1185
+ delete current.disposableReason;
850
1186
  await putRecord(current);
851
1187
  return entryView(current);
852
1188
  });
853
1189
  }
854
1190
 
1191
+ /**
1192
+ * Mark (or unmark) every ready managed worktree `sessionIds` own disposable:
1193
+ * an archived session's, so a removal pass deletes it once clean and merged.
1194
+ * The mark carries `disposableReason: 'archived'`, so the automatic passes
1195
+ * apply the archive rules to it (cleanupArchived, favorites, minimum age).
1196
+ * Snapshots are left alone (they never outlive their turn anyway).
1197
+ * @param {string[]} sessionIds
1198
+ * @returns {Promise<string[]>} the entry ids whose mark changed
1199
+ */
1200
+ export async function setSessionWorktreesDisposable(sessionIds, disposable = true) {
1201
+ const owners = new Set((Array.isArray(sessionIds) ? sessionIds : []).filter((id) => typeof id === 'string' && id));
1202
+ const changed = [];
1203
+ if (!owners.size) return changed;
1204
+ const want = disposable === true;
1205
+ for (const record of Object.values((await readRegistry()).entries)) {
1206
+ if (record.kind === WORKTREE_SNAPSHOT_KIND || !owners.has(record.ownerSessionId)) continue;
1207
+ await withRepoLock(record.repoCommonDir, async () => {
1208
+ const current = await getRecord(record.id);
1209
+ if (current?.state !== 'ready' || !owners.has(current.ownerSessionId) || current.disposable === want) return;
1210
+ current.disposable = want;
1211
+ if (want) current.disposableReason = 'archived';
1212
+ else delete current.disposableReason;
1213
+ await putRecord(current);
1214
+ changed.push(current.id);
1215
+ });
1216
+ }
1217
+ return changed;
1218
+ }
1219
+
855
1220
  /**
856
1221
  * Before a creation (under the repository lock): the entry this transaction
857
1222
  * or session already has ready (its lease re-written), else null once every
@@ -1043,7 +1408,7 @@ function newCreationRecord({
1043
1408
 
1044
1409
  /**
1045
1410
  * Locks the fresh checkout, binds its owner lease, moves or leaves the
1046
- * project folder's changes, applies the includes and links dependencies.
1411
+ * project folder's changes and applies the includes.
1047
1412
  * `carry.moved` is set as soon as changes were carried, so a later failure
1048
1413
  * can put them back.
1049
1414
  */
@@ -1087,15 +1452,6 @@ async function populateCheckout({
1087
1452
  }
1088
1453
  const applied = await applyIncludes(includePlan, record.path, (ledger) => recordInclude(record, ledger));
1089
1454
  throwIfAborted(signal);
1090
- // After the includes: one that already brought a node_modules wins.
1091
- const linkedDependencies = await linkWorktreeDependencies({
1092
- mainDir: repo.projectPath,
1093
- worktreeDir: projectSubdir ? join(record.path, ...projectSubdir.split('/')) : record.path,
1094
- worktreeRel: projectSubdir,
1095
- signal,
1096
- record: (ledger) => recordInclude(record, ledger),
1097
- });
1098
- throwIfAborted(signal);
1099
1455
  record.warnings = [...record.warnings, ...applied.warnings];
1100
1456
  const made = (record.resources.includes || []).filter((item) => item.status === 'created');
1101
1457
  const rels = (predicate) => made.filter(predicate).map((item) => item.rel);
@@ -1103,13 +1459,12 @@ async function populateCheckout({
1103
1459
  const prepared = {
1104
1460
  root: repo.topLevel,
1105
1461
  copied: rels((item) => item.mode === 'copy'),
1106
- linked: rels((item) => item.mode === 'link' && !item.dependency),
1107
- dependencies: linkedDependencies,
1462
+ linked: rels((item) => item.mode === 'link'),
1108
1463
  skipped: [...includePlan.warnings, ...applied.warnings],
1109
1464
  };
1110
1465
  // Whichever path made it (a worktree task, a branch selection, `git
1111
- // worktree add`), the first turn a session runs here prepares its
1112
- // dependencies (claimWorktreeSetup).
1466
+ // worktree add`), the first turn a session runs here installs its
1467
+ // dependencies in the worktree folder (claimWorktreeSetup).
1113
1468
  record.setup = { pending: true, prepared };
1114
1469
  record.state = 'ready';
1115
1470
  await putRecord(record);
@@ -1213,11 +1568,20 @@ export async function createManagedWorktree({
1213
1568
  });
1214
1569
  throwIfAborted(signal);
1215
1570
 
1571
+ // Every existing local branch under the prefix (all of them with no
1572
+ // prefix): a new worktree never takes over one, whoever made it.
1216
1573
  const branches = new Set(
1217
1574
  (await localBranches(repo.projectPath, { signal, prefix: settings.branchPrefix })).map((name) =>
1218
1575
  name.toLowerCase()
1219
1576
  )
1220
1577
  );
1578
+ // `x` is also taken while a branch `x/...` exists (the ref would be a directory).
1579
+ const branchTaken = (name) => {
1580
+ const key = name.toLowerCase();
1581
+ if (branches.has(key)) return true;
1582
+ for (const existing of branches) if (existing.startsWith(`${key}/`)) return true;
1583
+ return false;
1584
+ };
1221
1585
  const base = slugFromHint(nameHint);
1222
1586
  const reserve = async () => {
1223
1587
  let reserved = null;
@@ -1282,10 +1646,12 @@ export async function createManagedWorktree({
1282
1646
  base,
1283
1647
  (candidate) =>
1284
1648
  ids.has(candidate.toLowerCase()) ||
1285
- branches.has(`${settings.branchPrefix}${candidate}`.toLowerCase()) ||
1649
+ branchTaken(`${settings.branchPrefix}${candidate}`) ||
1286
1650
  existsSync(join(root, candidate))
1287
1651
  );
1288
1652
  const branch = requestedBranch ?? worktreeBranchName(slug, settings.branchPrefix);
1653
+ // A new checkout at this path: no size measured for an earlier one counts.
1654
+ forgetFolderSize(managedWorktreePath(repo.repoCommonDir, slug));
1289
1655
  if (!(await validBranchName(repo.projectPath, branch))) {
1290
1656
  throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Branch name ${branch} is not valid.`, { branch });
1291
1657
  }
@@ -1497,21 +1863,78 @@ export async function removeSessionSnapshots(sessionId, { txIds = null } = {}) {
1497
1863
  return report;
1498
1864
  }
1499
1865
 
1866
+ /** The node_modules links into the main checkout an earlier version recorded at creation. */
1867
+ function dependencyLinks(record) {
1868
+ return (record?.resources?.includes || []).filter((item) => item.mode === 'link' && item.dependency);
1869
+ }
1870
+
1871
+ /** The links this worktree's .worktreeinclude asked for (rel from the worktree's top level). */
1872
+ function userIncludeLinks(record) {
1873
+ return (record?.resources?.includes || []).filter(
1874
+ (item) => item.mode === 'link' && item.status === 'created' && !item.dependency
1875
+ );
1876
+ }
1877
+
1878
+ /**
1879
+ * Unlink (never empty) the recorded dependency links of a worktree made by
1880
+ * an earlier version, through which a reset (`npm ci`) in the worktree
1881
+ * empties the main checkout's node_modules, and forget those gone. One that
1882
+ * cannot be unlinked stays recorded, so removal still unlinks it first, and
1883
+ * blocks every install in the worktree (claimWorktreeSetup).
1884
+ * @returns {Promise<boolean>} whether any is gone
1885
+ */
1886
+ async function detachDependencyLinks(record) {
1887
+ const links = dependencyLinks(record);
1888
+ if (!links.length) return false;
1889
+ const stuck = new Set(await removeIncludeLinks(links));
1890
+ record.resources.includes = record.resources.includes.filter((item) => !links.includes(item) || stuck.has(item.path));
1891
+ return stuck.size < links.length;
1892
+ }
1893
+
1500
1894
  /**
1501
1895
  * The first turn a session runs in managed worktree `entryId` claims its
1502
1896
  * dependency preparation, once: `{ prepared }` (what creation did) while it
1503
1897
  * is still pending, else null. Claimed before it runs, so a failed or
1504
- * cancelled preparation is never repeated.
1898
+ * cancelled preparation is never repeated. A worktree that still holds an
1899
+ * earlier version's dependency link has it detached at its next turn and is
1900
+ * claimed again (`prepared` null), so that turn installs in the worktree.
1901
+ * While one cannot be unlinked, or any other installed artefact in the
1902
+ * project folder is a link (linkedArtefacts) that the worktree's
1903
+ * .worktreeinclude did not ask for, the claim is `{ prepared: null,
1904
+ * blocked }` (the links, relative to the worktree's top level) instead:
1905
+ * nothing is installed, since an install reaches beyond its own folder
1906
+ * (`npm ci` at the root resets every workspace package's node_modules), and
1907
+ * the setup stays pending, so each later turn tries again.
1505
1908
  */
1506
1909
  export async function claimWorktreeSetup(entryId) {
1507
1910
  const record = await getRecord(entryId);
1508
- if (!record?.setup?.pending) return null;
1911
+ if (!record?.setup?.pending && !dependencyLinks(record).length) return null;
1509
1912
  return withRepoLock(record.repoCommonDir, async () => {
1510
1913
  const current = await getRecord(entryId);
1511
- if (current?.state !== 'ready' || !current.setup?.pending) return null;
1914
+ if (current?.state !== 'ready') return null;
1915
+ const pending = current.setup?.pending === true;
1916
+ const detached = await detachDependencyLinks(current);
1917
+ const blocked = dependencyLinks(current).map((item) => item.rel);
1918
+ if (!blocked.length && !pending && !detached) return null;
1919
+ // Links the user asked for in .worktreeinclude (relative to the project
1920
+ // folder): shared folders on purpose, which the planner leaves alone.
1921
+ const shared = [];
1922
+ if (!blocked.length) {
1923
+ const asked = new Set(userIncludeLinks(current).map((item) => item.rel));
1924
+ for (const rel of await linkedArtefacts(sessionRootOf(current))) {
1925
+ const topLevel = [current.projectSubdir, rel].filter(Boolean).join('/');
1926
+ if (asked.has(topLevel)) shared.push(rel);
1927
+ else blocked.push(topLevel);
1928
+ }
1929
+ }
1930
+ if (blocked.length) {
1931
+ current.setup = { ...current.setup, pending: true };
1932
+ await putRecord(current);
1933
+ return { prepared: null, blocked };
1934
+ }
1512
1935
  current.setup = { ...current.setup, pending: false, claimedAt: Date.now() };
1513
1936
  await putRecord(current);
1514
- return { prepared: current.setup.prepared ?? null };
1937
+ return { prepared: pending ? (current.setup.prepared ?? null) : null, shared };
1515
1938
  });
1516
1939
  }
1517
1940
 
@@ -1622,11 +2045,16 @@ export async function releaseWorktreeOwner(entryId, owner, { forgetReference = f
1622
2045
  export async function listBranchOccupancy({ projectPath, sessionId } = {}) {
1623
2046
  requireText(projectPath, 'projectPath');
1624
2047
  const repo = await inspectRepo(projectPath);
1625
- const [branches, worktrees, doc] = await Promise.all([
1626
- localBranches(repo.projectPath),
2048
+ const [commits, worktrees, doc] = await Promise.all([
2049
+ localBranchCommits(repo.projectPath),
1627
2050
  listWorktrees(repo.projectPath),
1628
2051
  readRegistry(),
1629
2052
  ]);
2053
+ const branches = commits.map((commit) => commit.name);
2054
+ // The tip's committer time per branch: the menu groups recent branches by it.
2055
+ const committedAt = Object.fromEntries(
2056
+ commits.filter((commit) => commit.committedAt).map((commit) => [commit.name, commit.committedAt])
2057
+ );
1630
2058
  const managed = Object.values(doc.entries).filter((record) => samePath(record.repoCommonDir, repo.repoCommonDir));
1631
2059
  const occupancy = branches.map((branch) => {
1632
2060
  // A prunable registration (its folder is gone) holds nothing.
@@ -1640,7 +2068,7 @@ export async function listBranchOccupancy({ projectPath, sessionId } = {}) {
1640
2068
  const retained = sessionId
1641
2069
  ? managed.find((record) => record.ownerSessionId === sessionId && record.state === 'ready')
1642
2070
  : null;
1643
- return { branches, occupancy, retainedWorktree: retained ? entryView(retained) : null };
2071
+ return { branches, occupancy, committedAt, retainedWorktree: retained ? entryView(retained) : null };
1644
2072
  }
1645
2073
 
1646
2074
  /**
@@ -1898,7 +2326,11 @@ export async function assertWorktreeRemovable({ entryId, force = false, sessionI
1898
2326
  * `expected` ({ kind, txId, holderSessionId }) pins the record the caller
1899
2327
  * selected: one that no longer matches under the repository lock (removed,
1900
2328
  * its slug reused) is left alone and reported `{ removed: false, skipped: true }`.
1901
- * @param {{ entryId: string, force?: boolean, expected?: { kind?: string, txId?: string, holderSessionId?: string }|null }} input
2329
+ * `automatic` (a deleted session's leftover): the cleanup's verified path
2330
+ * (removeAutomatically): no force, no recursive delete; a keep is raised as
2331
+ * WORKTREE_DIRTY / WORKTREE_UNMERGED / WORKTREE_BUSY_JOBS, and an incomplete
2332
+ * git removal returns `{ removed: false, incomplete: true }` with the record ready again.
2333
+ * @param {{ entryId: string, force?: boolean, expected?: { kind?: string, txId?: string, holderSessionId?: string }|null, automatic?: boolean }} input
1902
2334
  * @returns {Promise<{ removed: boolean, skipped?: boolean, retryable?: boolean, remaining?: object[], branchDeleted?: boolean }>}
1903
2335
  */
1904
2336
  export async function removeManagedWorktree({
@@ -1908,6 +2340,7 @@ export async function removeManagedWorktree({
1908
2340
  preserveBranch = false,
1909
2341
  safeOnly = false,
1910
2342
  expected = null,
2343
+ automatic = false,
1911
2344
  } = {}) {
1912
2345
  const skipped = { removed: false, skipped: true };
1913
2346
  const matches = (record) =>
@@ -1926,13 +2359,37 @@ export async function removeManagedWorktree({
1926
2359
  if (!current && !expected) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
1927
2360
  return skipped;
1928
2361
  }
1929
- return removeLocked(current, { force, sessionId, preserveBranch, safeOnly });
2362
+ return removeLocked(current, { force, sessionId, preserveBranch, safeOnly, automatic });
1930
2363
  });
1931
2364
  }
1932
2365
 
2366
+ /** Kept reasons of removeAutomatically as the typed refusals removeManagedWorktree raises. */
2367
+ function automaticRefusal(record, kept) {
2368
+ if (kept.reason === 'dirty') return worktreeError('WORKTREE_DIRTY', undefined, { entryId: record.id });
2369
+ if (kept.reason === 'ignored') {
2370
+ return worktreeError('WORKTREE_DIRTY', 'The worktree holds ignored files no install restores.', {
2371
+ entryId: record.id,
2372
+ ignored: kept.files,
2373
+ count: kept.count,
2374
+ });
2375
+ }
2376
+ if (kept.reason === 'unmerged') return worktreeError('WORKTREE_UNMERGED', undefined, { entryId: record.id });
2377
+ return worktreeRefusal('folder-in-use', { entryId: record.id, kept: kept.reason });
2378
+ }
2379
+
1933
2380
  /** The removal of `current`, under its repository lock. */
1934
- async function removeLocked(current, { force, sessionId, preserveBranch = false, safeOnly = false }) {
2381
+ async function removeLocked(
2382
+ current,
2383
+ { force, sessionId, preserveBranch = false, safeOnly = false, automatic = false }
2384
+ ) {
1935
2385
  assertNotShared(current, sessionId);
2386
+ // A deleted session's leftover (cleanupDeletedSessionWorktree): the
2387
+ // verified automatic path, never a forced or recursive one.
2388
+ if (automatic && !force && current.state === 'ready' && current.kind !== WORKTREE_SNAPSHOT_KIND) {
2389
+ const outcome = await removeAutomatically(current, { preserveBranch, keepTip: !preserveBranch });
2390
+ if (!outcome.result) throw automaticRefusal(current, outcome.kept);
2391
+ return outcome.result;
2392
+ }
1936
2393
  if (current.state === 'creating' || current.state === 'recovery') {
1937
2394
  const rollback = await rollbackCreation(current);
1938
2395
  return rollback.complete ? { removed: true } : { removed: false, retryable: true, remaining: rollback.remaining };
@@ -1953,13 +2410,17 @@ async function removeLocked(current, { force, sessionId, preserveBranch = false,
1953
2410
  * so an owner that came back (a live session, a running lease holder) or a
1954
2411
  * session the store still keeps it for refuses (WORKTREE_BUSY_JOBS). Then
1955
2412
  * the ordinary removal guards apply (dirty or unmerged work needs `force`).
1956
- * @param {{ entryId: string, force?: boolean, liveSessionIds?: string[], retainedSessionIds?: string[] }} input
2413
+ * `allowRetained` (the user deleting it from the worktree list): a session
2414
+ * the store keeps no longer protects it, a live owner or another session's
2415
+ * shared reference still does.
2416
+ * @param {{ entryId: string, force?: boolean, liveSessionIds?: string[], retainedSessionIds?: string[], allowRetained?: boolean }} input
1957
2417
  */
1958
2418
  export async function removeOrphanedWorktree({
1959
2419
  entryId,
1960
2420
  force = false,
1961
2421
  liveSessionIds = [],
1962
2422
  retainedSessionIds = [],
2423
+ allowRetained = false,
1963
2424
  } = {}) {
1964
2425
  const record = await getRecord(entryId);
1965
2426
  if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
@@ -1970,6 +2431,13 @@ export async function removeOrphanedWorktree({
1970
2431
  }
1971
2432
  const { ownerLive, removable } = await retentionOf(current, new Set(liveSessionIds), new Set(retainedSessionIds));
1972
2433
  if (ownerLive) throw worktreeRefusal('workspace-lease', { entryId });
2434
+ // The owner was just checked (not live; retained only with allowRetained):
2435
+ // only other sessions' shared references refuse below.
2436
+ const sessionId = current.ownerSessionId;
2437
+ if (allowRetained) {
2438
+ await assertRecoveryStoresClear(current);
2439
+ return removeLocked(current, { force, sessionId });
2440
+ }
1973
2441
  if (!removable) {
1974
2442
  // Retained recovery data names itself; otherwise sessions still keep it.
1975
2443
  await assertRecoveryStoresClear(current);
@@ -1978,7 +2446,7 @@ export async function removeOrphanedWorktree({
1978
2446
  sessions: [current.ownerSessionId, ...(current.sharedSessionIds ?? [])].filter(Boolean),
1979
2447
  });
1980
2448
  }
1981
- return removeLocked(current, { force });
2449
+ return removeLocked(current, { force, sessionId });
1982
2450
  });
1983
2451
  }
1984
2452
 
@@ -2005,21 +2473,53 @@ function leaseProcessAlive(lease) {
2005
2473
  * `retainedSessionIds`, i.e. still in the session store), `ownerLive` (a
2006
2474
  * live session or a running lease holder), and `removable` (what a prune
2007
2475
  * would remove: neither, or disposable and not live, with no retained recovery data).
2008
- * @param {{ liveSessionIds?: string[], retainedSessionIds?: string[] }} input
2476
+ * `inspect` adds, for a ready branch worktree, `dirty` (uncommitted paths,
2477
+ * and the ignored files no install restores, also counted as `ignored`:
2478
+ * both keep it from the automatic cleanup) and `unmerged` (commits the
2479
+ * merge target lacks; 0 when merged) — what the
2480
+ * cleanup policies and the worktree list read; `size` adds `sizeBytes` and
2481
+ * `sizeComplete` (cachedFolderBytes: a cached size returns at once and
2482
+ * refreshes in the background after its TTL).
2483
+ * @param {{ liveSessionIds?: string[], retainedSessionIds?: string[], inspect?: boolean, size?: boolean }} input
2009
2484
  */
2010
- export async function listManagedWorktrees({ liveSessionIds = [], retainedSessionIds = [] } = {}) {
2485
+ export async function listManagedWorktrees({
2486
+ liveSessionIds = [],
2487
+ retainedSessionIds = [],
2488
+ inspect = false,
2489
+ size = false,
2490
+ } = {}) {
2011
2491
  const live = new Set(liveSessionIds);
2012
2492
  const retained = new Set(retainedSessionIds);
2013
2493
  const rows = [];
2014
2494
  for (const record of Object.values((await readRegistry()).entries)) {
2015
2495
  const { ownerLive, retained: isRetained, removable } = await retentionOf(record, live, retained);
2016
- rows.push({
2496
+ const row = {
2017
2497
  ...entryView(record),
2018
2498
  projectPath: record.projectPath,
2499
+ sharedSessionIds: [...(record.sharedSessionIds ?? [])],
2019
2500
  retained: isRetained,
2020
2501
  ownerLive,
2021
2502
  removable,
2022
- });
2503
+ };
2504
+ if (inspect && record.kind !== WORKTREE_SNAPSHOT_KIND && record.state === 'ready') {
2505
+ try {
2506
+ const { dirty, ignored } = await automaticKeepPaths(record);
2507
+ row.dirty = dirty.length + ignored.length;
2508
+ row.ignored = ignored.length;
2509
+ row.unmerged = await unmergedCommitCount(record);
2510
+ } catch {
2511
+ // Unreadable state counts as work to keep.
2512
+ row.dirty = null;
2513
+ row.ignored = null;
2514
+ row.unmerged = null;
2515
+ }
2516
+ }
2517
+ if (size) {
2518
+ const measured = existsSync(record.path) ? await cachedFolderBytes(record.path) : null;
2519
+ row.sizeBytes = measured?.bytes ?? null;
2520
+ row.sizeComplete = measured?.complete ?? true;
2521
+ }
2522
+ rows.push(row);
2023
2523
  }
2024
2524
  return rows;
2025
2525
  }
@@ -2047,6 +2547,143 @@ async function retentionOf(record, live, retained) {
2047
2547
  return { ownerLive, retained: isRetained, removable };
2048
2548
  }
2049
2549
 
2550
+ /**
2551
+ * One automatic removal of the ready worktree `record` (the cleanup's count,
2552
+ * age, archive and orphan removals, and a deleted session's leftover), under
2553
+ * its repository lock. The slow checks (full status with untracked and
2554
+ * ignored files and include copies, the commit walk and merge-tree) run before the folder is
2555
+ * held, so no turn waits through them. Then the folder's workspace admission
2556
+ * is held (a turn, worker, shell or git command already in it refuses the
2557
+ * hold; one starting now waits), and under it: the owner lease and
2558
+ * `inUse(record)` (the caller's current live sessions and jobs), a cheap
2559
+ * token (HEAD, index, tips) against the one taken before the checks, and one
2560
+ * time-bounded full status (finalDirtyCheck). Only then is the deletion
2561
+ * decided, `automatic`: removeCheckoutVerified deletes through git alone and
2562
+ * an incomplete attempt goes back to `ready` (keepIncompleteRemoval).
2563
+ * @returns {Promise<{ result: object } | { kept: { reason: string } & object }>}
2564
+ */
2565
+ async function removeAutomatically(
2566
+ record,
2567
+ {
2568
+ inUse = async () => false,
2569
+ preserveBranch = false,
2570
+ keepTip = false,
2571
+ finalCheckTimeoutMs = FINAL_DIRTY_CHECK_TIMEOUT_MS,
2572
+ } = {}
2573
+ ) {
2574
+ const token = await checkoutToken(record);
2575
+ const { dirty, ignored, artefacts } = await automaticKeepPaths(record);
2576
+ const unmerged = await unmergedCommitCount(record);
2577
+ if (dirty.length || unmerged > 0) return { kept: { reason: dirty.length ? 'dirty' : 'unmerged' } };
2578
+ if (ignored.length) return { kept: ignoredKeep(ignored) };
2579
+ let release;
2580
+ try {
2581
+ release = await holdSnapshotFolder(record);
2582
+ } catch (error) {
2583
+ if (!snapshotInUse(error)) throw error;
2584
+ return { kept: { reason: 'in-use' } };
2585
+ }
2586
+ try {
2587
+ if (leaseProcessAlive(await readOwnerLease(record)) || (await inUse(record))) return { kept: { reason: 'in-use' } };
2588
+ if ((await checkoutToken(record)) !== token) return { kept: { reason: 'changed' } };
2589
+ // The token misses nested untracked files and unstaged edits: one
2590
+ // bounded full status decides. Dirty, slow or failed → kept.
2591
+ const finalCheck = await finalDirtyCheck(record, finalCheckTimeoutMs, artefacts);
2592
+ if (finalCheck) return { kept: finalCheck };
2593
+ await assertRecoveryStoresClear(record);
2594
+ record.state = 'deleting';
2595
+ delete record.removalIncomplete;
2596
+ record.deleting = {
2597
+ force: false,
2598
+ automatic: true,
2599
+ ...(preserveBranch ? { preserveBranch: true } : keepTip ? { keepTip: true } : {}),
2600
+ // Checked above; finishDeletion trusts it while the tip is this one.
2601
+ mergedTip: record.branch ? await branchCommit(repoCwd(record), record.branch) : null,
2602
+ startedAt: Date.now(),
2603
+ remaining: [],
2604
+ };
2605
+ await putRecord(record);
2606
+ return { result: await finishDeletion(record) };
2607
+ } finally {
2608
+ await release();
2609
+ }
2610
+ }
2611
+
2612
+ /**
2613
+ * Whether the automatic pass would try to remove the ready, not-live
2614
+ * worktree `record` (the archive/policy rules of reconcileManagedWorktrees);
2615
+ * the verification (dirty, unmerged, in use) still decides at removal time.
2616
+ */
2617
+ function cleanupWants(record, isRetained, { selected, favorites, minAgeMs, now, cleanupArchived }) {
2618
+ const ruleAllows = () =>
2619
+ ![record.ownerSessionId, ...(record.sharedSessionIds ?? [])].some((id) => id && favorites.has(id)) &&
2620
+ now - (Number(record.createdAt) || 0) >= minAgeMs;
2621
+ if (!isRetained) return true;
2622
+ if (record.disposable === true) {
2623
+ if (record.disposableReason !== 'archived') return true;
2624
+ if (cleanupArchived && ruleAllows()) return true;
2625
+ }
2626
+ return selected.has(record.id) && ruleAllows();
2627
+ }
2628
+
2629
+ /**
2630
+ * Read-only counterpart of reconcileManagedWorktrees: what a prune with the
2631
+ * same options could act on, from the registry, the retention and the git
2632
+ * admin folders alone (no status, merge or lock). A candidate may still be
2633
+ * kept at removal time after verification. Same options as the reconcile;
2634
+ * `removeOrphans` is implied.
2635
+ * @returns {Promise<{ ids: string[], stale: string[] }>} entry ids and the
2636
+ * checkout paths of registrations whose folder is gone
2637
+ */
2638
+ export async function previewManagedWorktreeCleanup({
2639
+ liveSessionIds = [],
2640
+ retainedSessionIds = [],
2641
+ repoCommonDir: onlyRepo = null,
2642
+ removeEntryIds = [],
2643
+ protectedSessionIds = [],
2644
+ minAgeMs = 0,
2645
+ now = Date.now(),
2646
+ cleanupArchived = true,
2647
+ } = {}) {
2648
+ const live = new Set(liveSessionIds);
2649
+ const retained = new Set(retainedSessionIds);
2650
+ const rules = {
2651
+ selected: new Set(removeEntryIds),
2652
+ favorites: new Set(protectedSessionIds),
2653
+ minAgeMs,
2654
+ now,
2655
+ cleanupArchived,
2656
+ };
2657
+ const ids = [];
2658
+ const repos = new Map();
2659
+ for (const record of Object.values((await readRegistry()).entries)) {
2660
+ const key = pathKey(record.repoCommonDir);
2661
+ if (onlyRepo && key !== pathKey(onlyRepo)) continue;
2662
+ repos.set(key, record.repoCommonDir);
2663
+ if (record.sharedSessionIds?.length) continue;
2664
+ const { ownerLive, retained: isRetained } = await retentionOf(record, live, retained);
2665
+ if (record.kind === WORKTREE_SNAPSHOT_KIND) {
2666
+ if (!ownerLive) ids.push(record.id);
2667
+ } else if (record.state === 'deleting') {
2668
+ ids.push(record.id);
2669
+ } else if (record.state === 'creating' || record.state === 'recovery') {
2670
+ if (!isRetained) ids.push(record.id);
2671
+ } else if (!ownerLive && (record.removalIncomplete || cleanupWants(record, isRetained, rules))) {
2672
+ ids.push(record.id);
2673
+ }
2674
+ }
2675
+ const stale = [];
2676
+ for (const [, repoCommonDir] of repos) {
2677
+ if (!existsSync(repoCommonDir)) continue;
2678
+ for (const registration of await adminRegistrations(repoCommonDir)) {
2679
+ // git keeps a locked registration (a `locked` file) through prune.
2680
+ if (existsSync(join(registration.admin, 'locked'))) continue;
2681
+ if (await isGone(registration.worktreePath)) stale.push(canonicalPath(registration.worktreePath));
2682
+ }
2683
+ }
2684
+ return { ids, stale };
2685
+ }
2686
+
2050
2687
  /**
2051
2688
  * Conservative reconcile (daemon startup, and the manual prune). Always:
2052
2689
  * resumes interrupted deletions, rolls back interrupted creations of no
@@ -2058,20 +2695,62 @@ async function retentionOf(record, live, retained) {
2058
2695
  * session in the store still references. A snapshot whose holder is neither
2059
2696
  * live nor running in another process is removed in either mode.
2060
2697
  * `repoCommonDir` limits the whole reconcile to that repository.
2061
- * @param {{ liveSessionIds?: string[], retainedSessionIds?: string[], removeOrphans?: boolean, repoCommonDir?: string|null }} input
2698
+ *
2699
+ * The automatic cleanup (maintenance.mjs) adds, all with `removeOrphans`:
2700
+ * `removeEntryIds` — worktrees its count/age limits selected, removed even
2701
+ * though a stored session still names them; `protectedSessionIds` (favorite
2702
+ * sessions) and `minAgeMs`/`now` (younger worktrees) exempt those and
2703
+ * archived-session marks (`disposableReason: 'archived'`, which
2704
+ * `cleanupArchived: false` ignores); a deleted session's mark and a true
2705
+ * orphan are not subject to them. `preserveBranch` keeps the branch of every
2706
+ * worktree it removes; otherwise `keepTip` writes the recovery ref before the
2707
+ * branch goes. `maxRemovals` caps the count/age/archive removals of one pass
2708
+ * together (`kept: budget` beyond it). Dirty, unmerged, live, shared or
2709
+ * recovery-holding worktrees are always kept, as are ones holding ignored
2710
+ * files no install restores (`kept: ignored` with `files`, `count`:
2711
+ * unreproducibleIgnored), and every removal first holds
2712
+ * the folder's workspace admission (a turn, worker, shell or git command in
2713
+ * it keeps it; one starting meanwhile waits) and asks `inUse(record)` again
2714
+ * under that hold (the caller's current live sessions and running jobs).
2715
+ * @param {{ liveSessionIds?: string[], retainedSessionIds?: string[], removeOrphans?: boolean,
2716
+ * repoCommonDir?: string|null, removeEntryIds?: string[], protectedSessionIds?: string[],
2717
+ * minAgeMs?: number, now?: number, cleanupArchived?: boolean, preserveBranch?: boolean, keepTip?: boolean,
2718
+ * maxRemovals?: number, inUse?: (record: object) => Promise<boolean>|boolean,
2719
+ * finalCheckTimeoutMs?: number }} input
2720
+ * `finalCheckTimeoutMs` bounds the last dirty check under the hold (FINAL_DIRTY_CHECK_TIMEOUT_MS);
2721
+ * a check that does not finish in time keeps the worktree (`kept: busy`).
2062
2722
  */
2063
2723
  export async function reconcileManagedWorktrees({
2064
2724
  liveSessionIds = [],
2065
2725
  retainedSessionIds = [],
2066
2726
  removeOrphans = false,
2067
2727
  repoCommonDir: onlyRepo = null,
2728
+ removeEntryIds = [],
2729
+ protectedSessionIds = [],
2730
+ minAgeMs = 0,
2731
+ now = Date.now(),
2732
+ cleanupArchived = true,
2733
+ preserveBranch = false,
2734
+ keepTip = false,
2735
+ maxRemovals = Infinity,
2736
+ inUse = async () => false,
2737
+ finalCheckTimeoutMs = FINAL_DIRTY_CHECK_TIMEOUT_MS,
2068
2738
  } = {}) {
2069
2739
  const live = new Set(liveSessionIds);
2070
2740
  const retained = new Set(retainedSessionIds);
2741
+ const selected = new Set(removeEntryIds);
2742
+ let budgetUsed = 0;
2743
+ const rules = { selected, favorites: new Set(protectedSessionIds), minAgeMs, now, cleanupArchived };
2744
+ const wanted = (record, isRetained) => cleanupWants(record, isRetained, rules);
2071
2745
  const report = { removed: [], recovered: [], kept: [], leasesReleased: [], pruned: [], missing: [], errors: [] };
2072
2746
  const reportDeletion = (record, result) => {
2073
2747
  if (result.removed) report.removed.push(record.id);
2074
- else report.kept.push({ id: record.id, reason: 'deleting', remaining: result.remaining });
2748
+ else
2749
+ report.kept.push({
2750
+ id: record.id,
2751
+ reason: result.incomplete ? 'incomplete' : 'deleting',
2752
+ remaining: result.remaining,
2753
+ });
2075
2754
  };
2076
2755
  const byRepo = new Map();
2077
2756
  for (const record of Object.values((await readRegistry()).entries)) {
@@ -2125,6 +2804,19 @@ export async function reconcileManagedWorktrees({
2125
2804
  }
2126
2805
  continue;
2127
2806
  }
2807
+ if (record.state === 'deleting' && record.deleting?.automatic) {
2808
+ // An automatic removal that was interrupted is never resumed
2809
+ // blindly: what it took apart is put back (its recorded undo)
2810
+ // and the record is ready again, verified afresh by a later pass.
2811
+ const notRestored = await undoAutomaticAttempt(record, record.deleting.undo);
2812
+ await keepIncompleteRemoval(
2813
+ record,
2814
+ [...(record.deleting.remaining ?? []), ...notRestored],
2815
+ 'interrupted'
2816
+ );
2817
+ report.kept.push({ id: record.id, reason: 'incomplete', remaining: record.removalIncomplete.remaining });
2818
+ continue;
2819
+ }
2128
2820
  if (record.state === 'deleting') {
2129
2821
  // A decided deletion: its session already left the folder.
2130
2822
  reportDeletion(record, await finishDeletion(record));
@@ -2164,26 +2856,31 @@ export async function reconcileManagedWorktrees({
2164
2856
  report.leasesReleased.push(record.id);
2165
2857
  }
2166
2858
  if (!existsSync(record.path)) report.missing.push(record.id);
2167
- if (removeOrphans === true && !ownerLive && (record.disposable === true || !isRetained)) {
2168
- const dirty = await dirtyPaths(record);
2169
- const unmerged = await unmergedCommitCount(record);
2170
- if (dirty.length || unmerged > 0) {
2171
- report.kept.push({ id: record.id, reason: dirty.length ? 'dirty' : 'unmerged' });
2859
+ if (removeOrphans === true && !ownerLive && wanted(record, isRetained)) {
2860
+ // Count, age and archive removals share one budget per pass.
2861
+ const budgeted = isRetained && (selected.has(record.id) || record.disposableReason === 'archived');
2862
+ if (budgeted && budgetUsed >= maxRemovals) {
2863
+ report.kept.push({ id: record.id, reason: 'budget' });
2172
2864
  continue;
2173
2865
  }
2174
- await assertRecoveryStoresClear(record);
2175
- record.state = 'deleting';
2176
- record.deleting = { force: false, startedAt: Date.now(), remaining: [] };
2177
- await putRecord(record);
2178
- reportDeletion(record, await finishDeletion(record));
2866
+ const outcome = await removeAutomatically(record, {
2867
+ inUse,
2868
+ preserveBranch,
2869
+ keepTip,
2870
+ finalCheckTimeoutMs,
2871
+ });
2872
+ if (!outcome.result) {
2873
+ report.kept.push({ id: record.id, ...outcome.kept });
2874
+ continue;
2875
+ }
2876
+ reportDeletion(record, outcome.result);
2877
+ if (budgeted && outcome.result.removed) budgetUsed += 1;
2179
2878
  }
2180
2879
  } catch (error) {
2181
2880
  report.errors.push({ id: record.id, error: String(error?.message || error) });
2182
2881
  }
2183
2882
  }
2184
- for (const stale of await staleManagedRegistrations(repoCommonDir)) {
2185
- if (await pruneRegistration(repoCommonDir, stale)) report.pruned.push(canonicalPath(stale));
2186
- }
2883
+ report.pruned.push(...(await pruneStaleRegistrations(repoCommonDir)));
2187
2884
  });
2188
2885
  } catch (error) {
2189
2886
  report.errors.push({ repoCommonDir, error: String(error?.message || error) });