mixdog 1.1.2 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (424) hide show
  1. package/README.md +8 -5
  2. package/package.json +2 -1
  3. package/scripts/lib/run-node-tests.mjs +9 -0
  4. package/scripts/prune-desktop-runtime.mjs +3 -0
  5. package/scripts/test-direct.mjs +4 -2
  6. package/src/defaults/shipped-history.json +10 -0
  7. package/src/defaults/skills/pptx/SKILL.md +6 -6
  8. package/src/defaults/skills/pptx/references/review.md +6 -4
  9. package/src/defaults/skills/pptx/scripts/source-extract.mjs +1 -1
  10. package/src/defaults/skills/setup/references/actions.md +18 -5
  11. package/src/defaults/skills/visualize/SKILL.md +160 -0
  12. package/src/defaults/skills/visualize/references/craft.md +88 -0
  13. package/src/headless-command.mjs +56 -3
  14. package/src/headless-exec.mjs +17 -1
  15. package/src/help.mjs +2 -0
  16. package/src/rules/shared/60-verification.md +3 -1
  17. package/src/rules/shared/70-delivery.md +4 -2
  18. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
  19. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +1 -0
  20. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -0
  21. package/src/runtime/agent/orchestrator/cache-mtime.mjs +26 -5
  22. package/src/runtime/agent/orchestrator/config-presets.mjs +2 -0
  23. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +17 -11
  24. package/src/runtime/agent/orchestrator/config-storage.mjs +1 -0
  25. package/src/runtime/agent/orchestrator/config.mjs +19 -5
  26. package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
  27. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +11 -5
  28. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +74 -21
  29. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +133 -15
  30. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +55 -8
  31. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +86 -8
  32. package/src/runtime/agent/orchestrator/mcp/client.mjs +474 -164
  33. package/src/runtime/agent/orchestrator/mcp/lazy-output-validator.mjs +25 -0
  34. package/src/runtime/agent/orchestrator/mcp/plugin-placeholders.mjs +54 -0
  35. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +4 -0
  36. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +108 -17
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  38. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  39. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -1
  40. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -12
  41. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -0
  42. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +9 -0
  43. package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +337 -0
  44. package/src/runtime/agent/orchestrator/providers/command-code-oauth-tokens.mjs +159 -0
  45. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +444 -0
  46. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +2 -0
  47. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +2 -1
  49. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +7 -4
  50. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +21 -6
  51. package/src/runtime/agent/orchestrator/providers/effort-rejection.mjs +244 -0
  52. package/src/runtime/agent/orchestrator/providers/gemini.mjs +41 -28
  53. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +7 -1
  56. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +8 -40
  57. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +2 -0
  58. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +51 -12
  59. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +37 -2
  60. package/src/runtime/agent/orchestrator/providers/model-proxy-bridge.mjs +1080 -0
  61. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +82 -0
  62. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +556 -0
  63. package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +251 -0
  64. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +2 -0
  65. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +69 -102
  66. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +15 -0
  67. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +6 -5
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -1
  69. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +6 -2
  70. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +11 -0
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +2 -1
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +15 -4
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +1 -1
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +5 -2
  75. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +3 -0
  76. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +4 -4
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -7
  78. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +10 -0
  79. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +36 -3
  80. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +15 -11
  81. package/src/runtime/agent/orchestrator/runtime-core/extension-scopes.mjs +15 -55
  82. package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +15 -2
  83. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +16 -1
  84. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +278 -84
  85. package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +4 -1
  86. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +21 -0
  87. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +54 -3
  88. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +10 -3
  89. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -0
  90. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +9 -5
  91. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +18 -8
  92. package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +284 -0
  93. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +268 -0
  94. package/src/runtime/agent/orchestrator/session/catalog-db/sqlite-gate.mjs +27 -0
  95. package/src/runtime/agent/orchestrator/session/catalog-db/worker.mjs +628 -0
  96. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +10 -3
  97. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +24 -13
  98. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +13 -5
  99. package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +362 -0
  100. package/src/runtime/agent/orchestrator/session/compaction-archive.mjs +253 -0
  101. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +184 -0
  102. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +136 -0
  103. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +128 -0
  104. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +127 -0
  105. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +101 -0
  106. package/src/runtime/agent/orchestrator/session/explicit-skills.mjs +83 -5
  107. package/src/runtime/agent/orchestrator/session/imported-message.mjs +11 -0
  108. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +2 -1
  109. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -1
  110. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +1 -1
  111. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/diagnostic.mjs +1 -7
  112. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/refusal.mjs +1 -1
  113. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +3 -2
  114. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +5 -3
  115. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +7 -4
  116. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +21 -10
  117. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +3 -0
  118. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +3 -0
  119. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +12 -3
  120. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +15 -3
  121. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +22 -12
  122. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +9 -2
  123. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +9 -3
  124. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +5 -2
  125. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -16
  126. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +4 -0
  127. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +17 -4
  128. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +90 -0
  129. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -12
  130. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +32 -3
  131. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +27 -0
  132. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +16 -1
  133. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -0
  134. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -1
  136. package/src/runtime/agent/orchestrator/session/message-ids.mjs +123 -0
  137. package/src/runtime/agent/orchestrator/session/message-ids.test-support.mjs +29 -0
  138. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +33 -22
  139. package/src/runtime/agent/orchestrator/session/store/listing.mjs +6 -2
  140. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +3 -0
  141. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +6 -0
  142. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +10 -3
  143. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +11 -0
  144. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +817 -0
  145. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +36 -0
  146. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +23 -12
  147. package/src/runtime/agent/orchestrator/session/store-summary-reader-catalog-fixture.mjs +158 -0
  148. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +202 -374
  149. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +20 -3
  150. package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +3 -1
  151. package/src/runtime/agent/orchestrator/session/tool-batch/pre-dispatch.mjs +1 -1
  152. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +48 -9
  153. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +161 -0
  154. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +17 -1
  155. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -0
  156. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +52 -2
  157. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -22
  158. package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +10 -3
  159. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +2 -2
  160. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +6 -6
  161. package/src/runtime/agent/orchestrator/tools/builtin/read-cache-policy.mjs +8 -0
  162. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +203 -33
  163. package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +32 -35
  164. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +8 -4
  165. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +11 -12
  166. package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +9 -6
  167. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -68
  168. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +8 -3
  169. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +20 -3
  170. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +16 -7
  171. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
  172. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +137 -0
  173. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +11 -10
  174. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +21 -11
  175. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +12 -1
  176. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +13 -10
  177. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +1 -1
  178. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +17 -12
  179. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +56 -13
  180. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +58 -0
  181. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +2 -2
  182. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +1 -1
  183. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +25 -1
  184. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +4 -4
  185. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/route.mjs +2 -2
  186. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  187. package/src/runtime/agent/orchestrator/tools/patch/read-redirects.mjs +34 -30
  188. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +7 -7
  189. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +1 -1
  190. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +78 -5
  191. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +2 -0
  192. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -0
  193. package/src/runtime/attachments/store.d.mts +3 -0
  194. package/src/runtime/attachments/store.mjs +38 -0
  195. package/src/runtime/channels/lib/owned-runtime/owned-start.mjs +1 -1
  196. package/src/runtime/channels/lib/webhook/session-dispatch.mjs +16 -4
  197. package/src/runtime/channels/lib/worker-main.mjs +17 -14
  198. package/src/runtime/computer-bridge/linux-readiness.mjs +0 -2
  199. package/src/runtime/effort-judge/judge-client.mjs +56 -18
  200. package/src/runtime/memory/lib/memory.mjs +1 -1
  201. package/src/runtime/memory/lib/session-ingest.mjs +2 -2
  202. package/src/runtime/shared/atomic-file.mjs +33 -6
  203. package/src/runtime/shared/best-effort-stderr.mjs +8 -0
  204. package/src/runtime/shared/code-graph-state.mjs +12 -0
  205. package/src/runtime/shared/defaults-separation.mjs +2 -0
  206. package/src/runtime/shared/extension-scopes.mjs +5 -3
  207. package/src/runtime/shared/file-lock.mjs +37 -11
  208. package/src/runtime/shared/git-runner-worker.mjs +3 -2
  209. package/src/runtime/shared/git-runner.mjs +4 -3
  210. package/src/runtime/shared/llm/anthropic-effort.mjs +14 -0
  211. package/src/runtime/shared/llm/default-context-window.mjs +6 -0
  212. package/src/runtime/shared/llm/model-catalog-projection.mjs +5 -4
  213. package/src/runtime/shared/llm/model-catalog.mjs +240 -130
  214. package/src/runtime/shared/llm/provider-catalog-cache.mjs +36 -28
  215. package/src/runtime/shared/llm/provider-catalog-registry.mjs +162 -0
  216. package/src/runtime/shared/llm/provider-seed-rosters.mjs +130 -0
  217. package/src/runtime/shared/model-proxy-mode.d.mts +3 -0
  218. package/src/runtime/shared/model-proxy-mode.mjs +19 -0
  219. package/src/runtime/shared/plugin-manifest.mjs +18 -9
  220. package/src/runtime/shared/pristine-execution-contract.json +9 -2
  221. package/src/runtime/shared/pristine-execution.mjs +28 -4
  222. package/src/runtime/shared/provider-accounts.mjs +125 -21
  223. package/src/runtime/shared/provider-api-key.mjs +33 -1
  224. package/src/runtime/shared/schedule-model-ref.mjs +11 -2
  225. package/src/runtime/shared/session-usage-totals.mjs +19 -0
  226. package/src/runtime/shared/sharp-electron-linux.mjs +25 -0
  227. package/src/runtime/shared/shell-display.mjs +9 -3
  228. package/src/runtime/shared/singleton-owner.mjs +36 -6
  229. package/src/runtime/shared/skill-mentions.d.mts +10 -0
  230. package/src/runtime/shared/skill-mentions.mjs +133 -0
  231. package/src/runtime/shared/skill-script-runtime.mjs +264 -0
  232. package/src/runtime/shared/statusline/statusline-segments.mjs +68 -14
  233. package/src/runtime/shared/tool-primitives.mjs +35 -2
  234. package/src/runtime/shared/tool-surface.mjs +7 -0
  235. package/src/runtime/shared/tool-work-units.mjs +14 -0
  236. package/src/runtime/shared/transcript-metadata.mjs +17 -1
  237. package/src/runtime/shared/turn-changes.mjs +791 -0
  238. package/src/runtime/shared/turn-snapshot-store.mjs +346 -12
  239. package/src/runtime/shared/turn-snapshot.mjs +690 -81
  240. package/src/runtime/shared/turn-worktree-snapshot.mjs +185 -15
  241. package/src/runtime/shared/worker-requests.mjs +15 -2
  242. package/src/runtime/shared/worktrees/cache-cleanups.mjs +52 -0
  243. package/src/runtime/shared/worktrees/contract.mjs +551 -0
  244. package/src/runtime/shared/worktrees/disk.mjs +57 -0
  245. package/src/runtime/shared/worktrees/git.mjs +247 -0
  246. package/src/runtime/shared/worktrees/include.mjs +480 -0
  247. package/src/runtime/shared/worktrees/lock.mjs +46 -0
  248. package/src/runtime/shared/worktrees/maintenance.mjs +96 -0
  249. package/src/runtime/shared/worktrees/manager.mjs +1215 -0
  250. package/src/runtime/shared/worktrees/merge.mjs +182 -0
  251. package/src/runtime/shared/worktrees/owner.mjs +46 -0
  252. package/src/runtime/shared/worktrees/paths.mjs +62 -0
  253. package/src/runtime/shared/worktrees/registry.mjs +110 -0
  254. package/src/runtime/shared/worktrees/settings.mjs +84 -0
  255. package/src/runtime/shared/worktrees/slug.mjs +62 -0
  256. package/src/runtime/shared/worktrees/test-support.mjs +96 -0
  257. package/src/runtime/visualize-bridge/client.mjs +181 -0
  258. package/src/runtime/visualize-bridge/tool-defs.mjs +97 -0
  259. package/src/session-runtime/automation-agents.mjs +8 -2
  260. package/src/session-runtime/boot/apis.mjs +90 -0
  261. package/src/session-runtime/boot/config.mjs +7 -2
  262. package/src/session-runtime/boot/facade.mjs +21 -0
  263. package/src/session-runtime/boot/infrastructure.mjs +30 -18
  264. package/src/session-runtime/boot/session.mjs +1 -0
  265. package/src/session-runtime/boot/tools.mjs +4 -0
  266. package/src/session-runtime/config-lifecycle.mjs +10 -1
  267. package/src/session-runtime/conversation-import/discovery.mjs +698 -0
  268. package/src/session-runtime/conversation-import/service.mjs +369 -0
  269. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +21 -6
  270. package/src/session-runtime/cwd-plugins/plugin-status.mjs +47 -42
  271. package/src/session-runtime/global-extensions.mjs +2 -2
  272. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +27 -2
  273. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +32 -1
  274. package/src/session-runtime/internal-tool-executor.mjs +9 -1
  275. package/src/session-runtime/lifecycle/session-catalog.mjs +8 -1
  276. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +5 -0
  277. package/src/session-runtime/lifecycle/session-switch/resume.mjs +21 -3
  278. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +16 -0
  279. package/src/session-runtime/mcp-glue/connect.mjs +68 -16
  280. package/src/session-runtime/mcp-glue/plugin-servers.mjs +499 -0
  281. package/src/session-runtime/mcp-glue/server-catalog.mjs +122 -33
  282. package/src/session-runtime/mcp-glue/server-input.mjs +29 -0
  283. package/src/session-runtime/mcp-glue.mjs +22 -2
  284. package/src/session-runtime/model-route/route-persist.mjs +3 -0
  285. package/src/session-runtime/model-route/set-route.mjs +15 -2
  286. package/src/session-runtime/new-session-config.mjs +3 -2
  287. package/src/session-runtime/notification-bus.mjs +18 -3
  288. package/src/session-runtime/provider-auth/accounts.mjs +33 -15
  289. package/src/session-runtime/provider-models/row-hydration.mjs +2 -0
  290. package/src/session-runtime/provider-models/web-search-catalog.mjs +5 -1
  291. package/src/session-runtime/provider-readiness.mjs +16 -10
  292. package/src/session-runtime/resource-extension-sync.mjs +45 -7
  293. package/src/session-runtime/resource-mcp-api.mjs +121 -84
  294. package/src/session-runtime/resource-plugins-api.mjs +29 -41
  295. package/src/session-runtime/resource-scope-decorate.mjs +6 -8
  296. package/src/session-runtime/resource-skills-api.mjs +23 -2
  297. package/src/session-runtime/route-resolution.mjs +10 -1
  298. package/src/session-runtime/route-state.mjs +16 -2
  299. package/src/session-runtime/runtime-core.mjs +5 -0
  300. package/src/session-runtime/runtime-facade.mjs +8 -0
  301. package/src/session-runtime/runtime-review-api.mjs +9 -0
  302. package/src/session-runtime/schedule-session-run.mjs +1 -1
  303. package/src/session-runtime/services/agent-tool/helpers.mjs +5 -0
  304. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +3 -0
  305. package/src/session-runtime/services/agent-tool/job-views/job-lookup.mjs +1 -0
  306. package/src/session-runtime/services/agent-tool/job-views/spawn-meta.mjs +1 -0
  307. package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +15 -0
  308. package/src/session-runtime/services/agent-tool/send-flow.mjs +1 -0
  309. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +10 -2
  310. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -7
  311. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
  312. package/src/session-runtime/services/agent-tool/tag-registry.mjs +2 -1
  313. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -0
  314. package/src/session-runtime/services/agent-tool/worker-rows.mjs +1 -0
  315. package/src/session-runtime/services/agent-tool.mjs +1 -0
  316. package/src/session-runtime/services/channel-session-router.mjs +4 -1
  317. package/src/session-runtime/services/daemon-candidate.mjs +3 -2
  318. package/src/session-runtime/services/daemon-crash-capture/spawn-capture.mjs +55 -0
  319. package/src/session-runtime/services/hook-bus/command-handler.mjs +4 -1
  320. package/src/session-runtime/services/hook-bus/handlers.mjs +7 -3
  321. package/src/session-runtime/services/provider-admin.mjs +64 -18
  322. package/src/session-runtime/services/usage-dashboard-model.mjs +3 -0
  323. package/src/session-runtime/session-create/session-options.mjs +10 -1
  324. package/src/session-runtime/session-turn-api.mjs +1 -0
  325. package/src/session-runtime/settings-api.mjs +4 -2
  326. package/src/session-runtime/settings-builtin-tools-api.mjs +9 -2
  327. package/src/session-runtime/settings-worktree-api.mjs +17 -0
  328. package/src/session-runtime/setup-tool/executor.mjs +23 -5
  329. package/src/session-runtime/setup-tool/extended-actions.mjs +1 -0
  330. package/src/session-runtime/setup-tool/settings-contract.mjs +4 -1
  331. package/src/session-runtime/setup-tool/tool-defs.mjs +38 -4
  332. package/src/session-runtime/skills-api.mjs +7 -3
  333. package/src/session-runtime/turn/agent-ops.mjs +12 -1
  334. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +14 -3
  335. package/src/session-runtime/turn/turn-run/snapshot-tracker.mjs +20 -15
  336. package/src/session-runtime/turn/turn-run/turn-open.mjs +4 -5
  337. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +29 -1
  338. package/src/session-runtime/turn/turn-run/turn-settle.mjs +6 -1
  339. package/src/session-runtime/turn/turn-run.mjs +6 -7
  340. package/src/session-runtime/webhook-session-run.mjs +16 -4
  341. package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -3
  342. package/src/session-runtime/workspace/admission-gate.mjs +197 -0
  343. package/src/session-runtime/workspace/project-branches.mjs +136 -0
  344. package/src/session-runtime/workspace/transition-steps.mjs +364 -0
  345. package/src/session-runtime/workspace/transitions.mjs +651 -0
  346. package/src/session-runtime/workspace/workspace-state.mjs +112 -0
  347. package/src/skill-script-entry.mjs +36 -0
  348. package/src/standalone/channel-transport/http-routes.mjs +17 -0
  349. package/src/standalone/channel-transport.mjs +13 -1
  350. package/src/standalone/daemon-desktop-runtime.mjs +1 -0
  351. package/src/standalone/daemon-owner.mjs +372 -0
  352. package/src/standalone/daemon-stored-session-views.mjs +15 -4
  353. package/src/standalone/daemon.mjs +179 -8
  354. package/src/standalone/desktop-service-registry.mjs +20 -0
  355. package/src/standalone/local-session-runtime.mjs +11 -0
  356. package/src/standalone/session-client/attachment-calls.mjs +3 -1
  357. package/src/standalone/session-client/daemon-unavailable.mjs +62 -0
  358. package/src/standalone/session-client/lease-heartbeat.mjs +47 -0
  359. package/src/standalone/session-client.mjs +104 -13
  360. package/src/standalone/session-protocol.mjs +21 -0
  361. package/src/standalone/session-proxy.mjs +3 -0
  362. package/src/standalone/session-service/agent-tree/agent-child.mjs +4 -0
  363. package/src/standalone/session-service/agent-tree/agent-rehydrate.mjs +1 -0
  364. package/src/standalone/session-service/entries/lifecycle.mjs +6 -0
  365. package/src/standalone/session-service/projection/revisions.mjs +2 -0
  366. package/src/standalone/session-service/projection/transcript-window.mjs +149 -19
  367. package/src/standalone/session-service/projection/workspace.mjs +14 -0
  368. package/src/standalone/session-service/session-calls/session-action.mjs +17 -4
  369. package/src/standalone/session-transport/call-route.mjs +13 -0
  370. package/src/standalone/session-transport/client-registry/client-grace.mjs +45 -3
  371. package/src/standalone/session-transport/client-registry/registration-replays.mjs +1 -0
  372. package/src/standalone/session-transport/client-registry.mjs +20 -3
  373. package/src/standalone/session-transport/routes.mjs +79 -2
  374. package/src/standalone/session-transport/stack.mjs +2 -0
  375. package/src/standalone/session-transport.mjs +24 -1
  376. package/src/tui/App.jsx +9 -1
  377. package/src/tui/app/app-view/floating-panel.jsx +9 -1
  378. package/src/tui/app/app-view.jsx +6 -0
  379. package/src/tui/app/extension-pickers/plugins-pickers.mjs +35 -16
  380. package/src/tui/app/message-selector.mjs +2 -1
  381. package/src/tui/app/model-options.mjs +37 -5
  382. package/src/tui/app/model-picker/provider-models-list.mjs +2 -2
  383. package/src/tui/app/model-picker.mjs +4 -2
  384. package/src/tui/app/skill-mention-palette.mjs +88 -0
  385. package/src/tui/app/slash-palette.mjs +23 -7
  386. package/src/tui/app/transcript-row-estimate.mjs +11 -3
  387. package/src/tui/app/use-prompt-draft-flow.mjs +7 -2
  388. package/src/tui/app/use-prompt-state.mjs +5 -0
  389. package/src/tui/components/Message.jsx +22 -1
  390. package/src/tui/components/PromptInput.jsx +21 -3
  391. package/src/tui/components/SlashCommandPalette.jsx +12 -3
  392. package/src/tui/components/TranscriptItem.jsx +9 -1
  393. package/src/tui/components/prompt-input/key-handler.mjs +21 -8
  394. package/src/tui/components/user-message-skills.mjs +38 -0
  395. package/src/tui/dist/index.mjs +839 -207
  396. package/src/tui/index.jsx +10 -2
  397. package/src/tui/session/compaction-view.mjs +147 -0
  398. package/src/tui/session/context-state.mjs +6 -0
  399. package/src/tui/session/initial-state.mjs +3 -0
  400. package/src/tui/session/queue-helpers.mjs +7 -2
  401. package/src/tui/session/quota-resume.mjs +2 -1
  402. package/src/tui/session/session-api/commands.mjs +10 -2
  403. package/src/tui/session/session-api/controls.mjs +4 -0
  404. package/src/tui/session/session-api/extensions.mjs +4 -2
  405. package/src/tui/session/session-api/intake/rewind.mjs +2 -1
  406. package/src/tui/session/session-api/integrations.mjs +17 -0
  407. package/src/tui/session/session-api/lifecycle/dispose.mjs +6 -1
  408. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -0
  409. package/src/tui/session/session-api/lifecycle/resume.mjs +3 -0
  410. package/src/tui/session/session-api/routes.mjs +10 -1
  411. package/src/tui/session/session-api/settings.mjs +9 -0
  412. package/src/tui/session/session-api/worktree.mjs +60 -0
  413. package/src/tui/session/session-api.mjs +2 -0
  414. package/src/tui/session/session-flow/auto-clear.mjs +31 -12
  415. package/src/tui/session/session-flow/drain.mjs +27 -3
  416. package/src/tui/session/session-flow/session-reset.mjs +4 -3
  417. package/src/tui/session/session-flow.mjs +2 -0
  418. package/src/tui/session/transcript-intake.mjs +14 -5
  419. package/src/tui/session/tui-steering-persist.mjs +25 -9
  420. package/src/tui/session/turn-stream.mjs +32 -4
  421. package/src/tui/session/turn-tool-cards.mjs +10 -2
  422. package/src/tui/session/turn.mjs +56 -10
  423. package/src/runtime/effort-judge/previous-human-request.mjs +0 -162
  424. package/src/runtime/effort-judge/verified-context-input.mjs +0 -10
