borgmcp 1.1.15 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (459) hide show
  1. package/CONTRIBUTING.md +15 -0
  2. package/LICENSE +201 -33
  3. package/NOTICE +2 -0
  4. package/README.md +85 -36
  5. package/SECURITY.md +7 -0
  6. package/dist/agent-runtime.d.ts.map +1 -0
  7. package/dist/agent-runtime.js +63 -1
  8. package/dist/agent-runtime.js.map +1 -0
  9. package/dist/assimilate-cmd.d.ts +96 -17
  10. package/dist/assimilate-cmd.d.ts.map +1 -0
  11. package/dist/assimilate-cmd.js +1396 -48
  12. package/dist/assimilate-cmd.js.map +1 -0
  13. package/dist/assimilate-deps.d.ts.map +1 -0
  14. package/dist/assimilate-deps.js +384 -3
  15. package/dist/assimilate-deps.js.map +1 -0
  16. package/dist/assimilate-guard.d.ts +5 -7
  17. package/dist/assimilate-guard.d.ts.map +1 -0
  18. package/dist/assimilate-guard.js +60 -2
  19. package/dist/assimilate-guard.js.map +1 -0
  20. package/dist/assimilate-welcome.d.ts +1 -1
  21. package/dist/assimilate-welcome.d.ts.map +1 -0
  22. package/dist/assimilate-welcome.js +32 -2
  23. package/dist/assimilate-welcome.js.map +1 -0
  24. package/dist/auth-env.d.ts +7 -50
  25. package/dist/auth-env.d.ts.map +1 -0
  26. package/dist/auth-env.js +22 -1
  27. package/dist/auth-env.js.map +1 -0
  28. package/dist/backends/launch-all-pastelist.d.ts.map +1 -0
  29. package/dist/backends/launch-all-pastelist.js +11 -4
  30. package/dist/backends/launch-all-pastelist.js.map +1 -0
  31. package/dist/backends/launch-all-tmux.d.ts.map +1 -0
  32. package/dist/backends/launch-all-tmux.js +49 -1
  33. package/dist/backends/launch-all-tmux.js.map +1 -0
  34. package/dist/backends/launch-all-windows.d.ts.map +1 -0
  35. package/dist/backends/launch-all-windows.js +86 -12
  36. package/dist/backends/launch-all-windows.js.map +1 -0
  37. package/dist/bare-launch-menu.d.ts.map +1 -0
  38. package/dist/bare-launch-menu.js +83 -6
  39. package/dist/bare-launch-menu.js.map +1 -0
  40. package/dist/claude-launch-args.d.ts.map +1 -0
  41. package/dist/claude-launch-args.js +34 -1
  42. package/dist/claude-launch-args.js.map +1 -0
  43. package/dist/claude.d.ts.map +1 -0
  44. package/dist/claude.js +486 -14
  45. package/dist/claude.js.map +1 -0
  46. package/dist/cleanup-cmd.d.ts +5 -5
  47. package/dist/cleanup-cmd.d.ts.map +1 -0
  48. package/dist/cleanup-cmd.js +453 -14
  49. package/dist/cleanup-cmd.js.map +1 -0
  50. package/dist/clear-rewake-core.d.ts.map +1 -0
  51. package/dist/clear-rewake-core.js +23 -2
  52. package/dist/clear-rewake-core.js.map +1 -0
  53. package/dist/clear-rewake.d.ts.map +1 -0
  54. package/dist/clear-rewake.js +19 -1
  55. package/dist/clear-rewake.js.map +1 -0
  56. package/dist/cli-help.d.ts +5 -0
  57. package/dist/cli-help.d.ts.map +1 -0
  58. package/dist/cli-help.js +104 -56
  59. package/dist/cli-help.js.map +1 -0
  60. package/dist/cli-platform.d.ts.map +1 -0
  61. package/dist/cli-platform.js +106 -4
  62. package/dist/cli-platform.js.map +1 -0
  63. package/dist/cli-tool-approval.d.ts +84 -0
  64. package/dist/cli-tool-approval.d.ts.map +1 -0
  65. package/dist/cli-tool-approval.js +583 -0
  66. package/dist/cli-tool-approval.js.map +1 -0
  67. package/dist/codex-app-server.d.ts.map +1 -0
  68. package/dist/codex-app-server.js +228 -4
  69. package/dist/codex-app-server.js.map +1 -0
  70. package/dist/codex-app-wake.d.ts.map +1 -0
  71. package/dist/codex-app-wake.js +432 -2
  72. package/dist/codex-app-wake.js.map +1 -0
  73. package/dist/codex-launch.d.ts.map +1 -0
  74. package/dist/codex-launch.js +102 -2
  75. package/dist/codex-launch.js.map +1 -0
  76. package/dist/codex-remote.d.ts +6 -1
  77. package/dist/codex-remote.d.ts.map +1 -0
  78. package/dist/codex-remote.js +394 -1
  79. package/dist/codex-remote.js.map +1 -0
  80. package/dist/codex-wake-resolve.d.ts.map +1 -0
  81. package/dist/codex-wake-resolve.js +127 -1
  82. package/dist/codex-wake-resolve.js.map +1 -0
  83. package/dist/config-utils.d.ts +2 -2
  84. package/dist/config-utils.d.ts.map +1 -0
  85. package/dist/config-utils.js +766 -3
  86. package/dist/config-utils.js.map +1 -0
  87. package/dist/config.d.ts +98 -61
  88. package/dist/config.d.ts.map +1 -0
  89. package/dist/config.js +426 -1
  90. package/dist/config.js.map +1 -0
  91. package/dist/console-prefix.d.ts.map +1 -0
  92. package/dist/console-prefix.js +96 -1
  93. package/dist/console-prefix.js.map +1 -0
  94. package/dist/cube-name.d.ts.map +1 -0
  95. package/dist/cube-name.js +67 -1
  96. package/dist/cube-name.js.map +1 -0
  97. package/dist/cubes.d.ts +126 -18
  98. package/dist/cubes.d.ts.map +1 -0
  99. package/dist/cubes.js +412 -4
  100. package/dist/cubes.js.map +1 -0
  101. package/dist/debug.d.ts.map +1 -0
  102. package/dist/debug.js +71 -1
  103. package/dist/debug.js.map +1 -0
  104. package/dist/direct-log.d.ts.map +1 -0
  105. package/dist/direct-log.js +11 -1
  106. package/dist/direct-log.js.map +1 -0
  107. package/dist/docs-sections.d.ts +11 -10
  108. package/dist/docs-sections.d.ts.map +1 -0
  109. package/dist/docs-sections.js +113 -3
  110. package/dist/docs-sections.js.map +1 -0
  111. package/dist/drone-lifecycle.d.ts +5 -21
  112. package/dist/drone-lifecycle.d.ts.map +1 -0
  113. package/dist/drone-lifecycle.js +57 -8
  114. package/dist/drone-lifecycle.js.map +1 -0
  115. package/dist/drone-management.d.ts +29 -0
  116. package/dist/drone-management.d.ts.map +1 -0
  117. package/dist/drone-management.js +109 -0
  118. package/dist/drone-management.js.map +1 -0
  119. package/dist/ensure-mcp-config.d.ts.map +1 -0
  120. package/dist/ensure-mcp-config.js +36 -1
  121. package/dist/ensure-mcp-config.js.map +1 -0
  122. package/dist/evict-drone.d.ts +10 -2
  123. package/dist/evict-drone.d.ts.map +1 -0
  124. package/dist/evict-drone.js +60 -1
  125. package/dist/evict-drone.js.map +1 -0
  126. package/dist/gc-orphan-inboxes.d.ts.map +1 -0
  127. package/dist/gc-orphan-inboxes.js +177 -1
  128. package/dist/gc-orphan-inboxes.js.map +1 -0
  129. package/dist/inbox-monitor.d.ts +1 -1
  130. package/dist/inbox-monitor.d.ts.map +1 -0
  131. package/dist/inbox-monitor.js +856 -3
  132. package/dist/inbox-monitor.js.map +1 -0
  133. package/dist/index.d.ts +6 -4
  134. package/dist/index.d.ts.map +1 -0
  135. package/dist/index.js +1034 -37
  136. package/dist/index.js.map +1 -0
  137. package/dist/launch-all-cmd.d.ts.map +1 -0
  138. package/dist/launch-all-cmd.js +377 -37
  139. package/dist/launch-all-cmd.js.map +1 -0
  140. package/dist/launch-all-command.d.ts.map +1 -0
  141. package/dist/launch-all-command.js +24 -1
  142. package/dist/launch-all-command.js.map +1 -0
  143. package/dist/launch-all-deps.d.ts +3 -8
  144. package/dist/launch-all-deps.d.ts.map +1 -0
  145. package/dist/launch-all-deps.js +99 -1
  146. package/dist/launch-all-deps.js.map +1 -0
  147. package/dist/launch-all-discovery.d.ts +1 -0
  148. package/dist/launch-all-discovery.d.ts.map +1 -0
  149. package/dist/launch-all-discovery.js +90 -4
  150. package/dist/launch-all-discovery.js.map +1 -0
  151. package/dist/launch-all-locks.d.ts.map +1 -0
  152. package/dist/launch-all-locks.js +54 -1
  153. package/dist/launch-all-locks.js.map +1 -0
  154. package/dist/launch-gate.d.ts +2 -2
  155. package/dist/launch-gate.d.ts.map +1 -0
  156. package/dist/launch-gate.js +73 -1
  157. package/dist/launch-gate.js.map +1 -0
  158. package/dist/lifecycle-log-guard.d.ts.map +1 -0
  159. package/dist/lifecycle-log-guard.js +93 -2
  160. package/dist/lifecycle-log-guard.js.map +1 -0
  161. package/dist/list-roles-render.d.ts.map +1 -0
  162. package/dist/list-roles-render.js +40 -6
  163. package/dist/list-roles-render.js.map +1 -0
  164. package/dist/local-log-routing.d.ts +19 -0
  165. package/dist/local-log-routing.d.ts.map +1 -0
  166. package/dist/local-log-routing.js +67 -0
  167. package/dist/local-log-routing.js.map +1 -0
  168. package/dist/local-manage-tool-result.d.ts +8 -0
  169. package/dist/local-manage-tool-result.d.ts.map +1 -0
  170. package/dist/local-manage-tool-result.js +10 -0
  171. package/dist/local-manage-tool-result.js.map +1 -0
  172. package/dist/local-server-cursor.d.ts +34 -0
  173. package/dist/local-server-cursor.d.ts.map +1 -0
  174. package/dist/local-server-cursor.js +151 -0
  175. package/dist/local-server-cursor.js.map +1 -0
  176. package/dist/log-audit.d.ts.map +1 -0
  177. package/dist/log-audit.js +194 -3
  178. package/dist/log-audit.js.map +1 -0
  179. package/dist/log-stream.d.ts +31 -18
  180. package/dist/log-stream.d.ts.map +1 -0
  181. package/dist/log-stream.js +1214 -12
  182. package/dist/log-stream.js.map +1 -0
  183. package/dist/model-presets.d.ts.map +1 -0
  184. package/dist/model-presets.js +19 -1
  185. package/dist/model-presets.js.map +1 -0
  186. package/dist/name-validator.d.ts.map +1 -0
  187. package/dist/name-validator.js +22 -1
  188. package/dist/name-validator.js.map +1 -0
  189. package/dist/opencode-drone.d.ts.map +1 -0
  190. package/dist/opencode-drone.js +438 -4
  191. package/dist/opencode-drone.js.map +1 -0
  192. package/dist/opencode-plugin.d.ts +1 -1
  193. package/dist/opencode-plugin.d.ts.map +1 -0
  194. package/dist/opencode-plugin.js +29 -4
  195. package/dist/opencode-plugin.js.map +1 -0
  196. package/dist/parse-assimilate-args.d.ts +1 -1
  197. package/dist/parse-assimilate-args.d.ts.map +1 -0
  198. package/dist/parse-assimilate-args.js +125 -1
  199. package/dist/parse-assimilate-args.js.map +1 -0
  200. package/dist/parse-launch-all-args.d.ts.map +1 -0
  201. package/dist/parse-launch-all-args.js +70 -1
  202. package/dist/parse-launch-all-args.js.map +1 -0
  203. package/dist/postinstall-banner.d.ts.map +1 -0
  204. package/dist/postinstall-banner.js +27 -2
  205. package/dist/postinstall-banner.js.map +1 -0
  206. package/dist/postinstall.d.ts.map +1 -0
  207. package/dist/postinstall.js +32 -5
  208. package/dist/postinstall.js.map +1 -0
  209. package/dist/readiness-probe.d.ts.map +1 -0
  210. package/dist/readiness-probe.js +9 -1
  211. package/dist/readiness-probe.js.map +1 -0
  212. package/dist/regen-format.d.ts.map +1 -0
  213. package/dist/regen-format.js +545 -13
  214. package/dist/regen-format.js.map +1 -0
  215. package/dist/regen.d.ts.map +1 -0
  216. package/dist/regen.js +141 -5
  217. package/dist/regen.js.map +1 -0
  218. package/dist/remote-client.d.ts +65 -175
  219. package/dist/remote-client.d.ts.map +1 -0
  220. package/dist/remote-client.js +942 -1
  221. package/dist/remote-client.js.map +1 -0
  222. package/dist/reset-local-seat-cmd.d.ts +50 -0
  223. package/dist/reset-local-seat-cmd.d.ts.map +1 -0
  224. package/dist/reset-local-seat-cmd.js +169 -0
  225. package/dist/reset-local-seat-cmd.js.map +1 -0
  226. package/dist/role-match.d.ts.map +1 -0
  227. package/dist/role-match.js +23 -1
  228. package/dist/role-match.js.map +1 -0
  229. package/dist/role-resolver.d.ts.map +1 -0
  230. package/dist/role-resolver.js +60 -1
  231. package/dist/role-resolver.js.map +1 -0
  232. package/dist/roster-render.d.ts.map +1 -0
  233. package/dist/roster-render.js +121 -3
  234. package/dist/roster-render.js.map +1 -0
  235. package/dist/seat-probe.d.ts +32 -13
  236. package/dist/seat-probe.d.ts.map +1 -0
  237. package/dist/seat-probe.js +78 -1
  238. package/dist/seat-probe.js.map +1 -0
  239. package/dist/seat-store.d.ts +76 -0
  240. package/dist/seat-store.d.ts.map +1 -0
  241. package/dist/seat-store.js +309 -0
  242. package/dist/seat-store.js.map +1 -0
  243. package/dist/seats.d.ts +303 -0
  244. package/dist/seats.d.ts.map +1 -0
  245. package/dist/seats.js +551 -0
  246. package/dist/seats.js.map +1 -0
  247. package/dist/self-path.d.ts +27 -0
  248. package/dist/self-path.d.ts.map +1 -0
  249. package/dist/self-path.js +43 -0
  250. package/dist/self-path.js.map +1 -0
  251. package/dist/server-endpoint.d.ts +9 -0
  252. package/dist/server-endpoint.d.ts.map +1 -0
  253. package/dist/server-endpoint.js +38 -0
  254. package/dist/server-endpoint.js.map +1 -0
  255. package/dist/server-errors.d.ts +50 -0
  256. package/dist/server-errors.d.ts.map +1 -0
  257. package/dist/server-errors.js +79 -0
  258. package/dist/server-errors.js.map +1 -0
  259. package/dist/server-handshake.d.ts +197 -0
  260. package/dist/server-handshake.d.ts.map +1 -0
  261. package/dist/server-handshake.js +505 -0
  262. package/dist/server-handshake.js.map +1 -0
  263. package/dist/server-response.d.ts +3 -0
  264. package/dist/server-response.d.ts.map +1 -0
  265. package/dist/server-response.js +67 -0
  266. package/dist/server-response.js.map +1 -0
  267. package/dist/server-trust.d.ts +14 -0
  268. package/dist/server-trust.d.ts.map +1 -0
  269. package/dist/server-trust.js +225 -0
  270. package/dist/server-trust.js.map +1 -0
  271. package/dist/setup-confirm.d.ts +3 -3
  272. package/dist/setup-confirm.d.ts.map +1 -0
  273. package/dist/setup-confirm.js +126 -2
  274. package/dist/setup-confirm.js.map +1 -0
  275. package/dist/setup.d.ts +5 -3
  276. package/dist/setup.d.ts.map +1 -0
  277. package/dist/setup.js +169 -46
  278. package/dist/setup.js.map +1 -0
  279. package/dist/shell-escape.d.ts.map +1 -0
  280. package/dist/shell-escape.js +22 -1
  281. package/dist/shell-escape.js.map +1 -0
  282. package/dist/spawn.d.ts +2 -2
  283. package/dist/spawn.d.ts.map +1 -0
  284. package/dist/spawn.js +29 -10
  285. package/dist/spawn.js.map +1 -0
  286. package/dist/startup-services.d.ts +0 -1
  287. package/dist/startup-services.d.ts.map +1 -0
  288. package/dist/startup-services.js +26 -1
  289. package/dist/startup-services.js.map +1 -0
  290. package/dist/stream-owner.d.ts +6 -2
  291. package/dist/stream-owner.d.ts.map +1 -0
  292. package/dist/stream-owner.js +547 -2
  293. package/dist/stream-owner.js.map +1 -0
  294. package/dist/stream-status.d.ts +1 -1
  295. package/dist/stream-status.d.ts.map +1 -0
  296. package/dist/stream-status.js +268 -3
  297. package/dist/stream-status.js.map +1 -0
  298. package/dist/sync-roles-render.d.ts.map +1 -0
  299. package/dist/sync-roles-render.js +118 -3
  300. package/dist/sync-roles-render.js.map +1 -0
  301. package/dist/sync.d.ts +1 -1
  302. package/dist/sync.d.ts.map +1 -0
  303. package/dist/sync.js +286 -22
  304. package/dist/sync.js.map +1 -0
  305. package/dist/terminal-title.d.ts.map +1 -0
  306. package/dist/terminal-title.js +68 -1
  307. package/dist/terminal-title.js.map +1 -0
  308. package/dist/token-store.d.ts +17 -111
  309. package/dist/token-store.d.ts.map +1 -0
  310. package/dist/token-store.js +81 -1
  311. package/dist/token-store.js.map +1 -0
  312. package/dist/tool-manifest.d.ts +4 -8
  313. package/dist/tool-manifest.d.ts.map +1 -0
  314. package/dist/tool-manifest.js +554 -1
  315. package/dist/tool-manifest.js.map +1 -0
  316. package/dist/tool-scope.d.ts +8 -12
  317. package/dist/tool-scope.d.ts.map +1 -0
  318. package/dist/tool-scope.js +97 -1
  319. package/dist/tool-scope.js.map +1 -0
  320. package/dist/types.d.ts +1 -5
  321. package/dist/types.d.ts.map +1 -0
  322. package/dist/types.js +5 -0
  323. package/dist/types.js.map +1 -0
  324. package/dist/unknown-subcommand.d.ts +1 -1
  325. package/dist/unknown-subcommand.d.ts.map +1 -0
  326. package/dist/unknown-subcommand.js +37 -1
  327. package/dist/unknown-subcommand.js.map +1 -0
  328. package/dist/version.d.ts +1 -1
  329. package/dist/version.d.ts.map +1 -0
  330. package/dist/version.js +78 -2
  331. package/dist/version.js.map +1 -0
  332. package/dist/working-repo.d.ts.map +1 -0
  333. package/dist/working-repo.js +81 -1
  334. package/dist/working-repo.js.map +1 -0
  335. package/dist/worktree-lifecycle.d.ts.map +1 -0
  336. package/dist/worktree-lifecycle.js +205 -2
  337. package/dist/worktree-lifecycle.js.map +1 -0
  338. package/docs/EXTRACTION_PROVENANCE.md +40 -0
  339. package/docs/LOCAL_SERVER.md +103 -0
  340. package/docs/RELEASING.md +120 -0
  341. package/package.json +52 -34
  342. package/src/agent-runtime.ts +69 -0
  343. package/src/assimilate-cmd.ts +1864 -0
  344. package/src/assimilate-deps.ts +444 -0
  345. package/src/assimilate-guard.ts +84 -0
  346. package/src/assimilate-welcome.ts +39 -0
  347. package/src/auth-env.ts +21 -0
  348. package/src/backends/launch-all-pastelist.ts +18 -0
  349. package/src/backends/launch-all-tmux.ts +75 -0
  350. package/src/backends/launch-all-windows.ts +110 -0
  351. package/src/bare-launch-menu.ts +148 -0
  352. package/src/claude-launch-args.ts +35 -0
  353. package/src/claude.ts +561 -0
  354. package/src/cleanup-cmd.ts +581 -0
  355. package/src/clear-rewake-core.ts +34 -0
  356. package/src/clear-rewake.ts +18 -0
  357. package/src/cli-help.ts +116 -0
  358. package/src/cli-platform.ts +127 -0
  359. package/src/cli-tool-approval.ts +716 -0
  360. package/src/codex-app-server.ts +238 -0
  361. package/src/codex-app-wake.ts +512 -0
  362. package/src/codex-launch.ts +140 -0
  363. package/src/codex-remote.ts +500 -0
  364. package/src/codex-wake-resolve.ts +155 -0
  365. package/src/config-utils.ts +811 -0
  366. package/src/config.ts +595 -0
  367. package/src/console-prefix.ts +99 -0
  368. package/src/cube-name.ts +69 -0
  369. package/src/cubes.ts +583 -0
  370. package/src/debug.ts +73 -0
  371. package/src/direct-log.ts +9 -0
  372. package/src/docs-sections.ts +129 -0
  373. package/src/drone-lifecycle.ts +62 -0
  374. package/src/drone-management.ts +162 -0
  375. package/src/ensure-mcp-config.ts +54 -0
  376. package/src/evict-drone.ts +82 -0
  377. package/src/gc-orphan-inboxes.ts +229 -0
  378. package/src/inbox-monitor.ts +1005 -0
  379. package/src/index.ts +1201 -0
  380. package/src/launch-all-cmd.ts +450 -0
  381. package/src/launch-all-command.ts +30 -0
  382. package/src/launch-all-deps.ts +188 -0
  383. package/src/launch-all-discovery.ts +118 -0
  384. package/src/launch-all-locks.ts +74 -0
  385. package/src/launch-gate.ts +80 -0
  386. package/src/lifecycle-log-guard.ts +144 -0
  387. package/src/list-roles-render.ts +52 -0
  388. package/src/local-log-routing.ts +106 -0
  389. package/src/local-manage-tool-result.ts +10 -0
  390. package/src/local-server-cursor.ts +188 -0
  391. package/src/log-audit.ts +207 -0
  392. package/src/log-stream.ts +1505 -0
  393. package/src/model-presets.ts +22 -0
  394. package/src/name-validator.ts +23 -0
  395. package/src/opencode-drone.ts +528 -0
  396. package/src/opencode-plugin.ts +38 -0
  397. package/src/parse-assimilate-args.ts +125 -0
  398. package/src/parse-launch-all-args.ts +100 -0
  399. package/src/postinstall-banner.ts +37 -0
  400. package/src/postinstall.ts +36 -0
  401. package/src/readiness-probe.ts +12 -0
  402. package/src/regen-format.ts +651 -0
  403. package/src/regen.ts +150 -0
  404. package/src/remote-client.ts +1379 -0
  405. package/src/reset-local-seat-cmd.ts +227 -0
  406. package/src/role-match.ts +29 -0
  407. package/src/role-resolver.ts +93 -0
  408. package/src/roster-render.ts +196 -0
  409. package/src/seat-probe.ts +115 -0
  410. package/src/seat-store.ts +333 -0
  411. package/src/seats.ts +741 -0
  412. package/src/self-path.ts +51 -0
  413. package/src/server-endpoint.ts +38 -0
  414. package/src/server-errors.ts +93 -0
  415. package/src/server-handshake.ts +783 -0
  416. package/src/server-response.ts +76 -0
  417. package/src/server-trust.ts +245 -0
  418. package/src/setup-confirm.ts +171 -0
  419. package/src/setup.ts +186 -0
  420. package/src/shell-escape.ts +22 -0
  421. package/src/spawn.ts +31 -0
  422. package/src/startup-services.ts +36 -0
  423. package/src/stream-owner.ts +686 -0
  424. package/src/stream-status.ts +337 -0
  425. package/src/sync-roles-render.ts +163 -0
  426. package/src/sync.ts +363 -0
  427. package/src/terminal-title.ts +76 -0
  428. package/src/token-store.ts +102 -0
  429. package/src/tool-manifest.ts +610 -0
  430. package/src/tool-scope.ts +110 -0
  431. package/src/types.ts +5 -0
  432. package/src/unknown-subcommand.ts +35 -0
  433. package/src/version.ts +82 -0
  434. package/src/working-repo.ts +99 -0
  435. package/src/worktree-lifecycle.ts +254 -0
  436. package/dist/auth-recovery.d.ts +0 -33
  437. package/dist/auth-recovery.js +0 -1
  438. package/dist/auth.d.ts +0 -103
  439. package/dist/auth.js +0 -39
  440. package/dist/device-auth.d.ts +0 -75
  441. package/dist/device-auth.js +0 -1
  442. package/dist/drone-address.d.ts +0 -26
  443. package/dist/drone-address.js +0 -1
  444. package/dist/get-started.d.ts +0 -20
  445. package/dist/get-started.js +0 -2
  446. package/dist/health-beat.d.ts +0 -134
  447. package/dist/health-beat.js +0 -1
  448. package/dist/role-section.d.ts +0 -110
  449. package/dist/role-section.js +0 -8
  450. package/dist/setup-action.d.ts +0 -20
  451. package/dist/setup-action.js +0 -1
  452. package/dist/stale-version-check.d.ts +0 -40
  453. package/dist/stale-version-check.js +0 -1
  454. package/dist/subscription-retry.d.ts +0 -40
  455. package/dist/subscription-retry.js +0 -1
  456. package/dist/templates.d.ts +0 -107
  457. package/dist/templates.js +0 -359
  458. package/dist/token-crypto.d.ts +0 -50
  459. package/dist/token-crypto.js +0 -1
