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
@@ -1,334 +1,262 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { readFileSync } from 'node:fs';
1
+ /**
2
+ * Workspace admission: the turn, command and management leases of a Git
3
+ * repository (activity-ledger.mjs keeps their ledger). One admission thread
4
+ * per process (activity-ledger-worker.mjs) owns every lease the process holds
5
+ * and runs the ledger's locked read-modify-publish, one request at a time in
6
+ * the order the requests were made. The fsynced writes therefore never block
7
+ * this thread's event loop, and no two threads of the process ever update the
8
+ * ledger at once.
9
+ *
10
+ * The asynchronous API posts its request and settles with the outcome. The
11
+ * synchronous API posts the same request and blocks (Atomics.wait) until it
12
+ * is answered, for at most MIXDOG_ADMISSION_SYNC_TIMEOUT_MS: a request still
13
+ * running then is reported with an unknown outcome — a grant is withdrawn by
14
+ * the admission thread once it finishes, a release still lands. A grant or
15
+ * release is answered only once both copies hold its outcome (or it was
16
+ * refused). If the admission thread stops, every outstanding and later
17
+ * request fails (workspace-admission-unreadable) until the process restarts:
18
+ * it is never restarted, since a ledger lock it held still names this live
19
+ * process. The exit waits (bounded the same way) for the requests already
20
+ * posted, so a release made just before exiting still lands.
21
+ */
3
22
  import { setTimeout as delay } from 'node:timers/promises';
4
- import { isAbsolute, join } from 'node:path';
5
- import { parseJsonText, quarantinePath, withFileLockSync, writeFileAtomicSync } from '../atomic-file.mjs';
6
- import { isPidAliveOrUnknown } from '../pid-liveness.mjs';
7
- import { findGitLayout } from './git-head.mjs';
8
- import { canonicalPath, isWithin, samePath } from './paths.mjs';
9
- import { worktreeError, worktreeRefusal } from './contract.mjs';
10
- import { readRegistrySync } from './registry.mjs';
23
+ import { MessageChannel, receiveMessageOnPort, SHARE_ENV, Worker } from 'node:worker_threads';
24
+ import { WorktreeError, worktreeRefusal } from './contract.mjs';
25
+ import { envMs, LEDGER_SLOTS as SLOT } from './activity-ledger.mjs';
11
26
 
12
- const FILE = 'mixdog-workspace-admission.json';
13
- const overlaps = (a, b) => isWithin(a, b) || isWithin(b, a);
27
+ export { isExternalCheckout } from './activity-ledger.mjs';
14
28
 
15
- /**
16
- * The admission policy's external exception: a linked checkout Mixdog did not
17
- * create (not a project's main folder, not a registered managed worktree) is
18
- * not lock-blocked by what is held elsewhere. Management takes no lease on it
19
- * and its readers are admitted whatever is held outside it; its running
20
- * readers still refuse a management action that touches it. A management lease
21
- * published inside the checkout itself (another profile, or a registry
22
- * recovered empty) still refuses its readers.
23
- */
24
- export function isExternalCheckout(folder) {
25
- const layout = findGitLayout(folder, { strict: true });
26
- if (!layout || samePath(layout.gitDir, layout.commonDir)) return false;
27
- let entries;
28
- try {
29
- entries = readRegistrySync().entries;
30
- } catch {
31
- // A damaged registry is kept aside and reads as a restored or empty one;
32
- // only a real I/O failure (access denied, in use) lands here.
33
- throw worktreeRefusal('workspace-admission-unreadable');
34
- }
35
- return !Object.values(entries).some(
36
- (record) => typeof record?.path === 'string' && samePath(record.path, layout.topLevel)
37
- );
38
- }
29
+ // The longest a synchronous caller (or the exit) waits for the admission
30
+ // thread, the requests queued ahead of it included: each is bounded by the
31
+ // ledger lock timeout (file-lock.mjs, MIXDOG_LOCK_TIMEOUT_MS) and the atomic
32
+ // writes' rename retries. A chosen ceiling, not a measurement: reaching it is
33
+ // the typed `outcome: 'unknown'` refusal, and MIXDOG_ADMISSION_SYNC_TIMEOUT_MS
34
+ // sets it.
35
+ const syncTimeoutMs = () => envMs('MIXDOG_ADMISSION_SYNC_TIMEOUT_MS', 60_000);
39
36
 