@@ -0,0 +1,1215 @@
1
+ /**
2
+ * Managed git worktrees for Lead sessions.
3
+ *
4
+ * One retained worktree per Lead session, created lazily from the project's
5
+ * current branch on "<branchPrefix><slug>" at managedWorktreePath(). Every
6
+ * mutation runs under withRepoLock (in-process git lock keyed by the common
7
+ * git dir + cross-process file lock) and is recorded in the durable registry
8
+ * (registry.mjs) before it touches anything:
9
+ *
10
+ * creating → ready → deleting → (record removed)
11
+ * ↘ recovery (rollback in progress / interrupted)
12
+ *
13
+ * Creation is a transaction keyed by txId: a retry returns the same entry, a
14
+ * failure rolls back exactly the resources the ledger proves this
15
+ * transaction made, and an interrupted rollback leaves a `recovery` record
16
+ * that reconcile/remove/retry finish. Deletion keeps a `deleting` record until
17
+ * the checkout, registration, owner lease, git lock and root cleanups are all
18
+ * gone, so a sharing violation yields a retryable partial result.
19
+ *
20
+ * Nothing outside a managed checkout is ever deleted: before any recursive
21
+ * delete the record's path must be managedWorktreePath(repoCommonDir, id)
22
+ * and its `.git` must link back to this repository (WORKTREE_PATH_UNSAFE).
23
+ * A forced removal that drops unmerged commits keeps the branch tip
24
+ * reachable at `refs/mixdog/removed/<id>` (returned as `recoveryRef`/`tip`;
25
+ * `git branch <name> refs/mixdog/removed/<id>` restores it, `git update-ref
26
+ * -d` forgets it).
27
+ */
28
+ import { existsSync } from 'node:fs';
29
+ import { lstat, mkdir, readdir, readFile, rm, rmdir } from 'node:fs/promises';
30
+ import { dirname, join, relative, sep } from 'node:path';
31
+ import {
32
+ managedWorktreePath,
33
+ managedWorktreeRoot,
34
+ normalizeWorktreeOwner,
35
+ runRootCleanups,
36
+ runRootReleasers,
37
+ worktreeBranchName,
38
+ worktreeError,
39
+ } from './contract.mjs';
40
+ import { freeBytesAt, requiredBytes, trackedCheckoutBytes } from './disk.mjs';
41
+ import {
42
+ branchCommit,
43
+ currentBranch,
44
+ firstLine,
45
+ gitStatusRunner,
46
+ inspectRepo,
47
+ isDiskFullError,
48
+ listWorktrees,
49
+ localBranches,
50
+ parseStatusPaths,
51
+ runGit,
52
+ throwIfAborted,
53
+ validBranchName,
54
+ } from './git.mjs';
55
+ import {
56
+ applyIncludes,
57
+ coveredByIncludes,
58
+ modifiedIncludeCopies,
59
+ planIncludes,
60
+ readIncludeEntries,
61
+ removeIncludeLinks,
62
+ } from './include.mjs';
63
+ import { withRepoLock as withRepoMutationLock } from './lock.mjs';
64
+ import { mergeBranchIntoCheckout } from './merge.mjs';
65
+ import { readOwnerLease, removeOwnerLease, sameOwner, writeOwnerLease } from './owner.mjs';
66
+ import { canonicalPath, checkoutFolder, isWithin, pathKey, projectSubdirOf, samePath } from './paths.mjs';
67
+ import { deleteRecord, entryView, getRecord, insertRecord, putRecord, readRegistry } from './registry.mjs';
68
+ import { normalizeWorktreeSettings } from './settings.mjs';
69
+ import { slugFromHint, uniqueSlug } from './slug.mjs';
70
+
71
+ const WORKTREE_ADD_TIMEOUT_MS = 30 * 60_000;
72
+ const REMOVE_RETRIES = 6;
73
+ const REMOVE_RETRY_DELAY_MS = 150;
74
+ // The session copies the project identity into each managed worktree; it is
75
+ // Mixdog metadata, not user data, so it alone never makes a worktree dirty.
76
+ const SESSION_PROJECT_ID_PATH = '.mixdog/project.id';
77
+
78
+ export { registerRootReleaser } from './contract.mjs';
79
+
80
+ /** The session execution root inside the record's checkout. */
81
+ function sessionRootOf(record) {
82
+ return checkoutFolder(record.path, record.projectSubdir);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Host configuration (settings source, free-space probe); tests override it.
87
+
88
+ /** pid probe (signal 0); EPERM means the process exists. */
89
+ function processAlive(pid) {
90
+ try {
91
+ process.kill(pid, 0);
92
+ return true;
93
+ } catch (error) {
94
+ return error?.code === 'EPERM';
95
+ }
96
+ }
97
+
98
+ const DEFAULT_CONFIG = Object.freeze({ settings: null, freeBytes: freeBytesAt, processAlive });
99
+ let config = { ...DEFAULT_CONFIG };
100
+
101
+ /**
102
+ * Override host hooks. `settings`: stored worktree settings object or a
103
+ * function returning it (default: agent config `worktree`). `freeBytes(dir)`:
104
+ * free-space probe. `processAlive(pid)`: lease holder liveness. Returns a
105
+ * restore function.
106
+ * @param {{ settings?: unknown, freeBytes?: (dir: string) => Promise<number>, processAlive?: (pid: number) => boolean }} overrides
107
+ */
108
+ export function configureWorktreeManager(overrides = {}) {
109
+ const previous = config;
110
+ config = { ...config, ...overrides };
111
+ return () => {
112
+ config = previous;
113
+ };
114
+ }
115
+
116
+ /** The effective worktree settings (configured source, else the agent config). */
117
+ export async function currentWorktreeSettings() {
118
+ if (config.settings !== null && config.settings !== undefined) {
119
+ const stored = typeof config.settings === 'function' ? await config.settings() : config.settings;
120
+ return normalizeWorktreeSettings(stored);
121
+ }
122
+ try {
123
+ const { readSection } = await import('../config.mjs');
124
+ return normalizeWorktreeSettings(readSection('agent')?.worktree);
125
+ } catch {
126
+ return normalizeWorktreeSettings(null);
127
+ }
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Helpers
132
+
133
+ function requireText(value, name) {
134
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${name} must be a non-empty string.`);
135
+ return value;
136
+ }
137
+
138
+ /** A directory git can run in for this record's repository. */
139
+ function repoCwd(record) {
140
+ return existsSync(record.projectPath) ? record.projectPath : record.repoCommonDir;
141
+ }
142
+
143
+ async function removeTree(path) {
144
+ try {
145
+ await rm(path, { recursive: true, force: true, maxRetries: REMOVE_RETRIES, retryDelay: REMOVE_RETRY_DELAY_MS });
146
+ return null;
147
+ } catch (error) {
148
+ return error;
149
+ }
150
+ }
151
+
152
+ async function registrationFor(record) {
153
+ const worktrees = await listWorktrees(repoCwd(record));
154
+ return worktrees.find((worktree) => samePath(worktree.path, record.path)) || null;
155
+ }
156
+
157
+ /**
158
+ * True only when `path` definitely does not exist (ENOENT/ENOTDIR). An
159
+ * unreadable or unmounted location (EACCES, EIO, …) is not "gone".
160
+ */
161
+ async function isGone(path) {
162
+ try {
163
+ await lstat(path);
164
+ return false;
165
+ } catch (error) {
166
+ return error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
167
+ }
168
+ }
169
+
170
+ /**
171
+ * The repository's linked-worktree admin dirs (<common dir>/worktrees/<name>)
172
+ * with the checkout each one's `gitdir` points at; unreadable ones are skipped.
173
+ * @returns {Promise<Array<{ admin: string, worktreePath: string }>>}
174
+ */
175
+ async function adminRegistrations(repoCommonDir) {
176
+ const adminRoot = join(repoCommonDir, 'worktrees');
177
+ let names;
178
+ try {
179
+ names = await readdir(adminRoot);
180
+ } catch {
181
+ return [];
182
+ }
183
+ const registrations = [];
184
+ for (const name of names) {
185
+ let gitdir;
186
+ try {
187
+ gitdir = firstLine(await readFile(join(adminRoot, name, 'gitdir'), 'utf8'));
188
+ } catch {
189
+ continue;
190
+ }
191
+ if (gitdir) registrations.push({ admin: join(adminRoot, name), worktreePath: dirname(gitdir) });
192
+ }
193
+ return registrations;
194
+ }
195
+
196
+ /**
197
+ * Drop the git admin dir of a registration whose checkout is definitely gone
198
+ * (targeted `git worktree prune`: unrelated stale registrations stay). A
199
+ * `locked` registration is kept unless `allowLocked` (the record that owns it
200
+ * is being deleted or rolled back).
201
+ */
202
+ async function pruneRegistration(repoCommonDir, worktreePath, { allowLocked = false } = {}) {
203
+ if (!(await isGone(worktreePath))) return false;
204
+ let pruned = false;
205
+ for (const registration of await adminRegistrations(repoCommonDir)) {
206
+ if (!samePath(registration.worktreePath, worktreePath)) continue;
207
+ if (!allowLocked && !(await isGone(join(registration.admin, 'locked')))) continue;
208
+ await rm(registration.admin, { recursive: true, force: true });
209
+ pruned = true;
210
+ }
211
+ return pruned;
212
+ }
213
+
214
+ /**
215
+ * WORKTREE_PATH_UNSAFE unless `record.path` is exactly the managed path of
216
+ * its id, inside the managed root, and (when present) its `.git` file links
217
+ * back into this repository's worktree admin dir. A checkout whose `.git` is
218
+ * already gone passes only while empty or after a recorded removal attempt
219
+ * (a partly deleted folder).
220
+ */
221
+ async function assertManagedCheckoutPath(record) {
222
+ const unsafe = (reason) =>
223
+ worktreeError('WORKTREE_PATH_UNSAFE', undefined, { entryId: record.id, path: record.path, reason });
224
+ let expected;
225
+ try {
226
+ expected = managedWorktreePath(record.repoCommonDir, record.id);
227
+ } catch {
228
+ throw unsafe('invalid-id');
229
+ }
230
+ const root = managedWorktreeRoot(record.repoCommonDir);
231
+ if (!samePath(record.path, expected) || samePath(record.path, root) || !isWithin(root, record.path)) {
232
+ throw unsafe('outside-managed-root');
233
+ }
234
+ if (await isGone(record.path)) return;
235
+ const dotGit = join(record.path, '.git');
236
+ let info = null;
237
+ try {
238
+ info = await lstat(dotGit);
239
+ } catch (error) {
240
+ if (error?.code !== 'ENOENT') throw unsafe('unreadable');
241
+ }
242
+ if (!info) {
243
+ const attempted = !!record.deleting?.lastAttemptAt || !!record.recovery?.remaining || record.state === 'recovery';
244
+ if (attempted || (await readdir(record.path)).length === 0) return;
245
+ throw unsafe('no-back-link');
246
+ }
247
+ if (!info.isFile()) throw unsafe('not-a-linked-checkout');
248
+ const gitdir = /^gitdir:\s*(.+)$/m.exec(await readFile(dotGit, 'utf8'))?.[1]?.trim();
249
+ const admin = join(record.repoCommonDir, 'worktrees');
250
+ if (!gitdir || samePath(gitdir, admin) || !isWithin(admin, canonicalPath(gitdir))) {
251
+ throw unsafe('foreign-back-link');
252
+ }
253
+ }
254
+
255
+ /** Keep the tip of a branch whose unmerged commits a forced removal drops. */
256
+ async function keepRemovedTip(record) {
257
+ const tip = await branchCommit(repoCwd(record), record.branch);
258
+ if (!tip) return { tip: null, recoveryRef: null };
259
+ const ref = `refs/mixdog/removed/${record.id}`;
260
+ await runGit(repoCwd(record), ['update-ref', '-m', `mixdog: removed worktree ${record.id}`, ref, tip]);
261
+ return { tip, recoveryRef: ref };
262
+ }
263
+
264
+ /** Managed checkouts whose registration is stale (directory missing). */
265
+ async function staleManagedRegistrations(repoCommonDir) {
266
+ const root = managedWorktreeRoot(repoCommonDir);
267
+ const stale = [];
268
+ for (const { worktreePath } of await adminRegistrations(repoCommonDir)) {
269
+ if (!(await isGone(worktreePath))) continue;
270
+ const rel = relative(pathKey(root), pathKey(worktreePath));
271
+ if (rel && !rel.startsWith('..') && !rel.includes(sep)) stale.push(worktreePath);
272
+ }
273
+ return stale;
274
+ }
275
+
276
+ async function setResource(record, name, status) {
277
+ record.resources = { ...record.resources, [name]: status };
278
+ await putRecord(record);
279
+ }
280
+
281
+ async function recordInclude(record, ledger) {
282
+ const includes = [...(record.resources.includes || [])];
283
+ const index = includes.findIndex((item) => item.path === ledger.path);
284
+ if (index >= 0) includes[index] = { ...ledger };
285
+ else includes.push({ ...ledger });
286
+ record.resources = { ...record.resources, includes };
287
+ await putRecord(record);
288
+ }
289
+
290
+ async function unmergedCommitCount(record) {
291
+ const cwd = repoCwd(record);
292
+ const tip = await branchCommit(cwd, record.branch);
293
+ if (!tip) return 0;
294
+ const reference = (record.mergeTarget && (await branchCommit(cwd, record.mergeTarget))) || record.baseCommit;
295
+ if (!reference) return 0;
296
+ const result = await runGit(cwd, ['rev-list', '--count', tip, '--not', reference]);
297
+ return Number(firstLine(result.stdout)) || 0;
298
+ }
299
+
300
+ /** Uncommitted/untracked paths plus modified include copies. */
301
+ async function dirtyPaths(record) {
302
+ if (!existsSync(record.path)) return [];
303
+ const includes = record.resources?.includes || [];
304
+ const status = await runGit(record.path, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], {
305
+ readOnly: true,
306
+ });
307
+ const identity = record.projectSubdir
308
+ ? `${record.projectSubdir}/${SESSION_PROJECT_ID_PATH}`
309
+ : SESSION_PROJECT_ID_PATH;
310
+ const dirty = parseStatusPaths(status.stdout).filter(
311
+ (path) => path !== identity && !coveredByIncludes(includes, path)
312
+ );
313
+ return [...dirty, ...(await modifiedIncludeCopies(includes))];
314
+ }
315
+
316
+ async function deleteBranchIfFree(record) {
317
+ const cwd = repoCwd(record);
318
+ if (!(await branchCommit(cwd, record.branch))) return { deleted: false, reason: 'missing' };
319
+ const holder = (await listWorktrees(cwd)).find((worktree) => worktree.branch === record.branch);
320
+ if (holder) return { deleted: false, reason: 'checked-out', path: holder.path };
321
+ const result = await runGit(cwd, ['branch', '-D', record.branch], { allowFailure: true });
322
+ return result.code === 0 ? { deleted: true } : { deleted: false, reason: firstLine(result.stderr) };
323
+ }
324
+
325
+ /**
326
+ * Remove the managed checkout and its registration. Returns the resources
327
+ * still present (empty when everything is gone).
328
+ */
329
+ async function removeCheckout(record) {
330
+ await assertManagedCheckoutPath(record);
331
+ const remaining = [];
332
+ for (const path of await removeIncludeLinks(record.resources?.includes)) remaining.push({ kind: 'link', path });
333
+ if (remaining.length) return remaining;
334
+ const cwd = repoCwd(record);
335
+ if (existsSync(record.path)) {
336
+ // A built-in fsmonitor daemon holds the folder open (Windows) until stopped.
337
+ await runGit(record.path, ['fsmonitor--daemon', 'stop'], { allowFailure: true });
338
+ }
339
+ if (await registrationFor(record)) {
340
+ await runGit(cwd, ['worktree', 'unlock', record.path], { allowFailure: true });
341
+ if (existsSync(record.path)) {
342
+ await runGit(cwd, ['worktree', 'remove', '--force', record.path], { allowFailure: true });
343
+ }
344
+ }
345
+ if (existsSync(record.path)) {
346
+ const error = await removeTree(record.path);
347
+ if (existsSync(record.path)) {
348
+ remaining.push({ kind: 'directory', path: record.path, error: error?.code || error?.message || 'busy' });
349
+ return remaining;
350
+ }
351
+ }
352
+ await pruneRegistration(record.repoCommonDir, record.path, { allowLocked: true });
353
+ if (await registrationFor(record)) remaining.push({ kind: 'registration', path: record.path });
354
+ return remaining;
355
+ }
356
+
357
+ /**
358
+ * After the last worktree of a repository is gone, drop its now-empty store
359
+ * folder (<dataDir>/worktrees-v1/<repoHash>/ and its .owners/). Runs under the
360
+ * repository lock; never while the registry still has an entry of that
361
+ * repository, and only removes empty directories (rmdir), so another
362
+ * checkout or owner lease keeps them.
363
+ */
364
+ async function pruneEmptyRepoRoot(repoCommonDir) {
365
+ const key = pathKey(repoCommonDir);
366
+ if (Object.values((await readRegistry()).entries).some((record) => pathKey(record.repoCommonDir) === key)) return;
367
+ const root = managedWorktreeRoot(repoCommonDir);
368
+ for (const dir of [join(root, '.owners'), root]) {
369
+ try {
370
+ await rmdir(dir);
371
+ } catch (error) {
372
+ // Missing, not empty, or held: it stays.
373
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM', 'EACCES', 'ENOTDIR'].includes(error?.code)) throw error;
374
+ }
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Undo an incomplete creation using only the ledger's proof. The record moves
380
+ * to `recovery` first, so an interruption stays attributable.
381
+ * @returns {Promise<{ complete: boolean, remaining: object[] }>}
382
+ */
383
+ async function rollbackCreation(record, cause) {
384
+ record.state = 'recovery';
385
+ record.recovery = {
386
+ txId: record.txId,
387
+ reason: cause ? String(cause.message || cause) : 'interrupted creation',
388
+ at: Date.now(),
389
+ };
390
+ // The filesystem cleanup never waits on the registry: with a full disk this
391
+ // write can fail too, and the checkout must still go.
392
+ const remaining = [];
393
+ await putRecord(record).catch((error) =>
394
+ remaining.push({ kind: 'registry', error: String(error?.message || error) })
395
+ );
396
+ const resources = record.resources || {};
397
+ try {
398
+ if (resources.owner) await removeOwnerLease(record);
399
+ if (resources.worktree) {
400
+ const left = await removeCheckout(record);
401
+ remaining.push(...left);
402
+ if (!left.length) {
403
+ // The slug was free when this transaction reserved it, so a branch
404
+ // still at the base commit is the one `worktree add -b` made. A
405
+ // branch that moved holds someone's commits and is kept.
406
+ const tip = await branchCommit(repoCwd(record), record.branch);
407
+ if (tip && tip === record.baseCommit) {
408
+ const result = await deleteBranchIfFree(record);
409
+ if (!result.deleted) remaining.push({ kind: 'branch', branch: record.branch, reason: result.reason });
410
+ } else if (tip) {
411
+ remaining.push({ kind: 'branch', branch: record.branch, reason: 'moved' });
412
+ }
413
+ }
414
+ }
415
+ } catch (error) {
416
+ remaining.push({ kind: 'error', error: String(error?.message || error) });
417
+ }
418
+ if (!remaining.some((item) => item.kind !== 'registry')) {
419
+ try {
420
+ await deleteRecord(record.id);
421
+ await pruneEmptyRepoRoot(record.repoCommonDir);
422
+ return { complete: true, remaining: [] };
423
+ } catch (error) {
424
+ remaining.push({ kind: 'registry', error: String(error?.message || error) });
425
+ }
426
+ }
427
+ record.recovery = { ...record.recovery, remaining };
428
+ await putRecord(record).catch(() => {});
429
+ return { complete: false, remaining };
430
+ }
431
+
432
+ /**
433
+ * Finish (or resume) a decided deletion, in order: release the live handles
434
+ * on the folder (desktop terminals), drop the caches keyed by it (the checkout
435
+ * root and the session root of a subfolder Project), then `git worktree
436
+ * remove` and delete the directory. A failed releaser or cleanup is reported
437
+ * and retried with the deletion; it never keeps the checkout.
438
+ */
439
+ async function finishDeletion(record) {
440
+ const remaining = [];
441
+ try {
442
+ await removeOwnerLease(record);
443
+ const ctx = { reason: 'worktree-removed', entryId: record.id };
444
+ const results = await runRootReleasers(record.path, ctx);
445
+ for (const root of new Set([record.path, sessionRootOf(record)])) {
446
+ results.push(...(await runRootCleanups(root, ctx)));
447
+ }
448
+ for (const result of results) {
449
+ if (!result.ok) remaining.push({ kind: 'cleanup', name: result.name, error: result.error.message });
450
+ }
451
+ remaining.push(...(await removeCheckout(record)));
452
+ } catch (error) {
453
+ remaining.push({ kind: 'error', error: String(error?.message || error) });
454
+ }
455
+ if (remaining.length) {
456
+ record.deleting = { ...record.deleting, remaining, lastAttemptAt: Date.now() };
457
+ await putRecord(record);
458
+ return { removed: false, retryable: true, remaining };
459
+ }
460
+ let branch = { deleted: false, reason: 'kept' };
461
+ let kept = { tip: null, recoveryRef: null };
462
+ const merged = (await unmergedCommitCount(record)) === 0;
463
+ if (record.deleting?.force || merged) {
464
+ // Forced past unmerged commits: the tip stays reachable before `branch -D`.
465
+ if (!merged) kept = await keepRemovedTip(record);
466
+ else kept.tip = await branchCommit(repoCwd(record), record.branch);
467
+ branch = await deleteBranchIfFree(record);
468
+ }
469
+ await deleteRecord(record.id);
470
+ await pruneEmptyRepoRoot(record.repoCommonDir);
471
+ return {
472
+ removed: true,
473
+ branchDeleted: branch.deleted,
474
+ ...(branch.deleted && kept.tip ? { tip: kept.tip } : {}),
475
+ ...(kept.recoveryRef ? { recoveryRef: kept.recoveryRef } : {}),
476
+ };
477
+ }
478
+
479
+ function findRecord(doc, predicate) {
480
+ return Object.values(doc.entries).find(predicate) || null;
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // Public API
485
+
486
+ /**
487
+ * Serialize managed mutations of one repository (in-process + cross-process).
488
+ * @template T
489
+ * @param {string} repoCommonDir
490
+ * @param {() => Promise<T>|T} fn
491
+ */
492
+ export function withRepoLock(repoCommonDir, fn) {
493
+ return withRepoMutationLock(repoCommonDir, fn);
494
+ }
495
+
496
+ /** The worktree retained by `sessionId` (ready first), or null. */
497
+ export async function getSessionWorktree(sessionId) {
498
+ if (typeof sessionId !== 'string' || !sessionId) return null;
499
+ const records = Object.values((await readRegistry()).entries).filter((record) => record.ownerSessionId === sessionId);
500
+ const record = records.find((item) => item.state === 'ready') || records[0];
501
+ return record ? entryView(record) : null;
502
+ }
503
+
504
+ /** The managed worktree at `path` (canonical, case-insensitive on Windows), or null. */
505
+ export async function getWorktreeByPath(path) {
506
+ if (typeof path !== 'string' || !path.trim()) return null;
507
+ const key = pathKey(path);
508
+ const record = Object.values((await readRegistry()).entries).find((item) => pathKey(item.path) === key);
509
+ return record ? entryView(record) : null;
510
+ }
511
+
512
+ /** The managed worktree whose checkout is `path` or contains it (a subfolder Project's session root), or null. */
513
+ export async function findWorktreeContaining(path) {
514
+ if (typeof path !== 'string' || !path.trim()) return null;
515
+ const record = Object.values((await readRegistry()).entries).find((item) => isWithin(item.path, path));
516
+ return record ? entryView(record) : null;
517
+ }
518
+
519
+ /** Mark an entry disposable: startup reconcile may remove it once clean and merged. */
520
+ export async function markWorktreeDisposable(entryId, disposable = true) {
521
+ const record = await getRecord(entryId);
522
+ if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
523
+ return withRepoLock(record.repoCommonDir, async () => {
524
+ const current = await getRecord(entryId);
525
+ if (!current) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
526
+ current.disposable = disposable === true;
527
+ await putRecord(current);
528
+ return entryView(current);
529
+ });
530
+ }
531
+
532
+ /**
533
+ * Before a creation (under the repository lock): the entry this transaction
534
+ * or session already has ready (its lease re-written), else null once every
535
+ * unfinished earlier attempt of the session in this repository is completed.
536
+ * WORKTREE_REVISION_CONFLICT for another session's transaction, a ready
537
+ * worktree of another repository, or an attempt that is still being cleaned up.
538
+ */
539
+ async function settleSessionAttempts({ txId, owner, repoCommonDir }) {
540
+ const doc = await readRegistry();
541
+ const sameRepo = (record) => samePath(record.repoCommonDir, repoCommonDir);
542
+
543
+ const prior = findRecord(doc, (record) => record.txId === txId);
544
+ if (prior?.state === 'ready') {
545
+ if (prior.ownerSessionId !== owner.sessionId) {
546
+ throw worktreeError('WORKTREE_REVISION_CONFLICT', 'This transaction belongs to another session.', { txId });
547
+ }
548
+ await writeOwnerLease(prior, owner);
549
+ return entryView(prior);
550
+ }
551
+
552
+ for (const record of Object.values(doc.entries).filter((item) => item.ownerSessionId === owner.sessionId)) {
553
+ if (record.state === 'ready') {
554
+ if (!sameRepo(record)) {
555
+ throw worktreeError(
556
+ 'WORKTREE_REVISION_CONFLICT',
557
+ 'This session already retains a worktree of another repository.',
558
+ { entryId: record.id }
559
+ );
560
+ }
561
+ await writeOwnerLease(record, owner);
562
+ return entryView(record);
563
+ }
564
+ if (!sameRepo(record)) continue;
565
+ // An earlier attempt of this session never finished (or its removal
566
+ // was interrupted): complete that first, then create.
567
+ const outcome =
568
+ record.state === 'deleting'
569
+ ? await finishDeletion(record)
570
+ : await rollbackCreation(record).then((result) => ({
571
+ removed: result.complete,
572
+ remaining: result.remaining,
573
+ }));
574
+ if (!outcome.removed) {
575
+ throw worktreeError(
576
+ 'WORKTREE_REVISION_CONFLICT',
577
+ 'A previous worktree of this session is still being cleaned up; try again.',
578
+ { entryId: record.id, remaining: outcome.remaining }
579
+ );
580
+ }
581
+ }
582
+ return null;
583
+ }
584
+
585
+ /**
586
+ * A full disk during `worktree add` or an include copy is the same refusal
587
+ * the preflight gives, with the sizes the user needs.
588
+ */
589
+ async function diskFullFailure(error, { needed, root }) {
590
+ return worktreeError('WORKTREE_DISK_INSUFFICIENT', undefined, {
591
+ requiredBytes: needed,
592
+ freeBytes: await config.freeBytes(root).catch(() => null),
593
+ path: root,
594
+ reason:
595
+ `${error.stderr || ''}\n${error.message || ''}`
596
+ .split(/\r?\n/)
597
+ .find((line) => /space|ENOSPC|disk/i.test(line))
598
+ ?.trim() || firstLine(error.message),
599
+ });
600
+ }
601
+
602
+ /**
603
+ * @param {{ projectPath: string, sessionId: string, runtimeToken: string, baseBranch?: string|null,
604
+ * nameHint?: string, confirmLarge?: boolean, txId: string, signal?: AbortSignal }} input
605
+ */
606
+ export async function createManagedWorktree({
607
+ projectPath,
608
+ sessionId,
609
+ runtimeToken,
610
+ baseBranch = null,
611
+ nameHint = '',
612
+ confirmLarge = false,
613
+ txId,
614
+ signal,
615
+ } = {}) {
616
+ requireText(projectPath, 'projectPath');
617
+ requireText(txId, 'txId');
618
+ const owner = normalizeWorktreeOwner({ sessionId, runtimeToken, pid: process.pid });
619
+ throwIfAborted(signal);
620
+ const repo = await inspectRepo(projectPath, { signal });
621
+ return withRepoLock(repo.repoCommonDir, async () => {
622
+ const existing = await settleSessionAttempts({ txId, owner, repoCommonDir: repo.repoCommonDir });
623
+ if (existing) return existing;
624
+ throwIfAborted(signal);
625
+
626
+ const settings = await currentWorktreeSettings();
627
+ let baseCommit = repo.headCommit;
628
+ let mergeTarget = repo.currentBranch;
629
+ if (baseBranch) {
630
+ baseCommit = await branchCommit(repo.projectPath, baseBranch, { signal });
631
+ if (!baseCommit) {
632
+ throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Base branch ${baseBranch} does not exist.`, { baseBranch });
633
+ }
634
+ mergeTarget = baseBranch;
635
+ }
636
+
637
+ const includePlan = await planIncludes(repo.topLevel, await readIncludeEntries(repo.topLevel));
638
+ const tracked = await trackedCheckoutBytes(repo.topLevel, baseCommit, { signal });
639
+ const contentBytes = tracked.bytes + includePlan.copyBytes;
640
+ const root = managedWorktreeRoot(repo.repoCommonDir);
641
+ const needed = requiredBytes(contentBytes);
642
+ const free = await config.freeBytes(root);
643
+ if (free < needed) {
644
+ throw worktreeError('WORKTREE_DISK_INSUFFICIENT', undefined, {
645
+ requiredBytes: needed,
646
+ freeBytes: free,
647
+ path: root,
648
+ });
649
+ }
650
+ if (settings.sizeWarnBytes > 0 && contentBytes > settings.sizeWarnBytes && confirmLarge !== true) {
651
+ throw worktreeError('WORKTREE_LARGE_REPO_CONFIRM', undefined, {
652
+ estimateBytes: contentBytes,
653
+ thresholdBytes: settings.sizeWarnBytes,
654
+ });
655
+ }
656
+ throwIfAborted(signal);
657
+
658
+ const branches = new Set(
659
+ (await localBranches(repo.projectPath, { signal, prefix: settings.branchPrefix })).map((name) =>
660
+ name.toLowerCase()
661
+ )
662
+ );
663
+ const base = slugFromHint(nameHint);
664
+ const reserve = async () => {
665
+ let reserved = null;
666
+ for (let attempt = 0; attempt < 5 && !reserved; attempt++) {
667
+ reserved = await reserveName();
668
+ }
669
+ if (!reserved) throw worktreeError('WORKTREE_REVISION_CONFLICT', 'Could not reserve a worktree name; try again.');
670
+ return reserved;
671
+ };
672
+ const addCheckout = (target) =>
673
+ runGit(repo.projectPath, ['worktree', 'add', '-b', target.branch, target.path, baseCommit], {
674
+ signal,
675
+ timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
676
+ });
677
+ let record = await reserve();
678
+
679
+ try {
680
+ await mkdir(root, { recursive: true });
681
+ await setResource(record, 'worktree', 'pending');
682
+ try {
683
+ await addCheckout(record);
684
+ } catch (error) {
685
+ // Another process took the branch name (or folder) after the
686
+ // reservation: git refused before making anything, so reserve the
687
+ // next free name once and try again.
688
+ // ("already exists", or a ref directory/file clash: "… exists; cannot create …").
689
+ if (!/already exists|exists; cannot create/i.test(`${error?.stderr || ''}${error?.message || ''}`)) throw error;
690
+ branches.add(record.branch.toLowerCase());
691
+ record.resources = { ...record.resources, worktree: null };
692
+ await deleteRecord(record.id);
693
+ record = await reserve();
694
+ await setResource(record, 'worktree', 'pending');
695
+ await addCheckout(record);
696
+ }
697
+ await setResource(record, 'worktree', 'created');
698
+ throwIfAborted(signal);
699
+ await setResource(record, 'lock', 'pending');
700
+ await runGit(repo.projectPath, [
701
+ 'worktree',
702
+ 'lock',
703
+ '--reason',
704
+ `mixdog session=${owner.sessionId}`,
705
+ record.path,
706
+ ]);
707
+ await setResource(record, 'lock', 'created');
708
+ await setResource(record, 'owner', 'pending');
709
+ await writeOwnerLease(record, owner);
710
+ await setResource(record, 'owner', 'created');
711
+ const applied = await applyIncludes(includePlan, record.path, (ledger) => recordInclude(record, ledger));
712
+ throwIfAborted(signal);
713
+ record.warnings = [...record.warnings, ...applied.warnings];
714
+ record.state = 'ready';
715
+ await putRecord(record);
716
+ return entryView(record);
717
+ } catch (error) {
718
+ const rollback = await rollbackCreation(record, error);
719
+ const failure = isDiskFullError(error) ? await diskFullFailure(error, { needed, root }) : error;
720
+ if (!rollback.complete) failure.rollback = rollback;
721
+ throw failure;
722
+ }
723
+
724
+ async function reserveName() {
725
+ const ids = new Set(Object.keys((await readRegistry()).entries).map((id) => id.toLowerCase()));
726
+ const slug = uniqueSlug(
727
+ base,
728
+ (candidate) =>
729
+ ids.has(candidate.toLowerCase()) ||
730
+ branches.has(`${settings.branchPrefix}${candidate}`.toLowerCase()) ||
731
+ existsSync(join(root, candidate))
732
+ );
733
+ const branch = worktreeBranchName(slug, settings.branchPrefix);
734
+ if (!(await validBranchName(repo.projectPath, branch))) {
735
+ throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Branch name ${branch} is not valid.`, { branch });
736
+ }
737
+ const candidate = {
738
+ id: slug,
739
+ txId,
740
+ repoCommonDir: repo.repoCommonDir,
741
+ projectPath: repo.projectPath,
742
+ projectSubdir: projectSubdirOf(repo.topLevel, repo.projectPath),
743
+ path: managedWorktreePath(repo.repoCommonDir, slug),
744
+ branch,
745
+ baseCommit,
746
+ mergeTarget,
747
+ createdAt: Date.now(),
748
+ ownerSessionId: owner.sessionId,
749
+ state: 'creating',
750
+ disposable: false,
751
+ warnings: [...(tracked.submodules ? ['submodules'] : []), ...includePlan.warnings],
752
+ resources: { worktree: null, lock: null, owner: null, includes: [] },
753
+ deleting: null,
754
+ recovery: null,
755
+ };
756
+ return (await insertRecord(candidate)) ? candidate : null;
757
+ }
758
+ });
759
+ }
760
+
761
+ /**
762
+ * Claim the live lease of a retained worktree for one runtime of its session.
763
+ * @param {string} entryId
764
+ * @param {{ sessionId: string, runtimeToken: string, pid?: number|null }} owner
765
+ */
766
+ export async function claimWorktreeOwner(entryId, owner) {
767
+ const claimant = normalizeWorktreeOwner(owner);
768
+ const record = await getRecord(entryId);
769
+ if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
770
+ return withRepoLock(record.repoCommonDir, async () => {
771
+ const current = await getRecord(entryId);
772
+ if (current?.state !== 'ready') throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
773
+ if (current.ownerSessionId && current.ownerSessionId !== claimant.sessionId) {
774
+ throw worktreeError('WORKTREE_BRANCH_OCCUPIED', 'The worktree is retained by another session.', {
775
+ entryId,
776
+ sessionId: current.ownerSessionId,
777
+ });
778
+ }
779
+ if (!current.ownerSessionId) {
780
+ const other = findRecord(
781
+ await readRegistry(),
782
+ (item) => item.ownerSessionId === claimant.sessionId && item.id !== entryId
783
+ );
784
+ if (other) {
785
+ throw worktreeError('WORKTREE_REVISION_CONFLICT', 'The session already retains another worktree.', {
786
+ entryId: other.id,
787
+ });
788
+ }
789
+ current.ownerSessionId = claimant.sessionId;
790
+ await putRecord(current);
791
+ }
792
+ const previous = await readOwnerLease(current);
793
+ const lease = await writeOwnerLease(current, claimant);
794
+ return { entry: entryView(current), lease, replaced: previous && !sameOwner(previous, claimant) ? previous : null };
795
+ });
796
+ }
797
+
798
+ /**
799
+ * Release the live lease when (and only when) it is held by exactly `owner`.
800
+ * Durable ownership (the session retaining the worktree) is unchanged.
801
+ */
802
+ export async function releaseWorktreeOwner(entryId, owner) {
803
+ const claimant = normalizeWorktreeOwner(owner);
804
+ const record = await getRecord(entryId);
805
+ if (!record) return { released: false };
806
+ return withRepoLock(record.repoCommonDir, async () => {
807
+ const current = await getRecord(entryId);
808
+ if (!current) return { released: false };
809
+ const lease = await readOwnerLease(current);
810
+ if (!sameOwner(lease, claimant)) return { released: false };
811
+ await removeOwnerLease(current);
812
+ return { released: true };
813
+ });
814
+ }
815
+
816
+ /**
817
+ * Local branches of the project and who holds each.
818
+ * @param {{ projectPath: string, sessionId?: string }} input
819
+ */
820
+ export async function listBranchOccupancy({ projectPath, sessionId } = {}) {
821
+ requireText(projectPath, 'projectPath');
822
+ const repo = await inspectRepo(projectPath);
823
+ const [branches, worktrees, doc] = await Promise.all([
824
+ localBranches(repo.projectPath),
825
+ listWorktrees(repo.projectPath),
826
+ readRegistry(),
827
+ ]);
828
+ const managed = Object.values(doc.entries).filter((record) => samePath(record.repoCommonDir, repo.repoCommonDir));
829
+ const occupancy = branches.map((branch) => {
830
+ // A prunable registration (its folder is gone) holds nothing.
831
+ const holder = worktrees.find((worktree) => worktree.branch === branch && !worktree.prunable);
832
+ if (!holder) return { branch, kind: 'free' };
833
+ if (samePath(holder.path, repo.topLevel)) return { branch, kind: 'current', path: holder.path };
834
+ const record = managed.find((item) => samePath(item.path, holder.path));
835
+ if (record?.ownerSessionId) return { branch, kind: 'session', sessionId: record.ownerSessionId, path: holder.path };
836
+ return { branch, kind: 'external', path: holder.path };
837
+ });
838
+ const retained = sessionId
839
+ ? managed.find((record) => record.ownerSessionId === sessionId && record.state === 'ready')
840
+ : null;
841
+ return { branches, occupancy, retainedWorktree: retained ? entryView(retained) : null };
842
+ }
843
+
844
+ /**
845
+ * The full id of the commit an object id (4–64 hex) names. Object names
846
+ * only (`--disambiguate`): a branch or tag spelled like a hex id never
847
+ * shadows the commit, and nothing parses as an option.
848
+ */
849
+ async function commitById(cwd, value) {
850
+ const id = String(value).trim();
851
+ if (!/^[0-9a-f]{4,64}$/i.test(id)) throw new TypeError('A commit id is required.');
852
+ const listed = await runGit(cwd, ['rev-parse', `--disambiguate=${id}`], { allowFailure: true });
853
+ const commits = new Set();
854
+ for (const candidate of String(listed.stdout).split(/\s+/).filter(Boolean)) {
855
+ const peeled = await runGit(cwd, ['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`], {
856
+ allowFailure: true,
857
+ });
858
+ if (peeled.code === 0) commits.add(firstLine(peeled.stdout));
859
+ }
860
+ if (commits.size !== 1) {
861
+ throw new TypeError(commits.size ? `Commit ${id} is ambiguous.` : `Commit ${id} does not exist.`);
862
+ }
863
+ return [...commits][0];
864
+ }
865
+
866
+ /** Detached checkout of a commit id in the project folder (see checkoutProjectBranch). */
867
+ async function detachProjectFolder(projectPath, commitId) {
868
+ const repo = await inspectRepo(projectPath);
869
+ return withRepoLock(repo.repoCommonDir, async () => {
870
+ const commit = await commitById(repo.projectPath, commitId);
871
+ const head = firstLine((await runGit(repo.projectPath, ['rev-parse', 'HEAD'])).stdout);
872
+ if (head === commit && !(await currentBranch(repo.projectPath))) {
873
+ return { branch: null, changed: false, commit, detached: true };
874
+ }
875
+ await assertProjectFolderClean(repo);
876
+ await runGit(repo.projectPath, ['switch', '--detach', commit]);
877
+ return { branch: null, changed: true, commit, detached: true };
878
+ });
879
+ }
880
+
881
+ /**
882
+ * WORKTREE_DIRTY (reason `project-dirty`) while the project folder has
883
+ * uncommitted tracked changes. No carry: `git switch` keeps compatible
884
+ * uncommitted changes, which would silently move them onto the other commit.
885
+ */
886
+ async function assertProjectFolderClean(repo) {
887
+ const status = await runGit(repo.topLevel, ['status', '--porcelain=v1', '-z', '--untracked-files=no'], {
888
+ readOnly: true,
889
+ });
890
+ const dirty = parseStatusPaths(status.stdout);
891
+ if (dirty.length) {
892
+ throw worktreeError(
893
+ 'WORKTREE_DIRTY',
894
+ 'The project folder has uncommitted changes; commit or stash them before switching branches.',
895
+ { reason: 'project-dirty', files: dirty.slice(0, 50), count: dirty.length }
896
+ );
897
+ }
898
+ }
899
+
900
+ /**
901
+ * Coordinated checkout in the project folder. Refuses a branch that is
902
+ * checked out in any other worktree (WORKTREE_BRANCH_OCCUPIED) and a project
903
+ * folder with uncommitted tracked changes (WORKTREE_DIRTY, reason
904
+ * `project-dirty`): nothing is carried to the other branch.
905
+ *
906
+ * `remote`: `branch` is a remote-tracking branch ("origin/x"); its local
907
+ * branch ("x") is checked out, created to track the remote branch when it
908
+ * does not exist yet.
909
+ *
910
+ * `startPoint` (with `create`): the new branch starts at that commit (a
911
+ * history entry) instead of the checked-out one.
912
+ *
913
+ * `detach` (alone, no branch): check out that commit id detached (a history
914
+ * entry), resolved by object id only; same lock and no-carry refusal.
915
+ * @param {{ projectPath: string, branch?: string, create?: boolean, remote?: boolean, startPoint?: string|null, detach?: string|null }} input
916
+ * @returns {Promise<{ branch: string|null, changed: boolean, commit: string, tracking?: string, detached?: true }>}
917
+ */
918
+ export async function checkoutProjectBranch({
919
+ projectPath,
920
+ branch: requested,
921
+ create = false,
922
+ remote = false,
923
+ startPoint = null,
924
+ detach = null,
925
+ } = {}) {
926
+ requireText(projectPath, 'projectPath');
927
+ if (detach !== null) {
928
+ if (requested !== undefined || create || remote || startPoint !== null) {
929
+ throw new TypeError('detach excludes branch, create, remote and startPoint.');
930
+ }
931
+ return detachProjectFolder(projectPath, detach);
932
+ }
933
+ requireText(requested, 'branch');
934
+ if (create && remote) throw new TypeError('create and remote are exclusive.');
935
+ if (startPoint !== null && (!create || typeof startPoint !== 'string' || !startPoint.trim())) {
936
+ throw new TypeError('startPoint needs create and a commit.');
937
+ }
938
+ const repo = await inspectRepo(projectPath);
939
+ return withRepoLock(repo.repoCommonDir, async () => {
940
+ let branch = requested;
941
+ let tracking = null;
942
+ let start = null;
943
+ if (startPoint !== null) start = await commitById(repo.projectPath, startPoint);
944
+ if (remote) {
945
+ const ref = `refs/remotes/${requested}^{commit}`;
946
+ const tip = await runGit(repo.projectPath, ['rev-parse', '--verify', '--quiet', ref], { allowFailure: true });
947
+ if (tip.code !== 0) throw new TypeError(`Remote branch ${requested} does not exist.`);
948
+ branch = requested.replace(/^[^/]+\//, '');
949
+ if (!(await branchCommit(repo.projectPath, branch))) tracking = requested;
950
+ }
951
+ if (!(await validBranchName(repo.projectPath, branch))) throw new TypeError(`Branch name ${branch} is not valid.`);
952
+ const current = await currentBranch(repo.projectPath);
953
+ if (current === branch && !create) return { branch, changed: false, commit: repo.headCommit };
954
+ let holder = (await listWorktrees(repo.projectPath)).find(
955
+ (worktree) => worktree.branch === branch && !samePath(worktree.path, repo.topLevel)
956
+ );
957
+ // A stale (prunable) unmanaged registration: drop exactly that one and go on.
958
+ if (holder?.prunable && !(await getWorktreeByPath(holder.path))) {
959
+ if (await pruneRegistration(repo.repoCommonDir, holder.path)) holder = null;
960
+ }
961
+ if (holder) {
962
+ const record = await getWorktreeByPath(holder.path);
963
+ throw worktreeError('WORKTREE_BRANCH_OCCUPIED', `Branch ${branch} is checked out in ${holder.path}.`, {
964
+ branch,
965
+ path: holder.path,
966
+ kind: record?.ownerSessionId ? 'session' : 'external',
967
+ ...(record?.ownerSessionId ? { sessionId: record.ownerSessionId } : {}),
968
+ });
969
+ }
970
+ await assertProjectFolderClean(repo);
971
+ let args = ['switch', branch];
972
+ if (create) args = ['switch', '-c', branch, ...(start ? [start] : [])];
973
+ else if (tracking) args = ['switch', '-c', branch, '--track', tracking];
974
+ await runGit(repo.projectPath, args);
975
+ const head = await runGit(repo.projectPath, ['rev-parse', 'HEAD']);
976
+ return { branch, changed: true, commit: firstLine(head.stdout), ...(tracking ? { tracking } : {}) };
977
+ });
978
+ }
979
+
980
+ /**
981
+ * Merge a worktree's branch into the project folder's checked-out `target`.
982
+ * `projectPath` (when given) is the destination the caller guarded and the
983
+ * user confirmed: a record of another Project folder is WORKTREE_PROJECT_MISMATCH.
984
+ * @param {{ entryId: string, target: string, confirmed: boolean, projectPath?: string }} input
985
+ * @returns {Promise<{ merged: boolean, commit: string|null, conflicts?: string[], upToDate?: boolean, target: string }>}
986
+ */
987
+ export async function mergeWorktreeIntoProject({ entryId, target, confirmed, projectPath } = {}) {
988
+ requireText(target, 'target');
989
+ const record = await getRecord(entryId);
990
+ if (record?.state !== 'ready') throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
991
+ return withRepoLock(record.repoCommonDir, async () => {
992
+ const current = await getRecord(entryId);
993
+ if (current?.state !== 'ready') throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
994
+ if (projectPath !== undefined && !samePath(current.projectPath, projectPath)) {
995
+ throw worktreeError('WORKTREE_PROJECT_MISMATCH', undefined, {
996
+ worktreeId: current.id,
997
+ worktreeProject: current.projectPath,
998
+ projectPath,
999
+ });
1000
+ }
1001
+ const result = await mergeBranchIntoCheckout({
1002
+ git: gitStatusRunner,
1003
+ cwd: current.projectPath,
1004
+ source: current.branch,
1005
+ target,
1006
+ confirmed,
1007
+ });
1008
+ if (result.merged && current.mergeTarget !== target) {
1009
+ current.mergeTarget = target;
1010
+ await putRecord(current);
1011
+ }
1012
+ return result;
1013
+ });
1014
+ }
1015
+
1016
+ /**
1017
+ * Merge any local branch `source` into the project folder's checked-out
1018
+ * `target` (the source-control dock), under the repository lock, with the
1019
+ * same preflight/--no-ff backend as a worktree merge. `target` is the branch
1020
+ * the user confirmed: the folder on another branch is WORKTREE_TARGET_MISMATCH.
1021
+ * @param {{ projectPath: string, source: string, target: string, confirmed: boolean }} input
1022
+ */
1023
+ export async function mergeBranchIntoProject({ projectPath, source, target, confirmed } = {}) {
1024
+ requireText(projectPath, 'projectPath');
1025
+ requireText(source, 'source');
1026
+ requireText(target, 'target');
1027
+ const repo = await inspectRepo(projectPath);
1028
+ return withRepoLock(repo.repoCommonDir, () =>
1029
+ mergeBranchIntoCheckout({ git: gitStatusRunner, cwd: repo.projectPath, source, target, confirmed })
1030
+ );
1031
+ }
1032
+
1033
+ /** WORKTREE_DIRTY / WORKTREE_UNMERGED for a ready worktree removal would refuse. */
1034
+ async function assertRemovable(record) {
1035
+ const dirty = await dirtyPaths(record);
1036
+ if (dirty.length) {
1037
+ throw worktreeError('WORKTREE_DIRTY', undefined, {
1038
+ entryId: record.id,
1039
+ files: dirty.slice(0, 50),
1040
+ count: dirty.length,
1041
+ });
1042
+ }
1043
+ const unmerged = await unmergedCommitCount(record);
1044
+ if (unmerged > 0) {
1045
+ throw worktreeError('WORKTREE_UNMERGED', undefined, {
1046
+ entryId: record.id,
1047
+ branch: record.branch,
1048
+ mergeTarget: record.mergeTarget,
1049
+ commits: unmerged,
1050
+ });
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * The refusal removeManagedWorktree would raise, checked before anything
1056
+ * moves (a session leaves its worktree only for a removal that can proceed).
1057
+ * Unknown entries are WORKTREE_MISSING; `force` and non-ready entries pass.
1058
+ * @param {{ entryId: string, force?: boolean }} input
1059
+ */
1060
+ export async function assertWorktreeRemovable({ entryId, force = false } = {}) {
1061
+ const record = await getRecord(entryId);
1062
+ if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
1063
+ if (record.state !== 'ready') return;
1064
+ await assertManagedCheckoutPath(record);
1065
+ if (!force) await assertRemovable(record);
1066
+ }
1067
+
1068
+ /**
1069
+ * Remove a managed worktree. Refuses dirty (incl. untracked and modified
1070
+ * include copies) or unmerged worktrees unless `force`.
1071
+ * @param {{ entryId: string, force?: boolean }} input
1072
+ * @returns {Promise<{ removed: boolean, retryable?: boolean, remaining?: object[], branchDeleted?: boolean }>}
1073
+ */
1074
+ export async function removeManagedWorktree({ entryId, force = false } = {}) {
1075
+ const record = await getRecord(entryId);
1076
+ if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
1077
+ return withRepoLock(record.repoCommonDir, async () => {
1078
+ const current = await getRecord(entryId);
1079
+ if (!current) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
1080
+ if (current.state === 'creating' || current.state === 'recovery') {
1081
+ const rollback = await rollbackCreation(current);
1082
+ return rollback.complete ? { removed: true } : { removed: false, retryable: true, remaining: rollback.remaining };
1083
+ }
1084
+ if (current.state === 'ready') {
1085
+ await assertManagedCheckoutPath(current);
1086
+ if (!force) await assertRemovable(current);
1087
+ current.state = 'deleting';
1088
+ current.deleting = { force: force === true, startedAt: Date.now(), remaining: [] };
1089
+ await putRecord(current);
1090
+ }
1091
+ return finishDeletion(current);
1092
+ });
1093
+ }
1094
+
1095
+ /** A lease whose recorded process still runs. */
1096
+ function leaseProcessAlive(lease) {
1097
+ return !!lease?.pid && config.processAlive(lease.pid);
1098
+ }
1099
+
1100
+ /**
1101
+ * Every managed worktree with who keeps it: `retained` (its session is in
1102
+ * `retainedSessionIds`, i.e. still in the session store), `ownerLive` (a
1103
+ * live session or a running lease holder), and `removable` (what a prune
1104
+ * would remove: neither, or disposable and not live).
1105
+ * @param {{ liveSessionIds?: string[], retainedSessionIds?: string[] }} input
1106
+ */
1107
+ export async function listManagedWorktrees({ liveSessionIds = [], retainedSessionIds = [] } = {}) {
1108
+ const live = new Set(liveSessionIds);
1109
+ const retained = new Set(retainedSessionIds);
1110
+ const rows = [];
1111
+ for (const record of Object.values((await readRegistry()).entries)) {
1112
+ const ownerLive =
1113
+ (!!record.ownerSessionId && live.has(record.ownerSessionId)) || leaseProcessAlive(await readOwnerLease(record));
1114
+ const isRetained = !!record.ownerSessionId && retained.has(record.ownerSessionId);
1115
+ rows.push({
1116
+ ...entryView(record),
1117
+ projectPath: record.projectPath,
1118
+ retained: isRetained,
1119
+ ownerLive,
1120
+ removable: record.state !== 'ready' || (!ownerLive && (record.disposable === true || !isRetained)),
1121
+ });
1122
+ }
1123
+ return rows;
1124
+ }
1125
+
1126
+ /**
1127
+ * Conservative reconcile (daemon startup, and the manual prune). Always:
1128
+ * resumes interrupted deletions, rolls back interrupted creations of no
1129
+ * retained session, releases leases of owners that are neither live nor
1130
+ * running, prunes stale managed registrations. `removeOrphans` (the
1131
+ * autoCleanup setting at startup; true for a manual prune) also removes
1132
+ * clean, merged worktrees that no stored session retains (or that a deleted
1133
+ * session left marked disposable) and whose owner is not live — never one a
1134
+ * session in the store still references.
1135
+ * @param {{ liveSessionIds?: string[], retainedSessionIds?: string[], removeOrphans?: boolean }} input
1136
+ */
1137
+ export async function reconcileManagedWorktrees({
1138
+ liveSessionIds = [],
1139
+ retainedSessionIds = [],
1140
+ removeOrphans = false,
1141
+ } = {}) {
1142
+ const live = new Set(liveSessionIds);
1143
+ const retained = new Set(retainedSessionIds);
1144
+ const report = { removed: [], recovered: [], kept: [], leasesReleased: [], pruned: [], missing: [], errors: [] };
1145
+ const reportDeletion = (record, result) => {
1146
+ if (result.removed) report.removed.push(record.id);
1147
+ else report.kept.push({ id: record.id, reason: 'deleting', remaining: result.remaining });
1148
+ };
1149
+ const byRepo = new Map();
1150
+ for (const record of Object.values((await readRegistry()).entries)) {
1151
+ const key = pathKey(record.repoCommonDir);
1152
+ if (!byRepo.has(key)) byRepo.set(key, record.repoCommonDir);
1153
+ }
1154
+ for (const [key, repoCommonDir] of byRepo) {
1155
+ if (!existsSync(repoCommonDir)) {
1156
+ report.errors.push({ repoCommonDir, error: 'repository missing' });
1157
+ continue;
1158
+ }
1159
+ try {
1160
+ await withRepoLock(repoCommonDir, async () => {
1161
+ const records = Object.values((await readRegistry()).entries).filter(
1162
+ (record) => pathKey(record.repoCommonDir) === key
1163
+ );
1164
+ for (const record of records) {
1165
+ const isRetained = !!record.ownerSessionId && retained.has(record.ownerSessionId);
1166
+ try {
1167
+ if (record.state === 'creating' || record.state === 'recovery') {
1168
+ if (isRetained) {
1169
+ report.kept.push({ id: record.id, reason: 'retained' });
1170
+ continue;
1171
+ }
1172
+ // Holding the repository lock proves no creator is still running.
1173
+ const result = await rollbackCreation(record);
1174
+ if (result.complete) report.recovered.push(record.id);
1175
+ else report.kept.push({ id: record.id, reason: 'recovery', remaining: result.remaining });
1176
+ continue;
1177
+ }
1178
+ if (record.state === 'deleting') {
1179
+ // A decided deletion: its session already left the folder.
1180
+ reportDeletion(record, await finishDeletion(record));
1181
+ continue;
1182
+ }
1183
+ const lease = await readOwnerLease(record);
1184
+ const ownerLive = (!!record.ownerSessionId && live.has(record.ownerSessionId)) || leaseProcessAlive(lease);
1185
+ if (!ownerLive && lease) {
1186
+ await removeOwnerLease(record);
1187
+ report.leasesReleased.push(record.id);
1188
+ }
1189
+ if (!existsSync(record.path)) report.missing.push(record.id);
1190
+ if (removeOrphans === true && !ownerLive && (record.disposable === true || !isRetained)) {
1191
+ const dirty = await dirtyPaths(record);
1192
+ const unmerged = await unmergedCommitCount(record);
1193
+ if (dirty.length || unmerged > 0) {
1194
+ report.kept.push({ id: record.id, reason: dirty.length ? 'dirty' : 'unmerged' });
1195
+ continue;
1196
+ }
1197
+ record.state = 'deleting';
1198
+ record.deleting = { force: false, startedAt: Date.now(), remaining: [] };
1199
+ await putRecord(record);
1200
+ reportDeletion(record, await finishDeletion(record));
1201
+ }
1202
+ } catch (error) {
1203
+ report.errors.push({ id: record.id, error: String(error?.message || error) });
1204
+ }
1205
+ }
1206
+ for (const stale of await staleManagedRegistrations(repoCommonDir)) {
1207
+ if (await pruneRegistration(repoCommonDir, stale)) report.pruned.push(canonicalPath(stale));
1208
+ }
1209
+ });
1210
+ } catch (error) {
1211
+ report.errors.push({ repoCommonDir, error: String(error?.message || error) });
1212
+ }
1213
+ }
1214
+ return report;
1215
+ }