mixdog 1.4.1 → 1.4.2

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 (326) hide show
  1. package/package.json +3 -2
  2. package/scripts/build-shipped-history.mjs +115 -33
  3. package/scripts/gate-local.mjs +172 -61
  4. package/scripts/lib/gate-scheduler.mjs +138 -32
  5. package/scripts/lib/gate-timing-reporter.mjs +16 -0
  6. package/scripts/lib/run-node-tests.mjs +54 -29
  7. package/scripts/lib/scoped-temp.mjs +1 -0
  8. package/scripts/lib/test-failure-records.mjs +14 -1
  9. package/scripts/lib/test-failure-summary.mjs +16 -4
  10. package/scripts/lib/test-spawn-ledger.mjs +12 -3
  11. package/scripts/pptx-kit-index.mjs +5 -6
  12. package/scripts/suite-health.mjs +110 -28
  13. package/scripts/test.mjs +9 -14
  14. package/src/agents/advisor/AGENT.md +4 -4
  15. package/src/agents/advisor/agent.json +1 -1
  16. package/src/agents/explore/AGENT.md +22 -0
  17. package/src/agents/explore/agent.json +6 -0
  18. package/src/cli.mjs +7 -0
  19. package/src/defaults/agents.json +7 -0
  20. package/src/defaults/shipped-history.json +18 -2
  21. package/src/defaults/skills/browser-use/SKILL.md +4 -1
  22. package/src/defaults/skills/code-tidy/SKILL.md +71 -102
  23. package/src/defaults/skills/code-tidy/references/agent-cleanup.md +66 -114
  24. package/src/defaults/skills/code-tidy/references/cleanup-report.md +19 -35
  25. package/src/defaults/skills/code-tidy/references/dead-code.md +3 -9
  26. package/src/defaults/skills/code-tidy/references/test-suite-slop.md +16 -27
  27. package/src/defaults/skills/computer-use/SKILL.md +12 -2
  28. package/src/defaults/skills/goal-management/SKILL.md +1 -1
  29. package/src/defaults/skills/setup/references/actions.md +12 -6
  30. package/src/defaults/skills/setup/references/surfaces.md +2 -2
  31. package/src/defaults/skills/setup-guide/SKILL.md +5 -2
  32. package/src/defaults/skills/setup-guide/model-recommendations.json +13 -0
  33. package/src/defaults/skills/skill-creator/SKILL.md +85 -190
  34. package/src/defaults/skills/skill-creator/references/design.md +70 -162
  35. package/src/defaults/skills/skill-creator/references/evaluation.md +33 -92
  36. package/src/defaults/skills/test-suite/SKILL.md +194 -0
  37. package/src/defaults/skills/test-suite/references/case-management.md +51 -0
  38. package/src/defaults/skills/test-suite/references/flaky-policy.md +39 -0
  39. package/src/defaults/skills/test-suite/references/reliability.md +49 -0
  40. package/src/defaults/skills/test-suite/references/suite-design.md +78 -0
  41. package/src/defaults/skills/test-suite/scripts/inventory.mjs +335 -0
  42. package/src/defaults/skills/visualize/SKILL.md +6 -2
  43. package/src/lib/rules-builder.cjs +6 -30
  44. package/src/rules/lead/LEAD.md +3 -3
  45. package/src/rules/shared/20-research.md +3 -6
  46. package/src/rules/shared/40-editing.md +1 -0
  47. package/src/rules/shared/60-verification.md +10 -9
  48. package/src/rules/shared/70-delivery.md +4 -3
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/abort-link.mjs +5 -1
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +10 -1
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +130 -21
  52. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +13 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +4 -1
  54. package/src/runtime/agent/orchestrator/config.mjs +12 -7
  55. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +7 -7
  56. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +5 -26
  57. package/src/runtime/agent/orchestrator/internal-agents.mjs +3 -14
  58. package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +122 -12
  59. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +5 -3
  60. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +13 -2
  61. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +152 -58
  62. package/src/runtime/agent/orchestrator/mcp/client.mjs +51 -18
  63. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +83 -52
  64. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +42 -26
  65. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +43 -8
  66. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +30 -28
  67. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +31 -21
  69. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-login.mjs +3 -1
  70. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -2
  71. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  72. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +1 -1
  73. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +40 -0
  74. package/src/runtime/agent/orchestrator/providers/custom-provider-keys.mjs +34 -0
  75. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +3 -0
  76. package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +14 -7
  77. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +3 -2
  78. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +2 -2
  79. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +4 -1
  80. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +48 -31
  81. package/src/runtime/agent/orchestrator/providers/lib/oauth-page.mjs +0 -18
  82. package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +138 -6
  83. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +1 -1
  84. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +100 -33
  85. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/providers/oauth-secret-fields.fixtures.mjs +45 -0
  87. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +26 -28
  88. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +3 -1
  89. package/src/runtime/agent/orchestrator/providers/openai-ws-watchdogs.mjs +1 -0
  90. package/src/runtime/agent/orchestrator/providers/opencode-go-oauth.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +3 -1
  92. package/src/runtime/agent/orchestrator/providers/registry.mjs +33 -2
  93. package/src/runtime/agent/orchestrator/providers/vertex-ai-auth.mjs +622 -0
  94. package/src/runtime/agent/orchestrator/providers/vertex-ai-maas.mjs +185 -0
  95. package/src/runtime/agent/orchestrator/providers/vertex-ai-models.mjs +445 -0
  96. package/src/runtime/agent/orchestrator/providers/vertex-ai.mjs +567 -0
  97. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +2 -0
  98. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +2 -1
  99. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  100. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +3 -0
  101. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +14 -5
  102. package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -0
  103. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +30 -5
  104. package/src/runtime/agent/orchestrator/session/agent-room.mjs +171 -37
  105. package/src/runtime/agent/orchestrator/session/cache-warm.mjs +94 -10
  106. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +3 -3
  107. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +10 -5
  108. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +5 -5
  109. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +11 -1
  110. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -3
  111. package/src/runtime/agent/orchestrator/session/human-gate/requests.mjs +36 -6
  112. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +5 -3
  113. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +21 -2
  114. package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +2 -0
  115. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +23 -1
  116. package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +13 -8
  117. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +50 -28
  118. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +1 -0
  119. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +3 -4
  120. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +2 -0
  121. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +7 -0
  122. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +15 -0
  123. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +25 -14
  124. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +17 -14
  125. package/src/runtime/agent/orchestrator/session/manager/session-scratch-environment.mjs +70 -0
  126. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +0 -3
  127. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -3
  128. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +47 -29
  129. package/src/runtime/agent/orchestrator/session/manager/turn-reap.mjs +125 -0
  130. package/src/runtime/agent/orchestrator/session/mixy-name.mjs +11 -5
  131. package/src/runtime/agent/orchestrator/session/restart-prefix-parity.fixture.mjs +1 -1
  132. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +243 -38
  133. package/src/runtime/agent/orchestrator/session/store-transcript-cache.mjs +163 -64
  134. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +164 -4
  135. package/src/runtime/agent/orchestrator/session/store-transcript-worker.mjs +142 -18
  136. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +6 -2
  137. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +36 -26
  138. package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +3 -5
  139. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +58 -20
  140. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +20 -15
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +96 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +59 -10
  143. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  144. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +99 -2
  145. package/src/runtime/agent/orchestrator/tools/lib/owned-shell-trees.mjs +2 -0
  146. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +103 -13
  147. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +3 -1
  148. package/src/runtime/agent/orchestrator/tools/lib/shell-run-settle.mjs +49 -15
  149. package/src/runtime/agent/orchestrator/tools/lib/shell-run-spawn.mjs +45 -27
  150. package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +34 -10
  151. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +2 -0
  152. package/src/runtime/agent/orchestrator/tools/lib/wait-until-fixture.mjs +26 -0
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -2
  154. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +13 -13
  155. package/src/runtime/channels/lib/whisper-port.mjs +19 -10
  156. package/src/runtime/channels/lib/whisper-server.mjs +10 -1
  157. package/src/runtime/connectors/connector-accounts-mcp.mjs +236 -103
  158. package/src/runtime/connectors/gmail-app-password.mjs +3 -3
  159. package/src/runtime/explore/explorer.mjs +93 -0
  160. package/src/runtime/explore/tool-defs.mjs +35 -0
  161. package/src/runtime/explore/tool.mjs +123 -0
  162. package/src/runtime/memory/index.mjs +4 -0
  163. package/src/runtime/memory/lib/embedding-provider.mjs +4 -1
  164. package/src/runtime/memory/lib/embedding-worker.mjs +4 -0
  165. package/src/runtime/office/quality/assurance-structure-docx.mjs +1 -1
  166. package/src/runtime/shared/agent-route-config.mjs +17 -2
  167. package/src/runtime/shared/atomic-file.mjs +103 -2
  168. package/src/runtime/shared/background-tasks.mjs +15 -6
  169. package/src/runtime/shared/connector-accounts.mjs +5 -5
  170. package/src/runtime/shared/connector-catalog.d.mts +11 -4
  171. package/src/runtime/shared/connector-catalog.mjs +60 -52
  172. package/src/runtime/shared/custom-provider-keys.mjs +20 -0
  173. package/src/runtime/shared/file-has-data.mjs +26 -2
  174. package/src/runtime/shared/file-lock.mjs +13 -15
  175. package/src/runtime/shared/git-launch.d.mts +9 -0
  176. package/src/runtime/shared/git-launch.mjs +213 -0
  177. package/src/runtime/shared/git-runner-worker.mjs +2 -1
  178. package/src/runtime/shared/llm/cost.mjs +6 -1
  179. package/src/runtime/shared/llm/http-agent.mjs +313 -29
  180. package/src/runtime/shared/llm/model-catalog-projection.mjs +3 -2
  181. package/src/runtime/shared/llm/model-catalog.mjs +46 -6
  182. package/src/runtime/shared/llm/provider-catalog-registry.mjs +3 -0
  183. package/src/runtime/shared/llm/system-proxy.mjs +452 -0
  184. package/src/runtime/shared/provider-accounts.mjs +52 -2
  185. package/src/runtime/shared/staged-install-worker.mjs +5 -0
  186. package/src/runtime/shared/staged-update.mjs +47 -2
  187. package/src/runtime/shared/tool-surface.mjs +110 -36
  188. package/src/runtime/shared/tool-work-units.mjs +4 -0
  189. package/src/runtime/shared/turn-worktree-snapshot.mjs +414 -54
  190. package/src/runtime/shared/worktrees/activity-gate.mjs +215 -318
  191. package/src/runtime/shared/worktrees/activity-ledger-worker.mjs +56 -0
  192. package/src/runtime/shared/worktrees/activity-ledger.mjs +391 -0
  193. package/src/runtime/shared/worktrees/branch-management.mjs +9 -3
  194. package/src/runtime/shared/worktrees/git.mjs +57 -0
  195. package/src/runtime/shared/worktrees/manager.mjs +205 -137
  196. package/src/runtime/tidy/install.mjs +95 -15
  197. package/src/runtime/tidy/languages.mjs +17 -15
  198. package/src/runtime/tidy/process.mjs +7 -4
  199. package/src/runtime/tidy/resolve.mjs +19 -16
  200. package/src/runtime/tidy/runners/biome.mjs +3 -3
  201. package/src/runtime/tidy/tool.mjs +1 -1
  202. package/src/runtime/tidy/worktree.mjs +10 -6
  203. package/src/runtime/visualize-bridge/client.mjs +4 -1
  204. package/src/runtime/visualize-bridge/tool-defs.mjs +7 -2
  205. package/src/runtime/web-search/index.mjs +1 -1
  206. package/src/session-runtime/agent-task-sessions.mjs +410 -353
  207. package/src/session-runtime/boot/tools.mjs +2 -0
  208. package/src/session-runtime/bot-status.mjs +103 -103
  209. package/src/session-runtime/connector-merge-migration.mjs +18 -15
  210. package/src/session-runtime/conversation-import/discovery.mjs +159 -40
  211. package/src/session-runtime/conversation-import/service.mjs +9 -3
  212. package/src/session-runtime/handoff-attachments.mjs +1 -1
  213. package/src/session-runtime/held-requests.mjs +3 -1
  214. package/src/session-runtime/internal-tool-executor/feature-tools.mjs +2 -0
  215. package/src/session-runtime/internal-tool-executor/knowledge-tools.mjs +8 -0
  216. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +143 -116
  217. package/src/session-runtime/lifecycle/teardown/session-close.mjs +6 -1
  218. package/src/session-runtime/lifecycle/teardown.mjs +1 -1
  219. package/src/session-runtime/mcp-glue/server-catalog.mjs +16 -10
  220. package/src/session-runtime/mcp-glue/server-input.mjs +4 -1
  221. package/src/session-runtime/mixy-heartbeat.mjs +1 -1
  222. package/src/session-runtime/provider-auth/login.mjs +2 -2
  223. package/src/session-runtime/resource-mcp-api.mjs +98 -35
  224. package/src/session-runtime/resource-mcp-connector-accounts.mjs +44 -31
  225. package/src/session-runtime/resource-mcp-secrets.mjs +28 -11
  226. package/src/session-runtime/resource-mcp-servers.mjs +5 -4
  227. package/src/session-runtime/services/agent-tool/send-flow.mjs +10 -2
  228. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +8 -3
  229. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +4 -1
  230. package/src/session-runtime/services/agent-watchdog-registry.mjs +32 -4
  231. package/src/session-runtime/services/channel-worker/daemon-spawn.mjs +2 -0
  232. package/src/session-runtime/services/daemon-candidate.mjs +4 -1
  233. package/src/session-runtime/services/memory-runtime-proxy/daemon-spawn.mjs +4 -0
  234. package/src/session-runtime/services/plugin-admin.mjs +10 -6
  235. package/src/session-runtime/services/provider-admin.mjs +281 -10
  236. package/src/session-runtime/session-turn-api.mjs +2 -0
  237. package/src/session-runtime/settings-builtin-tools-api.mjs +4 -1
  238. package/src/session-runtime/settings-system-api.mjs +1 -1
  239. package/src/session-runtime/setup-tool/agent-settings-actions.mjs +13 -3
  240. package/src/session-runtime/setup-tool/connector-widen-approval.mjs +96 -0
  241. package/src/session-runtime/setup-tool/executor.mjs +6 -2
  242. package/src/session-runtime/setup-tool/tool-defs.mjs +2 -1
  243. package/src/session-runtime/turn/agent-ops.mjs +23 -0
  244. package/src/session-runtime/workflow-agents-api/agent-connectors.mjs +49 -11
  245. package/src/session-runtime/workflow-agents-api/agent-route.mjs +10 -1
  246. package/src/session-runtime/workspace/activity-guards.mjs +176 -0
  247. package/src/session-runtime/workspace/session-view.mjs +149 -0
  248. package/src/session-runtime/workspace/side-checkouts.mjs +203 -0
  249. package/src/session-runtime/workspace/transition-runner.mjs +188 -0
  250. package/src/session-runtime/workspace/transitions.mjs +108 -1066
  251. package/src/session-runtime/workspace/workspace-merge.mjs +132 -0
  252. package/src/session-runtime/workspace/worktree-intents.mjs +242 -0
  253. package/src/session-runtime/workspace/worktree-maintenance.mjs +80 -0
  254. package/src/session-runtime/workspace/worktree-setup.mjs +85 -0
  255. package/src/session-runtime/workspace/worktree-tool.mjs +54 -54
  256. package/src/standalone/daemon-desktop-runtime.mjs +2 -0
  257. package/src/standalone/daemon-session-control.mjs +151 -136
  258. package/src/standalone/daemon.mjs +7 -0
  259. package/src/standalone/desktop-service-registry.mjs +9 -0
  260. package/src/standalone/local-session-runtime.mjs +5 -1
  261. package/src/standalone/session-client/daemon-unavailable.mjs +3 -0
  262. package/src/standalone/session-client.mjs +5 -1
  263. package/src/standalone/session-frame-stream.mjs +83 -11
  264. package/src/standalone/session-protocol.mjs +4 -0
  265. package/src/standalone/session-service/agent-tree.mjs +45 -9
  266. package/src/standalone/session-service/projection/transcript-window.mjs +700 -64
  267. package/src/standalone/session-service/session-calls/session-views.mjs +160 -104
  268. package/src/standalone/session-service/stored-reader.mjs +127 -40
  269. package/src/tui/App.jsx +154 -164
  270. package/src/tui/app/app-view/floating-panel.jsx +3 -0
  271. package/src/tui/app/app-view/text-entry-labels.mjs +1 -0
  272. package/src/tui/app/clipboard.mjs +21 -10
  273. package/src/tui/app/prompt-handlers/use-prompt-history-nav.mjs +1 -1
  274. package/src/tui/app/prompt-handlers/use-prompt-interrupt.mjs +3 -0
  275. package/src/tui/app/prompt-submit/provider-prompts.mjs +3 -1
  276. package/src/tui/app/provider-setup/api-key-actions.mjs +31 -1
  277. package/src/tui/app/provider-setup/oauth-actions.mjs +25 -6
  278. package/src/tui/app/provider-setup-picker.mjs +20 -13
  279. package/src/tui/app/shell-layout/row-budgets.mjs +12 -10
  280. package/src/tui/app/slash-commands.mjs +1 -0
  281. package/src/tui/app/slash-dispatch/session-commands.mjs +46 -0
  282. package/src/tui/app/slash-dispatch.mjs +1 -1
  283. package/src/tui/app/use-btw-panel.mjs +29 -0
  284. package/src/tui/app/use-context-panel-refresh.mjs +31 -0
  285. package/src/tui/app/use-model-catalog-cache.mjs +13 -10
  286. package/src/tui/app/use-mouse-input.mjs +11 -0
  287. package/src/tui/app/use-onboarding-start.mjs +8 -3
  288. package/src/tui/app/use-panel-transition.mjs +10 -1
  289. package/src/tui/app/use-pasted-buffers.mjs +28 -20
  290. package/src/tui/app/use-prompt-draft-flow.mjs +7 -0
  291. package/src/tui/app/use-prompt-layout-rows.mjs +3 -3
  292. package/src/tui/app/use-prompt-queue-history.mjs +3 -2
  293. package/src/tui/app/use-selection-focus.mjs +11 -2
  294. package/src/tui/app/use-selection-geometry.mjs +18 -9
  295. package/src/tui/app/use-selection-paint.mjs +4 -4
  296. package/src/tui/app/use-selection-stitch.mjs +3 -3
  297. package/src/tui/app/use-slash-palette.mjs +62 -0
  298. package/src/tui/app/use-terminal-chrome.mjs +2 -2
  299. package/src/tui/app/use-transcript-activity.mjs +47 -32
  300. package/src/tui/app/use-transcript-follow.mjs +12 -4
  301. package/src/tui/app/use-transcript-scroll.mjs +46 -17
  302. package/src/tui/app/use-transcript-swap-reset.mjs +27 -0
  303. package/src/tui/app/use-transcript-window/geometry.mjs +19 -5
  304. package/src/tui/app/use-transcript-window/instance-state.mjs +4 -0
  305. package/src/tui/app/use-transcript-window/selection-shift.mjs +5 -1
  306. package/src/tui/app/use-transcript-window.mjs +21 -11
  307. package/src/tui/app/use-tui-ready.mjs +18 -0
  308. package/src/tui/app/use-welcome-prompt-hint.mjs +5 -0
  309. package/src/tui/app/use-workflow-tab-cycle.mjs +5 -3
  310. package/src/tui/components/Picker.jsx +27 -3
  311. package/src/tui/components/SignInUrl.jsx +68 -0
  312. package/src/tui/components/TextEntryPanel.jsx +76 -46
  313. package/src/tui/components/sign-in-url-layout.mjs +24 -0
  314. package/src/tui/dist/index.mjs +1896 -1341
  315. package/src/tui/session/goal-turn-state.mjs +6 -2
  316. package/src/tui/session/session-api/controls.mjs +4 -0
  317. package/src/tui/session/session-api/extensions.mjs +8 -0
  318. package/src/tui/session/session-api/intake/abort.mjs +2 -1
  319. package/src/tui/session/session-api/intake.mjs +2 -0
  320. package/src/tui/session/session-api/integrations.mjs +2 -2
  321. package/src/tui/session/session-api/routes-agent-definitions.mjs +1 -0
  322. package/src/tui/session/session-api/settings.mjs +2 -2
  323. package/src/tui/session/transcript-spill/spill-writer.mjs +15 -0
  324. package/src/tui/session/transcript-spill.mjs +92 -4
  325. package/src/tui/session/transcript-store.mjs +2 -0
  326. package/src/defaults/skills/skill-creator/references/source-review.md +0 -94