package/src/seats.ts ADDED
@@ -0,0 +1,741 @@
1
+ /**
2
+ * The unified 0600 seat store (Queen rescope, scope A) — `seats.json`.
3
+ *
4
+ * Collapses the per-seat drone-session CREDENTIAL and its worktree BINDING into
5
+ * ONE atomically-written record, keyed by the deterministic seat ref
6
+ * (origin+trust+cube+role+operation). Because a record's `state` and its
7
+ * `worktree` binding are written together in a single `withStore` commit, the
8
+ * invariant "an ACTIVE credential without a binding" is UNREACHABLE BY
9
+ * CONSTRUCTION: PREPARE mints `{credential, state:'pending'}` with NO worktree;
10
+ * FINALIZE sets `state:'active' + server metadata + worktree + display` in one
11
+ * commit; a reset deletes the whole record so credential+binding vanish together
12
+ * (there is no cross-store 'partial').
13
+ *
14
+ * The single store flock (seat-store.withStore) serializes every read-compare-
15
+ * write; there is no second lock and no composite. The raw bearer rests ONLY in
16
+ * the 0600 file and is never returned past this module except by the sole
17
+ * hydration reader; every other observation is digest-only.
18
+ */
19
+
20
+ import os from 'os';
21
+ import path from 'path';
22
+ import { createHash } from 'node:crypto';
23
+ import { readStoreFile, withStore } from './seat-store.js';
24
+
25
+ export const SEATS_FILE = path.join(os.homedir(), '.config', 'borgmcp', 'seats.json');
26
+ const SEATS_VERSION = 1 as const;
27
+ const REF_RE = /^borg-server-session:[a-f0-9]{64}$/;
28
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29
+
30
+ export interface SeatOperation {
31
+ projectRoot: string;
32
+ kind: 'seat' | 'sibling';
33
+ operationKey: string;
34
+ }
35
+
36
+ /** One seat = its credential + binding + display, written as one atomic unit. */
37
+ export interface SeatRecord {
38
+ // identity (the ref inputs)
39
+ origin: string;
40
+ trustIdentity: string;
41
+ cubeId: string;
42
+ roleId: string;
43
+ operation: SeatOperation;
44
+ // credential
45
+ credential: string;
46
+ state: 'pending' | 'active';
47
+ // server metadata (active only)
48
+ droneId?: string;
49
+ sessionId?: string;
50
+ expiresAt?: string;
51
+ // binding + display (set atomically at FINALIZE; absent while pending)
52
+ worktree?: string;
53
+ name?: string;
54
+ droneLabel?: string;
55
+ roleName?: string;
56
+ roleClass?: 'queen' | 'worker';
57
+ isHumanSeat?: boolean;
58
+ }
59
+
60
+ interface SeatsFile {
61
+ version: number;
62
+ seats: Record<string, SeatRecord>;
63
+ }
64
+
65
+ /** The deterministic per-seat ref (identical algorithm to the retired keychain account). */
66
+ export function seatRef(input: {
67
+ origin: string;
68
+ trustIdentity: string;
69
+ cubeId: string;
70
+ roleId: string;
71
+ operation: SeatOperation;
72
+ }): string {
73
+ const binding = createHash('sha256')
74
+ .update(input.origin)
75
+ .update('\0')
76
+ .update(input.trustIdentity)
77
+ .update('\0')
78
+ .update(input.cubeId)
79
+ .update('\0')
80
+ .update(input.roleId)
81
+ .update('\0')
82
+ .update(input.operation.projectRoot)
83
+ .update('\0')
84
+ .update(input.operation.kind)
85
+ .update('\0')
86
+ .update(input.operation.operationKey)
87
+ .digest('hex');
88
+ return `borg-server-session:${binding}`;
89
+ }
90
+
91
+ function digestOf(bearer: string): string {
92
+ return createHash('sha256').update(bearer).digest('hex');
93
+ }
94
+
95
+ function emptyStore(): SeatsFile {
96
+ return { version: SEATS_VERSION, seats: {} };
97
+ }
98
+
99
+ const ROLE_CLASSES = new Set(['queen', 'worker']);
100
+ const OPERATION_KINDS = new Set(['seat', 'sibling']);
101
+
102
+ function isNonEmptyString(value: unknown): value is string {
103
+ return typeof value === 'string' && value.length > 0;
104
+ }
105
+
106
+ function isValidOperation(value: unknown): value is SeatOperation {
107
+ if (value === null || typeof value !== 'object') return false;
108
+ const op = value as Record<string, unknown>;
109
+ return (
110
+ isNonEmptyString(op.projectRoot) &&
111
+ typeof op.kind === 'string' &&
112
+ OPERATION_KINDS.has(op.kind) &&
113
+ isNonEmptyString(op.operationKey)
114
+ );
115
+ }
116
+
117
+ /**
118
+ * CR#2: FULL per-entry validation. Every key/value/invariant of a seat record is
119
+ * checked — the ref is well-formed and self-consistent (the map key equals the
120
+ * record's derived ref), state ∈ {pending,active}, the credential is a non-empty
121
+ * string, the operation is well-shaped, an ACTIVE record carries ALL its required
122
+ * server + binding fields, and a PENDING record carries NO active-only fields
123
+ * (session id / expiry). A single invalid entry ⇒ the whole store is rejected
124
+ * (fail closed at the caller, bytes preserved) — never a silent cast.
125
+ */
126
+ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
127
+ if (!REF_RE.test(ref)) return false;
128
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
129
+ const r = value as Record<string, unknown>;
130
+ // Identity + credential + operation.
131
+ if (
132
+ !isNonEmptyString(r.origin) ||
133
+ !isNonEmptyString(r.trustIdentity) ||
134
+ !isNonEmptyString(r.cubeId) ||
135
+ !isNonEmptyString(r.roleId) ||
136
+ !isValidOperation(r.operation) ||
137
+ !isNonEmptyString(r.credential)
138
+ ) {
139
+ return false;
140
+ }
141
+ if (r.state !== 'pending' && r.state !== 'active') return false;
142
+ // Optional display/typed fields, validated when present.
143
+ if (r.name !== undefined && typeof r.name !== 'string') return false;
144
+ if (r.droneLabel !== undefined && typeof r.droneLabel !== 'string') return false;
145
+ if (r.roleName !== undefined && typeof r.roleName !== 'string') return false;
146
+ if (r.roleClass !== undefined && (typeof r.roleClass !== 'string' || !ROLE_CLASSES.has(r.roleClass))) return false;
147
+ if (r.isHumanSeat !== undefined && typeof r.isHumanSeat !== 'boolean') return false;
148
+ if (r.worktree !== undefined && typeof r.worktree !== 'string') return false;
149
+ if (r.droneId !== undefined && (typeof r.droneId !== 'string' || !UUID_RE.test(r.droneId))) return false;
150
+ if (r.sessionId !== undefined && (typeof r.sessionId !== 'string' || !UUID_RE.test(r.sessionId))) return false;
151
+ if (
152
+ r.expiresAt !== undefined &&
153
+ (typeof r.expiresAt !== 'string' || !Number.isFinite(Date.parse(r.expiresAt)))
154
+ ) {
155
+ return false;
156
+ }
157
+ // State-consistency invariants (no inconsistent active|pending).
158
+ if (r.state === 'active') {
159
+ // An ACTIVE record MUST carry its full server session + worktree binding.
160
+ if (
161
+ typeof r.droneId !== 'string' ||
162
+ typeof r.sessionId !== 'string' ||
163
+ typeof r.expiresAt !== 'string' ||
164
+ typeof r.worktree !== 'string' ||
165
+ typeof r.name !== 'string' ||
166
+ typeof r.droneLabel !== 'string'
167
+ ) {
168
+ return false;
169
+ }
170
+ } else {
171
+ // A PENDING record must NOT carry active-only server session fields.
172
+ if (r.sessionId !== undefined || r.expiresAt !== undefined) return false;
173
+ }
174
+ // The map key must equal the record's derived ref (no cross-key aliasing).
175
+ return seatRef(value as SeatRecord) === ref;
176
+ }
177
+
178
+ /**
179
+ * CR4 + CR#2: parse + FULL version/schema/per-entry validation. Returns null (→ fail
180
+ * closed at the caller, bytes preserved) for any malformed JSON, wrong version, or
181
+ * ANY invalid entry; never throws and never coerces an invalid file into a
182
+ * valid-looking or empty store.
183
+ */
184
+ function parseStore(raw: string): SeatsFile | null {
185
+ let parsed: unknown;
186
+ try {
187
+ parsed = JSON.parse(raw);
188
+ } catch {
189
+ return null;
190
+ }
191
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
192
+ const candidate = parsed as { version?: unknown; seats?: unknown };
193
+ if (
194
+ candidate.version === SEATS_VERSION &&
195
+ candidate.seats &&
196
+ typeof candidate.seats === 'object' &&
197
+ !Array.isArray(candidate.seats)
198
+ ) {
199
+ const seats = candidate.seats as Record<string, unknown>;
200
+ for (const [ref, record] of Object.entries(seats)) {
201
+ if (!isValidSeatRecord(ref, record)) return null;
202
+ }
203
+ return { version: SEATS_VERSION, seats: seats as Record<string, SeatRecord> };
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ async function readStore(): Promise<SeatsFile> {
210
+ const raw = await readStoreFile(SEATS_FILE);
211
+ // CR4 fail-closed: ENOENT alone initializes empty. A present-but-malformed /
212
+ // wrong-version / schema-invalid store MUST NOT read as empty (a following commit
213
+ // would erase every seat); throw so the on-disk bytes are preserved.
214
+ if (raw === null) return emptyStore();
215
+ const parsed = parseStore(raw);
216
+ if (parsed === null) {
217
+ throw new Error(
218
+ 'Borg seat store is malformed or has an unsupported version; refusing to read it',
219
+ );
220
+ }
221
+ return parsed;
222
+ }
223
+
224
+ /** True iff `record` is a well-formed seat for `ref` + `binding` (origin/trust/cube). */
225
+ function recordMatches(
226
+ record: SeatRecord | undefined,
227
+ ref: string,
228
+ binding: { origin: string; trustIdentity: string; cubeId: string },
229
+ ): record is SeatRecord {
230
+ return (
231
+ record !== undefined &&
232
+ record.origin === binding.origin &&
233
+ record.trustIdentity === binding.trustIdentity &&
234
+ record.cubeId === binding.cubeId &&
235
+ (record.state === 'pending' || record.state === 'active') &&
236
+ typeof record.credential === 'string' &&
237
+ seatRef(record) === ref
238
+ );
239
+ }
240
+
241
+ // ─── Observation (digest-only; raw bearer never returned) ────────────────────
242
+
243
+ export type SeatObservation =
244
+ | { state: 'active'; digest: string; droneId: string }
245
+ | { state: 'pending'; digest: string }
246
+ | { state: 'absent' };
247
+
248
+ /**
249
+ * TYPED, token-safe observation of the record at `ref` (CR#3). Lock-free read
250
+ * (atomic rename → complete file); the authoritative delete/activate re-reads
251
+ * under the flock. Returns only a digest + drone id, never the raw bearer.
252
+ */
253
+ export async function observeSeat(
254
+ ref: string,
255
+ binding: { origin: string; trustIdentity: string; cubeId: string },
256
+ ): Promise<SeatObservation> {
257
+ if (!REF_RE.test(ref)) return { state: 'absent' };
258
+ const store = await readStore();
259
+ const record = store.seats[ref];
260
+ if (!recordMatches(record, ref, binding)) return { state: 'absent' };
261
+ const digest = digestOf(record.credential);
262
+ if (record.state === 'active') {
263
+ return { state: 'active', digest, droneId: typeof record.droneId === 'string' ? record.droneId : '' };
264
+ }
265
+ return { state: 'pending', digest };
266
+ }
267
+
268
+ /**
269
+ * The SOLE raw-bearer reader (SR-seven #5). Returns the ACTIVE bearer for `ref`
270
+ * iff the record is active AND the binding matches — used only to hydrate the
271
+ * live seat for authenticated requests. Every other caller observes digest-only.
272
+ */
273
+ export async function getActiveSeatCredential(
274
+ ref: string,
275
+ binding: { origin: string; trustIdentity: string; cubeId: string },
276
+ ): Promise<string | null> {
277
+ if (!REF_RE.test(ref)) return null;
278
+ const store = await readStore();
279
+ const record = store.seats[ref];
280
+ if (!recordMatches(record, ref, binding) || record.state !== 'active') return null;
281
+ return record.credential;
282
+ }
283
+
284
+ // ─── PREPARE / mint (no worktree yet) ────────────────────────────────────────
285
+
286
+ /**
287
+ * Mint the client bearer for one seat, or return the existing record (pending or
288
+ * active) so a lost-response retry re-sends the identical bearer. The minted
289
+ * record has NO worktree binding — FINALIZE adds it atomically with activation,
290
+ * so a pending record is never a live binding.
291
+ */
292
+ export async function mintPendingSeat(input: {
293
+ origin: string;
294
+ trustIdentity: string;
295
+ cubeId: string;
296
+ roleId: string;
297
+ operation: SeatOperation;
298
+ credential: string;
299
+ }): Promise<SeatRecord> {
300
+ const ref = seatRef(input);
301
+ return withStore<SeatsFile, SeatRecord>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
302
+ const existing = txn.data.seats[ref];
303
+ if (recordMatches(existing, ref, { origin: input.origin, trustIdentity: input.trustIdentity, cubeId: input.cubeId })) {
304
+ return existing;
305
+ }
306
+ const record: SeatRecord = {
307
+ origin: input.origin,
308
+ trustIdentity: input.trustIdentity,
309
+ cubeId: input.cubeId,
310
+ roleId: input.roleId,
311
+ operation: input.operation,
312
+ credential: input.credential,
313
+ state: 'pending',
314
+ };
315
+ txn.data.seats[ref] = record;
316
+ await txn.commit();
317
+ return record;
318
+ });
319
+ }
320
+
321
+ // ─── PREPARE (revalidate typed expectation + mint, under ONE flock) ──────────
322
+
323
+ /**
324
+ * Typed prepare-time expectation for the single-store attach (CR#1). EXACT — the
325
+ * exact prior ACTIVE record must STILL hold at prepare time (its ref, and, when
326
+ * pinned, its drone id and its live-bearer digest). ABSENT — no ACTIVE record may
327
+ * hold this seat (a fresh enroll / a fresh sibling seat). Field name `credentialRef`
328
+ * is kept for call-site parity with the retired cross-store ExpectedBinding.
329
+ */
330
+ export type SeatExpectation =
331
+ | { kind: 'exact'; credentialRef: string; droneId?: string; sessionDigest?: string }
332
+ | { kind: 'absent' };
333
+
334
+ export type PrepareSeatOutcome =
335
+ | { ok: true; record: SeatRecord }
336
+ | { ok: false; reason: 'expectation-mismatch' };
337
+
338
+ /**
339
+ * CR#1 PREPARE-time abort in the single-store model. Under ONE store flock:
340
+ * REVALIDATE the typed expectation against the record currently at the seat ref
341
+ * (EXACT: the exact prior ACTIVE record must still hold — ref, optional drone id,
342
+ * optional live-bearer digest; ABSENT: no ACTIVE record may hold this seat), then
343
+ * — only if it holds — MINT the pending record in the SAME lock hold. A pre-existing
344
+ * valid record (pending from a lost-response retry, or the active record being
345
+ * reattached) is REUSED so the identical bearer is re-sent. `scrubBeforeMint`
346
+ * discards a known-invalid saved record (eviction remint) before minting, still
347
+ * under the one flock. On a mismatch NOTHING is minted or scrubbed (abort).
348
+ */
349
+ export async function prepareSeat(input: {
350
+ expected: SeatExpectation;
351
+ /** Default true. When false (a fresh sibling target key that cannot yet hold a
352
+ * record) the mint runs under the flock but no expectation is revalidated. */
353
+ revalidate?: boolean;
354
+ /** Eviction remint: delete the known-invalid saved record for this seat BEFORE
355
+ * minting a fresh bearer, still inside the one flock. */
356
+ scrubBeforeMint?: boolean;
357
+ seed: {
358
+ origin: string;
359
+ trustIdentity: string;
360
+ cubeId: string;
361
+ roleId: string;
362
+ operation: SeatOperation;
363
+ credential: string;
364
+ };
365
+ }): Promise<PrepareSeatOutcome> {
366
+ const { expected, revalidate = true, scrubBeforeMint = false, seed } = input;
367
+ const ref = seatRef(seed);
368
+ const binding = { origin: seed.origin, trustIdentity: seed.trustIdentity, cubeId: seed.cubeId };
369
+ return withStore<SeatsFile, PrepareSeatOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
370
+ if (revalidate) {
371
+ const prior = txn.data.seats[ref];
372
+ let mismatch: boolean;
373
+ if (expected.kind === 'exact') {
374
+ const holds =
375
+ recordMatches(prior, ref, binding) &&
376
+ prior.state === 'active' &&
377
+ ref === expected.credentialRef &&
378
+ (expected.droneId === undefined || prior.droneId === expected.droneId) &&
379
+ (expected.sessionDigest === undefined || digestOf(prior.credential) === expected.sessionDigest);
380
+ mismatch = !holds;
381
+ } else {
382
+ // ABSENT: an ACTIVE record holding this seat is a mismatch. A PENDING record
383
+ // (a lost-response retry / crash-in-gap) is NOT a live binding and is reused
384
+ // below so the identical bearer is re-sent.
385
+ mismatch = recordMatches(prior, ref, binding) && prior.state === 'active';
386
+ }
387
+ if (mismatch) return { ok: false as const, reason: 'expectation-mismatch' as const };
388
+ }
389
+ if (scrubBeforeMint) {
390
+ delete txn.data.seats[ref];
391
+ }
392
+ const existing = txn.data.seats[ref];
393
+ if (recordMatches(existing, ref, binding)) {
394
+ // Idempotent reuse: re-send the exact bearer the server already digest-bound
395
+ // (a lost-response retry, a crash-in-gap pending, or an active reattach).
396
+ return { ok: true as const, record: existing };
397
+ }
398
+ const record: SeatRecord = {
399
+ origin: seed.origin,
400
+ trustIdentity: seed.trustIdentity,
401
+ cubeId: seed.cubeId,
402
+ roleId: seed.roleId,
403
+ operation: seed.operation,
404
+ credential: seed.credential,
405
+ state: 'pending',
406
+ };
407
+ txn.data.seats[ref] = record;
408
+ await txn.commit();
409
+ return { ok: true as const, record };
410
+ });
411
+ }
412
+
413
+ // ─── FINALIZE (activate + bind in ONE commit) ────────────────────────────────
414
+
415
+ export type ActivateSeatOutcome = 'activated' | 'missing' | 'replaced';
416
+
417
+ /** The worktree binding + display supplied at FINALIZE (known only once the target
418
+ * worktree is decided). Merged atomically with activation by activateAndBindSeat. */
419
+ export interface SeatBinding {
420
+ worktree: string;
421
+ name: string;
422
+ droneLabel: string;
423
+ roleName?: string;
424
+ roleClass?: 'queen' | 'worker';
425
+ isHumanSeat?: boolean;
426
+ }
427
+
428
+ /**
429
+ * ATOMIC compare-and-activate + bind (CR#2 + the scope-A collapse). Under ONE
430
+ * flock: the exact pending/active record whose bearer digest matches
431
+ * `expectedPendingDigest` is stamped ACTIVE with the server metadata AND the
432
+ * worktree binding + display, in a single commit. A same-ref replacement or a
433
+ * missing record fails closed (`replaced`/`missing`) — server metadata for bearer
434
+ * A is never bound onto bearer B, and the digest guard holds even on the
435
+ * no-expectation-digest paths (the SENT bearer's digest is always pinned).
436
+ * Because `state:'active'` and `worktree` land together, ACTIVE-without-binding
437
+ * is unreachable by construction.
438
+ */
439
+ export async function activateAndBindSeat(input: {
440
+ origin: string;
441
+ trustIdentity: string;
442
+ cubeId: string;
443
+ roleId: string;
444
+ operation: SeatOperation;
445
+ droneId: string;
446
+ sessionId: string;
447
+ expiresAt: string;
448
+ expectedPendingDigest: string;
449
+ worktree: string;
450
+ name: string;
451
+ droneLabel: string;
452
+ roleName?: string;
453
+ roleClass?: 'queen' | 'worker';
454
+ isHumanSeat?: boolean;
455
+ }): Promise<ActivateSeatOutcome> {
456
+ if (!UUID_RE.test(input.droneId) || !UUID_RE.test(input.sessionId)) {
457
+ throw new Error('invalid Borg server session identity');
458
+ }
459
+ if (typeof input.expiresAt !== 'string' || !Number.isFinite(Date.parse(input.expiresAt))) {
460
+ throw new Error('invalid Borg server session expiry');
461
+ }
462
+ const ref = seatRef(input);
463
+ const binding = { origin: input.origin, trustIdentity: input.trustIdentity, cubeId: input.cubeId };
464
+ return withStore<SeatsFile, ActivateSeatOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
465
+ const record = txn.data.seats[ref];
466
+ if (!recordMatches(record, ref, binding)) return 'missing';
467
+ if (digestOf(record.credential) !== input.expectedPendingDigest) return 'replaced';
468
+ txn.data.seats[ref] = {
469
+ ...record,
470
+ state: 'active',
471
+ droneId: input.droneId,
472
+ sessionId: input.sessionId,
473
+ expiresAt: input.expiresAt,
474
+ worktree: input.worktree,
475
+ name: input.name,
476
+ droneLabel: input.droneLabel,
477
+ ...(input.roleName !== undefined ? { roleName: input.roleName } : {}),
478
+ ...(input.roleClass !== undefined ? { roleClass: input.roleClass } : {}),
479
+ ...(input.isHumanSeat !== undefined ? { isHumanSeat: input.isHumanSeat } : {}),
480
+ };
481
+ await txn.commit();
482
+ return 'activated';
483
+ });
484
+ }
485
+
486
+ // ─── CR#2: bind a PENDING record to a preserved worktree (no activation) ─────
487
+
488
+ export type BindPendingSeatOutcome = 'bound' | 'missing' | 'replaced';
489
+
490
+ /**
491
+ * CR#2: bind an existing PENDING record to a worktree WITHOUT activating it. On a
492
+ * SIBLING attach whose atomic activate+bind failed, the spawned worktree is
493
+ * preserved for an operator-driven rerun (CR#5). This stamps the worktree locator +
494
+ * display (and, if known, the drone id) onto the EXACT digest-matched PENDING record
495
+ * so the rerun FROM that worktree can DISCOVER and RESUME it — re-sending the
496
+ * identical bearer the server already digest-bound, converging on the SAME seat (no
497
+ * ghost). The record STAYS `state:'pending'`: it is non-hydratable as a live seat
498
+ * (getActiveSeatForWorktree still requires state==='active'), so this introduces no
499
+ * ACTIVE-without-activation state. Under ONE store flock (CR#3), fail-closed (CR#4):
500
+ * a missing / already-ACTIVE / same-ref-replaced record is a typed no-op — never
501
+ * binds a worktree onto a different bearer, never mutates an ACTIVE record.
502
+ */
503
+ export async function bindPendingSeatToWorktree(input: {
504
+ origin: string;
505
+ trustIdentity: string;
506
+ cubeId: string;
507
+ roleId: string;
508
+ operation: SeatOperation;
509
+ expectedPendingDigest: string;
510
+ droneId?: string;
511
+ worktree: string;
512
+ name: string;
513
+ droneLabel: string;
514
+ roleName?: string;
515
+ roleClass?: 'queen' | 'worker';
516
+ isHumanSeat?: boolean;
517
+ }): Promise<BindPendingSeatOutcome> {
518
+ if (input.droneId !== undefined && !UUID_RE.test(input.droneId)) {
519
+ throw new Error('invalid Borg server session identity');
520
+ }
521
+ const ref = seatRef(input);
522
+ const binding = { origin: input.origin, trustIdentity: input.trustIdentity, cubeId: input.cubeId };
523
+ return withStore<SeatsFile, BindPendingSeatOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
524
+ const record = txn.data.seats[ref];
525
+ if (!recordMatches(record, ref, binding)) return 'missing';
526
+ // Never touch an ACTIVE record, and never bind a worktree onto a same-ref
527
+ // replacement — the record's live digest must still equal the sent bearer's.
528
+ if (record.state !== 'pending' || digestOf(record.credential) !== input.expectedPendingDigest) {
529
+ return 'replaced';
530
+ }
531
+ txn.data.seats[ref] = {
532
+ ...record,
533
+ // state STAYS 'pending' — NOT activated. Only the worktree locator + display
534
+ // (+ drone id) land, so the preserved worktree owns a discoverable, resumable
535
+ // pending record without becoming a live/hydratable binding.
536
+ worktree: input.worktree,
537
+ name: input.name,
538
+ droneLabel: input.droneLabel,
539
+ ...(input.droneId !== undefined ? { droneId: input.droneId } : {}),
540
+ ...(input.roleName !== undefined ? { roleName: input.roleName } : {}),
541
+ ...(input.roleClass !== undefined ? { roleClass: input.roleClass } : {}),
542
+ ...(input.isHumanSeat !== undefined ? { isHumanSeat: input.isHumanSeat } : {}),
543
+ };
544
+ await txn.commit();
545
+ return 'bound';
546
+ });
547
+ }
548
+
549
+ // ─── Hydration / enumeration (scan by worktree) ──────────────────────────────
550
+
551
+ /** The exact ACTIVE seat bound to `worktree`, or null. A pending record (no
552
+ * worktree, or non-active) is NEVER surfaced as a live binding. */
553
+ export async function getActiveSeatForWorktree(worktree: string): Promise<SeatRecord | null> {
554
+ const store = await readStore();
555
+ for (const [ref, record] of Object.entries(store.seats)) {
556
+ if (record.state === 'active' && record.worktree === worktree && seatRef(record) === ref) {
557
+ return record;
558
+ }
559
+ }
560
+ return null;
561
+ }
562
+
563
+ /**
564
+ * CR#2: the seat bound to `worktree` regardless of state — an ACTIVE seat OR a
565
+ * bound-PENDING record (a sibling whose activation failed, bound to its preserved
566
+ * worktree by `bindPendingSeatToWorktree`). Used ONLY by the resume path to recover
567
+ * a bound-pending record's stored `operation` + `state` so the rerun can re-derive
568
+ * the EXACT seat ref and re-send the identical bearer. A bound-pending record is
569
+ * still NON-hydratable as a live seat — `getActiveSeatForWorktree` (and hence
570
+ * `getActiveCube`) still require state==='active', so this reader does NOT weaken
571
+ * the no-ACTIVE-without-binding / non-hydratable-pending invariants.
572
+ */
573
+ export async function getSeatForWorktree(worktree: string): Promise<SeatRecord | null> {
574
+ const store = await readStore();
575
+ for (const [ref, record] of Object.entries(store.seats)) {
576
+ if (record.worktree === worktree && seatRef(record) === ref) {
577
+ return record;
578
+ }
579
+ }
580
+ return null;
581
+ }
582
+
583
+ /**
584
+ * CR#3: find an in-flight IMPLICIT-sibling attempt for `binding` — a PENDING,
585
+ * kind==='sibling' record whose operation.projectRoot is the source repo and which
586
+ * has NO worktree binding yet (unbound). Such a record is the persisted, collision-
587
+ * safe attempt identity left when a crash struck AFTER the server accepted the attach
588
+ * but BEFORE the worktree bind: its per-invocation-unique operationKey would otherwise
589
+ * be undiscoverable, so a rerun would mint a NEW bearer and the server (digest-
590
+ * correlating) would create a GHOST seat. Recovering it lets the rerun re-derive the
591
+ * EXACT ref and re-send the identical bearer (the server reuses its seat). A BOUND
592
+ * pending sibling (already discoverable by its worktree) and an ACTIVE record are NOT
593
+ * returned — so a COMPLETED sibling frees the source-repo key and the next distinct
594
+ * sibling mints a fresh identity. Deterministic first match.
595
+ */
596
+ export async function findIncompleteSiblingAttempt(binding: {
597
+ origin: string;
598
+ trustIdentity: string;
599
+ cubeId: string;
600
+ projectRoot: string;
601
+ }): Promise<SeatRecord | null> {
602
+ const store = await readStore();
603
+ for (const [ref, record] of Object.entries(store.seats)) {
604
+ if (
605
+ record.state === 'pending' &&
606
+ record.worktree === undefined &&
607
+ record.operation.kind === 'sibling' &&
608
+ record.origin === binding.origin &&
609
+ record.trustIdentity === binding.trustIdentity &&
610
+ record.cubeId === binding.cubeId &&
611
+ record.operation.projectRoot === binding.projectRoot &&
612
+ seatRef(record) === ref
613
+ ) {
614
+ return record;
615
+ }
616
+ }
617
+ return null;
618
+ }
619
+
620
+ /** True iff this worktree has ANY persisted seat record (active OR a bound
621
+ * pending), so a crash-in-gap PENDING seat is discoverable (not mislabeled). */
622
+ export async function hasSeatForWorktree(worktree: string): Promise<boolean> {
623
+ const store = await readStore();
624
+ return Object.values(store.seats).some((r) => r.worktree === worktree);
625
+ }
626
+
627
+ /** All ACTIVE bound seats — {worktree, record}. */
628
+ export async function readAllActiveSeats(): Promise<Array<{ worktree: string; record: SeatRecord }>> {
629
+ const store = await readStore();
630
+ const out: Array<{ worktree: string; record: SeatRecord }> = [];
631
+ for (const [ref, record] of Object.entries(store.seats)) {
632
+ if (record.state === 'active' && typeof record.worktree === 'string' && seatRef(record) === ref) {
633
+ out.push({ worktree: record.worktree, record });
634
+ }
635
+ }
636
+ return out;
637
+ }
638
+
639
+ // ─── Reset / scrub / metadata (all single-commit) ────────────────────────────
640
+
641
+ export type ResetSeatOutcome =
642
+ | { outcome: 'reset'; ref: string }
643
+ | { outcome: 'no-binding' }
644
+ | { outcome: 'changed' };
645
+
646
+ /**
647
+ * Reset the seat bound to `worktree`: under ONE flock, re-check the exact FULL
648
+ * binding (ref + drone id) and the token-safe observation, then DELETE the whole
649
+ * record — credential AND binding vanish together in one commit (no 'partial',
650
+ * no cross-store skew). Any drift (missing / different ref or drone / same-ref
651
+ * digest replacement) is an honest no-op.
652
+ */
653
+ export async function resetSeatForWorktree(expected: {
654
+ worktree: string;
655
+ ref: string;
656
+ /** CR#4: undefined for a bound-PENDING record with no drone id yet — the reset
657
+ * matches undefined-vs-undefined and still deletes the exact record. */
658
+ droneId?: string;
659
+ observation: SeatObservation;
660
+ }): Promise<ResetSeatOutcome> {
661
+ return withStore<SeatsFile, ResetSeatOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
662
+ const record = txn.data.seats[expected.ref];
663
+ if (
664
+ record === undefined ||
665
+ record.worktree !== expected.worktree ||
666
+ record.droneId !== expected.droneId ||
667
+ seatRef(record) !== expected.ref
668
+ ) {
669
+ // Look for whether ANY record still binds this worktree — if not, no-op.
670
+ const anyBound = Object.values(txn.data.seats).some((r) => r.worktree === expected.worktree);
671
+ return anyBound ? { outcome: 'changed' as const } : { outcome: 'no-binding' as const };
672
+ }
673
+ // Same-ref replacement guard: the live digest must still match the snapshot.
674
+ const expDigest = expected.observation.state === 'absent' ? null : expected.observation.digest;
675
+ if (expDigest !== null && digestOf(record.credential) !== expDigest) {
676
+ return { outcome: 'changed' as const };
677
+ }
678
+ delete txn.data.seats[expected.ref];
679
+ await txn.commit();
680
+ return { outcome: 'reset' as const, ref: expected.ref };
681
+ });
682
+ }
683
+
684
+ /** Abort-scrub of the caller's OWN pending record only (CR#2 finalize abort):
685
+ * delete iff still pending AND digest matches — never an active record, never a
686
+ * same-ref replacement. */
687
+ export async function scrubPendingSeat(
688
+ ref: string,
689
+ binding: { origin: string; trustIdentity: string; cubeId: string },
690
+ expectedPendingDigest: string,
691
+ ): Promise<boolean> {
692
+ if (!REF_RE.test(ref)) return false;
693
+ return withStore<SeatsFile, boolean>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
694
+ const record = txn.data.seats[ref];
695
+ if (!recordMatches(record, ref, binding) || record.state !== 'pending') return false;
696
+ if (digestOf(record.credential) !== expectedPendingDigest) return false;
697
+ delete txn.data.seats[ref];
698
+ await txn.commit();
699
+ return true;
700
+ });
701
+ }
702
+
703
+ /** Discard any record for one seat ref (eviction remint before a fresh mint). */
704
+ export async function clearSeat(ref: string): Promise<void> {
705
+ if (!REF_RE.test(ref)) throw new Error('invalid Borg server session credential reference');
706
+ await withStore<SeatsFile, void>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
707
+ if (txn.data.seats[ref] !== undefined) {
708
+ delete txn.data.seats[ref];
709
+ await txn.commit();
710
+ }
711
+ });
712
+ }
713
+
714
+ /** Metadata-only refresh of one exact ACTIVE seat. The expected tuple prevents
715
+ * a delayed response from updating a replacement seat that reused the worktree. */
716
+ export async function refreshSeatMetadata(
717
+ worktree: string,
718
+ expected: { credentialRef: string; cubeId: string; droneId: string },
719
+ display: { name: string; droneLabel: string; roleName?: string; roleClass?: 'queen' | 'worker'; isHumanSeat?: boolean },
720
+ ): Promise<boolean> {
721
+ if (!REF_RE.test(expected.credentialRef)) return false;
722
+ return withStore<SeatsFile, boolean>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
723
+ const record = txn.data.seats[expected.credentialRef];
724
+ if (record?.state !== 'active' || record.worktree !== worktree ||
725
+ record.cubeId !== expected.cubeId || record.droneId !== expected.droneId ||
726
+ seatRef(record) !== expected.credentialRef) return false;
727
+ txn.data.seats[expected.credentialRef] = {
728
+ ...record,
729
+ name: display.name,
730
+ droneLabel: display.droneLabel,
731
+ ...(display.roleName !== undefined ? { roleName: display.roleName } : {}),
732
+ ...(display.roleClass !== undefined ? { roleClass: display.roleClass } : {}),
733
+ ...(display.isHumanSeat !== undefined ? { isHumanSeat: display.isHumanSeat } : {}),
734
+ };
735
+ await txn.commit();
736
+ return true;
737
+ });
738
+ }
739
+
740
+ /** @internal Test-only: point the store at a fixture path is done via HOME; this
741
+ * clears the module cache (there is none — every op reads fresh under the lock). */