mixdog 1.4.1 → 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 (630) hide show
  1. package/package.json +4 -3
  2. package/scripts/build-shipped-history.mjs +125 -33
  3. package/scripts/gate-local.mjs +220 -63
  4. package/scripts/graph-parity.mjs +8 -3
  5. package/scripts/lib/gate-scheduler.mjs +139 -32
  6. package/scripts/lib/gate-timing-reporter.mjs +16 -0
  7. package/scripts/lib/run-node-tests.mjs +71 -34
  8. package/scripts/lib/scoped-temp.mjs +1 -0
  9. package/scripts/lib/test-failure-records.mjs +14 -1
  10. package/scripts/lib/test-failure-summary.mjs +9 -4
  11. package/scripts/lib/test-spawn-ledger.mjs +43 -3
  12. package/scripts/post-release-check.mjs +26 -16
  13. package/scripts/pptx-kit-index.mjs +5 -6
  14. package/scripts/provider-pingpong-live.mjs +1 -1
  15. package/scripts/prune-embedding-runtime.mjs +3 -1
  16. package/scripts/release-paths.mjs +7 -1
  17. package/scripts/suite-health.mjs +110 -28
  18. package/scripts/test.mjs +9 -14
  19. package/src/agents/advisor/AGENT.md +4 -4
  20. package/src/agents/advisor/agent.json +1 -1
  21. package/src/agents/explore/AGENT.md +22 -0
  22. package/src/agents/explore/agent.json +6 -0
  23. package/src/cli.mjs +7 -0
  24. package/src/defaults/agents.json +7 -0
  25. package/src/defaults/shipped-history.json +38 -2
  26. package/src/defaults/skills/browser-use/SKILL.md +4 -1
  27. package/src/defaults/skills/code-tidy/SKILL.md +71 -102
  28. package/src/defaults/skills/code-tidy/references/agent-cleanup.md +66 -114
  29. package/src/defaults/skills/code-tidy/references/cleanup-report.md +19 -35
  30. package/src/defaults/skills/code-tidy/references/dead-code.md +3 -9
  31. package/src/defaults/skills/code-tidy/references/test-suite-slop.md +16 -27
  32. package/src/defaults/skills/computer-use/SKILL.md +12 -2
  33. package/src/defaults/skills/setup/SKILL.md +2 -2
  34. package/src/defaults/skills/setup/references/actions.md +28 -9
  35. package/src/defaults/skills/setup/references/surfaces.md +5 -2
  36. package/src/defaults/skills/setup-guide/SKILL.md +11 -3
  37. package/src/defaults/skills/setup-guide/model-recommendations.json +13 -0
  38. package/src/defaults/skills/skill-creator/SKILL.md +85 -190
  39. package/src/defaults/skills/skill-creator/references/design.md +70 -162
  40. package/src/defaults/skills/skill-creator/references/evaluation.md +33 -92
  41. package/src/defaults/skills/test-suite/SKILL.md +194 -0
  42. package/src/defaults/skills/test-suite/references/case-management.md +51 -0
  43. package/src/defaults/skills/test-suite/references/flaky-policy.md +39 -0
  44. package/src/defaults/skills/test-suite/references/reliability.md +49 -0
  45. package/src/defaults/skills/test-suite/references/suite-design.md +78 -0
  46. package/src/defaults/skills/test-suite/scripts/inventory.mjs +335 -0
  47. package/src/defaults/skills/visualize/SKILL.md +6 -2
  48. package/src/lib/instruction-text.cjs +36 -0
  49. package/src/lib/rules-builder.cjs +40 -50
  50. package/src/orchestration/balanced.md +23 -0
  51. package/src/orchestration/focused.md +22 -0
  52. package/src/orchestration/none.md +5 -0
  53. package/src/orchestration/swarm.md +24 -0
  54. package/src/rules/agent-mode/AGENT_MODE.md +3 -1
  55. package/src/rules/agent-mode/MIXY.md +2 -2
  56. package/src/rules/lead/LEAD.md +3 -3
  57. package/src/rules/shared/20-research.md +3 -6
  58. package/src/rules/shared/40-editing.md +1 -0
  59. package/src/rules/shared/60-verification.md +10 -9
  60. package/src/rules/shared/70-delivery.md +4 -3
  61. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/abort-link.mjs +5 -1
  62. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/session-prep.mjs +3 -3
  63. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +10 -2
  64. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +132 -21
  65. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +13 -9
  66. package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +2 -2
  67. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +67 -0
  69. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +1 -0
  70. package/src/runtime/agent/orchestrator/config.mjs +12 -7
  71. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +7 -7
  72. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +9 -30
  73. package/src/runtime/agent/orchestrator/internal-agents.mjs +3 -14
  74. package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +124 -12
  75. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +204 -49
  76. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +16 -7
  77. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +157 -57
  78. package/src/runtime/agent/orchestrator/mcp/client.mjs +54 -23
  79. package/src/runtime/agent/orchestrator/mcp/oauth-client.mjs +1 -1
  80. package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +8 -2
  81. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +83 -52
  82. package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +1 -1
  83. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +14 -0
  84. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +43 -27
  85. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +43 -8
  86. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +30 -28
  87. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +31 -21
  89. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-stop.mjs +3 -3
  90. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-login.mjs +3 -1
  91. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -2
  92. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  93. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +1 -1
  94. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +40 -0
  95. package/src/runtime/agent/orchestrator/providers/custom-provider-keys.mjs +34 -0
  96. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +3 -0
  97. package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +14 -7
  98. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +3 -2
  99. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +2 -2
  100. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +4 -1
  101. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +2 -2
  102. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +48 -31
  103. package/src/runtime/agent/orchestrator/providers/lib/oauth-page.mjs +0 -18
  104. package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +138 -6
  105. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +1 -1
  106. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +100 -33
  107. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +1 -1
  108. package/src/runtime/agent/orchestrator/providers/oauth-secret-fields.fixtures.mjs +45 -0
  109. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +4 -6
  110. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-head-parity.json +2784 -2872
  111. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +5 -8
  112. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +26 -28
  113. package/src/runtime/agent/orchestrator/providers/openai-responses-payload-parity.fixture.json +288 -300
  114. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +3 -1
  115. package/src/runtime/agent/orchestrator/providers/openai-ws-watchdogs.mjs +1 -0
  116. package/src/runtime/agent/orchestrator/providers/opencode-go-oauth.mjs +1 -1
  117. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +3 -1
  118. package/src/runtime/agent/orchestrator/providers/registry.mjs +33 -2
  119. package/src/runtime/agent/orchestrator/providers/vertex-ai-auth.mjs +622 -0
  120. package/src/runtime/agent/orchestrator/providers/vertex-ai-maas.mjs +185 -0
  121. package/src/runtime/agent/orchestrator/providers/vertex-ai-models.mjs +445 -0
  122. package/src/runtime/agent/orchestrator/providers/vertex-ai.mjs +576 -0
  123. package/src/runtime/agent/orchestrator/providers/xai-tool-search-head-parity.json +10 -30
  124. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +32 -0
  125. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +0 -11
  126. package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-availability.mjs +8 -0
  127. package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-delta.mjs +17 -3
  128. package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +47 -27
  129. package/src/runtime/agent/orchestrator/runtime-core/goal-deadlines.mjs +141 -10
  130. package/src/runtime/agent/orchestrator/runtime-core/goal-mutations.mjs +26 -63
  131. package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +28 -3
  132. package/src/runtime/agent/orchestrator/runtime-core/goal-runtime.mjs +36 -11
  133. package/src/runtime/agent/orchestrator/runtime-core/goal-state.mjs +88 -56
  134. package/src/runtime/agent/orchestrator/runtime-core/goal-storage.mjs +4 -1
  135. package/src/runtime/agent/orchestrator/runtime-core/goal-store.mjs +51 -32
  136. package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +45 -145
  137. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +15 -98
  138. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +32 -99
  139. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +23 -78
  140. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +69 -47
  141. package/src/runtime/agent/orchestrator/runtime-core/todo-list.mjs +367 -0
  142. package/src/runtime/agent/orchestrator/runtime-core/todo-tool-defs.mjs +47 -0
  143. package/src/runtime/agent/orchestrator/runtime-core/tool-availability.mjs +155 -0
  144. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +12 -0
  145. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +119 -35
  146. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +4 -1
  147. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +15 -6
  148. package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -0
  149. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -22
  150. package/src/runtime/agent/orchestrator/session/agent-pool-progress.mjs +30 -0
  151. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +30 -5
  152. package/src/runtime/agent/orchestrator/session/agent-room.mjs +171 -37
  153. package/src/runtime/agent/orchestrator/session/attachment-preflight.mjs +1 -1
  154. package/src/runtime/agent/orchestrator/session/cache-warm.mjs +94 -10
  155. package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +2 -2
  156. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +1 -1
  157. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +1 -1
  158. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +3 -0
  159. package/src/runtime/agent/orchestrator/session/compact/summary-route.mjs +1 -1
  160. package/src/runtime/agent/orchestrator/session/context-utils.mjs +8 -12
  161. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +4 -4
  162. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +11 -6
  163. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +6 -6
  164. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +7 -3
  165. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +11 -1
  166. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -3
  167. package/src/runtime/agent/orchestrator/session/human-gate/held-message.mjs +1 -1
  168. package/src/runtime/agent/orchestrator/session/human-gate/requests.mjs +37 -8
  169. package/src/runtime/agent/orchestrator/session/human-gate/run-policy.mjs +1 -1
  170. package/src/runtime/agent/orchestrator/session/image-strip-recovery.mjs +1 -1
  171. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +11 -1
  172. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  173. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +5 -3
  174. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +8 -1
  175. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/todo-check.mjs +196 -0
  176. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +116 -50
  177. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +8 -1
  178. package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +1 -1
  179. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +1 -1
  180. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +3 -1
  181. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +5 -6
  182. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +27 -3
  183. package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +18 -2
  184. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +23 -1
  185. package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +13 -8
  186. package/src/runtime/agent/orchestrator/session/loop/turn-report.mjs +35 -0
  187. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +1 -1
  188. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +103 -50
  189. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +9 -7
  190. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +21 -3
  191. package/src/runtime/agent/orchestrator/session/manager/ask-turn-failure.mjs +27 -11
  192. package/src/runtime/agent/orchestrator/session/manager/ask-turn-open.mjs +37 -34
  193. package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +15 -0
  194. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +25 -15
  195. package/src/runtime/agent/orchestrator/session/manager/ask-turn-state.mjs +79 -24
  196. package/src/runtime/agent/orchestrator/session/manager/ask-turn-steering-drain.mjs +3 -0
  197. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +12 -1
  198. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  199. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +6 -1
  200. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +9 -1
  201. package/src/runtime/agent/orchestrator/session/manager/pending-spool-path.mjs +2 -2
  202. package/src/runtime/agent/orchestrator/session/manager/pending-spool-shard.mjs +1 -1
  203. package/src/runtime/agent/orchestrator/session/manager/prompt-settings.mjs +598 -0
  204. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +20 -0
  205. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +22 -14
  206. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +2 -0
  207. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +12 -0
  208. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +15 -0
  209. package/src/runtime/agent/orchestrator/session/manager/session-fallback-title.mjs +1 -1
  210. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +56 -14
  211. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +31 -0
  212. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +59 -20
  213. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +2 -0
  214. package/src/runtime/agent/orchestrator/session/manager/session-scratch-environment.mjs +70 -0
  215. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +0 -3
  216. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +18 -4
  217. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-context.mjs +8 -9
  218. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +339 -88
  219. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-recovery.mjs +135 -11
  220. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +377 -104
  221. package/src/runtime/agent/orchestrator/session/manager/turn-durability.mjs +144 -0
  222. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +48 -30
  223. package/src/runtime/agent/orchestrator/session/manager/turn-reap.mjs +125 -0
  224. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +4 -0
  225. package/src/runtime/agent/orchestrator/session/manager.mjs +6 -0
  226. package/src/runtime/agent/orchestrator/session/mixy-name.mjs +12 -6
  227. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +8 -1
  228. package/src/runtime/agent/orchestrator/session/restart-prefix-parity.fixture.mjs +1 -1
  229. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +5 -1
  230. package/src/runtime/agent/orchestrator/session/store/canonical-reader.mjs +1 -1
  231. package/src/runtime/agent/orchestrator/session/store/liveness-veto.mjs +2 -2
  232. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +23 -2
  233. package/src/runtime/agent/orchestrator/session/store/sweep/sub-agent-retention.mjs +3 -3
  234. package/src/runtime/agent/orchestrator/session/store/transcript-durability.mjs +88 -0
  235. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +5 -0
  236. package/src/runtime/agent/orchestrator/session/store-summary-log.mjs +1 -1
  237. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +243 -38
  238. package/src/runtime/agent/orchestrator/session/store-transcript-cache.mjs +163 -64
  239. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +177 -8
  240. package/src/runtime/agent/orchestrator/session/store-transcript-worker.mjs +142 -18
  241. package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
  242. package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +7 -1
  243. package/src/runtime/agent/orchestrator/session/tail-reminder-rules.mjs +9 -1
  244. package/src/runtime/agent/orchestrator/session/transcript-restore/agent-envelope.mjs +1 -1
  245. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +3 -1
  246. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +2 -2
  247. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -5
  248. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/backgrounded-result.mjs +4 -1
  249. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +6 -2
  250. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/misuse-guard.mjs +21 -3
  251. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/result-format.mjs +1 -1
  252. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/worktree-add-guard.mjs +77 -0
  253. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +0 -1
  254. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +42 -28
  255. package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +30 -14
  256. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +2 -2
  257. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +58 -20
  258. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +1 -1
  259. package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +0 -1
  260. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +20 -15
  261. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +0 -10
  262. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +95 -20
  263. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +59 -10
  264. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -24
  265. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary/child-kill.mjs +4 -3
  266. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +1 -2
  267. package/src/runtime/agent/orchestrator/tools/code-graph/modes/calls.mjs +1 -1
  268. package/src/runtime/agent/orchestrator/tools/code-graph/modes/symbols.mjs +2 -2
  269. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +4 -5
  270. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +3 -4
  271. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4 -22
  272. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  273. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +99 -2
  274. package/src/runtime/agent/orchestrator/tools/lib/owned-shell-trees.mjs +2 -0
  275. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +105 -15
  276. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +3 -1
  277. package/src/runtime/agent/orchestrator/tools/lib/shell-run-settle.mjs +49 -15
  278. package/src/runtime/agent/orchestrator/tools/lib/shell-run-spawn.mjs +45 -27
  279. package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +35 -11
  280. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +2 -0
  281. package/src/runtime/agent/orchestrator/tools/lib/wait-until-fixture.mjs +26 -0
  282. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -6
  283. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +0 -1
  284. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +13 -13
  285. package/src/runtime/attachments/attachment-reference.mjs +1 -1
  286. package/src/runtime/attachments/pdf-extract.mjs +1 -1
  287. package/src/runtime/attachments/store.d.mts +1 -0
  288. package/src/runtime/attachments/store.mjs +2 -2
  289. package/src/runtime/channels/lib/config-reload-watch.mjs +42 -0
  290. package/src/runtime/channels/lib/owned-runtime/config-reload.mjs +29 -9
  291. package/src/runtime/channels/lib/scheduler.mjs +33 -0
  292. package/src/runtime/channels/lib/schedules-db.mjs +11 -4
  293. package/src/runtime/channels/lib/webhook/session-dispatch.mjs +4 -1
  294. package/src/runtime/channels/lib/webhooks-db.mjs +9 -3
  295. package/src/runtime/channels/lib/whisper-port.mjs +19 -10
  296. package/src/runtime/channels/lib/whisper-server.mjs +22 -13
  297. package/src/runtime/channels/lib/worker-main.mjs +9 -31
  298. package/src/runtime/computer-bridge/linux-readiness.mjs +10 -14
  299. package/src/runtime/connectors/connector-accounts-mcp.mjs +372 -103
  300. package/src/runtime/connectors/gmail-app-password.mjs +4 -4
  301. package/src/runtime/connectors/google-mcp.mjs +1 -1
  302. package/src/runtime/effort-judge/judge-client.mjs +2 -2
  303. package/src/runtime/effort-judge/judge-input.mjs +4 -4
  304. package/src/runtime/explore/explorer.mjs +88 -0
  305. package/src/runtime/explore/tool-defs.mjs +35 -0
  306. package/src/runtime/explore/tool.mjs +123 -0
  307. package/src/runtime/local-provider/hardware.mjs +1 -1
  308. package/src/runtime/media/adapters/codex-image.mjs +3 -3
  309. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  310. package/src/runtime/media/media-usage.mjs +1 -1
  311. package/src/runtime/media/store.mjs +1 -1
  312. package/src/runtime/memory/index.mjs +15 -1
  313. package/src/runtime/memory/lib/embedding-provider.mjs +4 -1
  314. package/src/runtime/memory/lib/embedding-reindex.mjs +27 -0
  315. package/src/runtime/memory/lib/embedding-worker.mjs +4 -0
  316. package/src/runtime/memory/lib/memory-embed.mjs +83 -14
  317. package/src/runtime/memory/lib/memory-process-lock.mjs +159 -36
  318. package/src/runtime/memory/lib/memory-text-utils.mjs +6 -1
  319. package/src/runtime/memory/lib/memory.mjs +17 -0
  320. package/src/runtime/memory/lib/pg/process.mjs +1 -1
  321. package/src/runtime/memory/lib/pg/supervisor.mjs +16 -96
  322. package/src/runtime/memory/lib/recall-hybrid/hybrid-sql.mjs +12 -3
  323. package/src/runtime/office/authoring/docx-html-runner.mjs +1 -1
  324. package/src/runtime/office/authoring/pptx-author-action.mjs +1 -1
  325. package/src/runtime/office/authoring/xlsx-html-build.mjs +1 -1
  326. package/src/runtime/office/authoring/xlsx-html-runner.mjs +1 -1
  327. package/src/runtime/office/capabilities.mjs +1 -1
  328. package/src/runtime/office/core/office-core.mjs +1 -1
  329. package/src/runtime/office/design/content-model.mjs +3 -3
  330. package/src/runtime/office/design/docx/design-docx/section.mjs +6 -6
  331. package/src/runtime/office/design/xlsx/design-xlsx-components.mjs +1 -1
  332. package/src/runtime/office/design/xlsx/design-xlsx.mjs +1 -1
  333. package/src/runtime/office/pdf/ocr-engine.mjs +1 -1
  334. package/src/runtime/office/pdf/pdf-writer.mjs +4 -4
  335. package/src/runtime/office/portable/portable-docx-operations.mjs +2 -2
  336. package/src/runtime/office/portable/portable-soffice.mjs +8 -6
  337. package/src/runtime/office/portable/xlsx-contract.mjs +1 -1
  338. package/src/runtime/office/quality/assurance-structure-docx.mjs +1 -1
  339. package/src/runtime/shared/agent-route-config.mjs +17 -2
  340. package/src/runtime/shared/atomic-file.d.mts +8 -0
  341. package/src/runtime/shared/atomic-file.mjs +103 -2
  342. package/src/runtime/shared/background-tasks.mjs +37 -10
  343. package/src/runtime/shared/background-wait.mjs +66 -0
  344. package/src/runtime/shared/child-guardian.mjs +17 -7
  345. package/src/runtime/shared/config.mjs +313 -16
  346. package/src/runtime/shared/connector-accounts.mjs +5 -5
  347. package/src/runtime/shared/connector-catalog.d.mts +14 -8
  348. package/src/runtime/shared/connector-catalog.mjs +70 -52
  349. package/src/runtime/shared/custom-provider-keys.mjs +20 -0
  350. package/src/runtime/shared/file-has-data.mjs +26 -2
  351. package/src/runtime/shared/file-lock.mjs +21 -18
  352. package/src/runtime/shared/git-launch.d.mts +9 -0
  353. package/src/runtime/shared/git-launch.mjs +213 -0
  354. package/src/runtime/shared/git-runner-worker.mjs +2 -1
  355. package/src/runtime/shared/git-runner.mjs +7 -2
  356. package/src/runtime/shared/idle-gc.mjs +30 -7
  357. package/src/runtime/shared/llm/cost.mjs +6 -1
  358. package/src/runtime/shared/llm/http-agent.mjs +316 -29
  359. package/src/runtime/shared/llm/model-catalog-projection.mjs +3 -2
  360. package/src/runtime/shared/llm/model-catalog.mjs +46 -6
  361. package/src/runtime/shared/llm/provider-catalog-registry.mjs +3 -0
  362. package/src/runtime/shared/llm/system-proxy.mjs +453 -0
  363. package/src/runtime/shared/llm/usage-ledger-quota.mjs +31 -12
  364. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +84 -8
  365. package/src/runtime/shared/llm/usage-ledger.mjs +3 -0
  366. package/src/runtime/shared/llm/usage-rollup.mjs +48 -0
  367. package/src/runtime/shared/maintenance-schedule.mjs +109 -0
  368. package/src/runtime/shared/process-runner.mjs +8 -4
  369. package/src/runtime/shared/provider-accounts.mjs +52 -2
  370. package/src/runtime/shared/routine-trigger.d.mts +13 -0
  371. package/src/runtime/shared/routine-trigger.mjs +34 -0
  372. package/src/runtime/shared/run-child.d.mts +2 -1
  373. package/src/runtime/shared/run-child.mjs +70 -18
  374. package/src/runtime/shared/session-scratch.mjs +14 -2
  375. package/src/runtime/shared/shipped-definitions.mjs +7 -1
  376. package/src/runtime/shared/staged-install-worker.mjs +5 -0
  377. package/src/runtime/shared/staged-update.mjs +47 -2
  378. package/src/runtime/shared/tool-card-model.golden.json +3 -12
  379. package/src/runtime/shared/tool-execution-contract.mjs +2 -0
  380. package/src/runtime/shared/tool-surface.mjs +110 -36
  381. package/src/runtime/shared/tool-work-units.mjs +35 -6
  382. package/src/runtime/shared/turn-snapshot.mjs +26 -4
  383. package/src/runtime/shared/turn-worktree-snapshot.mjs +409 -54
  384. package/src/runtime/shared/worker-requests.mjs +17 -3
  385. package/src/runtime/shared/worktrees/activity-gate.mjs +215 -318
  386. package/src/runtime/shared/worktrees/activity-ledger-worker.mjs +56 -0
  387. package/src/runtime/shared/worktrees/activity-ledger.mjs +391 -0
  388. package/src/runtime/shared/worktrees/branch-management.mjs +5 -3
  389. package/src/runtime/shared/worktrees/contract.mjs +16 -6
  390. package/src/runtime/shared/worktrees/dependencies.mjs +127 -89
  391. package/src/runtime/shared/worktrees/disk.mjs +79 -2
  392. package/src/runtime/shared/worktrees/git.mjs +97 -7
  393. package/src/runtime/shared/worktrees/include.mjs +6 -4
  394. package/src/runtime/shared/worktrees/maintenance.mjs +422 -30
  395. package/src/runtime/shared/worktrees/manager.mjs +962 -197
  396. package/src/runtime/shared/worktrees/settings.mjs +33 -7
  397. package/src/runtime/shared/worktrees/test-support.mjs +5 -1
  398. package/src/runtime/shared/worktrees/worker-snapshots.mjs +82 -0
  399. package/src/runtime/tidy/install.mjs +95 -15
  400. package/src/runtime/tidy/languages.mjs +17 -15
  401. package/src/runtime/tidy/process.mjs +9 -5
  402. package/src/runtime/tidy/resolve.mjs +19 -16
  403. package/src/runtime/tidy/runners/biome.mjs +3 -3
  404. package/src/runtime/tidy/tool.mjs +1 -1
  405. package/src/runtime/tidy/worktree.mjs +10 -6
  406. package/src/runtime/visualize-bridge/client.mjs +4 -1
  407. package/src/runtime/visualize-bridge/tool-defs.mjs +7 -2
  408. package/src/runtime/web-search/index.mjs +4 -23
  409. package/src/runtime/web-search/lib/web-tools.mjs +2 -2
  410. package/src/runtime/web-search/tool-defs.mjs +17 -26
  411. package/src/session-runtime/agent-profile-changes.mjs +55 -4
  412. package/src/session-runtime/agent-task-sessions.mjs +410 -353
  413. package/src/session-runtime/boot/apis.mjs +27 -4
  414. package/src/session-runtime/boot/begin.mjs +2 -0
  415. package/src/session-runtime/boot/config.mjs +10 -1
  416. package/src/session-runtime/boot/facade.mjs +1 -0
  417. package/src/session-runtime/boot/providers.mjs +70 -3
  418. package/src/session-runtime/boot/session.mjs +2 -0
  419. package/src/session-runtime/boot/tools.mjs +71 -25
  420. package/src/session-runtime/bot-status.mjs +103 -103
  421. package/src/session-runtime/channel-config-api.mjs +1 -0
  422. package/src/session-runtime/config-change-effects.mjs +179 -0
  423. package/src/session-runtime/config-lifecycle/config-writers.mjs +1 -0
  424. package/src/session-runtime/config-lifecycle.mjs +160 -24
  425. package/src/session-runtime/connector-merge-migration.mjs +18 -15
  426. package/src/session-runtime/conversation-import/discovery.mjs +245 -47
  427. package/src/session-runtime/conversation-import/service.mjs +12 -5
  428. package/src/session-runtime/goal-facade-api.mjs +49 -3
  429. package/src/session-runtime/handoff-attachments.mjs +1 -1
  430. package/src/session-runtime/held-requests.mjs +3 -1
  431. package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +3 -0
  432. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +3 -2
  433. package/src/session-runtime/internal-tool-executor/feature-tools.mjs +5 -11
  434. package/src/session-runtime/internal-tool-executor/knowledge-tools.mjs +8 -0
  435. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +161 -120
  436. package/src/session-runtime/internal-tool-executor.mjs +12 -19
  437. package/src/session-runtime/lifecycle/cold-heir.mjs +7 -0
  438. package/src/session-runtime/lifecycle/memory-ingest.mjs +3 -3
  439. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +53 -5
  440. package/src/session-runtime/lifecycle/session-switch.mjs +13 -2
  441. package/src/session-runtime/lifecycle/teardown/session-close.mjs +10 -5
  442. package/src/session-runtime/lifecycle/teardown.mjs +1 -1
  443. package/src/session-runtime/lifecycle-api.mjs +1 -0
  444. package/src/session-runtime/mcp-glue/server-catalog.mjs +27 -10
  445. package/src/session-runtime/mcp-glue/server-connection.mjs +34 -8
  446. package/src/session-runtime/mcp-glue/server-input.mjs +4 -1
  447. package/src/session-runtime/mixy-heartbeat.mjs +1 -1
  448. package/src/session-runtime/model-route/set-route.mjs +40 -7
  449. package/src/session-runtime/native-web-search/routes.mjs +3 -3
  450. package/src/session-runtime/provider-auth/login.mjs +2 -2
  451. package/src/session-runtime/provider-auth/refresh.mjs +6 -3
  452. package/src/session-runtime/provider-auth/setup.mjs +1 -1
  453. package/src/session-runtime/provider-readiness.mjs +53 -4
  454. package/src/session-runtime/resource-api.mjs +1 -1
  455. package/src/session-runtime/resource-extension-sync.mjs +4 -8
  456. package/src/session-runtime/resource-mcp-api.mjs +98 -35
  457. package/src/session-runtime/resource-mcp-connector-accounts.mjs +49 -32
  458. package/src/session-runtime/resource-mcp-secrets.mjs +27 -10
  459. package/src/session-runtime/resource-mcp-servers.mjs +5 -4
  460. package/src/session-runtime/resource-plugins-api.mjs +4 -0
  461. package/src/session-runtime/routine-executor.mjs +152 -0
  462. package/src/session-runtime/routine-home-turn.mjs +46 -5
  463. package/src/session-runtime/runtime-facade.mjs +5 -0
  464. package/src/session-runtime/runtime-feature-gates.mjs +2 -11
  465. package/src/session-runtime/schedule-session-run.mjs +1 -0
  466. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +6 -1
  467. package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +7 -0
  468. package/src/session-runtime/services/agent-tool/send-flow.mjs +120 -8
  469. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +12 -0
  470. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +5 -0
  471. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +8 -3
  472. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +28 -7
  473. package/src/session-runtime/services/agent-tool.mjs +51 -1
  474. package/src/session-runtime/services/agent-watchdog-registry.mjs +32 -4
  475. package/src/session-runtime/services/channel-admin.mjs +18 -0
  476. package/src/session-runtime/services/channel-worker/daemon-spawn.mjs +2 -0
  477. package/src/session-runtime/services/daemon-candidate.mjs +4 -1
  478. package/src/session-runtime/services/hook-bus/command-handler.mjs +7 -11
  479. package/src/session-runtime/services/memory-runtime-proxy/daemon-spawn.mjs +4 -0
  480. package/src/session-runtime/services/plugin-admin.mjs +14 -6
  481. package/src/session-runtime/services/provider-admin.mjs +281 -10
  482. package/src/session-runtime/services/seeds.mjs +7 -0
  483. package/src/session-runtime/services/usage-stats-model.mjs +32 -11
  484. package/src/session-runtime/session-create/materialize.mjs +4 -2
  485. package/src/session-runtime/session-create/session-options.mjs +2 -0
  486. package/src/session-runtime/session-turn-api.mjs +2 -0
  487. package/src/session-runtime/settings-builtin-tools-api.mjs +5 -10
  488. package/src/session-runtime/settings-system-api.mjs +1 -1
  489. package/src/session-runtime/setup-tool/agent-settings-actions.mjs +13 -3
  490. package/src/session-runtime/setup-tool/connector-widen-approval.mjs +96 -0
  491. package/src/session-runtime/setup-tool/executor.mjs +14 -3
  492. package/src/session-runtime/setup-tool/extended-actions.mjs +2 -0
  493. package/src/session-runtime/setup-tool/settings-contract.mjs +28 -0
  494. package/src/session-runtime/setup-tool/tool-defs.mjs +28 -2
  495. package/src/session-runtime/tool-policy/agent-profile-fold.mjs +40 -43
  496. package/src/session-runtime/tool-policy/mode-switch.mjs +1 -1
  497. package/src/session-runtime/tool-policy/policy-stage.mjs +106 -8
  498. package/src/session-runtime/tool-policy/workflow-overlay.mjs +2 -1
  499. package/src/session-runtime/tool-policy-refresh.mjs +182 -5
  500. package/src/session-runtime/tool-surface.mjs +38 -1
  501. package/src/session-runtime/turn/agent-ops.mjs +33 -2
  502. package/src/session-runtime/turn/session-ops.mjs +2 -0
  503. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +5 -0
  504. package/src/session-runtime/turn/turn-run.mjs +1 -1
  505. package/src/session-runtime/webhook-session-run.mjs +2 -0
  506. package/src/session-runtime/workflow-agents-api/agent-connectors.mjs +46 -11
  507. package/src/session-runtime/workflow-agents-api/agent-route.mjs +2 -0
  508. package/src/session-runtime/workflow-agents-api/instruction-files.mjs +165 -0
  509. package/src/session-runtime/workflow-agents-api/style-and-mode.mjs +5 -4
  510. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +2 -3
  511. package/src/session-runtime/workflow-agents-api.mjs +2 -0
  512. package/src/session-runtime/workspace/activity-guards.mjs +176 -0
  513. package/src/session-runtime/workspace/project-branches.mjs +1 -1
  514. package/src/session-runtime/workspace/session-view.mjs +149 -0
  515. package/src/session-runtime/workspace/side-checkouts.mjs +203 -0
  516. package/src/session-runtime/workspace/transition-runner.mjs +188 -0
  517. package/src/session-runtime/workspace/transitions.mjs +128 -1070
  518. package/src/session-runtime/workspace/workspace-merge.mjs +132 -0
  519. package/src/session-runtime/workspace/worktree-intents.mjs +242 -0
  520. package/src/session-runtime/workspace/worktree-maintenance.mjs +200 -0
  521. package/src/session-runtime/workspace/worktree-setup.mjs +97 -0
  522. package/src/session-runtime/workspace/worktree-tool.mjs +70 -54
  523. package/src/standalone/daemon-agent-control.mjs +25 -8
  524. package/src/standalone/daemon-desktop-runtime.mjs +8 -2
  525. package/src/standalone/daemon-lag-profiler.mjs +7 -7
  526. package/src/standalone/daemon-session-control.mjs +151 -136
  527. package/src/standalone/daemon.mjs +61 -2
  528. package/src/standalone/desktop-service-registry.mjs +9 -0
  529. package/src/standalone/local-session-runtime.mjs +21 -1
  530. package/src/standalone/session-client/daemon-unavailable.mjs +4 -1
  531. package/src/standalone/session-client.mjs +5 -1
  532. package/src/standalone/session-frame-stream.mjs +83 -11
  533. package/src/standalone/session-protocol.mjs +15 -0
  534. package/src/standalone/session-service/agent-route-follow.mjs +23 -20
  535. package/src/standalone/session-service/agent-tree/agent-turns.mjs +18 -6
  536. package/src/standalone/session-service/agent-tree.mjs +45 -9
  537. package/src/standalone/session-service/projection/transcript-window.mjs +700 -64
  538. package/src/standalone/session-service/quota-resume.mjs +9 -0
  539. package/src/standalone/session-service/session-calls/session-action.mjs +51 -1
  540. package/src/standalone/session-service/session-calls/session-turns.mjs +37 -18
  541. package/src/standalone/session-service/session-calls/session-views.mjs +160 -104
  542. package/src/standalone/session-service/session-calls/turn-gate.mjs +34 -0
  543. package/src/standalone/session-service/session-calls.mjs +2 -1
  544. package/src/standalone/session-service/stored-reader.mjs +127 -40
  545. package/src/tui/App.jsx +162 -164
  546. package/src/tui/app/app-view/floating-panel.jsx +3 -0
  547. package/src/tui/app/app-view/text-entry-labels.mjs +1 -0
  548. package/src/tui/app/clipboard.mjs +22 -11
  549. package/src/tui/app/create-app-pickers.mjs +2 -0
  550. package/src/tui/app/doctor.mjs +2 -2
  551. package/src/tui/app/panel-signature.mjs +1 -1
  552. package/src/tui/app/prompt-handlers/use-prompt-history-nav.mjs +1 -1
  553. package/src/tui/app/prompt-handlers/use-prompt-interrupt.mjs +3 -0
  554. package/src/tui/app/prompt-submit/provider-prompts.mjs +3 -1
  555. package/src/tui/app/provider-setup/api-key-actions.mjs +31 -1
  556. package/src/tui/app/provider-setup/oauth-actions.mjs +25 -6
  557. package/src/tui/app/provider-setup-picker.mjs +20 -13
  558. package/src/tui/app/settings-picker.mjs +53 -8
  559. package/src/tui/app/shell-layout/row-budgets.mjs +14 -12
  560. package/src/tui/app/slash-commands.mjs +2 -1
  561. package/src/tui/app/slash-dispatch/route-commands.mjs +1 -1
  562. package/src/tui/app/slash-dispatch/session-commands.mjs +47 -1
  563. package/src/tui/app/slash-dispatch.mjs +1 -1
  564. package/src/tui/app/use-btw-panel.mjs +29 -0
  565. package/src/tui/app/use-context-panel-refresh.mjs +31 -0
  566. package/src/tui/app/use-model-catalog-cache.mjs +13 -10
  567. package/src/tui/app/use-mouse-input.mjs +11 -0
  568. package/src/tui/app/use-onboarding-start.mjs +8 -3
  569. package/src/tui/app/use-panel-transition.mjs +10 -1
  570. package/src/tui/app/use-pasted-buffers.mjs +28 -20
  571. package/src/tui/app/use-prompt-draft-flow.mjs +7 -0
  572. package/src/tui/app/use-prompt-layout-rows.mjs +3 -3
  573. package/src/tui/app/use-prompt-queue-history.mjs +5 -4
  574. package/src/tui/app/use-selection-focus.mjs +11 -2
  575. package/src/tui/app/use-selection-geometry.mjs +18 -9
  576. package/src/tui/app/use-selection-paint.mjs +4 -4
  577. package/src/tui/app/use-selection-stitch.mjs +3 -3
  578. package/src/tui/app/use-slash-palette.mjs +62 -0
  579. package/src/tui/app/use-terminal-chrome.mjs +2 -2
  580. package/src/tui/app/use-transcript-activity.mjs +48 -33
  581. package/src/tui/app/use-transcript-follow.mjs +12 -4
  582. package/src/tui/app/use-transcript-scroll.mjs +46 -17
  583. package/src/tui/app/use-transcript-swap-reset.mjs +27 -0
  584. package/src/tui/app/use-transcript-window/geometry.mjs +20 -6
  585. package/src/tui/app/use-transcript-window/instance-state.mjs +4 -0
  586. package/src/tui/app/use-transcript-window/selection-shift.mjs +5 -1
  587. package/src/tui/app/use-transcript-window.mjs +21 -11
  588. package/src/tui/app/use-tui-ready.mjs +18 -0
  589. package/src/tui/app/use-welcome-prompt-hint.mjs +5 -0
  590. package/src/tui/app/use-workflow-tab-cycle.mjs +5 -3
  591. package/src/tui/components/Picker.jsx +27 -3
  592. package/src/tui/components/SignInUrl.jsx +68 -0
  593. package/src/tui/components/TextEntryPanel.jsx +76 -46
  594. package/src/tui/components/prompt-input/escape-policy.d.mts +0 -1
  595. package/src/tui/components/prompt-input/escape-policy.mjs +1 -1
  596. package/src/tui/components/question-panel-model.mjs +4 -4
  597. package/src/tui/components/sign-in-url-layout.mjs +24 -0
  598. package/src/tui/components/tool-execution/standard-card.jsx +3 -4
  599. package/src/tui/components/user-message-skills.mjs +1 -1
  600. package/src/tui/dist/index.mjs +2205 -1371
  601. package/src/tui/markdown/render-ansi.mjs +1 -1
  602. package/src/tui/session/agent-job-feed/notification-router.mjs +10 -0
  603. package/src/tui/session/agent-job-feed.mjs +2 -0
  604. package/src/tui/session/context-state.mjs +3 -0
  605. package/src/tui/session/goal-turn-state.mjs +6 -2
  606. package/src/tui/session/quota-resume.mjs +1 -1
  607. package/src/tui/session/session-api/controls.mjs +4 -0
  608. package/src/tui/session/session-api/extensions.mjs +8 -0
  609. package/src/tui/session/session-api/intake/abort.mjs +2 -1
  610. package/src/tui/session/session-api/intake.mjs +2 -0
  611. package/src/tui/session/session-api/integrations.mjs +2 -2
  612. package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
  613. package/src/tui/session/session-api/lifecycle/session-reset.mjs +7 -0
  614. package/src/tui/session/session-api/routes-agent-definitions.mjs +6 -0
  615. package/src/tui/session/session-api/settings.mjs +2 -2
  616. package/src/tui/session/session-api/worktree.mjs +13 -1
  617. package/src/tui/session/session-flow/drain.mjs +11 -14
  618. package/src/tui/session/tool-card-results/grouped-result.mjs +2 -2
  619. package/src/tui/session/transcript-spill/spill-writer.mjs +15 -0
  620. package/src/tui/session/transcript-spill.mjs +92 -4
  621. package/src/tui/session/transcript-store.mjs +2 -0
  622. package/src/tui/session/turn-quiet.mjs +1 -1
  623. package/src/tui/session/turn.mjs +3 -0
  624. package/src/tui/session/worktree-setup-notice.mjs +1 -1
  625. package/src/tui/spinner-meta.mjs +3 -3
  626. package/src/tui/spinner-verbs.mjs +2 -2
  627. package/src/defaults/skills/goal-management/SKILL.md +0 -169
  628. package/src/defaults/skills/skill-creator/references/source-review.md +0 -94
  629. package/src/runtime/agent/orchestrator/runtime-core/goal-tasks.mjs +0 -156
  630. package/src/session-runtime/goal-tasks.mjs +0 -1