@@ -15,51 +15,28 @@
15
15
  * (runtime/shared/worktrees/manager.mjs), loaded on first use.
16
16
  */
17
17
  import { randomUUID } from 'node:crypto';
18
- import { setTimeout as delay } from 'node:timers/promises';
19
- import { isDeepStrictEqual } from 'node:util';
20
- import {
21
- WORKTREE_CARRY_CHOICES,
22
- WORKTREE_SNAPSHOT_KIND,
23
- branchMenuEntries,
24
- isWorktreeBound,
25
- isWorktreeError,
26
- normalizeSessionWorkspace,
27
- worktreeError,
28
- worktreeNameError,
29
- worktreeRefusal,
30
- } from '../../runtime/shared/worktrees/contract.mjs';
31
18
  import { normalizeWorktreeSettings } from '../../runtime/shared/worktrees/settings.mjs';
32
19
  import * as maintenance from '../../runtime/shared/worktrees/maintenance.mjs';
33
- import { inspectRepo, throwIfAborted } from '../../runtime/shared/worktrees/git.mjs';
34
- import { worktreePreparationNote } from '../../runtime/shared/worktrees/preparation-report.mjs';
35
- import { isWithin, samePath } from '../../runtime/shared/worktrees/paths.mjs';
36
- import { readGitHead } from '../../runtime/shared/worktrees/git-head.mjs';
37
- import {
38
- holdWorkspaceActivity,
39
- isExternalCheckout,
40
- releaseAll,
41
- } from '../../runtime/shared/worktrees/activity-gate.mjs';
42
- import { mergeConfirmedTarget } from '../../runtime/shared/worktrees/branch-management.mjs';
20
+ import { inspectRepo } from '../../runtime/shared/worktrees/git.mjs';
21
+ import { samePath } from '../../runtime/shared/worktrees/paths.mjs';
43
22
  import {
44
23
  clearReadDedupSession,
45
24
  resetReadStateAfterCompaction,
46
25
  } from '../../runtime/agent/orchestrator/session/cache/read-cache.mjs';