40
- /*
41
- * The admission ledger lists every turn, command and management lease of a
42
- * repository. It is kept in two copies with the same content in the Git
43
- * common directory: `mixdog-workspace-admission.json` and its `.management`
44
- * mirror. A grant returns its release only once both copies hold it, and a
45
- * release is forgotten only once both copies dropped it, so damage to either
46
- * copy alone never loses an acknowledged lease. `recoveredAt` marks a ledger
47
- * that may be missing leases because no complete copy survived: readers still
48
- * run, management refuses, and only a quiescent reset that removes both
49
- * copies clears it (docs/git-github-integration.md). Time, healing and
50
- * releases never do.
51
- */
52
- const VERSION = 2;
53
- const isLease = (lease) =>
54
- !!lease &&
55
- ['turn', 'command', 'management'].includes(lease.kind) &&
56
- typeof lease.token === 'string' &&
57
- Number.isSafeInteger(lease.pid) &&
58
- lease.pid > 0 &&
59
- typeof lease.path === 'string' &&
60
- isAbsolute(lease.path);
61
- // Only a pid PROVEN dead (ESRCH) drops its lease, whatever its kind; an
62
- // unprobeable pid still counts as running. A live runtime keeps a command
63
- // whose tree it cannot verify reserved under its own (live) pid until the
64
- // tree is confirmed gone, so a lease that outlives every pid belongs to a
65
- // restarted runtime that can no longer release it.
66
- const aliveLease = (lease) => isPidAliveOrUnknown(lease.pid);
67
- // A management hold shares one token across its folders, so a lease is
68
- // identified by token and path.
69
- const leaseKey = (lease) => `${lease.token}\0${lease.path}`;
70
- const unverifiable = () => worktreeRefusal('workspace-admission-unreadable');
71
- const copyPaths = (commonDir) => {
72
- const primary = join(commonDir, FILE);
73
- return [primary, `${primary}.management`];
74
- };
75
- const envMs = (name, fallback) => {
76
- const value = Number(process.env[name]);
77
- return Number.isFinite(value) && value > 0 ? value : fallback;
78
- };
79
- const healIntervalMs = () => envMs('MIXDOG_ADMISSION_HEAL_MS', 2_000);
37
+ // `outcome: 'unknown'`: the request was posted and may have landed.
38
+ const unknownOutcome = (details = {}) =>
39
+ worktreeRefusal('workspace-admission-unreadable', { ...details, outcome: 'unknown' });
40
+ const stoppedError = (posted) =>
41
+ posted
42
+ ? unknownOutcome({ admission: 'stopped' })
43
+ : worktreeRefusal('workspace-admission-unreadable', { admission: 'stopped' });
80
44
 
81
- // The leases this process holds, put back into a ledger that lost them, and
82
- // the tokens it released or failed to grant whose removal from both copies is
83
- // not confirmed yet (token -> common dir), retried until it is.
84
- const held = new Map();
85
- const pending = new Map();
86
- let healTimer = null;
45
+ let gate = null;
87
46
 
