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
@@ -40,6 +40,7 @@ import {
40
40
  } from './file-recovery.mjs';
41
41
  import { lstat, mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises';
42
42
  import { dirname, join, relative, resolve, sep } from 'node:path';
43
+ import { isPidAlive } from '../pid-liveness.mjs';
43
44
  import {
44
45
  managedWorktreePath,
45
46
  managedWorktreeRoot,
@@ -86,7 +87,7 @@ import {
86
87
  } from './include.mjs';
87
88
  import { linkWorktreeDependencies } from './dependencies.mjs';
88
89
  import { withRepoLock as withRepoMutationLock } from './lock.mjs';
89
- import { holdWorkspaceActivity } from './activity-gate.mjs';
90
+ import { holdWorkspaceActivityAsync } from './activity-gate.mjs';
90
91
  import { withAdvisoryLocks } from '../../agent/orchestrator/tools/builtin/advisory-lock.mjs';
91
92
  import { mergeBranchIntoCheckout, operationInProgress } from './merge.mjs';
92
93
  import { readOwnerLease, removeOwnerLease, sameOwner, writeOwnerLease } from './owner.mjs';
@@ -124,17 +125,7 @@ function sessionRootOf(record) {
124
125
  // ---------------------------------------------------------------------------
125
126
  // Host configuration (settings source, free-space probe); tests override it.
126
127
 
127
- /** pid probe (signal 0); EPERM means the process exists. */
128
- function processAlive(pid) {
129
- try {
130
- process.kill(pid, 0);
131
- return true;
132
- } catch (error) {
133
- return error?.code === 'EPERM';
134
- }
135
- }
136
-
137
- const DEFAULT_CONFIG = Object.freeze({ settings: null, freeBytes: freeBytesAt, processAlive });
128
+ const DEFAULT_CONFIG = Object.freeze({ settings: null, freeBytes: freeBytesAt, processAlive: isPidAlive });
138
129
  let config = { ...DEFAULT_CONFIG };
139
130
 
140
131
  /**
@@ -979,6 +970,174 @@ async function snapshotHolderLive(record, live) {
979
970
  return !!lease && lease.pid !== process.pid && leaseProcessAlive(lease);
980
971
  }
981
972
 
973
+ /** The project folder's uncommitted changes under the requested `carryChanges` mode; `ask` refuses while there are any. */
974
+ async function judgePendingChanges(repo, projectSubdir, carryChanges) {
975
+ // A carry is judged by the same status read that counts the changes.
976
+ let pendingChanges = [];
977
+ if (carryChanges === 'carry') pendingChanges = await assertCarryable(repo.topLevel, projectSubdir);
978
+ else if (carryChanges) pendingChanges = await uncommittedChanges(repo.topLevel, projectSubdir);
979
+ if (pendingChanges.length && carryChanges === 'ask') {
980
+ throw worktreeError('WORKTREE_DIRTY_CHOICE', undefined, {
981
+ count: pendingChanges.length,
982
+ paths: pendingChanges.slice(0, DIRTY_CHOICE_PREVIEW),
983
+ });
984
+ }
985
+ return pendingChanges;
986
+ }
987
+
988
+ /** The commit a new worktree starts from and the branch a merge targets by default. */
989
+ async function resolveStartPoint(repo, { baseBranch, startCommit, signal }) {
990
+ let baseCommit = repo.headCommit;
991
+ let mergeTarget = repo.currentBranch;
992
+ if (baseBranch) {
993
+ baseCommit = await branchCommit(repo.projectPath, baseBranch, { signal });
994
+ if (!baseCommit) {
995
+ throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Base branch ${baseBranch} does not exist.`, { baseBranch });
996
+ }
997
+ mergeTarget = baseBranch;
998
+ }
999
+ // A new branch at an explicit start point (`git worktree add -b <b> <path> <commit-ish>`).
1000
+ if (startCommit) baseCommit = await resolveCommit(repo.projectPath, startCommit, signal);
1001
+ return { baseCommit, mergeTarget };
1002
+ }
1003
+
1004
+ /** The record a creation reserves before git is asked to add the checkout. */
1005
+ function newCreationRecord({
1006
+ slug,
1007
+ branch,
1008
+ repo,
1009
+ owner,
1010
+ txId,
1011
+ baseCommit,
1012
+ mergeTarget,
1013
+ existingBranch,
1014
+ exactBranch,
1015
+ tracked,
1016
+ includePlan,
1017
+ }) {
1018
+ return {
1019
+ id: slug,
1020
+ txId,
1021
+ repoCommonDir: repo.repoCommonDir,
1022
+ projectPath: repo.projectPath,
1023
+ projectSubdir: projectSubdirOf(repo.topLevel, repo.projectPath),
1024
+ path: managedWorktreePath(repo.repoCommonDir, slug),
1025
+ branch,
1026
+ baseCommit,
1027
+ // An existing branch has no base it branched from: it merges where a
1028
+ // merge first takes it (recorded then), and until that its own work is
1029
+ // what it gained here since baseCommit.
1030
+ mergeTarget: existingBranch ? null : mergeTarget,
1031
+ createdAt: Date.now(),
1032
+ ownerSessionId: owner.sessionId,
1033
+ existingBranch,
1034
+ exactBranch,
1035
+ state: 'creating',
1036
+ disposable: false,
1037
+ warnings: [...(tracked.submodules ? ['submodules'] : []), ...includePlan.warnings],
1038
+ resources: { worktree: null, lock: null, owner: null, includes: [] },
1039
+ deleting: null,
1040
+ recovery: null,
1041
+ };
1042
+ }
1043
+
1044
+ /**
1045
+ * Locks the fresh checkout, binds its owner lease, moves or leaves the
1046
+ * project folder's changes, applies the includes and links dependencies.
1047
+ * `carry.moved` is set as soon as changes were carried, so a later failure
1048
+ * can put them back.
1049
+ */
1050
+ async function populateCheckout({
1051
+ record,
1052
+ owner,
1053
+ repo,
1054
+ projectSubdir,
1055
+ signal,
1056
+ pendingChanges,
1057
+ carryChanges,
1058
+ baseCommit,
1059
+ includePlan,
1060
+ carry,
1061
+ }) {
1062
+ await setResource(record, 'worktree', 'created');
1063
+ throwIfAborted(signal);
1064
+ await setResource(record, 'lock', 'pending');
1065
+ await runGit(repo.projectPath, ['worktree', 'lock', '--reason', `mixdog session=${owner.sessionId}`, record.path]);
1066
+ await setResource(record, 'lock', 'created');
1067
+ await setResource(record, 'owner', 'pending');
1068
+ await writeOwnerLease(record, owner);
1069
+ await setResource(record, 'owner', 'created');
1070
+ // Carried before the include entries are copied, so an include never
1071
+ // stands in the way of a carried file; the status is read again here,
1072
+ // right before acting.
1073
+ let changes = pendingChanges.length ? { mode: 'left', count: pendingChanges.length } : null;
1074
+ if (carryChanges === 'carry') {
1075
+ carry.moved = await carryUncommittedChanges({
1076
+ topLevel: repo.topLevel,
1077
+ projectSubdir,
1078
+ worktreePath: record.path,
1079
+ baseCommit,
1080
+ label: record.id,
1081
+ });
1082
+ // The recovery ref stays until the caller has committed the move
1083
+ // (dropCarryRecoveryRef) or put the changes back (restoreCarriedChanges).
1084
+ changes = carry.moved.count
1085
+ ? { mode: 'carried', count: carry.moved.count, recoveryRef: carry.moved.recoveryRef }
1086
+ : null;
1087
+ }
1088
+ const applied = await applyIncludes(includePlan, record.path, (ledger) => recordInclude(record, ledger));
1089
+ throwIfAborted(signal);
1090
+ // After the includes: one that already brought a node_modules wins.
1091
+ const linkedDependencies = await linkWorktreeDependencies({
1092
+ mainDir: repo.projectPath,
1093
+ worktreeDir: projectSubdir ? join(record.path, ...projectSubdir.split('/')) : record.path,
1094
+ worktreeRel: projectSubdir,
1095
+ signal,
1096
+ record: (ledger) => recordInclude(record, ledger),
1097
+ });
1098
+ throwIfAborted(signal);
1099
+ record.warnings = [...record.warnings, ...applied.warnings];
1100
+ const made = (record.resources.includes || []).filter((item) => item.status === 'created');
1101
+ const rels = (predicate) => made.filter(predicate).map((item) => item.rel);
1102
+ // What creation did, for the agent's note at the first turn there (transitions.mjs).
1103
+ const prepared = {
1104
+ root: repo.topLevel,
1105
+ copied: rels((item) => item.mode === 'copy'),
1106
+ linked: rels((item) => item.mode === 'link' && !item.dependency),
1107
+ dependencies: linkedDependencies,
1108
+ skipped: [...includePlan.warnings, ...applied.warnings],
1109
+ };
1110
+ // Whichever path made it (a worktree task, a branch selection, `git
1111
+ // worktree add`), the first turn a session runs here prepares its
1112
+ // dependencies (claimWorktreeSetup).
1113
+ record.setup = { pending: true, prepared };
1114
+ record.state = 'ready';
1115
+ await putRecord(record);
1116
+ return { ...entryView(record), prepared, ...(changes ? { changes } : {}) };
1117
+ }
1118
+
1119
+ /** The error a failed creation throws, after its carried changes and partial checkout are undone. */
1120
+ async function failCreation(error, { record, carried, repo, projectSubdir, needed, root }) {
1121
+ // Carried already: the changes go back to the project folder before the
1122
+ // worktree that holds them is rolled back (the recovery ref keeps them
1123
+ // either way).
1124
+ let carryRestore = null;
1125
+ if (carried?.recoveryRef) {
1126
+ carryRestore = await restoreFromRecoveryRef(repo.topLevel, projectSubdir, carried.recoveryRef)
1127
+ .then(() => ({ restored: true, recoveryRef: carried.recoveryRef }))
1128
+ .catch((restoreError) => ({
1129
+ restored: false,
1130
+ recoveryRef: carried.recoveryRef,
1131
+ error: String(restoreError?.message || restoreError),
1132
+ }));
1133
+ }
1134
+ const rollback = await rollbackCreation(record, error);
1135
+ const failure = isDiskFullError(error) ? await diskFullFailure(error, { needed, root }) : error;
1136
+ if (!rollback.complete) failure.rollback = rollback;
1137
+ if (carryRestore) failure.carryRestore = carryRestore;
1138
+ return failure;
1139
+ }
1140
+
982
1141
  /**
983
1142
  * `carryChanges` decides the project folder's uncommitted changes (tracked
984
1143
  * and untracked, ignored excluded), judged under the repository lock:
@@ -1034,29 +1193,10 @@ export async function createManagedWorktree({
1034
1193
  throwIfAborted(signal);
1035
1194
 
1036
1195
  const projectSubdir = projectSubdirOf(repo.topLevel, repo.projectPath);
1037
- // A carry is judged by the same status read that counts the changes.
1038
- let pendingChanges = [];
1039
- if (carryChanges === 'carry') pendingChanges = await assertCarryable(repo.topLevel, projectSubdir);
1040
- else if (carryChanges) pendingChanges = await uncommittedChanges(repo.topLevel, projectSubdir);
1041
- if (pendingChanges.length && carryChanges === 'ask') {
1042
- throw worktreeError('WORKTREE_DIRTY_CHOICE', undefined, {
1043
- count: pendingChanges.length,
1044
- paths: pendingChanges.slice(0, DIRTY_CHOICE_PREVIEW),
1045
- });
1046
- }
1196
+ const pendingChanges = await judgePendingChanges(repo, projectSubdir, carryChanges);
1047
1197
 
1048
1198
  const settings = await currentWorktreeSettings();
1049
- let baseCommit = repo.headCommit;
1050
- let mergeTarget = repo.currentBranch;
1051
- if (baseBranch) {
1052
- baseCommit = await branchCommit(repo.projectPath, baseBranch, { signal });
1053
- if (!baseCommit) {
1054
- throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Base branch ${baseBranch} does not exist.`, { baseBranch });
1055
- }
1056
- mergeTarget = baseBranch;
1057
- }
1058
- // A new branch at an explicit start point (`git worktree add -b <b> <path> <commit-ish>`).
1059
- if (startCommit) baseCommit = await resolveCommit(repo.projectPath, startCommit, signal);
1199
+ const { baseCommit, mergeTarget } = await resolveStartPoint(repo, { baseBranch, startCommit, signal });
1060
1200
 
1061
1201
  // The main checkout's local env files come along on their own;
1062
1202
  // `.worktreeinclude` applies on top.
@@ -1099,7 +1239,7 @@ export async function createManagedWorktree({
1099
1239
  }
1100
1240
  );
1101
1241
  let record = await reserve();
1102
- let carried = null;
1242
+ const carry = { moved: null };
1103
1243
 
1104
1244
  try {
1105
1245
  await mkdir(root, { recursive: true });
@@ -1120,84 +1260,20 @@ export async function createManagedWorktree({
1120
1260
  await setResource(record, 'worktree', 'pending');
1121
1261
  await addCheckout(record);
1122
1262
  }
1123
- await setResource(record, 'worktree', 'created');
1124
- throwIfAborted(signal);
1125
- await setResource(record, 'lock', 'pending');
1126
- await runGit(repo.projectPath, [
1127
- 'worktree',
1128
- 'lock',
1129
- '--reason',
1130
- `mixdog session=${owner.sessionId}`,
1131
- record.path,
1132
- ]);
1133
- await setResource(record, 'lock', 'created');
1134
- await setResource(record, 'owner', 'pending');
1135
- await writeOwnerLease(record, owner);
1136
- await setResource(record, 'owner', 'created');
1137
- // Carried before the include entries are copied, so an include never
1138
- // stands in the way of a carried file; the status is read again here,
1139
- // right before acting.
1140
- let changes = pendingChanges.length ? { mode: 'left', count: pendingChanges.length } : null;
1141
- if (carryChanges === 'carry') {
1142
- carried = await carryUncommittedChanges({
1143
- topLevel: repo.topLevel,
1144
- projectSubdir,
1145
- worktreePath: record.path,
1146
- baseCommit,
1147
- label: record.id,
1148
- });
1149
- // The recovery ref stays until the caller has committed the move
1150
- // (dropCarryRecoveryRef) or put the changes back (restoreCarriedChanges).
1151
- changes = carried.count ? { mode: 'carried', count: carried.count, recoveryRef: carried.recoveryRef } : null;
1152
- }
1153
- const applied = await applyIncludes(includePlan, record.path, (ledger) => recordInclude(record, ledger));
1154
- throwIfAborted(signal);
1155
- // After the includes: one that already brought a node_modules wins.
1156
- const linkedDependencies = await linkWorktreeDependencies({
1157
- mainDir: repo.projectPath,
1158
- worktreeDir: projectSubdir ? join(record.path, ...projectSubdir.split('/')) : record.path,
1159
- worktreeRel: projectSubdir,
1263
+ return await populateCheckout({
1264
+ record,
1265
+ owner,
1266
+ repo,
1267
+ projectSubdir,
1160
1268
  signal,
1161
- record: (ledger) => recordInclude(record, ledger),
1269
+ pendingChanges,
1270
+ carryChanges,
1271
+ baseCommit,
1272
+ includePlan,
1273
+ carry,
1162
1274
  });
1163
- throwIfAborted(signal);
1164
- record.warnings = [...record.warnings, ...applied.warnings];
1165
- const made = (record.resources.includes || []).filter((item) => item.status === 'created');
1166
- const rels = (predicate) => made.filter(predicate).map((item) => item.rel);
1167
- // What creation did, for the agent's note at the first turn there (transitions.mjs).
1168
- const prepared = {
1169
- root: repo.topLevel,
1170
- copied: rels((item) => item.mode === 'copy'),
1171
- linked: rels((item) => item.mode === 'link' && !item.dependency),
1172
- dependencies: linkedDependencies,
1173
- skipped: [...includePlan.warnings, ...applied.warnings],
1174
- };
1175
- // Whichever path made it (a worktree task, a branch selection, `git
1176
- // worktree add`), the first turn a session runs here prepares its
1177
- // dependencies (claimWorktreeSetup).
1178
- record.setup = { pending: true, prepared };
1179
- record.state = 'ready';
1180
- await putRecord(record);
1181
- return { ...entryView(record), prepared, ...(changes ? { changes } : {}) };
1182
1275
  } catch (error) {
1183
- // Carried already: the changes go back to the project folder before the
1184
- // worktree that holds them is rolled back (the recovery ref keeps them
1185
- // either way).
1186
- let carryRestore = null;
1187
- if (carried?.recoveryRef) {
1188
- carryRestore = await restoreFromRecoveryRef(repo.topLevel, projectSubdir, carried.recoveryRef)
1189
- .then(() => ({ restored: true, recoveryRef: carried.recoveryRef }))
1190
- .catch((restoreError) => ({
1191
- restored: false,
1192
- recoveryRef: carried.recoveryRef,
1193
- error: String(restoreError?.message || restoreError),
1194
- }));
1195
- }
1196
- const rollback = await rollbackCreation(record, error);
1197
- const failure = isDiskFullError(error) ? await diskFullFailure(error, { needed, root }) : error;
1198
- if (!rollback.complete) failure.rollback = rollback;
1199
- if (carryRestore) failure.carryRestore = carryRestore;
1200
- throw failure;
1276
+ throw await failCreation(error, { record, carried: carry.moved, repo, projectSubdir, needed, root });
1201
1277
  }
1202
1278
 
1203
1279
  async function reserveName() {
@@ -1213,30 +1289,19 @@ export async function createManagedWorktree({
1213
1289
  if (!(await validBranchName(repo.projectPath, branch))) {
1214
1290
  throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Branch name ${branch} is not valid.`, { branch });
1215
1291
  }
1216
- const candidate = {
1217
- id: slug,
1218
- txId,
1219
- repoCommonDir: repo.repoCommonDir,
1220
- projectPath: repo.projectPath,
1221
- projectSubdir: projectSubdirOf(repo.topLevel, repo.projectPath),
1222
- path: managedWorktreePath(repo.repoCommonDir, slug),
1292
+ const candidate = newCreationRecord({
1293
+ slug,
1223
1294
  branch,
1295
+ repo,
1296
+ owner,
1297
+ txId,
1224
1298
  baseCommit,
1225
- // An existing branch has no base it branched from: it merges where a
1226
- // merge first takes it (recorded then), and until that its own work is
1227
- // what it gained here since baseCommit.
1228
- mergeTarget: existingBranch ? null : mergeTarget,
1229
- createdAt: Date.now(),
1230
- ownerSessionId: owner.sessionId,
1299
+ mergeTarget,
1231
1300
  existingBranch,
1232
1301
  exactBranch: requestedBranch !== null,
1233
- state: 'creating',
1234
- disposable: false,
1235
- warnings: [...(tracked.submodules ? ['submodules'] : []), ...includePlan.warnings],
1236
- resources: { worktree: null, lock: null, owner: null, includes: [] },
1237
- deleting: null,
1238
- recovery: null,
1239
- };
1302
+ tracked,
1303
+ includePlan,
1304
+ });
1240
1305
  return (await insertRecord(candidate)) ? candidate : null;
1241
1306
  }
1242
1307
  });
@@ -1362,9 +1427,12 @@ export async function createSnapshotWorktree({
1362
1427
  * one is admitted there until the returned release. A folder no longer a
1363
1428
  * checkout admits no consumer (activity-gate tracks git checkouts only).
1364
1429
  */
1365
- function holdSnapshotFolder(record) {
1430
+ async function holdSnapshotFolder(record) {
1366
1431
  if (!existsSync(join(record.path, '.git'))) return () => {};
1367
- return holdWorkspaceActivity([record.path]);
1432
+ const release = await holdWorkspaceActivityAsync([record.path]);
1433
+ // Settles once the removal was attempted. It fails only when workspace
1434
+ // admission stopped (reported there), which keeps the folder held.
1435
+ return () => release().catch(() => {});
1368
1436
  }
1369
1437
 
1370
1438
  /** A refusal that only says "something still runs there" (retry once it ends). */
@@ -1871,11 +1939,11 @@ async function removeLocked(current, { force, sessionId, preserveBranch = false,
1871
1939
  }
1872
1940
  // Every deletion attempt of a snapshot, a resumed one included, waits
1873
1941
  // for (refuses on) its consumers.
1874
- const release = current.kind === WORKTREE_SNAPSHOT_KIND ? holdSnapshotFolder(current) : () => {};
1942
+ const release = current.kind === WORKTREE_SNAPSHOT_KIND ? await holdSnapshotFolder(current) : () => {};
1875
1943
  try {
1876
1944
  return await decideAndFinishDeletion(current, { force, preserveBranch, safeOnly });
1877
1945
  } finally {
1878
- release();
1946
+ await release();
1879
1947
  }
1880
1948
  }
1881
1949
 
@@ -2044,7 +2112,7 @@ export async function reconcileManagedWorktrees({
2044
2112
  // surviving folder since still keeps it.
2045
2113
  let release;
2046
2114
  try {
2047
- release = holdSnapshotFolder(record);
2115
+ release = await holdSnapshotFolder(record);
2048
2116
  } catch (error) {
2049
2117
  if (!snapshotInUse(error)) throw error;
2050
2118
  report.kept.push({ id: record.id, reason: 'snapshot-in-use' });
@@ -2053,7 +2121,7 @@ export async function reconcileManagedWorktrees({
2053
2121
  try {
2054
2122
  reportDeletion(record, await finishDeletion(record));
2055
2123
  } finally {
2056
- release();
2124
+ await release();
2057
2125
  }
2058
2126
  continue;
2059
2127
  }
@@ -2069,7 +2137,7 @@ export async function reconcileManagedWorktrees({
2069
2137
  let release = null;
2070
2138
  if (!(await snapshotHolderLive(record, live))) {
2071
2139
  try {
2072
- release = holdSnapshotFolder(record);
2140
+ release = await holdSnapshotFolder(record);
2073
2141
  } catch (error) {
2074
2142
  if (!snapshotInUse(error)) throw error;
2075
2143
  }
@@ -2085,7 +2153,7 @@ export async function reconcileManagedWorktrees({
2085
2153
  await putRecord(record);
2086
2154
  reportDeletion(record, await finishDeletion(record));
2087
2155
  } finally {
2088
- release();
2156
+ await release();
2089
2157
  }
2090
2158
  continue;
2091
2159
  }
@@ -4,12 +4,22 @@
4
4
  // Policy gating happens BEFORE any network call: 'never' refuses, 'ask' returns
5
5
  // needsApproval unless the call carried approveDownloads:true, 'auto' proceeds.
6
6
  // Toolchain engines (rustfmt, gofmt, zig fmt, ...) are never downloadable.
7
- import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs';
7
+ import {
8
+ chmodSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ readdirSync,
13
+ renameSync,
14
+ rmSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from 'node:fs';
8
18
  import { fileURLToPath } from 'node:url';
9
19
  import { join } from 'node:path';
10
20
  import { MAX_NATIVE_BINARY_DOWNLOAD_BYTES, streamResponseToFile } from '../shared/bounded-download.mjs';
11
- import { platformEntryKey, platformKey, sha256File } from '../shared/native-asset.mjs';
12
- import { fileHasData } from '../shared/file-has-data.mjs';
21
+ import { platformEntryKey, platformKey, sha256File, validSha256 } from '../shared/native-asset.mjs';
22
+ import { fileHasDataAsync } from '../shared/file-has-data.mjs';
13
23
  import { readJsonSafe } from '../shared/json-file.mjs';
14
24
  import { ENGINE_CATALOG } from './engines.mjs';
15
25
  import { extractArchive } from './extract.mjs';
@@ -45,14 +55,73 @@ export function manifestAsset(manifest, id, pkey = platformEntryKey(manifest?.en
45
55
  return { entry, asset, version: String(entry.version || '0'), pkey: entry?.assets?.[pkey] ? pkey : 'any' };
46
56
  }
47
57
 
48
- /** An already-installed managed binary for `id`, or null. Sync, no network. */
49
- export function managedEngineBinary({ id, manifest, pluginData }) {
58
+ // The installer records the extracted binary's sha256 beside it; a reuse
59
+ // re-hashes the binary against that record. Like the other managed binaries
60
+ // (mixdog-spawn / patch / graph resolve their path once per process), the hash
61
+ // runs once per process per binary, again only when its size or mtime changes.
62
+ // An install from before the record existed has no digest file: its first use
63
+ // records the digest (trust on first use). An existing record that is invalid
64
+ // or does not match is never overwritten; that binary is tampered or corrupt.
65
+ export const ENGINE_DIGEST_FILE = '.engine.sha256';
66
+ const verifiedEngineBins = new Map();
67
+
68
+ function recordEngineDigest(digestFile, digest) {
69
+ const tmp = `${digestFile}.tmp-${process.pid}-${Date.now()}`;
70
+ try {
71
+ writeFileSync(tmp, digest);
72
+ renameSync(tmp, digestFile);
73
+ } catch {
74
+ /* unrecorded: this install is trusted for this process only */
75
+ } finally {
76
+ try {
77
+ rmSync(tmp, { force: true });
78
+ } catch {
79
+ /* a leftover temp file never matches the digest path */
80
+ }
81
+ }
82
+ }
83
+
84
+ // The recorded digest decides whether a binary is intact. Only an install from
85
+ // before the record existed has none; there alone the content probe keeps a
86
+ // crash-torn (empty or NUL-filled) binary from becoming the digest it is
87
+ // trusted by. A binary that cannot be statted, read or hashed cannot be
88
+ // verified, so it is not reused.
89
+ async function engineBinaryIntact(dir, full) {
90
+ try {
91
+ const stamp = statSync(full);
92
+ const key = `${stamp.size}:${stamp.mtimeMs}`;
93
+ if (verifiedEngineBins.get(full) === key) return true;
94
+ const digestFile = join(dir, ENGINE_DIGEST_FILE);
95
+ let recorded = null;
96
+ try {
97
+ recorded = readFileSync(digestFile, 'utf8').trim().toLowerCase();
98
+ } catch (error) {
99
+ if (error?.code !== 'ENOENT') return false;
100
+ }
101
+ if (recorded === null && !(await fileHasDataAsync(full))) return false;
102
+ const actual = await sha256File(full);
103
+ if (recorded === null) {
104
+ recordEngineDigest(digestFile, actual);
105
+ recorded = actual;
106
+ }
107
+ if (!validSha256(recorded) || actual !== recorded) return false;
108
+ verifiedEngineBins.set(full, key);
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /** An already-installed managed binary for `id`, or null. No network; the
116
+ * binary probe is async so a slow (scanned) open never stalls the event loop. */
117
+ export async function managedEngineBinary({ id, manifest, pluginData }) {
50
118
  const found = manifestAsset(manifest, id);
51
119
  if (!found || !pluginData) return null;
52
120
  const binPath = String(found.asset.binPath || '');
53
121
  if (!binPath) return null;
54
- const full = join(managedEngineDir(pluginData, id, found.version), binPath);
55
- return fileHasData(full) ? { path: full, version: found.version } : null;
122
+ const dir = managedEngineDir(pluginData, id, found.version);
123
+ const full = join(dir, binPath);
124
+ return (await engineBinaryIntact(dir, full)) ? { path: full, version: found.version } : null;
56
125
  }
57
126
 
58
127
  /** Throws unless `filePath` hashes to `expected`. */
@@ -72,7 +141,13 @@ export async function verifyDownloadDigest(filePath, expected, label = LABEL) {
72
141
  * Decide what an install call may do, without touching the network.
73
142
  * Returns { targets, present, needsApproval, errors }.
74
143
  */
75
- export function planInstall({ ids = [], manifest, pluginData = '', policy = 'auto', approveDownloads = false } = {}) {
144
+ export async function planInstall({
145
+ ids = [],
146
+ manifest,
147
+ pluginData = '',
148
+ policy = 'auto',
149
+ approveDownloads = false,
150
+ } = {}) {
76
151
  const targets = [];
77
152
  const present = [];
78
153
  const errors = [];
@@ -101,7 +176,7 @@ export function planInstall({ ids = [], manifest, pluginData = '', policy = 'aut
101
176
  });
102
177
  continue;
103
178
  }
104
- const installed = managedEngineBinary({ id, manifest, pluginData });
179
+ const installed = await managedEngineBinary({ id, manifest, pluginData });
105
180
  if (installed) {
106
181
  present.push({ id, version: installed.version, path: installed.path });
107
182
  continue;
@@ -242,8 +317,9 @@ export async function installEngine(
242
317
  const engineRoot = join(managedToolsDir(pluginData), target.id);
243
318
  const finalDir = managedEngineDir(pluginData, target.id, target.version);
244
319
  const binFull = join(finalDir, target.binPath);
245
- // An engine binary a crash left NUL-filled is not present: it is installed again.
246
- if (fileHasData(binFull)) {
320
+ // An engine binary a crash left NUL-filled, or that no longer matches its
321
+ // recorded digest, is not present: it is installed again.
322
+ if (await engineBinaryIntact(finalDir, binFull)) {
247
323
  return { id: target.id, version: target.version, path: binFull, bytes: 0, status: 'present' };
248
324
  }
249
325
  mkdirSync(engineRoot, { recursive: true });
@@ -272,12 +348,16 @@ export async function installEngine(
272
348
  /* best-effort */
273
349
  }
274
350
  }
351
+ writeFileSync(join(stageDir, ENGINE_DIGEST_FILE), await sha256File(stagedBin));
275
352
  try {
276
- if (existsSync(finalDir) && !fileHasData(binFull)) rmSync(finalDir, { recursive: true, force: true });
353
+ if (existsSync(finalDir) && !(await engineBinaryIntact(finalDir, binFull))) {
354
+ verifiedEngineBins.delete(binFull);
355
+ rmSync(finalDir, { recursive: true, force: true });
356
+ }
277
357
  renameSync(stageDir, finalDir);
278
358
  } catch (error) {
279
- // A concurrent install won the race; its verified copy is equivalent.
280
- if (!existsSync(binFull)) throw error;
359
+ // A concurrent install won the race only if the surviving copy verifies.
360
+ if (!(await engineBinaryIntact(finalDir, binFull))) throw error;
281
361
  }
282
362
  return { id: target.id, version: target.version, path: binFull, bytes, status: 'installed' };
283
363
  } finally {
@@ -305,7 +385,7 @@ export async function installEngines({
305
385
  stallTimeoutMs = undefined,
306
386
  } = {}) {
307
387
  const loaded = manifest || readEnginesManifest();
308
- const plan = planInstall({ ids, manifest: loaded, pluginData, policy, approveDownloads });
388
+ const plan = await planInstall({ ids, manifest: loaded, pluginData, policy, approveDownloads });
309
389
  const installed = [...plan.present];
310
390
  const errors = [...plan.errors];
311
391
  if (!pluginData && plan.targets.length > 0) {
@@ -6,6 +6,7 @@
6
6
  // on the id. AUX_LANGUAGE_EXTENSIONS adds the config/markup languages the graph
7
7
  // never parses but formatters do (PowerShell, JSON, TOML, ...).
8
8
  import { extname } from 'node:path';
9
+ import { startGit } from '../shared/git-launch.mjs';
9
10
  import { runProcess } from './process.mjs';
10
11
 
11
12
  export const CODE_LANGUAGE_EXTENSIONS = Object.freeze({
@@ -156,21 +157,22 @@ export function parseGraphLangs(stdout) {
156
157
 
157
158
  /** git-tracked files under `cwd`, filtered to the requested scope paths. */
158
159
  async function listScopedFiles({ cwd, paths = [], signal = null, timeoutMs = 20_000 } = {}) {
159
- const result = await runProcess(
160
- 'git',
161
- [
162
- '-c',
163
- 'core.quotepath=false',
164
- '--literal-pathspecs',
165
- 'ls-files',
166
- '--cached',
167
- '--others',
168
- '--exclude-standard',
169
- '-z',
170
- '--',
171
- ...paths,
172
- ],
173
- { cwd, signal, timeoutMs }
160
+ const args = [
161
+ '-c',
162
+ 'core.quotepath=false',
163
+ '--literal-pathspecs',
164
+ 'ls-files',
165
+ '--cached',
166
+ '--others',
167
+ '--exclude-standard',
168
+ '-z',
169
+ '--',
170
+ ...paths,
171
+ ];
172
+ const result = await startGit(
173
+ process.env,
174
+ (command, env) => runProcess(command, args, { cwd, signal, timeoutMs, env }),
175
+ (outcome) => ({ code: outcome.errorCode })
174
176
  );
175
177
  if (result.code !== 0) {
176
178
  return {