47
- import { sessionHasConversationMessages } from '../../runtime/agent/orchestrator/runtime-core/session-text.mjs';
48
26
  import { buildWorkspacePromptContext } from '../../runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs';
49
- import {
50
- promptContentText,
51
- refreshSessionWorkspaceContext,
52
- } from '../../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
27
+ import { refreshSessionWorkspaceContext } from '../../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
53
28
  import { AGENT_OWNER } from '../../runtime/agent/orchestrator/agent-owner.mjs';
54
29
  import { _liveSessions } from '../../runtime/agent/orchestrator/session/store/live-state.mjs';
55
- import { admitTurn, beginWorkspaceTransition, holdCheckoutFolder } from './admission-gate.mjs';
30
+ import { createActivityGuards } from './activity-guards.mjs';
56
31
  import { createProjectBranchOps } from './project-branches.mjs';
57
- import { assertRetainedFits, createTransitionSteps, undoAll } from './transition-steps.mjs';
58
- import { readSessionWorkspace } from './workspace-state.mjs';
59
-
60
- const ACTIVE_STAGES = new Set(['connecting', 'requesting', 'streaming', 'tool_running', 'running', 'cancelling']);
61
- const BUSY_WORKER_STATES = new Set([...ACTIVE_STAGES, 'starting', 'spawning', 'queued', 'pending']);
62
- const BUSY_JOB_STATES = new Set(['starting', 'spawning', 'queued', 'pending', 'running']);
32
+ import { createSessionWorkspaceView } from './session-view.mjs';
33
+ import { createSideCheckouts } from './side-checkouts.mjs';
34
+ import { createTransitionRunner } from './transition-runner.mjs';
35
+ import { createTransitionSteps } from './transition-steps.mjs';
36
+ import { createWorkspaceMerge } from './workspace-merge.mjs';
37
+ import { createWorktreeIntents, creationIntent } from './worktree-intents.mjs';
38
+ import { createWorktreeMaintenance } from './worktree-maintenance.mjs';
39
+ import { createWorktreeSetup, defaultPlanWorktreeSetup, defaultRunWorktreeSetup } from './worktree-setup.mjs';
63
40
 
64
41
  const loadDefaultManager = () => import('../../runtime/shared/worktrees/manager.mjs');
65
42
 
@@ -71,28 +48,12 @@ async function defaultOwnShellJobCount(sessionIds) {
71
48
  return otherSessionBackgroundShellJobCount(new Set()) - otherSessionBackgroundShellJobCount(new Set(sessionIds));
72
49
  }
73
50
 
74
- const lower = (value) => String(value || '').toLowerCase();
75
-
76
- const WORKTREE_BOUND_SWITCH =
77
- 'The session works in a managed worktree; exit the worktree first (switch to a project-folder branch, or merge or remove the worktree) before moving it to another project.';
78
-
79
51
  /** The registered Project (projects.json) at `path`, or null. */
80
52
  async function defaultRegisteredProject(path) {
81
53
  const { listProjectsStrict } = await import('../../runtime/shared/projects.mjs');
82
54
  return listProjectsStrict().find((entry) => samePath(entry.path, path))?.path ?? null;
83
55
  }
84
56
 