88
- /**
89
- * One copy as read under the ledger lock. A copy that cannot be read (access
90
- * denied, in use, a directory) or that a newer build wrote refuses everything
91
- * and is never written: neither may pass for an empty ledger. Anything else
92
- * that is not a ledger, or holds an invalid lease record, is damaged; its
93
- * valid records still count. A copy is complete when it accounts for every
94
- * acknowledged lease: intact, not uncertain, and version 2 — or the version 1
95
- * primary, since the version 1 mirror held management leases only.
96
- */
97
- function readCopy(path, primary) {
98
- let bytes;
47
+ function startGate() {
48
+ if (gate) return gate;
49
+ const slots = new Int32Array(new SharedArrayBuffer(SLOT.COUNT * Int32Array.BYTES_PER_ELEMENT));
50
+ const { port1, port2 } = new MessageChannel();
51
+ let worker;
99
52
  try {
100
- bytes = readFileSync(path);
101
- } catch (error) {
102
- if (error?.code === 'ENOENT') return { path, leases: [] };
103
- throw unverifiable();
53
+ worker = new Worker(new URL('./activity-ledger-worker.mjs', import.meta.url), {
54
+ env: SHARE_ENV,
55
+ execArgv: [],
56
+ workerData: {
57
+ slots: slots.buffer,
58
+ syncPort: port2,
59
+ // Tests only: load the fixed fault hook beside the worker (never shipped).
60
+ faultHook: process.env.MIXDOG_ADMISSION_TEST_FAULT_HOOK === '1',
61
+ },
62
+ transferList: [port2],
63
+ });
64
+ } catch (cause) {
65
+ // Nothing ran: the next request tries again.
66
+ throw Object.assign(worktreeRefusal('workspace-admission-unreadable', { admission: 'unavailable' }), { cause });
104
67
  }
105
- let ledger = null;
106
- try {
107
- ledger = parseJsonText(bytes.toString('utf8'));
108
- } catch {
109
- // Unparseable: damaged below.
110
- }
111
- if (Number.isSafeInteger(ledger?.version) && ledger.version > VERSION) throw unverifiable();
112
- if (![1, VERSION].includes(ledger?.version) || !Array.isArray(ledger.leases)) {
113
- return { path, bytes, leases: [], damaged: true };
114
- }
115
- const leases = ledger.leases.filter(isLease);
116
- const damaged = leases.length !== ledger.leases.length;
117
- const uncertain = Object.hasOwn(ledger, 'recoveredAt');
118
- const complete = !damaged && !uncertain && (ledger.version === VERSION || primary);
119
- return { path, bytes, leases, damaged, uncertain, complete, recoveredAt: ledger.recoveredAt };
68
+ const current = { worker, slots, syncPort: port1, lastId: 0, posted: 0, waiting: new Map(), stopped: null };
69
+ gate = current;
70
+ worker.on('message', (answer) => settleAnswer(current, answer));
71
+ worker.on('error', (error) => stopGate(current, error));
72
+ worker.on('exit', (code) => stopGate(current, new Error(`exit code ${code}`)));
73
+ // Kept alive by outstanding asynchronous requests only (request); after the
74
+ // listeners, since attaching a 'message' listener refs the worker again.
75
+ worker.unref();
76
+ process.on('exit', () => drainOnExit(current));
77
+ return current;
120
78
  }
121
79
 
122
- /**
123
- * The ledger recovered from both copies: the union of their valid lease
124
- * records (one copy lacking a lease never drops it), less proven-dead pids
125
- * and this process's pending removals, plus the leases this process holds.
126
- * It stays uncertain when either copy is, and becomes so when a copy exists
127
- * but none is complete. Both copies missing reads as a new repository.
128
- */
129
- function recover(commonDir, copies) {
130
- const own = [...held.values()].filter((entry) => entry.commonDir === commonDir).map((entry) => entry.lease);
131
- const leases = new Map();
132
- for (const lease of [...copies.flatMap((copy) => copy.leases), ...own]) {
133
- const key = leaseKey(lease);
134
- if (!leases.has(key) && pending.get(lease.token) !== commonDir && aliveLease(lease)) leases.set(key, lease);
135
- }
136
- const ledger = { version: VERSION, leases: [...leases.values()] };
137
- const uncertain =
138
- copies.some((copy) => copy.uncertain) ||
139
- (copies.some((copy) => copy.bytes) && !copies.some((copy) => copy.complete));
140
- if (uncertain) ledger.recoveredAt = copies.map((copy) => copy.recoveredAt).find(Number.isFinite) ?? Date.now();
141
- return ledger;
80
+ function stopGate(current, cause) {
81
+ if (current.stopped) return;
82
+ current.stopped = cause;
83
+ process.stderr.write(
84
+ `[state] the workspace admission thread stopped (${cause?.message ?? cause}); workspace activity is refused until restart\n`
85
+ );
86
+ const waiting = [...current.waiting.values()];
87
+ current.waiting.clear();
88
+ for (const entry of waiting) entry.reject(stoppedError(true));
142
89
  }
143
90
 
144
- /**
145
- * #117: the bytes of a damaged copy are copied aside as `<copy>.corrupt-<ts>`
146
- * before it is rewritten. If they cannot be kept, nothing is written and
147
- * everything refuses.
148
- */
149
- function keepDamagedAside(copies) {
150
- for (const copy of copies.filter((each) => each.damaged)) {
151
- const aside = quarantinePath(copy.path);
152
- try {
153
- writeFileAtomicSync(aside, copy.bytes);
154
- } catch {
155
- throw unverifiable();
156
- }
157
- process.stderr.write(`[state] ${copy.path} is damaged; kept as ${aside}\n`);
158
- }
91
+ function revive(error) {
92
+ if (error.name === 'WorktreeError') return new WorktreeError(error.code, error.message, error.details);
93
+ const revived = new ({ TypeError, RangeError }[error.name] ?? Error)(error.message);
94
+ if (error.code !== undefined) revived.code = error.code;
95
+ return revived;
96
+ }
97
+
98
+ function post(current, op, args, sync) {
99
+ current.lastId = current.lastId >= 0x3fffffff ? 1 : current.lastId + 1;
100
+ const id = current.lastId;
101
+ if (sync) Atomics.store(current.slots, SLOT.SYNC, id);
102
+ current.worker.postMessage({ id, op, args, sync });
103
+ current.posted = (current.posted + 1) | 0;
104
+ return id;
159
105
  }
160
106
 
161
- /** Writes the primary, then the mirror; a copy that already holds the ledger is left alone. */
162
- function publish(copies, ledger) {
163
- const text = Buffer.from(`${JSON.stringify(ledger, null, 2)}\n`);
164
- for (const copy of copies) {
165
- if (copy.bytes?.equals(text)) continue;
166
- copy.bytes = null; // Unknown until the write lands.
167
- writeFileAtomicSync(copy.path, text);
168
- copy.bytes = text;
107
+ /** Posts the request at the call, so it runs after every earlier request of this process. */
108
+ function request(op, args) {
109
+ let current;
110
+ try {
111
+ current = startGate();
112
+ } catch (error) {
113
+ return Promise.reject(error);
169
114
  }
115
+ if (current.stopped) return Promise.reject(stoppedError(false));
116
+ return new Promise((resolve, reject) => {
117
+ const id = post(current, op, args, false);
118
+ current.waiting.set(id, { resolve, reject });
119
+ if (current.waiting.size === 1) current.worker.ref();
120
+ });
170
121
  }
171
122
 
172
- function settle(tokens) {
173
- for (const token of tokens) pending.delete(token);
174
- syncHealTimer();
123
+ function settleAnswer(current, { id, value, error }) {
124
+ const entry = current.waiting.get(id);
125
+ if (!entry) return;
126
+ current.waiting.delete(id);
127
+ if (!current.waiting.size) current.worker.unref();
128
+ if (error) entry.reject(revive(error));
129
+ else entry.resolve(value);
175
130
  }
176
131
 
177
132
  /**
178
- * Runs `update(leases, { unknown })` on the recovered ledger under the
179
- * primary copy's lock and publishes the result to both copies before
180
- * returning. `unknown`: the ledger may be missing leases. A refused update
181
- * still publishes the repaired ledger. A grant that cannot be published is
182
- * refused: its leases become pending removals and the ledger without them is
183
- * published again; whatever that leaves behind is withdrawn by the next
184
- * mutation or heal tick, which also blocks every later grant until it lands.
133
+ * The request answered while this thread blocks. The admission thread never
134
+ * waits for this one, so the wait ends with its answer, its stop, or the
135
+ * deadline — after which the request no longer counts as this caller's: the
136
+ * admission thread withdraws a grant it then completes (activity-ledger-worker).
185
137
  */
186
- function mutate(commonDir, update = () => {}) {
187
- const paths = copyPaths(commonDir);
188
- return withFileLockSync(
189
- `${paths[0]}.lock`,
190
- () => {
191
- const copies = paths.map((path, index) => readCopy(path, index === 0));
192
- keepDamagedAside(copies);
193
- const withdrawn = [...pending].filter(([, dir]) => dir === commonDir).map(([token]) => token);
194
- const base = recover(commonDir, copies);
195
- const leases = [...base.leases];
196
- let result;
197
- let refusal = null;
198
- try {
199
- result = update(leases, { unknown: Object.hasOwn(base, 'recoveredAt') });
200
- } catch (error) {
201
- refusal = error;
202
- }
203
- const granted = refusal ? [] : leases.filter((lease) => !base.leases.includes(lease));
204
- try {
205
- publish(copies, refusal ? base : { ...base, leases });
206
- settle(withdrawn);
207
- } catch (error) {
208
- process.stderr.write(`[state] ${paths[0]} could not be published (${error?.message})\n`);
209
- if (refusal) throw refusal;
210
- for (const lease of granted) pending.set(lease.token, commonDir);
211
- if (granted.length) {
212
- try {
213
- publish(copies, base);
214
- settle([...withdrawn, ...granted.map((lease) => lease.token)]);
215
- } catch {
216
- syncHealTimer();
217
- }
218
- }
219
- throw unverifiable();
220
- }
221
- if (refusal) throw refusal;
222
- return result;
223
- },
224
- { strictOwner: true }
225
- );
226
- }
227
-
228
- // Without the lock, a cheap check that both copies are the same current
229
- // ledger holding every live lease this process holds there and none of the
230
- // tokens it is withdrawing. Repair, never proof that the ledger is complete.
231
- function inSync(commonDir) {
232
- let ledger;
233
- try {
234
- const [primary, mirror] = copyPaths(commonDir).map((path) => readFileSync(path));
235
- if (!primary.equals(mirror)) return false;
236
- ledger = parseJsonText(primary.toString('utf8'));
237
- } catch {
238
- return false;
239
- }
240
- if (ledger?.version !== VERSION || !Array.isArray(ledger.leases)) return false;
241
- const stored = new Set(ledger.leases.filter(isLease).map(leaseKey));
242
- return (
243
- [...held].every(([key, entry]) => entry.commonDir !== commonDir || stored.has(key) || !aliveLease(entry.lease)) &&
244
- !ledger.leases.some((lease) => pending.get(lease?.token) === commonDir)
245
- );
246
- }
247
-
248
- function healHeld() {
249
- const dirs = new Set([...[...held.values()].map((entry) => entry.commonDir), ...pending.values()]);
250
- for (const commonDir of dirs) {
251
- if (inSync(commonDir)) continue;
252
- try {
253
- mutate(commonDir);
254
- } catch {
255
- // Retried on the next tick.
138
+ function callSync(op, args) {
139
+ const current = startGate();
140
+ if (current.stopped) throw stoppedError(false);
141
+ const { slots } = current;
142
+ const id = post(current, op, args, true);
143
+ const deadline = Date.now() + syncTimeoutMs();
144
+ for (;;) {
145
+ const seen = Atomics.load(slots, SLOT.ANSWERED);
146
+ for (
147
+ let received = receiveMessageOnPort(current.syncPort);
148
+ received;
149
+ received = receiveMessageOnPort(current.syncPort)
150
+ ) {
151
+ if (received.message.id !== id) continue;
152
+ if (received.message.error) throw revive(received.message.error);
153
+ return received.message.value;
154
+ }
155
+ if (Atomics.load(slots, SLOT.STOPPED) === 1) {
156
+ stopGate(current, new Error('stopped while a synchronous request waited'));
157
+ throw stoppedError(true);
256
158
  }
159
+ let left = deadline - Date.now();
160
+ if (left <= 0) {
161
+ if (Atomics.compareExchange(slots, SLOT.SYNC, id, 0) === id) throw unknownOutcome();
162
+ // The admission thread already claimed the slot (SYNC is -id): the
163
+ // answer is on its way and its notify wakes this wait; 1 s only bounds
164
+ // a missed wake.
165
+ left = 1_000;
166
+ }
167
+ Atomics.wait(slots, SLOT.ANSWERED, seen, left);
257
168
  }
258
- syncHealTimer();
259
169
  }
260
170
 
261
- function syncHealTimer() {
262
- if (held.size || pending.size) {
263
- healTimer ??= setInterval(healHeld, healIntervalMs());
264
- healTimer.unref?.();
265
- } else if (healTimer) {
266
- clearInterval(healTimer);
267
- healTimer = null;
171
+ // `exit` listeners run synchronously while the admission thread still runs:
172
+ // the requests already posted (a release made just before exiting) are let
173
+ // finish. Not a durability mechanism — every grant was answered only once it
174
+ // was written — and a forced termination skips it: a release then left
175
+ // behind belongs to a dead pid, which the next reader drops.
176
+ function drainOnExit(current) {
177
+ const { slots } = current;
178
+ const deadline = Date.now() + syncTimeoutMs();
179
+ for (;;) {
180
+ const done = Atomics.load(slots, SLOT.DONE);
181
+ if (done === current.posted || current.stopped || Atomics.load(slots, SLOT.STOPPED) === 1) return;
182
+ const left = deadline - Date.now();
183
+ if (left <= 0) return;
184
+ Atomics.wait(slots, SLOT.DONE, done, left);
268
185
  }
269
186
  }
270
187
 
271
- function track(commonDir, lease) {
272
- held.set(leaseKey(lease), { commonDir, lease });
273
- syncHealTimer();
274
- }
188
+ const handled = (promise) => {
189
+ promise.catch(() => {});
190
+ return promise;
191
+ };
275
192
 
276
- // Forgotten as held first, so nothing puts the lease back; its token stays
277
- // pending until both copies dropped it.
278
- function release(commonDir, token) {
193
+ /**
194
+ * The release of a synchronous grant: returns once the removal was attempted
195
+ * (one that did not land is retried by the admission thread, as always).
196
+ * Throws only when the admission thread stopped.
197
+ */
198
+ function syncRelease(leases) {
279
199
  let done = false;
280
200
  return () => {
281
- if (done) return;
201
+ if (done || !leases.length) return;
282
202
  done = true;
283
- for (const [key, entry] of held) if (entry.lease.token === token) held.delete(key);
284
- pending.set(token, commonDir);
285
203
  try {
286
- mutate(commonDir);
287
- } catch {
288
- // Retried by the next mutation or heal tick.
204
+ callSync('release', { leases });
205
+ } catch (error) {
206
+ if (error?.details?.outcome !== 'unknown' || error.details.admission === 'stopped') throw error;
207
+ process.stderr.write('[state] a workspace release outlasted the synchronous wait; it lands in its turn\n');
289
208
  }
290
- syncHealTimer();
291
209
  };
292
210
  }
293
211
 
212
+ /**
213
+ * The release of an asynchronous grant, requested at the call (so it lands
214
+ * before any later request of this process). Settles once the removal was
215
+ * attempted; rejects only when the admission thread stopped. Every call
216
+ * returns the same promise, which never goes unhandled.
217
+ */
218
+ function asyncRelease(leases) {
219
+ let released = null;
220
+ return () => (released ??= leases.length ? handled(request('release', { leases })) : Promise.resolve());
221
+ }
222
+
223
+ function readerArgs(sessionId, cwd, { pid = process.pid, kind = 'turn' } = {}) {
224
+ if (!['turn', 'command'].includes(kind)) throw new TypeError('Invalid workspace reader kind.');
225
+ return { sessionId, cwd, pid, kind };
226
+ }
227
+
294
228
  /**
295
229
  * Readers share a checkout freely. Null means a management writer currently
296
230
  * owns it; a reader in an external checkout is admitted whatever is held
297
231
  * outside that checkout, but refused by a management lease inside it. Throws
298
232
  * `workspace-admission-unreadable` when the ledger cannot be read, was
299
233
  * written by a newer build, or the grant could not be made durable in both
300
- * copies: no work may run unrecorded.
234
+ * copies: no work may run unrecorded. Blocks until answered;
235
+ * tryAdmitWorkspaceActivityAsync does not.
301
236
  */
302
- export function tryAdmitWorkspaceActivity(sessionId, cwd, { pid = process.pid, kind = 'turn' } = {}) {
303
- if (!['turn', 'command'].includes(kind)) throw new TypeError('Invalid workspace reader kind.');
304
- const layout = findGitLayout(cwd, { strict: true });
305
- if (!layout) return () => {};
306
- const external = isExternalCheckout(cwd);
307
- const path = canonicalPath(cwd);
308
- const token = randomUUID();
309
- const lease = { kind, sessionId, pid, token, path };
310
- // External is this profile's registry view: management another profile (or
311
- // a registry recovered empty) published inside this very checkout still holds.
312
- const checkout = canonicalPath(layout.topLevel);
313
- const held = (other) => (external ? isWithin(checkout, other.path) : overlaps(other.path, path));
314
- const admitted = mutate(layout.commonDir, (leases) => {
315
- if (leases.some((other) => other.kind === 'management' && held(other))) return false;
316
- leases.push(lease);
317
- return true;
318
- });
319
- if (!admitted) return null;
320
- track(layout.commonDir, lease);
321
- return release(layout.commonDir, token);
237
+ export function tryAdmitWorkspaceActivity(sessionId, cwd, options) {
238
+ const granted = callSync('admit', readerArgs(sessionId, cwd, options));
239
+ return granted && syncRelease(granted.leases);
240
+ }
241
+
242
+ /** tryAdmitWorkspaceActivity without blocking; its release settles once the removal was attempted. */
243
+ export async function tryAdmitWorkspaceActivityAsync(sessionId, cwd, options) {
244
+ const granted = await request('admit', readerArgs(sessionId, cwd, options));
245
+ return granted && asyncRelease(granted.leases);
322
246
  }
323
247
 
248
+ /** Waits (polling) until a reader is admitted; an abort registers nothing. Its release is asynchronous. */
324
249
  export async function admitWorkspaceActivity(sessionId, cwd, signal, options) {
325
250
  for (;;) {
326
251
  signal?.throwIfAborted();
327
- const leave = tryAdmitWorkspaceActivity(sessionId, cwd, options);
252
+ const leave = await tryAdmitWorkspaceActivityAsync(sessionId, cwd, options);
328
253
  if (leave) {
329
254
  if (signal?.aborted) {
330
- leave();
331
- signal.throwIfAborted();
255
+ try {
256
+ await leave();
257
+ } finally {
258
+ signal.throwIfAborted();
259
+ }
332
260
  }
333
261
  return leave;
334
262
  }
@@ -341,46 +269,15 @@ export async function admitWorkspaceActivity(sessionId, cwd, signal, options) {
341
269
  * exclude later readers until the returned release is called. External
342
270
  * checkouts are only checked, never held. `sessionId`: the acting session,
343
271
  * whose own readers in this process do not count (its idleness is the
344
- * caller's check).
272
+ * caller's check). Blocks until answered; holdWorkspaceActivityAsync does not.
345
273
  */
346
274
  export function holdWorkspaceActivity(folders, { sessionId = null } = {}) {
347
- const groups = new Map();
348
- for (const folder of folders) {
349
- const layout = findGitLayout(folder, { strict: true });
350
- if (!layout) throw worktreeError('WORKTREE_UNSUPPORTED_REPO');
351
- const paths = groups.get(layout.commonDir) ?? [];
352
- paths.push({ path: canonicalPath(folder), external: isExternalCheckout(folder) });
353
- groups.set(layout.commonDir, paths);
354
- }
355
- const releases = [];
356
- try {
357
- for (const [commonDir, paths] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
358
- const token = randomUUID();
359
- const taken = [];
360
- mutate(commonDir, (leases, { unknown }) => {
361
- const conflicts = leases.filter(
362
- (lease) =>
363
- !(sessionId && lease.sessionId === sessionId && lease.pid === process.pid) &&
364
- paths.some(({ path }) => overlaps(path, lease.path))
365
- );
366
- if (conflicts.length) throw worktreeRefusal('folder-in-use');
367
- // The ledger may be missing a live foreign lease: refused until reset.
368
- if (unknown) throw unverifiable();
369
- for (const { path, external } of paths) {
370
- if (external) continue;
371
- const lease = { kind: 'management', pid: process.pid, token, path };
372
- leases.push(lease);
373
- taken.push(lease);
374
- }
375
- });
376
- for (const lease of taken) track(commonDir, lease);
377
- releases.push(release(commonDir, token));
378
- }
379
- } catch (error) {
380
- releaseAll(releases);
381
- throw error;
382
- }
383
- return () => releaseAll(releases);
275
+ return syncRelease(callSync('hold', { folders: [...folders], sessionId }).leases);
276
+ }
277
+
278
+ /** holdWorkspaceActivity without blocking; its release settles once every removal was attempted. */
279
+ export async function holdWorkspaceActivityAsync(folders, { sessionId = null } = {}) {
280
+ return asyncRelease((await request('hold', { folders: [...folders], sessionId })).leases);
384
281
  }
385
282
 
386
283
  /** Runs every release in reverse order even when one throws; the first failure is rethrown after all ran. */