@@ -40,6 +40,7 @@ import {
40
40
  } from './file-recovery.mjs';
41
41
  import { lstat, mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises';
42
42
  import { dirname, join, relative, resolve, sep } from 'node:path';
43
+ import { isPidAlive } from '../pid-liveness.mjs';
43
44
  import {
44
45
  managedWorktreePath,
45
46
  managedWorktreeRoot,
@@ -58,7 +59,7 @@ import {
58
59
  restoreFromRecoveryRef,
59
60
  uncommittedChanges,
60
61
  } from './carry.mjs';
61
- import { freeBytesAt, requiredBytes, trackedCheckoutBytes } from './disk.mjs';
62
+ import { cachedFolderBytes, forgetFolderSize, freeBytesAt, requiredBytes, trackedCheckoutBytes } from './disk.mjs';
62
63
  import {
63
64
  branchCommit,
64
65
  currentBranch,
@@ -67,7 +68,9 @@ import {
67
68
  inspectRepo,
68
69
  isDiskFullError,
69
70
  listWorktrees,
71
+ localBranchCommits,
70
72
  localBranches,
73
+ parseStatusEntries,
71
74
  parseStatusPaths,
72
75
  remoteDefaultBranches,
73
76
  runGit,
@@ -84,9 +87,9 @@ import {
84
87
  removeIncludeLinks,
85
88
  restoreIncludeLinks,
86
89
  } from './include.mjs';
87
- import { linkWorktreeDependencies } from './dependencies.mjs';
90
+ import { dependencyArtefacts, linkedArtefacts } from './dependencies.mjs';
88
91
  import { withRepoLock as withRepoMutationLock } from './lock.mjs';
89
- import { holdWorkspaceActivity } from './activity-gate.mjs';
92
+ import { holdWorkspaceActivityAsync } from './activity-gate.mjs';
90
93
  import { withAdvisoryLocks } from '../../agent/orchestrator/tools/builtin/advisory-lock.mjs';
91
94
  import { mergeBranchIntoCheckout, operationInProgress } from './merge.mjs';
92
95
  import { readOwnerLease, removeOwnerLease, sameOwner, writeOwnerLease } from './owner.mjs';
@@ -124,25 +127,23 @@ function sessionRootOf(record) {
124
127
  // ---------------------------------------------------------------------------
125
128
  // Host configuration (settings source, free-space probe); tests override it.
126
129
 
127
- /** pid probe (signal 0); EPERM means the process exists. */
128
- function processAlive(pid) {
129
- try {
130
- process.kill(pid, 0);
131
- return true;
132
- } catch (error) {
133
- return error?.code === 'EPERM';
134
- }
135
- }
136
-
137
- const DEFAULT_CONFIG = Object.freeze({ settings: null, freeBytes: freeBytesAt, processAlive });
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
+ });
138
137
  let config = { ...DEFAULT_CONFIG };
139
138
 
140
139
  /**
141
140
  * Override host hooks. `settings`: stored worktree settings object or a
142
141
  * function returning it (default: agent config `worktree`). `freeBytes(dir)`:
143
- * free-space probe. `processAlive(pid)`: lease holder liveness. Returns a
144
- * restore function.
145
- * @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
146
147
  */
147
148
  export function configureWorktreeManager(overrides = {}) {
148
149
  const previous = config;
@@ -300,16 +301,22 @@ async function keepRemovedTip(record) {
300
301
  return { tip, recoveryRef: ref };
301
302
  }
302
303
 
303
- /** Managed checkouts whose registration is stale (directory missing). */
304
- async function staleManagedRegistrations(repoCommonDir) {
305
- 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) {
306
311
  const stale = [];
307
- for (const { worktreePath } of await adminRegistrations(repoCommonDir)) {
308
- if (!(await isGone(worktreePath))) continue;
309
- const rel = relative(pathKey(root), pathKey(worktreePath));
310
- 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);
311
314
  }
312
- 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));
313
320
  }
314
321
 
315
322
  async function setResource(record, name, status) {
@@ -335,23 +342,223 @@ async function unmergedCommitCount(record) {
335
342
  const reference = (record.mergeTarget && (await branchCommit(cwd, record.mergeTarget))) || record.baseCommit;
336
343
  if (!reference) return 0;
337
344
  const result = await runGit(cwd, ['rev-list', '--count', tip, '--not', reference]);
338
- 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));
339
417
  }