85
- /** The dependency installs a new worktree folder still needs (dependencies.mjs). */
86
- async function defaultPlanWorktreeSetup(input) {
87
- const { planDependencySetup } = await import('../../runtime/shared/worktrees/dependencies.mjs');
88
- return planDependencySetup(input);
89
- }
90
-
91
- async function defaultRunWorktreeSetup(input) {
92
- const { runWorktreeSetupCommands } = await import('../../runtime/shared/worktrees/setup-commands.mjs');
93
- return runWorktreeSetupCommands(input);
94
- }
95
-
96
57
  /**
97
58
  * Re-root one session's prompt at `cwd`: environment facts and instruction
98
59
  * files for every session, project memory for the Lead only (workers never
@@ -120,58 +81,6 @@ export async function refreshWorkspacePrompt(session, cwd, { loadCoreMemoryConte
120
81
  });
121
82
  }
122
83
 
123
- /**
124
- * Creation-time intent: `{ mode: 'worktree', name }` asks for a worktree;
125
- * `{ mode: 'default' }` — an interactive Lead the user starts (TUI launch) —
126
- * follows the settings `newTaskDefault`; `{ mode: 'branch' }` or no intent
127
- * runs in the project folder. Desktop tasks carry their choice from the
128
- * renderer, and agents, schedules, webhooks and resumed sessions pass none,
129
- * so the default never applies to them.
130
- */
131
- function creationIntent(worktree, settings) {
132
- if (worktree?.mode === 'worktree') {
133
- return { name: worktree.name ?? null, confirmLarge: false, explicit: true, sessionId: null, txId: null };
134
- }
135
- return worktree?.mode === 'default' && settings.newTaskDefault === 'worktree'
136
- ? { name: null, confirmLarge: false, explicit: false, sessionId: null, txId: null }
137
- : null;
138
- }
139
-
140
- function validName(name) {
141
- if (name === undefined || name === null) return null;
142
- const error = worktreeNameError(name);
143
- if (error) throw new TypeError(`worktree name is invalid: ${error}`);
144
- return name;
145
- }
146
-
147
- function normalizeTarget(target) {
148
- const kind = target?.kind;
149
- if (kind === 'worktree') return { kind, name: validName(target.name) };
150
- if (kind === 'branch') {
151
- if (typeof target.branch !== 'string' || !target.branch.trim()) {
152
- throw new TypeError('workspace target branch must be a non-empty string.');
153
- }
154
- return { kind, branch: target.branch.trim(), create: target.create === true };
155
- }
156
- if (kind === 'remove-worktree') return { kind, force: target.force === true };
157
- throw new TypeError('workspace target kind must be branch, worktree or remove-worktree.');
158
- }
159
-
160
- function validRevision(value) {
161
- if (value === undefined || value === null) return undefined;
162
- if (!Number.isSafeInteger(value) || value < 0) {
163
- throw new TypeError('expectedRevision must be a non-negative integer.');
164
- }
165
- return value;
166
- }
167
-
168
- /** WORKTREE_REVISION_CONFLICT when the caller expected another workspace revision. */
169
- function assertRevision(revision, expectedRevision) {
170
- if (expectedRevision !== undefined && expectedRevision !== revision) {
171
- throw worktreeError('WORKTREE_REVISION_CONFLICT', undefined, { expectedRevision, revision });
172
- }
173
- }
174
-
175
84
  /**
176
85
  * The workspace methods every external caller (the runtime facade: desktop
177
86
  * IPC, the daemon's `session.configure` lane, remote views) reaches: exactly
@@ -190,12 +99,6 @@ export function publicWorkspaceMethods(api) {
190
99
  };
191
100
  }
192
101
 
193
- // A settled turn's snapshot cleanup waits at most 2^5 times its first delay between attempts.
194
- const SNAPSHOT_BACKOFF_STEPS = 5;
195
-
196
- /** The in-flight marker state of each transition kind. */
197
- const TRANSITION_STATE = { worktree: 'provisioning', branch: 'switching', 'remove-worktree': 'removing' };
198
-
199
102
  /**
200
103
  * @param {object} deps
201
104
  * @param {() => any} deps.getSession The Lead session this runtime hosts.
@@ -247,15 +150,7 @@ export function createWorkspaceTransitions({
247
150
  snapshotRetryLimit = 20,
248
151
  }) {
249
152
  const worktreeSettings = () => normalizeWorktreeSettings(getConfig?.()?.worktree);
250
- let pendingIntent = creationIntent(initialIntent, worktreeSettings());
251
- // The creation txIds of the read-only snapshots the current admitted turn
252
- // made. Each turn owns its own set, so its cleanup never touches a later
253
- // turn's snapshots (a txId, unlike a freed slug, is never reused).
254
- let turnSnapshots = null;
255
- // The running cleanups of settled turns' snapshots (controller → session id).
256
- const snapshotCleanups = new Map();
257
- let disposed = false;
258
- const setupListeners = new Set();
153
+ const initialPending = creationIntent(initialIntent, worktreeSettings());
259
154
  let managerPromise = null;
260
155
  const backend = () => {
261
156
  if (manager) return Promise.resolve(manager);
@@ -263,209 +158,28 @@ export function createWorkspaceTransitions({
263
158
  return managerPromise;
264
159
  };
265
160
 
266
- function hostedSession(sessionRef) {
267
- const session = getSession();
268
- const requested = String(sessionRef?.sessionId || '').trim();
269
- if (!session?.id || (requested && requested !== session.id)) {
270
- throw new Error(`workspace: session ${requested || '(none)'} is not hosted by this runtime.`);
271
- }
272
- return session;
273
- }
274
-
275
- async function ensureSession() {
276
- if (!getSession()?.id && typeof createCurrentSession === 'function') await createCurrentSession('workspace');
277
- return hostedSession(null);
278
- }
279
-
280
- function projectPathOf(session) {
281
- const desktop = getDesktopSession?.() || session?.desktopSession;
282
- if (desktop?.classification === 'project' && desktop.projectPath) return desktop.projectPath;
283
- return session?.cwd || getCurrentCwd();
284
- }
285
-
286
- /** The stored record, or the project-folder workspace (revision 0) of a session that never moved. */
287
- function currentWorkspace(session) {
288
- const stored = readSessionWorkspace(session);
289
- if (stored) return stored;
290
- const projectPath = projectPathOf(session);
291
- return normalizeSessionWorkspace({ projectPath, cwd: getCurrentCwd() || projectPath, revision: 0 });
292
- }
293
-
294
- let liveWorkspace = null;
295
-
296
- /**
297
- * The workspace the session publishes: a worktree-bound record as stored; a
298
- * project-folder session (never moved, back from a worktree, or carrying a
299
- * transition stub) with the folder's checked-out branch / detached commit and
300
- * repoCommonDir read straight from HEAD — no git process. Null outside a git
301
- * repository for a session without a record. Identity-stable while equal.
302
- */
303
- function getLiveWorkspace() {
304
- const session = getSession();
305
- const stored = readSessionWorkspace(session);
306
- if (isWorktreeBound(stored)) return session.workspace;
307
- let base;
308
- try {
309
- base = stored ?? currentWorkspace(session);
310
- } catch {
311
- return session?.workspace ?? null;
312
- }
313
- const head = readGitHead(base.cwd);
314
- if (!head) return session?.workspace ?? null;
315
- const next = {
316
- ...base,
317
- repoCommonDir: head.repoCommonDir,
318
- branch: head.branch,
319
- baseCommit: head.branch ? base.baseCommit : head.commit,
320
- };
321
- if (!liveWorkspace || !isDeepStrictEqual(liveWorkspace, next)) liveWorkspace = next;
322
- return liveWorkspace;
323
- }
324
-
325
- function isSessionActive(session) {
326
- const runtime = sessionManager?.getSessionRuntime?.(session.id);
327
- if (runtime?.controller?.signal && !runtime.controller.signal.aborted) return true;
328
- return ACTIVE_STAGES.has(lower(runtime?.stage || session.status));
329
- }
330
-
331
- const workersOf = (session) =>
332
- listLiveSessions().filter(
333
- (candidate) =>
334
- candidate?.id &&
335
- candidate.id !== session.id &&
336
- candidate.owner === AGENT_OWNER &&
337
- candidate.closed !== true &&
338
- (candidate.ownerSessionId === session.id || candidate.parentSessionId === session.id)
339
- );
340
-
341
- /**
342
- * WORKTREE_BUSY_JOBS while this Lead has a turn, workers or background
343
- * shells running (`shellJobs: false` tolerates background shells).
344
- */
345
- async function assertLeadIdle(session, { ownTurn = false, shellJobs: checkShellJobs = true } = {}) {
346
- if (!ownTurn && getActiveTurnCount() > 0) {
347
- throw worktreeError('WORKTREE_BUSY_JOBS', 'The session is running a turn; wait for it to finish.', {
348
- reason: 'turn',
349
- });
350
- }
351
- const status = agentStatusState?.() || {};
352
- const workers = Array.isArray(status.agentWorkers) ? status.agentWorkers : [];
353
- const jobs = Array.isArray(status.agentJobs) ? status.agentJobs : [];
354
- const busyWorkers = workers.filter(
355
- (row) => BUSY_WORKER_STATES.has(lower(row?.status)) || BUSY_WORKER_STATES.has(lower(row?.stage))
356
- ).length;
357
- const busyJobs = jobs.filter((row) => BUSY_JOB_STATES.has(lower(row?.status))).length;
358
- if (busyWorkers || busyJobs) {
359
- throw worktreeError('WORKTREE_BUSY_JOBS', undefined, { reason: 'workers', workers: busyWorkers, jobs: busyJobs });
360
- }
361
- if (!checkShellJobs) return;
362
- const ids = new Set([session.id, ...workers.map((row) => row?.sessionId).filter(Boolean)]);
363
- for (const worker of workersOf(session)) ids.add(worker.id);
364
- const shellJobs = await countOwnShellJobs([...ids]);
365
- if (shellJobs > 0) throw worktreeError('WORKTREE_BUSY_JOBS', undefined, { reason: 'shell-jobs', shellJobs });
366
- }
367
-
368
- /**
369
- * A checkout or merge in the shared checkout `folder` refuses while another
370
- * session (not this Lead's own workers) works there, then holds the folder
371
- * until the returned release: other sessions' turns that arrive meanwhile
372
- * wait (admission gate). `sessionId` null: a session-less (draft) change.
373
- * @returns {() => void}
374
- */
375
- function holdProjectFolder(sessionId, folder, { hold = true } = {}) {
376
- const users = listLiveSessions().filter(
377
- (candidate) =>
378
- candidate?.id &&
379
- candidate.id !== sessionId &&
380
- candidate.closed !== true &&
381
- (!sessionId || (candidate.ownerSessionId !== sessionId && candidate.parentSessionId !== sessionId)) &&
382
- typeof candidate.cwd === 'string' &&
383
- isWithin(folder, candidate.cwd) &&
384
- isSessionActive(candidate)
385
- );
386
- if (users.length) throw worktreeRefusal('folder-in-use', { sessions: users.map((candidate) => candidate.id) });
387
- return holdCheckoutFolder(sessionId, folder, { hold });
388
- }
389
-
390
- /** Run `fn` holding the checkout `folder` (holdProjectFolder); released when it settles. */
391
- async function withProjectFolder(sessionId, folder, fn) {
392
- const release = holdProjectFolder(sessionId, folder);
393
- try {
394
- return await fn();
395
- } finally {
396
- release();
397
- }
398
- }
399
-
400
- /**
401
- * Before a carry clears the shared checkout `folder` (held by the caller,
402
- * so no new turn starts there): WORKTREE_BUSY_JOBS while any other session
403
- * working in it — idle or not, this Lead's workers included — owns a
404
- * running background shell (holdProjectFolder already refused running
405
- * turns and agents there, assertLeadIdle this Lead's own). Processes
406
- * outside Mixdog (editors, watchers) cannot be excluded.
407
- */
408
- async function assertCheckoutQuiet(session, folder) {
409
- const sharing = listLiveSessions()
410
- .filter(
411
- (candidate) =>
412
- candidate?.id &&
413
- candidate.id !== session.id &&
414
- candidate.closed !== true &&
415
- typeof candidate.cwd === 'string' &&
416
- isWithin(folder, candidate.cwd)
417
- )
418
- .map((candidate) => candidate.id);
419
- if (!sharing.length) return;
420
- const shellJobs = await countOwnShellJobs(sharing);
421
- if (shellJobs > 0) throw worktreeRefusal('folder-shell-jobs', { shellJobs });
422
- }
423
-
424
- /**
425
- * The shared admission of every merge and folder cleanup (manageProjectBranch):
426
- * refuses while a session or worker runs in an affected folder, or a
427
- * background command does; `removing` refuses while any session still uses
428
- * one. Then holds the folders — Mixdog checkouts only; an external checkout
429
- * takes no lease from this profile — so new Mixdog work there waits until `run`
430
- * settles. `actor`: the session acting on its own checkout, whose own turn
431
- * and workers its caller already checked (assertLeadIdle).
432
- */
433
- async function withManagementFolders(folders, removing, run, { actor = null } = {}) {
434
- const release = [];
435
- try {
436
- for (const folder of [...new Set(folders)].sort()) {
437
- release.push(holdProjectFolder(actor, folder, { hold: !isExternalCheckout(folder) }));
438
- }
439
- release.push(holdWorkspaceActivity(folders, { sessionId: actor }));
440
- const stored = await listWorkspaceConsumers();
441
- const consumers = new Map(stored.map((session) => [session.id, session]));
442
- for (const session of listLiveSessions()) if (session?.closed !== true) consumers.set(session.id, session);
443
- const affected = [...consumers.values()].filter((session) =>
444
- folders.some((folder) => session.cwd && isWithin(folder, session.cwd))
445
- );
446
- const blocking = removing
447
- ? affected
448
- : affected.filter(
449
- (session) =>
450
- !(actor && [session.id, session.ownerSessionId, session.parentSessionId].includes(actor)) &&
451
- isSessionActive(session)
452
- );
453
- if (blocking.length) throw worktreeRefusal('folder-in-use', { sessions: blocking.map((session) => session.id) });
454
- const jobs = await listWorkspaceJobs();
455
- if (jobs.some((job) => !job.terminal && folders.some((folder) => isWithin(folder, job.cwd)))) {
456
- throw worktreeRefusal('folder-shell-jobs');
457
- }
458
- if ((await countOwnShellJobs(affected.map((session) => session.id))) > 0) {
459
- throw worktreeRefusal('folder-shell-jobs');
460
- }
461
- return await run([...consumers.keys()]);
462
- } finally {
463
- releaseAll(release);
464
- }
465
- }
466
-
161
+ const view = createSessionWorkspaceView({
162
+ getSession,
163
+ getCurrentCwd,
164
+ getDesktopSession,
165
+ createCurrentSession,
166
+ inspectProject,
167
+ backend,
168
+ pendingWorktree: () => intents.pendingWorktree(),
169
+ });
170
+ const { hostedSession, ensureSession, currentWorkspace } = view;
171
+ const guards = createActivityGuards({
172
+ getActiveTurnCount,
173
+ agentStatusState,
174
+ countOwnShellJobs,
175
+ listLiveSessions,
176
+ sessionManager,
177
+ listWorkspaceConsumers,
178
+ listWorkspaceJobs,
179
+ });
180
+ const { assertLeadIdle, withProjectFolder, withManagementFolders } = guards;
467
181
  const steps = createTransitionSteps({
468
- assertCheckoutQuiet,
182
+ assertCheckoutQuiet: guards.assertCheckoutQuiet,
469
183
  backend,
470
184
  inspectProject,
471
185
  runtimeToken,
@@ -477,7 +191,7 @@ export function createWorkspaceTransitions({
477
191
  refreshWorkspaceContext,
478
192
  invalidateCaches,
479
193
  updateLeadWorkerCwd,
480
- workersOf,
194
+ workersOf: guards.workersOf,
481
195
  withProjectFolder,
482
196
  });
483
197
  const projectBranches = createProjectBranchOps({
@@ -487,765 +201,93 @@ export function createWorkspaceTransitions({
487
201
  withProjectFolder,
488
202
  withManagementFolders,
489
203
  });
490
-
491
- async function transition(session, target, options = {}) {
492
- throwIfAborted(options.signal);
493
- const previous = currentWorkspace(session);
494
- assertRevision(previous.revision, options.expectedRevision);
495
- const txId = String(options.txId || '').trim() || newTxId();
496
- const leave = beginWorkspaceTransition(session.id, txId);
497
- const hadRecord = Object.hasOwn(session, 'workspace');
498
- const stored = session.workspace;
499
- const marker = normalizeSessionWorkspace({
500
- ...previous,
501
- transition: { state: TRANSITION_STATE[target.kind], txId },
502
- });
503
- session.workspace = marker;
504
- const restore = () => {
505
- if (hadRecord) session.workspace = stored;
506
- else delete session.workspace;
507
- };
508
- const journal = [];
509
- try {
510
- await assertLeadIdle(session, { ownTurn: options.ownTurn === true });
511
- const run = { signal: options.signal, txId, journal };
512
- // A new checkout gets its dependencies prepared at the start of the
513
- // first turn a session runs there (runPendingSetup, from its registry entry).
514
- if (target.kind === 'worktree') {
515
- return await steps.enterWorktree(session, previous, {
516
- ...run,
517
- name: target.name,
518
- nameHint: options.nameHint,
519
- confirmLarge: options.confirmLarge,
520
- changes: options.changes,
521
- });
522
- }
523
- if (target.kind === 'branch') {
524
- return await steps.enterProjectBranch(session, previous, {
525
- ...run,
526
- branch: target.branch,
527
- create: target.create,
528
- confirmLarge: options.confirmLarge,
529
- });
530
- }
531
- return await steps.removeRetainedWorktree(session, previous, { ...run, force: target.force });
532
- } catch (error) {
533
- // Whatever already moved (cwd, record, prompts, worker roots, lease)
534
- // goes back exactly; the previous workspace stays authoritative.
535
- await undoAll(journal, error);
536
- throw error;
537
- } finally {
538
- if (session.workspace === marker) restore();
539
- leave();
540
- }
541
- }
542
-
543
- /**
544
- * The one entry of every transition. `ownTurn` (internal: the Lead's own
545
- * tool call) tolerates the calling turn; `txId` keys the creation.
546
- */
547
- async function runTransition(sessionRef, target, options = {}, { ownTurn = false, txId = '' } = {}) {
548
- const session = hostedSession(sessionRef);
549
- return transition(session, normalizeTarget(target), {
550
- expectedRevision: validRevision(options.expectedRevision),
551
- confirmLarge: options.confirmLarge === true,
552
- signal: options.signal,
553
- ownTurn: ownTurn === true,
554
- txId,
555
- });
556
- }
204
+ const runner = createTransitionRunner({
205
+ getSession,
206
+ hostedSession,
207
+ currentWorkspace,
208
+ assertLeadIdle,
209
+ steps,
210
+ newTxId,
211
+ applyResolvedCwd,
212
+ getCurrentCwd,
213
+ });
214
+ const setup = createWorktreeSetup({ backend, newTxId, planWorktreeSetup, runWorktreeSetup });
215
+ const intents = createWorktreeIntents({
216
+ initial: initialPending,
217
+ getSession,
218
+ getReservedSessionId,
219
+ ensureSession,
220
+ currentWorkspace,
221
+ backend,
222
+ transition: runner.transition,
223
+ runTransition: runner.runTransition,
224
+ runPendingSetup: setup.runPendingSetup,
225
+ newTxId,
226
+ });
227
+ const checkouts = createSideCheckouts({
228
+ getSession,
229
+ getCurrentCwd,
230
+ hostedSession,
231
+ sessionProject: view.sessionProject,
232
+ backend,
233
+ runtimeToken,
234
+ newTxId,
235
+ snapshotRetryMs,
236
+ snapshotRetryLimit,
237
+ });
238
+ const worktrees = createWorktreeMaintenance({
239
+ getSession,
240
+ listLiveSessions,
241
+ listStoredSessionIds,
242
+ backend,
243
+ withManagementFolders,
244
+ cancelSnapshotCleanups: checkouts.cancelSnapshotCleanups,
245
+ worktreeSettings,
246
+ });
247
+ const merge = createWorkspaceMerge({
248
+ hostedSession,
249
+ currentWorkspace,
250
+ assertLeadIdle,
251
+ inspectProject,
252
+ backend,
253
+ withManagementFolders,
254
+ removeWorktree: runner.removeWorktree,
255
+ worktreeSettings,
256
+ newTxId,
257
+ });
557
258
 
558
259
  /** @type {import('../../runtime/shared/worktrees/contract.mjs').ApplyWorkspaceTransition} */
559
260
  async function applyWorkspaceTransition(sessionRef, target, options = {}) {
560
- return runTransition(sessionRef, target, options);
561
- }
562
-
563
- /** The pending worktree intent that applies to `session`'s next turn, if any. */
564
- function intentFor(session) {
565
- const intent = pendingIntent;
566
- if (!intent) return null;
567
- if (isWorktreeBound(readSessionWorkspace(session))) {
568
- pendingIntent = null;
569
- return null;
570
- }
571
- if (intent.sessionId) return intent.sessionId === session.id ? intent : null;
572
- // A creation-time intent belongs to a new task: never to a resumed
573
- // conversation this runtime opened instead.
574
- const started =
575
- sessionHasConversationMessages(session) || sessionHasConversationMessages({ messages: session.liveTurnMessages });
576
- return started ? null : intent;
577
- }
578
-
579
- /**
580
- * Create and enter the worktree `intent` asks for now: its txId is fixed on
581
- * first use (`submissionId`, else a fresh one) so a retry reuses the same
582
- * worktree, and the prompt text names an unnamed one. A settled intent is
583
- * consumed.
584
- */
585
- async function provisionIntent(session, intent, submissionId, prompt, options) {
586
- intent.txId ||= String(submissionId || '').trim() || newTxId();
587
- const result = await transition(
588
- session,
589
- { kind: 'worktree', name: intent.name },
590
- { txId: intent.txId, nameHint: promptContentText(prompt), confirmLarge: intent.confirmLarge, ...options }
591
- );
592
- if (pendingIntent === intent) pendingIntent = null;
593
- return result;
594
- }
595
-
596
- /**
597
- * Turn-start workspace gate, run after the session exists and before the
598
- * user row, the review snapshot, hooks, the provider and any worker spawn.
599
- * A session in recovery refuses the turn; a pending worktree intent is
600
- * provisioned now (txId = the first submission id, reused by retries; an
601
- * unnamed worktree takes its slug from the first message).
602
- */
603
- async function prepareTurnWorkspace({ submissionId = '', prompt = '', signal, confirmLarge = false } = {}) {
604
- const session = getSession();
605
- if (!session?.id) return null;
606
- const stored = readSessionWorkspace(session);
607
- if (stored?.transition.state === 'recovery') {
608
- throw worktreeError(
609
- 'WORKTREE_MISSING',
610
- 'The worktree for this session no longer exists; pick a branch or remove the worktree to continue.',
611
- { worktreeId: stored.worktreeId, path: stored.cwd }
612
- );
613
- }
614
- const intent = intentFor(session);
615
- let result = null;
616
- if (intent) {
617
- try {
618
- result = await provisionIntent(session, intent, submissionId, prompt, {
619
- confirmLarge: intent.confirmLarge || confirmLarge === true,
620
- signal,
621
- ownTurn: true,
622
- });
623
- } catch (error) {
624
- // The settings default never blocks a task outside a git repository.
625
- if (!intent.explicit && isWorktreeError(error, 'WORKTREE_UNSUPPORTED_REPO')) {
626
- if (pendingIntent === intent) pendingIntent = null;
627
- return null;
628
- }
629
- throw error;
630
- }
631
- }
632
- await runPendingSetup(session, signal);
633
- return result;
634
- }
635
-
636
- /**
637
- * Prepare the session's new worktree, once, under the turn (its admission
638
- * keeps the folder from being merged or removed; its signal — the Stop
639
- * button — cancels): the frozen installs the links made at creation did
640
- * not cover. While an install runs, every onWorktreeSetup listener gets its
641
- * one-line progress; nothing else reaches the user. The agent gets one
642
- * compact note with this turn (`session.pendingSetupNotice`): the main
643
- * checkout, what was copied, linked and installed, what failed (with the
644
- * output tail) and what was skipped. A failure or timeout is in the note
645
- * and the turn goes on; a cancel aborts the turn like any other stop. The
646
- * worktree stays either way. The setup belongs to the worktree it was made
647
- * for, whichever path made it (a worktree task, a branch selection, `git
648
- * worktree add`): its registry entry keeps it pending (manager
649
- * claimWorktreeSetup) until the first turn a session runs there, never in
650
- * another folder.
651
- */
652
- async function runPendingSetup(session, signal) {
653
- const here = readSessionWorkspace(session);
654
- if (!isWorktreeBound(here)) return;
655
- const claimed = await (await backend()).claimWorktreeSetup(here.worktreeId);
656
- if (!claimed) return;
657
- const id = newTxId();
658
- const report = {
659
- root: claimed.prepared?.root || here.projectPath,
660
- prepared: claimed.prepared,
661
- dependencyNotes: [],
662
- installs: null,
663
- notes: [],
664
- };
665
- const emit = (event) => {
666
- for (const listener of setupListeners) {
667
- try {
668
- listener({ ...event, id, sessionId: session.id, cwd: here.cwd });
669
- } catch {
670
- /* a viewer's failure never stops the setup */
671
- }
672
- }
673
- };
674
- try {
675
- const plan = await planWorktreeSetup({ worktreeDir: here.cwd, mainDir: here.projectPath, signal });
676
- report.dependencyNotes = plan.notes;
677
- if (plan.commands.length) {
678
- report.installs = await runWorktreeSetup({ commands: plan.commands, signal, onProgress: emit });
679
- }
680
- } catch (error) {
681
- if (!signal?.aborted) report.notes.push(`Dependencies could not be prepared: ${error?.message || error}`);
682
- }
683
- session.pendingSetupNotice = { revision: Date.now(), content: worktreePreparationNote(report) };
684
- throwIfAborted(signal);
685
- }
686
-
687
- /** Subscribe to worktree setup progress; returns the unsubscribe. */
688
- function onWorktreeSetup(listener) {
689
- if (typeof listener !== 'function') return () => {};
690
- setupListeners.add(listener);
691
- return () => setupListeners.delete(listener);
692
- }
693
-
694
- async function getBranchMenu() {
695
- const session = getSession();
696
- const workspace = session?.id
697
- ? currentWorkspace(session)
698
- : normalizeSessionWorkspace({ projectPath: projectPathOf(null), cwd: getCurrentCwd(), revision: 0 });
699
- const menu = await (await backend()).listBranchOccupancy({
700
- projectPath: workspace.projectPath,
701
- sessionId: session?.id || null,
702
- });
703
- const selectedEntry = workspace.worktreeId ? await (await backend()).findWorktreeContaining(workspace.cwd) : null;
704
- // A session outside a managed worktree (the project folder — never moved,
705
- // or back from a worktree — or a linked checkout of the selected branch)
706
- // is on whatever that folder has checked out now.
707
- // Its repository is the project's (a never-moved session has no stored
708
- // record), and a detached checkout shows branch null with the commit in
709
- // baseCommit — never mistaken for "not a repository".
710
- let view = workspace;
711
- if (!isWorktreeBound(workspace)) {
712
- const project = await inspectProject(workspace.cwd);
713
- const checkedOut = project.currentBranch ?? null;
714
- view = {
715
- ...workspace,
716
- repoCommonDir: project.repoCommonDir ?? workspace.repoCommonDir,
717
- branch: checkedOut,
718
- baseCommit: checkedOut ? workspace.baseCommit : (project.headCommit ?? null),
719
- };
720
- }
721
- return {
722
- workspace: view,
723
- pendingWorktree: pendingIntent ? { name: pendingIntent.name } : null,
724
- branches: branchMenuEntries(menu.branches, menu.occupancy),
725
- occupancy: menu.occupancy,
726
- retainedWorktree: selectedEntry ?? menu.retainedWorktree ?? null,
727
- // Creation notes of the retained worktree (submodules, include entries).
728
- warnings: Array.isArray(menu.retainedWorktree?.warnings) ? [...menu.retainedWorktree.warnings] : [],
729
- };
730
- }
731
-
732
- /** The maintenance scope here: every live session of this process counts as a live owner. */
733
- async function maintenanceScope() {
734
- const liveSessionIds = [
735
- ...new Set([getSession()?.id, ...listLiveSessions().map((candidate) => candidate?.id)].filter(Boolean)),
736
- ];
737
- return { liveSessionIds, listSessionIds: listStoredSessionIds, manager: await backend() };
738
- }
739
-
740
- /**
741
- * Every managed worktree with who retains it (the manual list). Read-only
742
- * snapshots (no branch) only with `includeSnapshots` (the git tool); the
743
- * desktop list (DesktopManagedWorktreeRow, a branch per row) never gets them.
744
- */
745
- async function listWorktrees({ includeSnapshots = false } = {}) {
746
- const rows = await maintenance.listWorktreesWithRetention(await maintenanceScope());
747
- return includeSnapshots ? rows : rows.filter((row) => row.kind !== WORKTREE_SNAPSHOT_KIND);
748
- }
749
-
750
- /** The manual prune: clean, merged worktrees no stored or live session retains. */
751
- async function pruneWorktrees({ repoCommonDir = null } = {}) {
752
- return maintenance.pruneWorktrees({ ...(await maintenanceScope()), repoCommonDir });
753
- }
754
-
755
- /**
756
- * Remove managed worktree `entryId` whose owner is gone (listed `ownerLive:
757
- * false, removable: true`), on an explicit request: no owner lane exists to
758
- * do it. No stored or live session may still use the folder; the manager
759
- * reads the retention again under the repository lock (an owner that came
760
- * back refuses) and keeps its guards: dirty or unmerged work needs `force`,
761
- * other sessions' references and retained recovery data refuse, and only
762
- * the managed checkout is deleted.
763
- */
764
- async function removeOrphanWorktree({ entryId, force = false } = {}) {
765
- const row = (await listWorktrees()).find((item) => item.id === entryId);
766
- if (!row) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
767
- const removed = await withManagementFolders([row.path], true, async () => {
768
- const { liveSessionIds, listSessionIds, manager: backendApi } = await maintenanceScope();
769
- return backendApi.removeOrphanedWorktree({
770
- entryId,
771
- force: force === true,
772
- liveSessionIds,
773
- retainedSessionIds: await listSessionIds(),
774
- });
775
- });
776
- return { removed, entryId, path: row.path };
777
- }
778
-
779
- /** After `sessionId` was deleted: autoCleanup removes its clean, merged worktree. */
780
- async function cleanupDeletedSession(sessionId) {
781
- // Its snapshots go with it below; no turn cleanup keeps retrying them.
782
- cancelSnapshotCleanups(sessionId);
783
- return maintenance.cleanupDeletedSessionWorktree(sessionId, {
784
- manager: await backend(),
785
- autoCleanup: worktreeSettings().autoCleanup,
786
- });
787
- }
788
-
789
- /**
790
- * Turn admission (see admission-gate admitTurn); resolves the release. The
791
- * release also starts the removal of the read-only snapshots this turn
792
- * made — only those (removeTurnSnapshots).
793
- */
794
- async function admitTurnWorkspace({ signal } = {}) {
795
- const release = await admitTurn({ sessionId: getSession()?.id || '', cwd: () => getCurrentCwd() || '', signal });
796
- const made = new Set();
797
- turnSnapshots = made;
798
- return () => {
799
- release();
800
- if (turnSnapshots === made) turnSnapshots = null;
801
- const sessionId = getSession()?.id;
802
- if (made.size && sessionId) void removeTurnSnapshots(sessionId, [...made]);
803
- };
804
- }
805
-
806
- /**
807
- * Remove a settled turn's snapshots (by creation `txIds`) once nothing runs
808
- * in them: a worker turn or background shell holding a workspace lease
809
- * inside one keeps it (manager removeSessionSnapshots reports it `in-use`).
810
- * Those are retried with a doubling delay (`snapshotRetryMs`, at most
811
- * 2^SNAPSHOT_BACKOFF_STEPS times it) for at most `snapshotRetryLimit`
812
- * attempts; a snapshot removal that fails otherwise is not retried. What is
813
- * left then — or when the runtime is disposed, the session closes, is
814
- * deleted or is no longer the one hosted here — is the prune's and the
815
- * startup reconcile's. Admission is not held meanwhile: the removal only
816
- * ever names this turn's snapshots.
817
- */
818
- async function removeTurnSnapshots(sessionId, txIds) {
819
- if (disposed) return;
820
- const controller = new AbortController();
821
- snapshotCleanups.set(controller, sessionId);
822
- try {
823
- let pending = txIds;
824
- for (let attempt = 0; pending.length && attempt < snapshotRetryLimit; attempt++) {
825
- if (attempt > 0) {
826
- const wait = snapshotRetryMs * 2 ** Math.min(attempt - 1, SNAPSHOT_BACKOFF_STEPS);
827
- await delay(wait, undefined, { ref: false, signal: controller.signal });
828
- }
829
- const session = getSession();
830
- if (session?.id !== sessionId || session.closed === true) return;
831
- const report = await (await backend()).removeSessionSnapshots(sessionId, { txIds: pending });
832
- pending = report.kept.filter((row) => row.reason === 'in-use').map((row) => row.txId);
833
- }
834
- } catch {
835
- // Cancelled, or the backend failed: the rest is the maintenance's.
836
- } finally {
837
- snapshotCleanups.delete(controller);
838
- }
839
- }
840
-
841
- /** Stop the snapshot cleanups of `sessionId` (every session's when null). */
842
- function cancelSnapshotCleanups(sessionId = null) {
843
- for (const [controller, owner] of snapshotCleanups) {
844
- if (sessionId === null || owner === sessionId) controller.abort();
845
- }
846
- }
847
-
848
- /** Runtime teardown: no snapshot cleanup runs on (prune / startup reconcile finish them). */
849
- function dispose() {
850
- disposed = true;
851
- cancelSnapshotCleanups();
852
- }
853
-
854
- /**
855
- * Run in `branch`'s checkout (see enterProjectBranch). `ownTurn`
856
- * (internal: the Lead's own tool call) tolerates the calling turn.
857
- */
858
- async function selectBranch(
859
- { branch, create = false, expectedRevision, confirmLarge = false, txId = '' } = {},
860
- { ownTurn = false } = {}
861
- ) {
862
- const session = await ensureSession();
863
- const current = currentWorkspace(session);
864
- if (current.branch === branch && current.worktreeId) {
865
- assertRevision(current.revision, validRevision(expectedRevision));
866
- pendingIntent = null;
867
- return { revision: current.revision, workspace: current };
868
- }
869
- const target = { kind: 'branch', branch, create: create === true };
870
- const result = await runTransition(
871
- { sessionId: session.id },
872
- target,
873
- { expectedRevision, confirmLarge },
874
- { ownTurn, txId: String(txId || '').trim() }
875
- );
876
- pendingIntent = null;
877
- return result;
878
- }
879
-
880
- /** WORKTREE_PROJECT_MISMATCH unless `repoCommonDir` (when given) is the session Project's repository. */
881
- async function sessionProject(session, repoCommonDir) {
882
- const workspace = currentWorkspace(session);
883
- const project = await inspectProject(workspace.projectPath);
884
- if (repoCommonDir && !samePath(repoCommonDir, project.repoCommonDir)) {
885
- throw worktreeError(
886
- 'WORKTREE_PROJECT_MISMATCH',
887
- "Managed worktrees belong to this session's Project repository; run the command there.",
888
- { projectPath: workspace.projectPath, repoCommonDir }
889
- );
890
- }
891
- return { workspace, project };
892
- }
893
-
894
- /**
895
- * A managed worktree for `branch` without moving the session (`git
896
- * worktree add`): retained by this session like a branch it selected, so
897
- * entering it later (selectBranch) reuses it. A branch already checked out
898
- * elsewhere is WORKTREE_BRANCH_OCCUPIED, except in this session's own
899
- * managed worktree, which is returned as it is. `startPoint` (with
900
- * `create`): the commit the new branch starts at.
901
- */
902
- async function addBranchWorktree({
903
- branch,
904
- create = false,
905
- startPoint = null,
906
- confirmLarge = false,
907
- txId = '',
908
- repoCommonDir = null,
909
- } = {}) {
910
- const session = hostedSession(null);
911
- const { workspace } = await sessionProject(session, repoCommonDir);
912
- const backendApi = await backend();
913
- const { occupancy } = await backendApi.listBranchOccupancy({
914
- projectPath: workspace.projectPath,
915
- sessionId: session.id,
916
- });
917
- const held = occupancy.find((row) => row.branch === branch && row.kind !== 'free');
918
- if (held && !(held.kind === 'session' && held.sessionId === session.id)) {
919
- throw worktreeError('WORKTREE_BRANCH_OCCUPIED', `Branch ${branch} is checked out in ${held.path}.`, {
920
- branch,
921
- path: held.path,
922
- kind: held.kind,
923
- ...(held.sessionId ? { sessionId: held.sessionId } : {}),
924
- });
925
- }
926
- const resolved = await backendApi.resolveBranchWorkspace({
927
- projectPath: workspace.projectPath,
928
- branch,
929
- create: create === true,
930
- startPoint,
931
- sessionId: session.id,
932
- runtimeToken,
933
- txId: String(txId || '').trim() || newTxId(),
934
- confirmLarge: confirmLarge === true,
935
- });
936
- return {
937
- path: resolved.path,
938
- branch,
939
- worktreeId: resolved.entry?.id ?? null,
940
- created: resolved.created === true,
941
- baseCommit: resolved.entry?.baseCommit ?? null,
942
- mergeTarget: resolved.entry?.mergeTarget ?? null,
943
- };
944
- }
945
-
946
- /**
947
- * A read-only snapshot of `commit` for this session's turn (`git worktree
948
- * add --detach`): a detached managed checkout no session works in,
949
- * removed when the turn settles (and nothing runs in it any more). The
950
- * session does not move. Only inside an admitted turn: a snapshot always
951
- * belongs to the turn that made it.
952
- */
953
- async function createSnapshot({ commit = 'HEAD', confirmLarge = false, txId = '', repoCommonDir = null } = {}) {
954
- const session = hostedSession(null);
955
- const made = turnSnapshots;
956
- if (!made) throw new Error('A read-only snapshot belongs to a turn; none is running in this session.');
957
- const { workspace } = await sessionProject(session, repoCommonDir);
958
- const tx = String(txId || '').trim() || newTxId();
959
- const entry = await (await backend()).createSnapshotWorktree({
960
- projectPath: workspace.projectPath,
961
- sessionId: session.id,
962
- runtimeToken,
963
- commit,
964
- txId: tx,
965
- confirmLarge: confirmLarge === true,
966
- });
967
- made.add(tx);
968
- return entry;
969
- }
970
-
971
- /**
972
- * Remove this session's snapshot at `path` now; a dirty one only with
973
- * `force`, and none while a worker or background shell runs in it.
974
- */
975
- async function removeSnapshot({ path, force = false } = {}) {
976
- const session = hostedSession(null);
977
- const backendApi = await backend();
978
- const entry = await backendApi.getWorktreeByPath(path);
979
- if (entry?.kind !== WORKTREE_SNAPSHOT_KIND) throw worktreeError('WORKTREE_MISSING', undefined, { path });
980
- if (entry.holderSessionId !== session.id) {
981
- throw worktreeError(
982
- 'WORKTREE_BUSY_JOBS',
983
- "This snapshot belongs to another session's turn; it is removed when that turn ends, or by a prune.",
984
- { reason: 'snapshot-held', sessionId: entry.holderSessionId, entryId: entry.id }
985
- );
986
- }
987
- const removed = await backendApi.removeSnapshotWorktree({ entryId: entry.id, force: force === true });
988
- return { ...removed, entryId: entry.id, path: entry.path };
989
- }
990
-
991
- /**
992
- * "워크트리": re-enter the retained worktree now, or arm the lazy creation
993
- * for the next message (confirmLarge acknowledges WORKTREE_LARGE_REPO_CONFIRM).
994
- *
995
- * `provision` (a New Task's first send, before its prompt is submitted)
996
- * creates and enters the worktree right away instead: `prompt` names it
997
- * like the lazy path would and `txId` (the submission id) makes a resend
998
- * reuse it. Typed failures (large repository, disk, busy) therefore reach
999
- * the sender while its draft is still unsent, and the turn that follows
1000
- * finds the session already bound.
1001
- *
1002
- * A provisioning that creates a new worktree decides the project folder's
1003
- * uncommitted changes (tracked and untracked): none → it proceeds; without
1004
- * `carryChanges` it refuses with WORKTREE_DIRTY_CHOICE (`details.count`);
1005
- * `leave` starts the worktree at HEAD and leaves them; `carry` moves them
1006
- * into it (the result's `changes` reports which). Re-entering a retained
1007
- * worktree never touches them.
1008
- *
1009
- * `ownTurn` (internal: the Lead's own tool call) tolerates the calling turn.
1010
- */
1011
- async function requestWorktree(
1012
- {
1013
- name = null,
1014
- confirmLarge = false,
1015
- provision = false,
1016
- carryChanges,
1017
- prompt = '',
1018
- txId = '',
1019
- expectedRevision,
1020
- } = {},
1021
- { ownTurn = false } = {}
1022
- ) {
1023
- const nameHint = validName(name);
1024
- const expected = validRevision(expectedRevision);
1025
- if (carryChanges !== undefined && carryChanges !== null && !WORKTREE_CARRY_CHOICES.includes(carryChanges)) {
1026
- throw new TypeError(`carryChanges must be one of ${WORKTREE_CARRY_CHOICES.join(', ')}.`);
1027
- }
1028
- const session = provision === true ? await ensureSession() : getSession();
1029
- // No session yet: the menu it was read from had revision 0.
1030
- const current = session?.id ? currentWorkspace(session) : null;
1031
- assertRevision(current?.revision ?? 0, expected);
1032
- if (current) {
1033
- if (isWorktreeBound(current)) return { revision: current.revision, workspace: current, pending: false };
1034
- if (await (await backend()).getSessionWorktree(session.id)) {
1035
- const entered = await runTransition(
1036
- { sessionId: session.id },
1037
- { kind: 'worktree' },
1038
- { expectedRevision: expected },
1039
- { ownTurn }
1040
- );
1041
- return { ...entered, pending: false };
1042
- }
1043
- }
1044
- const priorIntent = pendingIntent;
1045
- pendingIntent = {
1046
- name: nameHint ?? pendingIntent?.name ?? null,
1047
- confirmLarge: confirmLarge === true || pendingIntent?.confirmLarge === true,
1048
- explicit: true,
1049
- sessionId: session?.id || getReservedSessionId?.() || null,
1050
- txId: pendingIntent?.txId ?? null,
1051
- };
1052
- if (provision === true) {
1053
- const intent = pendingIntent;
1054
- try {
1055
- return {
1056
- ...(await provisionIntent(session, intent, txId, prompt, {
1057
- expectedRevision: expected,
1058
- changes: carryChanges ?? 'ask',
1059
- ownTurn: ownTurn === true,
1060
- })),
1061
- pending: false,
1062
- };
1063
- } catch (error) {
1064
- // A refused move (uncommitted changes to decide, busy, …) is never
1065
- // re-armed for the next turn, where nobody could answer it.
1066
- if (pendingIntent === intent) pendingIntent = priorIntent;
1067
- throw error;
1068
- }
1069
- }
1070
- const workspace = session?.id ? currentWorkspace(session) : null;
1071
- return { revision: workspace?.revision ?? 0, workspace, pending: true };
1072
- }
1073
-
1074
- /**
1075
- * Merge the retained worktree into the project folder's checked-out branch.
1076
- * `ownTurn` (internal: the Lead's own tool call) tolerates the calling turn.
1077
- */
1078
- async function mergeWorkspace(
1079
- { confirmed = false, target = null, cleanup = false, expectedRevision } = {},
1080
- { ownTurn = false } = {}
1081
- ) {
1082
- if (confirmed !== true) {
1083
- throw worktreeError('WORKTREE_CONFIRM_REQUIRED', 'mergeWorkspace requires confirmed: true.');
1084
- }
1085
- const expected = validRevision(expectedRevision);
1086
- const session = hostedSession(null);
1087
- const merged = await mergeRetainedWorktree(session, { target, ownTurn, expectedRevision: expected });
1088
- if (cleanup !== true || merged.merged !== true) return merged;
1089
- // "Merge and clean up": with autoCleanup on, the merged worktree goes now
1090
- // (a dirty one stays, reported); off, nothing is removed automatically.
1091
- // The merge is committed: a failed or refused cleanup (dirty, retained
1092
- // file recovery data, ...) is a cleanup notice, never a merge failure.
1093
- if (worktreeSettings().autoCleanup !== true) {
1094
- return {
1095
- ...merged,
1096
- cleanup: { removed: false, reason: 'auto-cleanup-off', message: 'Auto cleanup is off; the worktree is kept.' },
1097
- };
1098
- }
1099
- // The cleanup never moves a session: while this session still works in the
1100
- // merged folder (or any other session uses it) the removal is refused.
1101
- try {
1102
- if (isWorktreeBound(currentWorkspace(session))) {
1103
- throw worktreeRefusal('folder-in-use', { sessions: [session.id] });
1104
- }
1105
- const entry = await (await backend()).getSessionWorktree(session.id);
1106
- const removed = await withManagementFolders(
1107
- entry ? [entry.path] : [],
1108
- true,
1109
- () => removeWorktree({ force: false }, { ownTurn }),
1110
- { actor: session.id }
1111
- );
1112
- return { ...merged, cleanup: removed.removed, revision: removed.revision, workspace: removed.workspace };
1113
- } catch (error) {
1114
- // The merge stands: a refused or incomplete cleanup (in use, dirty,
1115
- // unmerged, retained file recovery data, partly removed, …) is
1116
- // reported, never thrown, with the workspace as it is now.
1117
- const workspace = currentWorkspace(session);
1118
- const code = typeof error?.code === 'string' ? error.code : '';
1119
- return {
1120
- ...merged,
1121
- cleanup: {
1122
- removed: false,
1123
- reason: code || 'WORKTREE_CLEANUP_FAILED',
1124
- ...(code ? { code } : {}),
1125
- message: String(error?.message || error),
1126
- },
1127
- revision: workspace.revision,
1128
- workspace,
1129
- };
1130
- }
1131
- }
1132
-
1133
- /**
1134
- * The merge itself. The retained worktree must belong to this session's
1135
- * Project (same repository and Project folder — a Project switch may have
1136
- * left it behind); its branch, or the branch of the linked checkout the
1137
- * session works in, is merged into the project folder through the shared
1138
- * guarded path (mergeConfirmedTarget): both folders validated and held.
1139
- */
1140
- async function mergeRetainedWorktree(session, { target, ownTurn, expectedRevision }) {
1141
- const workspace = currentWorkspace(session);
1142
- assertRevision(workspace.revision, expectedRevision);
1143
- const leave = beginWorkspaceTransition(session.id, `merge:${newTxId()}`);
1144
- try {
1145
- await assertLeadIdle(session, { ownTurn: ownTurn === true });
1146
- const project = await inspectProject(workspace.projectPath);
1147
- let branch;
1148
- if (!workspace.worktreeId && !samePath(workspace.cwd, workspace.projectPath)) {
1149
- const source = await inspectProject(workspace.cwd);
1150
- if (!samePath(source.repoCommonDir, project.repoCommonDir)) throw worktreeError('WORKTREE_PROJECT_MISMATCH');
1151
- branch = source.currentBranch;
1152
- } else {
1153
- const backendApi = await backend();
1154
- const entry = workspace.worktreeId
1155
- ? await backendApi.findWorktreeContaining(workspace.cwd)
1156
- : await backendApi.getSessionWorktree(session.id);
1157
- if (!entry) throw worktreeError('WORKTREE_MISSING', 'This session has no worktree to merge.');
1158
- assertRetainedFits(entry, project, workspace.projectPath);
1159
- if (!samePath(entry.projectPath, workspace.projectPath)) {
1160
- throw worktreeError('WORKTREE_PROJECT_MISMATCH', undefined, {
1161
- worktreeId: entry.id,
1162
- worktreeProject: entry.projectPath,
1163
- projectPath: workspace.projectPath,
1164
- });
1165
- }
1166
- branch = entry.branch;
1167
- }
1168
- return await mergeConfirmedTarget(
1169
- { projectPath: workspace.projectPath, branch, target },
1170
- (folders, removing, run) => withManagementFolders(folders, removing, run, { actor: session.id })
1171
- );
1172
- } finally {
1173
- leave();
1174
- }
1175
- }
1176
-
1177
- /**
1178
- * Move the hosted Lead to another Project folder (session set-project, the
1179
- * desktop/remote setCwd): the one authority for a Project switch, admitted
1180
- * like a workspace transition. Refused while a turn runs (`ownTurn`: the
1181
- * Lead's own tool call tolerates its calling turn), while workers run, and
1182
- * while the session works in a managed worktree. Serialized with turns and
1183
- * other workspace changes; the prompt refresh of the Lead and its idle
1184
- * workers completes (or rolls the switch back) before it resolves. Without
1185
- * a session (a draft) only the runtime cwd and the selection move.
1186
- * `classifyByMembership` (the desktop/remote move): a folder outside every
1187
- * registered Project is classified as a task (cwd-apply).
1188
- * @returns {Promise<string>} the runtime cwd after the switch
1189
- */
1190
- async function switchProject(cwd, { ownTurn = false, classifyByMembership = false } = {}) {
1191
- const selection = { classifyByMembership: classifyByMembership === true };
1192
- const session = getSession();
1193
- if (!session?.id) {
1194
- applyResolvedCwd(cwd, { persistProjectSelection: true, ...selection });
1195
- return getCurrentCwd();
1196
- }
1197
- const leave = beginWorkspaceTransition(session.id, `project:${newTxId()}`);
1198
- const journal = [];
1199
- try {
1200
- await assertLeadIdle(session, { ownTurn: ownTurn === true, shellJobs: false });
1201
- if (isWorktreeBound(readSessionWorkspace(session))) throw new Error(WORKTREE_BOUND_SWITCH);
1202
- await steps.switchProject(session, cwd, journal, selection);
1203
- return getCurrentCwd();
1204
- } catch (error) {
1205
- await undoAll(journal, error);
1206
- throw error;
1207
- } finally {
1208
- leave();
1209
- }
1210
- }
1211
-
1212
- async function removeWorktree({ force = false, expectedRevision } = {}, { ownTurn = false } = {}) {
1213
- const expected = validRevision(expectedRevision);
1214
- const session = hostedSession(null);
1215
- return transition(
1216
- session,
1217
- { kind: 'remove-worktree', force: force === true },
1218
- { ownTurn: ownTurn === true, expectedRevision: expected }
1219
- );
261
+ return runner.runTransition(sessionRef, target, options);
1220
262
  }
1221
263
 
1222
264
  return {
1223
265
  applyWorkspaceTransition,
1224
- prepareTurnWorkspace,
1225
- admitTurnWorkspace,
266
+ prepareTurnWorkspace: intents.prepareTurnWorkspace,
267
+ admitTurnWorkspace: checkouts.admitTurnWorkspace,
1226
268
  /** The durable workspace record of the hosted session (null: project folder, never moved). */
1227
269
  getWorkspace: () => getSession()?.workspace ?? null,
1228
270
  /** What the session snapshot publishes (see getLiveWorkspace). */
1229
- getLiveWorkspace,
1230
- getBranchMenu,
271
+ getLiveWorkspace: view.getLiveWorkspace,
272
+ getBranchMenu: view.getBranchMenu,
1231
273
  getProjectBranchMenu: projectBranches.getProjectBranchMenu,
1232
- selectBranch,
274
+ selectBranch: intents.selectBranch,
1233
275
  selectProjectBranch: projectBranches.selectProjectBranch,
1234
276
  mergeProjectBranch: projectBranches.mergeProjectBranch,
1235
277
  inspectProjectBranch: projectBranches.inspectProjectBranch,
1236
278
  manageProjectBranch: projectBranches.manageProjectBranch,
1237
- requestWorktree,
1238
- mergeWorkspace,
1239
- removeWorktree,
1240
- addBranchWorktree,
1241
- createSnapshot,
1242
- removeSnapshot,
1243
- switchProject,
1244
- listWorktrees,
1245
- pruneWorktrees,
1246
- removeOrphanWorktree,
1247
- cleanupDeletedSession,
1248
- onWorktreeSetup,
1249
- dispose,
279
+ requestWorktree: intents.requestWorktree,
280
+ mergeWorkspace: merge.mergeWorkspace,
281
+ removeWorktree: runner.removeWorktree,
282
+ addBranchWorktree: checkouts.addBranchWorktree,
283
+ createSnapshot: checkouts.createSnapshot,
284
+ removeSnapshot: checkouts.removeSnapshot,
285
+ switchProject: runner.switchProject,
286
+ listWorktrees: worktrees.listWorktrees,
287
+ pruneWorktrees: worktrees.pruneWorktrees,
288
+ removeOrphanWorktree: worktrees.removeOrphanWorktree,
289
+ cleanupDeletedSession: worktrees.cleanupDeletedSession,
290
+ onWorktreeSetup: setup.onWorktreeSetup,
291
+ dispose: checkouts.dispose,
1250
292
  };
1251
293
  }