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