340
418
 
341
419
  /** Uncommitted/untracked paths plus modified include copies. */
342
420
  async function dirtyPaths(record) {
343
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) {
344
458
  const includes = record.resources?.includes || [];
345
- const status = await runGit(record.path, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], {
346
- readOnly: true,
347
- });
348
- const identity = record.projectSubdir
349
- ? `${record.projectSubdir}/${SESSION_PROJECT_ID_PATH}`
350
- : SESSION_PROJECT_ID_PATH;
351
- const dirty = parseStatusPaths(status.stdout).filter(
352
- (path) => path !== identity && !coveredByIncludes(includes, path)
353
- );
354
- 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
+ }
355
562
  }
356
563
 
357
564
  async function deleteBranchIfFree(record) {
@@ -515,6 +722,96 @@ async function removeCheckoutSafely(record) {
515
722
  return [];
516
723
  }
517
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
+
518
815
  /** Report what a failed safe removal could not put back on its error (never swallowed). */
519
816
  function withRollbackFailures(error, failures) {
520
817
  if (!failures.length) return error;
@@ -602,6 +899,21 @@ async function rollbackCreation(record, cause) {
602
899
  return { complete: false, remaining };
603
900
  }
604
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
+
605
917
  /** finishDeletion's folder step; what is left over goes to `remaining`. */
606
918
  async function removeFolder(record, remaining) {
607
919
  await removeOwnerLease(record);
@@ -613,7 +925,12 @@ async function removeFolder(record, remaining) {
613
925
  for (const result of results) {
614
926
  if (!result.ok) remaining.push({ kind: 'cleanup', name: result.name, error: result.error.message });
615
927
  }
616
- 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)));
617
934
  }
618
935
 
619
936
  /**
@@ -638,6 +955,7 @@ async function finishDeletion(record) {
638
955
  } catch (error) {
639
956
  remaining.push({ kind: 'error', error: String(error?.message || error) });
640
957
  }
958
+ if (remaining.length && record.deleting?.automatic) return keepIncompleteRemoval(record, remaining);
641
959
  if (remaining.length) {
642
960
  record.deleting = { ...record.deleting, remaining, lastAttemptAt: Date.now() };
643
961
  await putRecord(record);
@@ -645,14 +963,21 @@ async function finishDeletion(record) {
645
963
  }
646
964
  let branch = { deleted: false, reason: 'kept' };
647
965
  let kept = { tip: null, recoveryRef: null };
648
- 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;
649
972
  if (record.branch && !record.deleting?.preserveBranch && (record.deleting?.force || merged)) {
650
- // Forced past unmerged commits: the tip stays reachable before `branch -D`.
651
- 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);
652
976
  else kept.tip = await branchCommit(repoCwd(record), record.branch);
653
977
  branch = await deleteBranchIfFree(record);
654
978
  }
655
979
  await deleteRecord(record.id);
980
+ forgetFolderSize(record.path);
656
981
  await pruneEmptyRepoRoot(record.repoCommonDir);
657
982
  return {
658
983
  removed: true,
@@ -856,11 +1181,42 @@ export async function markWorktreeDisposable(entryId, disposable = true) {
856
1181
  const current = await getRecord(entryId);
857
1182
  if (!current) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
858
1183
  current.disposable = disposable === true;
1184
+ // A deleted session's mark: no archive rule applies to it.
1185
+ delete current.disposableReason;
859
1186
  await putRecord(current);
860
1187
  return entryView(current);
861
1188
  });
862
1189
  }
863
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
+
864
1220
  /**
865
1221
  * Before a creation (under the repository lock): the entry this transaction
866
1222
  * or session already has ready (its lease re-written), else null once every
@@ -979,6 +1335,164 @@ async function snapshotHolderLive(record, live) {
979
1335
  return !!lease && lease.pid !== process.pid && leaseProcessAlive(lease);
980
1336
  }
981
1337
 
1338
+ /** The project folder's uncommitted changes under the requested `carryChanges` mode; `ask` refuses while there are any. */
1339
+ async function judgePendingChanges(repo, projectSubdir, carryChanges) {
1340
+ // A carry is judged by the same status read that counts the changes.
1341
+ let pendingChanges = [];
1342
+ if (carryChanges === 'carry') pendingChanges = await assertCarryable(repo.topLevel, projectSubdir);
1343
+ else if (carryChanges) pendingChanges = await uncommittedChanges(repo.topLevel, projectSubdir);
1344
+ if (pendingChanges.length && carryChanges === 'ask') {
1345
+ throw worktreeError('WORKTREE_DIRTY_CHOICE', undefined, {
1346
+ count: pendingChanges.length,
1347
+ paths: pendingChanges.slice(0, DIRTY_CHOICE_PREVIEW),
1348
+ });
1349
+ }
1350
+ return pendingChanges;
1351
+ }
1352
+
1353
+ /** The commit a new worktree starts from and the branch a merge targets by default. */
1354
+ async function resolveStartPoint(repo, { baseBranch, startCommit, signal }) {
1355
+ let baseCommit = repo.headCommit;
1356
+ let mergeTarget = repo.currentBranch;
1357
+ if (baseBranch) {
1358
+ baseCommit = await branchCommit(repo.projectPath, baseBranch, { signal });
1359
+ if (!baseCommit) {
1360
+ throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Base branch ${baseBranch} does not exist.`, { baseBranch });
1361
+ }
1362
+ mergeTarget = baseBranch;
1363
+ }
1364
+ // A new branch at an explicit start point (`git worktree add -b <b> <path> <commit-ish>`).
1365
+ if (startCommit) baseCommit = await resolveCommit(repo.projectPath, startCommit, signal);
1366
+ return { baseCommit, mergeTarget };
1367
+ }
1368
+
1369
+ /** The record a creation reserves before git is asked to add the checkout. */
1370
+ function newCreationRecord({
1371
+ slug,
1372
+ branch,
1373
+ repo,
1374
+ owner,
1375
+ txId,
1376
+ baseCommit,
1377
+ mergeTarget,
1378
+ existingBranch,
1379
+ exactBranch,
1380
+ tracked,
1381
+ includePlan,
1382
+ }) {
1383
+ return {
1384
+ id: slug,
1385
+ txId,
1386
+ repoCommonDir: repo.repoCommonDir,
1387
+ projectPath: repo.projectPath,
1388
+ projectSubdir: projectSubdirOf(repo.topLevel, repo.projectPath),
1389
+ path: managedWorktreePath(repo.repoCommonDir, slug),
1390
+ branch,
1391
+ baseCommit,
1392
+ // An existing branch has no base it branched from: it merges where a
1393
+ // merge first takes it (recorded then), and until that its own work is
1394
+ // what it gained here since baseCommit.
1395
+ mergeTarget: existingBranch ? null : mergeTarget,
1396
+ createdAt: Date.now(),
1397
+ ownerSessionId: owner.sessionId,
1398
+ existingBranch,
1399
+ exactBranch,
1400
+ state: 'creating',
1401
+ disposable: false,
1402
+ warnings: [...(tracked.submodules ? ['submodules'] : []), ...includePlan.warnings],
1403
+ resources: { worktree: null, lock: null, owner: null, includes: [] },
1404
+ deleting: null,
1405
+ recovery: null,
1406
+ };
1407
+ }
1408
+
1409
+ /**
1410
+ * Locks the fresh checkout, binds its owner lease, moves or leaves the
1411
+ * project folder's changes and applies the includes.
1412
+ * `carry.moved` is set as soon as changes were carried, so a later failure
1413
+ * can put them back.
1414
+ */
1415
+ async function populateCheckout({
1416
+ record,
1417
+ owner,
1418
+ repo,
1419
+ projectSubdir,
1420
+ signal,
1421
+ pendingChanges,
1422
+ carryChanges,
1423
+ baseCommit,
1424
+ includePlan,
1425
+ carry,
1426
+ }) {
1427
+ await setResource(record, 'worktree', 'created');
1428
+ throwIfAborted(signal);
1429
+ await setResource(record, 'lock', 'pending');
1430
+ await runGit(repo.projectPath, ['worktree', 'lock', '--reason', `mixdog session=${owner.sessionId}`, record.path]);
1431
+ await setResource(record, 'lock', 'created');
1432
+ await setResource(record, 'owner', 'pending');
1433
+ await writeOwnerLease(record, owner);
1434
+ await setResource(record, 'owner', 'created');
1435
+ // Carried before the include entries are copied, so an include never
1436
+ // stands in the way of a carried file; the status is read again here,
1437
+ // right before acting.
1438
+ let changes = pendingChanges.length ? { mode: 'left', count: pendingChanges.length } : null;
1439
+ if (carryChanges === 'carry') {
1440
+ carry.moved = await carryUncommittedChanges({
1441
+ topLevel: repo.topLevel,
1442
+ projectSubdir,
1443
+ worktreePath: record.path,
1444
+ baseCommit,
1445
+ label: record.id,
1446
+ });
1447
+ // The recovery ref stays until the caller has committed the move
1448
+ // (dropCarryRecoveryRef) or put the changes back (restoreCarriedChanges).
1449
+ changes = carry.moved.count
1450
+ ? { mode: 'carried', count: carry.moved.count, recoveryRef: carry.moved.recoveryRef }
1451
+ : null;
1452
+ }
1453
+ const applied = await applyIncludes(includePlan, record.path, (ledger) => recordInclude(record, ledger));
1454
+ throwIfAborted(signal);
1455
+ record.warnings = [...record.warnings, ...applied.warnings];
1456
+ const made = (record.resources.includes || []).filter((item) => item.status === 'created');
1457
+ const rels = (predicate) => made.filter(predicate).map((item) => item.rel);
1458
+ // What creation did, for the agent's note at the first turn there (transitions.mjs).
1459
+ const prepared = {
1460
+ root: repo.topLevel,
1461
+ copied: rels((item) => item.mode === 'copy'),
1462
+ linked: rels((item) => item.mode === 'link'),
1463
+ skipped: [...includePlan.warnings, ...applied.warnings],
1464
+ };
1465
+ // Whichever path made it (a worktree task, a branch selection, `git
1466
+ // worktree add`), the first turn a session runs here installs its
1467
+ // dependencies in the worktree folder (claimWorktreeSetup).
1468
+ record.setup = { pending: true, prepared };
1469
+ record.state = 'ready';
1470
+ await putRecord(record);
1471
+ return { ...entryView(record), prepared, ...(changes ? { changes } : {}) };
1472
+ }
1473
+
1474
+ /** The error a failed creation throws, after its carried changes and partial checkout are undone. */
1475
+ async function failCreation(error, { record, carried, repo, projectSubdir, needed, root }) {
1476
+ // Carried already: the changes go back to the project folder before the
1477
+ // worktree that holds them is rolled back (the recovery ref keeps them
1478
+ // either way).
1479
+ let carryRestore = null;
1480
+ if (carried?.recoveryRef) {
1481
+ carryRestore = await restoreFromRecoveryRef(repo.topLevel, projectSubdir, carried.recoveryRef)
1482
+ .then(() => ({ restored: true, recoveryRef: carried.recoveryRef }))
1483
+ .catch((restoreError) => ({
1484
+ restored: false,
1485
+ recoveryRef: carried.recoveryRef,
1486
+ error: String(restoreError?.message || restoreError),
1487
+ }));
1488
+ }
1489
+ const rollback = await rollbackCreation(record, error);
1490
+ const failure = isDiskFullError(error) ? await diskFullFailure(error, { needed, root }) : error;
1491
+ if (!rollback.complete) failure.rollback = rollback;
1492
+ if (carryRestore) failure.carryRestore = carryRestore;
1493
+ return failure;
1494
+ }
1495
+
982
1496
  /**
983
1497
  * `carryChanges` decides the project folder's uncommitted changes (tracked
984
1498
  * and untracked, ignored excluded), judged under the repository lock:
@@ -1034,29 +1548,10 @@ export async function createManagedWorktree({
1034
1548
  throwIfAborted(signal);
1035
1549
 
1036
1550
  const projectSubdir = projectSubdirOf(repo.topLevel, repo.projectPath);
1037
- // A carry is judged by the same status read that counts the changes.
1038
- let pendingChanges = [];
1039
- if (carryChanges === 'carry') pendingChanges = await assertCarryable(repo.topLevel, projectSubdir);
1040
- else if (carryChanges) pendingChanges = await uncommittedChanges(repo.topLevel, projectSubdir);
1041
- if (pendingChanges.length && carryChanges === 'ask') {
1042
- throw worktreeError('WORKTREE_DIRTY_CHOICE', undefined, {
1043
- count: pendingChanges.length,
1044
- paths: pendingChanges.slice(0, DIRTY_CHOICE_PREVIEW),
1045
- });
1046
- }
1551
+ const pendingChanges = await judgePendingChanges(repo, projectSubdir, carryChanges);
1047
1552
 
1048
1553
  const settings = await currentWorktreeSettings();
1049
- let baseCommit = repo.headCommit;
1050
- let mergeTarget = repo.currentBranch;
1051
- if (baseBranch) {
1052
- baseCommit = await branchCommit(repo.projectPath, baseBranch, { signal });
1053
- if (!baseCommit) {
1054
- throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Base branch ${baseBranch} does not exist.`, { baseBranch });
1055
- }
1056
- mergeTarget = baseBranch;
1057
- }
1058
- // A new branch at an explicit start point (`git worktree add -b <b> <path> <commit-ish>`).
1059
- if (startCommit) baseCommit = await resolveCommit(repo.projectPath, startCommit, signal);
1554
+ const { baseCommit, mergeTarget } = await resolveStartPoint(repo, { baseBranch, startCommit, signal });
1060
1555
 
1061
1556
  // The main checkout's local env files come along on their own;
1062
1557
  // `.worktreeinclude` applies on top.
@@ -1073,11 +1568,20 @@ export async function createManagedWorktree({
1073
1568
  });
1074
1569
  throwIfAborted(signal);
1075
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.
1076
1573
  const branches = new Set(
1077
1574
  (await localBranches(repo.projectPath, { signal, prefix: settings.branchPrefix })).map((name) =>
1078
1575
  name.toLowerCase()
1079
1576
  )
1080
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
+ };
1081
1585
  const base = slugFromHint(nameHint);
1082
1586
  const reserve = async () => {
1083
1587
  let reserved = null;
@@ -1099,7 +1603,7 @@ export async function createManagedWorktree({
1099
1603
  }
1100
1604
  );
1101
1605
  let record = await reserve();
1102
- let carried = null;
1606
+ const carry = { moved: null };
1103
1607
 
1104
1608
  try {
1105
1609
  await mkdir(root, { recursive: true });
@@ -1120,84 +1624,20 @@ export async function createManagedWorktree({
1120
1624
  await setResource(record, 'worktree', 'pending');
1121
1625
  await addCheckout(record);
1122
1626
  }
1123
- await setResource(record, 'worktree', 'created');
1124
- throwIfAborted(signal);
1125
- await setResource(record, 'lock', 'pending');
1126
- await runGit(repo.projectPath, [
1127
- 'worktree',
1128
- 'lock',
1129
- '--reason',
1130
- `mixdog session=${owner.sessionId}`,
1131
- record.path,
1132
- ]);
1133
- await setResource(record, 'lock', 'created');
1134
- await setResource(record, 'owner', 'pending');
1135
- await writeOwnerLease(record, owner);
1136
- await setResource(record, 'owner', 'created');
1137
- // Carried before the include entries are copied, so an include never
1138
- // stands in the way of a carried file; the status is read again here,
1139
- // right before acting.
1140
- let changes = pendingChanges.length ? { mode: 'left', count: pendingChanges.length } : null;
1141
- if (carryChanges === 'carry') {
1142
- carried = await carryUncommittedChanges({
1143
- topLevel: repo.topLevel,
1144
- projectSubdir,
1145
- worktreePath: record.path,
1146
- baseCommit,
1147
- label: record.id,
1148
- });
1149
- // The recovery ref stays until the caller has committed the move
1150
- // (dropCarryRecoveryRef) or put the changes back (restoreCarriedChanges).
1151
- changes = carried.count ? { mode: 'carried', count: carried.count, recoveryRef: carried.recoveryRef } : null;
1152
- }
1153
- const applied = await applyIncludes(includePlan, record.path, (ledger) => recordInclude(record, ledger));
1154
- throwIfAborted(signal);
1155
- // After the includes: one that already brought a node_modules wins.
1156
- const linkedDependencies = await linkWorktreeDependencies({
1157
- mainDir: repo.projectPath,
1158
- worktreeDir: projectSubdir ? join(record.path, ...projectSubdir.split('/')) : record.path,
1159
- worktreeRel: projectSubdir,
1627
+ return await populateCheckout({
1628
+ record,
1629
+ owner,
1630
+ repo,
1631
+ projectSubdir,
1160
1632
  signal,
1161
- record: (ledger) => recordInclude(record, ledger),
1633
+ pendingChanges,
1634
+ carryChanges,
1635
+ baseCommit,
1636
+ includePlan,
1637
+ carry,
1162
1638
  });
1163
- throwIfAborted(signal);
1164
- record.warnings = [...record.warnings, ...applied.warnings];
1165
- const made = (record.resources.includes || []).filter((item) => item.status === 'created');
1166
- const rels = (predicate) => made.filter(predicate).map((item) => item.rel);
1167
- // What creation did, for the agent's note at the first turn there (transitions.mjs).
1168
- const prepared = {
1169
- root: repo.topLevel,
1170
- copied: rels((item) => item.mode === 'copy'),
1171
- linked: rels((item) => item.mode === 'link' && !item.dependency),
1172
- dependencies: linkedDependencies,
1173
- skipped: [...includePlan.warnings, ...applied.warnings],
1174
- };
1175
- // Whichever path made it (a worktree task, a branch selection, `git
1176
- // worktree add`), the first turn a session runs here prepares its
1177
- // dependencies (claimWorktreeSetup).
1178
- record.setup = { pending: true, prepared };
1179
- record.state = 'ready';
1180
- await putRecord(record);
1181
- return { ...entryView(record), prepared, ...(changes ? { changes } : {}) };
1182
1639
  } catch (error) {
1183
- // Carried already: the changes go back to the project folder before the
1184
- // worktree that holds them is rolled back (the recovery ref keeps them
1185
- // either way).
1186
- let carryRestore = null;
1187
- if (carried?.recoveryRef) {
1188
- carryRestore = await restoreFromRecoveryRef(repo.topLevel, projectSubdir, carried.recoveryRef)
1189
- .then(() => ({ restored: true, recoveryRef: carried.recoveryRef }))
1190
- .catch((restoreError) => ({
1191
- restored: false,
1192
- recoveryRef: carried.recoveryRef,
1193
- error: String(restoreError?.message || restoreError),
1194
- }));
1195
- }
1196
- const rollback = await rollbackCreation(record, error);
1197
- const failure = isDiskFullError(error) ? await diskFullFailure(error, { needed, root }) : error;
1198
- if (!rollback.complete) failure.rollback = rollback;
1199
- if (carryRestore) failure.carryRestore = carryRestore;
1200
- throw failure;
1640
+ throw await failCreation(error, { record, carried: carry.moved, repo, projectSubdir, needed, root });
1201
1641
  }
1202
1642
 
1203
1643
  async function reserveName() {
@@ -1206,37 +1646,28 @@ export async function createManagedWorktree({
1206
1646
  base,
1207
1647
  (candidate) =>
1208
1648
  ids.has(candidate.toLowerCase()) ||
1209
- branches.has(`${settings.branchPrefix}${candidate}`.toLowerCase()) ||
1649
+ branchTaken(`${settings.branchPrefix}${candidate}`) ||
1210
1650
  existsSync(join(root, candidate))
1211
1651
  );
1212
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));
1213
1655
  if (!(await validBranchName(repo.projectPath, branch))) {
1214
1656
  throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Branch name ${branch} is not valid.`, { branch });
1215
1657
  }
1216
- const candidate = {
1217
- id: slug,
1218
- txId,
1219
- repoCommonDir: repo.repoCommonDir,
1220
- projectPath: repo.projectPath,
1221
- projectSubdir: projectSubdirOf(repo.topLevel, repo.projectPath),
1222
- path: managedWorktreePath(repo.repoCommonDir, slug),
1658
+ const candidate = newCreationRecord({
1659
+ slug,
1223
1660
  branch,
1661
+ repo,
1662
+ owner,
1663
+ txId,
1224
1664
  baseCommit,
1225
- // An existing branch has no base it branched from: it merges where a
1226
- // merge first takes it (recorded then), and until that its own work is
1227
- // what it gained here since baseCommit.
1228
- mergeTarget: existingBranch ? null : mergeTarget,
1229
- createdAt: Date.now(),
1230
- ownerSessionId: owner.sessionId,
1665
+ mergeTarget,
1231
1666
  existingBranch,
1232
1667
  exactBranch: requestedBranch !== null,
1233
- state: 'creating',
1234
- disposable: false,
1235
- warnings: [...(tracked.submodules ? ['submodules'] : []), ...includePlan.warnings],
1236
- resources: { worktree: null, lock: null, owner: null, includes: [] },
1237
- deleting: null,
1238
- recovery: null,
1239
- };
1668
+ tracked,
1669
+ includePlan,
1670
+ });
1240
1671
  return (await insertRecord(candidate)) ? candidate : null;
1241
1672
  }
1242
1673
  });
@@ -1362,9 +1793,12 @@ export async function createSnapshotWorktree({
1362
1793
  * one is admitted there until the returned release. A folder no longer a
1363
1794
  * checkout admits no consumer (activity-gate tracks git checkouts only).
1364
1795
  */
1365
- function holdSnapshotFolder(record) {
1796
+ async function holdSnapshotFolder(record) {
1366
1797
  if (!existsSync(join(record.path, '.git'))) return () => {};
1367
- return holdWorkspaceActivity([record.path]);
1798
+ const release = await holdWorkspaceActivityAsync([record.path]);
1799
+ // Settles once the removal was attempted. It fails only when workspace
1800
+ // admission stopped (reported there), which keeps the folder held.
1801
+ return () => release().catch(() => {});
1368
1802
  }
1369
1803
 
1370
1804
  /** A refusal that only says "something still runs there" (retry once it ends). */
@@ -1429,21 +1863,78 @@ export async function removeSessionSnapshots(sessionId, { txIds = null } = {}) {
1429
1863
  return report;
1430
1864
  }
1431
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
+
1432
1894
  /**
1433
1895
  * The first turn a session runs in managed worktree `entryId` claims its
1434
1896
  * dependency preparation, once: `{ prepared }` (what creation did) while it
1435
1897
  * is still pending, else null. Claimed before it runs, so a failed or
1436
- * 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.
1437
1908
  */
1438
1909
  export async function claimWorktreeSetup(entryId) {
1439
1910
  const record = await getRecord(entryId);
1440
- if (!record?.setup?.pending) return null;
1911
+ if (!record?.setup?.pending && !dependencyLinks(record).length) return null;
1441
1912
  return withRepoLock(record.repoCommonDir, async () => {
1442
1913
  const current = await getRecord(entryId);
1443
- 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
+ }
1444
1935
  current.setup = { ...current.setup, pending: false, claimedAt: Date.now() };
1445
1936
  await putRecord(current);
1446
- return { prepared: current.setup.prepared ?? null };
1937
+ return { prepared: pending ? (current.setup.prepared ?? null) : null, shared };
1447
1938
  });
1448
1939
  }
1449
1940
 
@@ -1554,11 +2045,16 @@ export async function releaseWorktreeOwner(entryId, owner, { forgetReference = f
1554
2045
  export async function listBranchOccupancy({ projectPath, sessionId } = {}) {
1555
2046
  requireText(projectPath, 'projectPath');
1556
2047
  const repo = await inspectRepo(projectPath);
1557
- const [branches, worktrees, doc] = await Promise.all([
1558
- localBranches(repo.projectPath),
2048
+ const [commits, worktrees, doc] = await Promise.all([
2049
+ localBranchCommits(repo.projectPath),
1559
2050
  listWorktrees(repo.projectPath),
1560
2051
  readRegistry(),
1561
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
+ );
1562
2058
  const managed = Object.values(doc.entries).filter((record) => samePath(record.repoCommonDir, repo.repoCommonDir));
1563
2059
  const occupancy = branches.map((branch) => {
1564
2060
  // A prunable registration (its folder is gone) holds nothing.
@@ -1572,7 +2068,7 @@ export async function listBranchOccupancy({ projectPath, sessionId } = {}) {
1572
2068
  const retained = sessionId
1573
2069
  ? managed.find((record) => record.ownerSessionId === sessionId && record.state === 'ready')
1574
2070
  : null;
1575
- return { branches, occupancy, retainedWorktree: retained ? entryView(retained) : null };
2071
+ return { branches, occupancy, committedAt, retainedWorktree: retained ? entryView(retained) : null };
1576
2072
  }
1577
2073
 
1578
2074
  /**
@@ -1830,7 +2326,11 @@ export async function assertWorktreeRemovable({ entryId, force = false, sessionI
1830
2326
  * `expected` ({ kind, txId, holderSessionId }) pins the record the caller
1831
2327
  * selected: one that no longer matches under the repository lock (removed,
1832
2328
  * its slug reused) is left alone and reported `{ removed: false, skipped: true }`.
1833
- * @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
1834
2334
  * @returns {Promise<{ removed: boolean, skipped?: boolean, retryable?: boolean, remaining?: object[], branchDeleted?: boolean }>}
1835
2335
  */
1836
2336
  export async function removeManagedWorktree({
@@ -1840,6 +2340,7 @@ export async function removeManagedWorktree({
1840
2340
  preserveBranch = false,
1841
2341
  safeOnly = false,
1842
2342
  expected = null,
2343
+ automatic = false,
1843
2344
  } = {}) {
1844
2345
  const skipped = { removed: false, skipped: true };
1845
2346
  const matches = (record) =>
@@ -1858,24 +2359,48 @@ export async function removeManagedWorktree({
1858
2359
  if (!current && !expected) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
1859
2360
  return skipped;
1860
2361
  }
1861
- return removeLocked(current, { force, sessionId, preserveBranch, safeOnly });
2362
+ return removeLocked(current, { force, sessionId, preserveBranch, safeOnly, automatic });
1862
2363
  });
1863
2364
  }
1864
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
+
1865
2380
  /** The removal of `current`, under its repository lock. */
1866
- 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
+ ) {
1867
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
+ }
1868
2393
  if (current.state === 'creating' || current.state === 'recovery') {
1869
2394
  const rollback = await rollbackCreation(current);
1870
2395
  return rollback.complete ? { removed: true } : { removed: false, retryable: true, remaining: rollback.remaining };
1871
2396
  }
1872
2397
  // Every deletion attempt of a snapshot, a resumed one included, waits
1873
2398
  // for (refuses on) its consumers.
1874
- const release = current.kind === WORKTREE_SNAPSHOT_KIND ? holdSnapshotFolder(current) : () => {};
2399
+ const release = current.kind === WORKTREE_SNAPSHOT_KIND ? await holdSnapshotFolder(current) : () => {};
1875
2400
  try {
1876
2401
  return await decideAndFinishDeletion(current, { force, preserveBranch, safeOnly });
1877
2402
  } finally {
1878
- release();
2403
+ await release();
1879
2404
  }
1880
2405
  }
1881
2406
 
@@ -1885,13 +2410,17 @@ async function removeLocked(current, { force, sessionId, preserveBranch = false,
1885
2410
  * so an owner that came back (a live session, a running lease holder) or a
1886
2411
  * session the store still keeps it for refuses (WORKTREE_BUSY_JOBS). Then
1887
2412
  * the ordinary removal guards apply (dirty or unmerged work needs `force`).
1888
- * @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
1889
2417
  */
1890
2418
  export async function removeOrphanedWorktree({
1891
2419
  entryId,
1892
2420
  force = false,
1893
2421
  liveSessionIds = [],
1894
2422
  retainedSessionIds = [],
2423
+ allowRetained = false,
1895
2424
  } = {}) {
1896
2425
  const record = await getRecord(entryId);
1897
2426
  if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
@@ -1902,6 +2431,13 @@ export async function removeOrphanedWorktree({
1902
2431
  }
1903
2432
  const { ownerLive, removable } = await retentionOf(current, new Set(liveSessionIds), new Set(retainedSessionIds));
1904
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
+ }
1905
2441
  if (!removable) {
1906
2442
  // Retained recovery data names itself; otherwise sessions still keep it.
1907
2443
  await assertRecoveryStoresClear(current);
@@ -1910,7 +2446,7 @@ export async function removeOrphanedWorktree({
1910
2446
  sessions: [current.ownerSessionId, ...(current.sharedSessionIds ?? [])].filter(Boolean),
1911
2447
  });
1912
2448
  }
1913
- return removeLocked(current, { force });
2449
+ return removeLocked(current, { force, sessionId });
1914
2450
  });
1915
2451
  }
1916
2452
 
@@ -1937,21 +2473,53 @@ function leaseProcessAlive(lease) {
1937
2473
  * `retainedSessionIds`, i.e. still in the session store), `ownerLive` (a
1938
2474
  * live session or a running lease holder), and `removable` (what a prune
1939
2475
  * would remove: neither, or disposable and not live, with no retained recovery data).
1940
- * @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
1941
2484
  */
1942
- export async function listManagedWorktrees({ liveSessionIds = [], retainedSessionIds = [] } = {}) {
2485
+ export async function listManagedWorktrees({
2486
+ liveSessionIds = [],
2487
+ retainedSessionIds = [],
2488
+ inspect = false,
2489
+ size = false,
2490
+ } = {}) {
1943
2491
  const live = new Set(liveSessionIds);
1944
2492
  const retained = new Set(retainedSessionIds);
1945
2493
  const rows = [];
1946
2494
  for (const record of Object.values((await readRegistry()).entries)) {
1947
2495
  const { ownerLive, retained: isRetained, removable } = await retentionOf(record, live, retained);
1948
- rows.push({
2496
+ const row = {
1949
2497
  ...entryView(record),
1950
2498
  projectPath: record.projectPath,
2499
+ sharedSessionIds: [...(record.sharedSessionIds ?? [])],
1951
2500
  retained: isRetained,
1952
2501
  ownerLive,
1953
2502
  removable,
1954
- });
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);
1955
2523
  }
1956
2524
  return rows;
1957
2525
  }
@@ -1979,6 +2547,143 @@ async function retentionOf(record, live, retained) {
1979
2547
  return { ownerLive, retained: isRetained, removable };
1980
2548
  }
1981
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
+
1982
2687
  /**
1983
2688
  * Conservative reconcile (daemon startup, and the manual prune). Always:
1984
2689
  * resumes interrupted deletions, rolls back interrupted creations of no
@@ -1990,20 +2695,62 @@ async function retentionOf(record, live, retained) {
1990
2695
  * session in the store still references. A snapshot whose holder is neither
1991
2696
  * live nor running in another process is removed in either mode.
1992
2697
  * `repoCommonDir` limits the whole reconcile to that repository.
1993
- * @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`).
1994
2722
  */
1995
2723
  export async function reconcileManagedWorktrees({
1996
2724
  liveSessionIds = [],
1997
2725
  retainedSessionIds = [],
1998
2726
  removeOrphans = false,
1999
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,
2000
2738
  } = {}) {
2001
2739
  const live = new Set(liveSessionIds);
2002
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);
2003
2745
  const report = { removed: [], recovered: [], kept: [], leasesReleased: [], pruned: [], missing: [], errors: [] };
2004
2746
  const reportDeletion = (record, result) => {
2005
2747
  if (result.removed) report.removed.push(record.id);
2006
- 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
+ });
2007
2754
  };
2008
2755
  const byRepo = new Map();
2009
2756
  for (const record of Object.values((await readRegistry()).entries)) {
@@ -2044,7 +2791,7 @@ export async function reconcileManagedWorktrees({
2044
2791
  // surviving folder since still keeps it.
2045
2792
  let release;
2046
2793
  try {
2047
- release = holdSnapshotFolder(record);
2794
+ release = await holdSnapshotFolder(record);
2048
2795
  } catch (error) {
2049
2796
  if (!snapshotInUse(error)) throw error;
2050
2797
  report.kept.push({ id: record.id, reason: 'snapshot-in-use' });
@@ -2053,10 +2800,23 @@ export async function reconcileManagedWorktrees({
2053
2800
  try {
2054
2801
  reportDeletion(record, await finishDeletion(record));
2055
2802
  } finally {
2056
- release();
2803
+ await release();
2057
2804
  }
2058
2805
  continue;
2059
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
+ }
2060
2820
  if (record.state === 'deleting') {
2061
2821
  // A decided deletion: its session already left the folder.
2062
2822
  reportDeletion(record, await finishDeletion(record));
@@ -2069,7 +2829,7 @@ export async function reconcileManagedWorktrees({
2069
2829
  let release = null;
2070
2830
  if (!(await snapshotHolderLive(record, live))) {
2071
2831
  try {
2072
- release = holdSnapshotFolder(record);
2832
+ release = await holdSnapshotFolder(record);
2073
2833
  } catch (error) {
2074
2834
  if (!snapshotInUse(error)) throw error;
2075
2835
  }
@@ -2085,7 +2845,7 @@ export async function reconcileManagedWorktrees({
2085
2845
  await putRecord(record);
2086
2846
  reportDeletion(record, await finishDeletion(record));
2087
2847
  } finally {
2088
- release();
2848
+ await release();
2089
2849
  }
2090
2850
  continue;
2091
2851
  }
@@ -2096,26 +2856,31 @@ export async function reconcileManagedWorktrees({
2096
2856
  report.leasesReleased.push(record.id);
2097
2857
  }
2098
2858
  if (!existsSync(record.path)) report.missing.push(record.id);
2099
- if (removeOrphans === true && !ownerLive && (record.disposable === true || !isRetained)) {
2100
- const dirty = await dirtyPaths(record);
2101
- const unmerged = await unmergedCommitCount(record);
2102
- if (dirty.length || unmerged > 0) {
2103
- 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' });
2104
2864
  continue;
2105
2865
  }
2106
- await assertRecoveryStoresClear(record);
2107
- record.state = 'deleting';
2108
- record.deleting = { force: false, startedAt: Date.now(), remaining: [] };
2109
- await putRecord(record);
2110
- 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;
2111
2878
  }
2112
2879
  } catch (error) {
2113
2880
  report.errors.push({ id: record.id, error: String(error?.message || error) });
2114
2881
  }
2115
2882
  }
2116
- for (const stale of await staleManagedRegistrations(repoCommonDir)) {
2117
- if (await pruneRegistration(repoCommonDir, stale)) report.pruned.push(canonicalPath(stale));
2118
- }
2883
+ report.pruned.push(...(await pruneStaleRegistrations(repoCommonDir)));
2119
2884
  });
2120
2885
  } catch (error) {
2121
2886
  report.errors.push({ repoCommonDir, error: String(error?.message || error) });