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
@@ -0,0 +1,1379 @@
1
+ /**
2
+ * HTTP client for a verified local (self-hosted) Borg server.
3
+ *
4
+ * Handles:
5
+ * - Pinned-TLS requests to the selected local server
6
+ * - Drone-session / enrollment-credential injection
7
+ * - Network failure handling with retry + exponential backoff
8
+ *
9
+ * There is no hosted-authority path: every request must carry verified local
10
+ * server trust or it fails closed before any network or credential use.
11
+ */
12
+
13
+ import {
14
+ getServerCredential,
15
+ } from './config.js';
16
+ import { randomUUID } from 'node:crypto';
17
+ import {
18
+ createProtocolEnvelope,
19
+ decodeEvictDroneResult,
20
+ decodeProtocolEnvelope,
21
+ decodeProtocolErrorEnvelope,
22
+ decodeReassignDroneResult,
23
+ ErrorCode,
24
+ type EvictDroneResult,
25
+ type ReassignDroneResult,
26
+ } from 'borgmcp-shared/protocol';
27
+ import { consolePrefix } from './console-prefix.js';
28
+ import { debugLog } from './debug.js';
29
+ import { assertUuidShape } from './evict-drone.js';
30
+ import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
31
+ import type { MessageTaxonomy, MessageTaxonomyClass } from 'borgmcp-shared/templates';
32
+ import type { WorkingRepo } from './working-repo.js';
33
+ import { loadBorgServerTrust, type ServerFetch } from './server-trust.js';
34
+ import {
35
+ BorgServerError,
36
+ BorgServerHttpError,
37
+ BorgServerTrustError,
38
+ BorgServerUnreachableError,
39
+ LocalManageCredentialUnavailableError,
40
+ LocalManageRequiredError,
41
+ } from './server-errors.js';
42
+ import { getActiveCube, type ActiveCube } from './cubes.js';
43
+ import {
44
+ advanceLocalServerCursor,
45
+ getLocalServerCursor,
46
+ type LocalServerCursor,
47
+ } from './local-server-cursor.js';
48
+ import { readBoundedResponseBody } from './server-response.js';
49
+ import { resolveLocalLogRecipients } from './local-log-routing.js';
50
+
51
+ export interface RemoteConnection {
52
+ apiUrl: string;
53
+ authToken: string;
54
+ serverTrustIdentity: string;
55
+ }
56
+
57
+ // gh#330: honor the server's Retry-After on 429 instead of failing the
58
+ // (often required) coordination signal outright. Bounded so a CLI call
59
+ // never blocks unboundedly; capped per attempt so a large window-reset
60
+ // retryAfter can't wedge the call.
61
+ const RATE_LIMIT_MAX_RETRIES = 3;
62
+ const RATE_LIMIT_MAX_WAIT_MS = 60_000; // cap a single Retry-After honor
63
+ export const LOCAL_SERVER_RESPONSE_LIMIT_BYTES = 32 * 1024 * 1024;
64
+ // A typed auth-error envelope is tiny; anything larger is hostile and the
65
+ // bounded read throws → the 401 fails closed to non-destructive CREDENTIAL_REJECTED.
66
+ const AUTH_ERROR_ENVELOPE_LIMIT_BYTES = 64 * 1024;
67
+ export const LOCAL_SERVER_REQUEST_TIMEOUT_MS = 5_000;
68
+ const LOCAL_SERVER_RESPONSE_LIMIT_MESSAGE =
69
+ 'Local Borg server response exceeded the response limit';
70
+
71
+ /**
72
+ * Parse a `Retry-After` header (delta-seconds form, which the worker
73
+ * emits — mcp-server.ts:382/583) into milliseconds. Returns null when
74
+ * absent or not a non-negative integer count of seconds. (The HTTP-date
75
+ * form is not emitted by the worker, so it is intentionally unhandled.)
76
+ */
77
+ export function parseRetryAfterMs(headerValue: string | null): number | null {
78
+ if (headerValue == null) return null;
79
+ const trimmed = headerValue.trim();
80
+ if (!/^\d+$/.test(trimmed)) return null;
81
+ return parseInt(trimmed, 10) * 1000;
82
+ }
83
+
84
+ /**
85
+ * How long to wait before the next 429 retry. Honors the server's
86
+ * Retry-After when present (capped at `capMs` so a full-window reset
87
+ * can't wedge a CLI call); falls back to an escalating 1s·(attempt+1)
88
+ * when absent. Adds jitter (injected for tests) so co-located sibling
89
+ * drones sharing one per-IP bucket don't retry in lockstep.
90
+ */
91
+ export function rateLimitWaitMs(
92
+ retryAfterMs: number | null,
93
+ attempt: number,
94
+ capMs: number = RATE_LIMIT_MAX_WAIT_MS,
95
+ jitter: () => number = () => Math.random() * 500
96
+ ): number {
97
+ const base = retryAfterMs != null ? retryAfterMs : 1000 * (attempt + 1);
98
+ return Math.min(base, capMs) + jitter();
99
+ }
100
+
101
+ /**
102
+ * Given an ALREADY-OBTAINED response, while it is a 429 and retries
103
+ * remain, wait per `rateLimitWaitMs` (honoring the CURRENT response's
104
+ * Retry-After) and THEN re-run `doRequest`. Takes `initialResponse`
105
+ * (not a first request) because the caller has already made the request
106
+ * and read its status — re-fetching first would ignore the first 429's
107
+ * Retry-After and double-fire an immediate extra request (CR blocker
108
+ * d3a564f5). Returns the last Response (200-class on success, or a final
109
+ * 429 if retries exhaust — the caller surfaces that). `sleep` is
110
+ * injected for deterministic tests; no fetch-global mocking required.
111
+ */
112
+ export async function retryOn429(
113
+ initialResponse: Response,
114
+ doRequest: () => Promise<Response>,
115
+ opts: {
116
+ maxRetries?: number;
117
+ capMs?: number;
118
+ sleep: (ms: number) => Promise<void>;
119
+ jitter?: () => number;
120
+ log?: (msg: string) => void;
121
+ }
122
+ ): Promise<Response> {
123
+ const maxRetries = opts.maxRetries ?? RATE_LIMIT_MAX_RETRIES;
124
+ let response = initialResponse;
125
+ let attempt = 0;
126
+ while (response.status === 429 && attempt < maxRetries) {
127
+ // Honor THIS 429's Retry-After BEFORE issuing the next request.
128
+ const waitMs = rateLimitWaitMs(
129
+ parseRetryAfterMs(response.headers.get('Retry-After')),
130
+ attempt,
131
+ opts.capMs,
132
+ opts.jitter
133
+ );
134
+ opts.log?.(
135
+ `rate limited (429); retrying in ${Math.round(waitMs)}ms (attempt ${attempt + 1}/${maxRetries})`
136
+ );
137
+ await opts.sleep(waitMs);
138
+ attempt++;
139
+ response = await doRequest();
140
+ }
141
+ return response;
142
+ }
143
+
144
+ async function localAuthorityContext(
145
+ sessionToken: string,
146
+ apiUrl: string,
147
+ expectedServerTrustIdentity?: string,
148
+ ): Promise<ActiveCube> {
149
+ const active = await getActiveCube();
150
+ const matched = active?.serverTrustIdentity !== undefined &&
151
+ active.apiUrl === apiUrl &&
152
+ active.sessionToken === sessionToken
153
+ ? active
154
+ : null;
155
+ if (!matched) {
156
+ throw new Error('Selected Borg server authority state is missing or unreadable');
157
+ }
158
+ assertUuidShape(matched.cubeId, 'cube_id');
159
+ assertUuidShape(matched.droneId, 'drone_id');
160
+ if (expectedServerTrustIdentity !== undefined) {
161
+ if (matched.serverTrustIdentity !== expectedServerTrustIdentity) {
162
+ throw new Error('Selected Borg server authority state is missing or unreadable');
163
+ }
164
+ return matched;
165
+ }
166
+ // Only a hydrated local ActiveCube carrying the verified trust anchor
167
+ // authorizes the request. cubes.json is mutable local state and a
168
+ // legacy-looking sessionToken proves nothing — fail closed before any
169
+ // network use when no verified local authority is present.
170
+ return matched;
171
+ }
172
+
173
+ function localUnsupported(capability: string): never {
174
+ throw new Error(`Local Borg server does not support ${capability}`);
175
+ }
176
+
177
+ function waitForLocalRequest<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
178
+ if (signal.aborted) return Promise.reject(signal.reason);
179
+ return new Promise((resolve, reject) => {
180
+ const onAbort = () => reject(signal.reason);
181
+ signal.addEventListener('abort', onAbort, { once: true });
182
+ promise.then(resolve, reject).finally(() => {
183
+ signal.removeEventListener('abort', onAbort);
184
+ });
185
+ });
186
+ }
187
+
188
+ async function decodeLocalProtocolResponse<T>(
189
+ request: (signal: AbortSignal) => Promise<Response>,
190
+ allowNoContent: boolean,
191
+ decodePayload: (value: unknown) => T = (value) => value as T,
192
+ ): Promise<T | null> {
193
+ const controller = new AbortController();
194
+ const timeout = setTimeout(() => {
195
+ controller.abort(new Error('Local Borg server request timed out'));
196
+ }, LOCAL_SERVER_REQUEST_TIMEOUT_MS);
197
+ try {
198
+ const response = await waitForLocalRequest(request(controller.signal), controller.signal);
199
+ if (response.status === 204 && allowNoContent) return null;
200
+
201
+ const encoded = await readBoundedResponseBody(
202
+ response,
203
+ LOCAL_SERVER_RESPONSE_LIMIT_BYTES,
204
+ LOCAL_SERVER_RESPONSE_LIMIT_MESSAGE,
205
+ controller.signal,
206
+ );
207
+ let body: unknown;
208
+ try {
209
+ body = JSON.parse(encoded);
210
+ } catch {
211
+ throw new Error('Local Borg server returned an invalid protocol envelope');
212
+ }
213
+ return decodeProtocolEnvelope(body, decodePayload).payload;
214
+ } catch (error) {
215
+ if (controller.signal.aborted) {
216
+ // CR5: a TYPED transport-timeout verdict (message kept for call-site parity).
217
+ throw new BorgServerUnreachableError('Local Borg server request timed out');
218
+ }
219
+ throw error;
220
+ } finally {
221
+ clearTimeout(timeout);
222
+ }
223
+ }
224
+
225
+ async function localServerRequest<T>(
226
+ active: ActiveCube,
227
+ path: string,
228
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH',
229
+ payload?: Record<string, unknown>,
230
+ ): Promise<T | null> {
231
+ return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
232
+ method,
233
+ signal,
234
+ droneSession: active.sessionToken,
235
+ apiUrl: active.apiUrl,
236
+ serverTrustIdentity: active.serverTrustIdentity,
237
+ redirect: 'error',
238
+ ...(payload === undefined
239
+ ? { headers: { Accept: 'application/json' } }
240
+ : {
241
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
242
+ body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
243
+ }),
244
+ }), true);
245
+ }
246
+
247
+ export interface LocalManageOperation {
248
+ operation: string;
249
+ cubeName: string;
250
+ noMutation: string;
251
+ }
252
+
253
+ function manageCopyValue(value: string): string {
254
+ return JSON.stringify(value);
255
+ }
256
+
257
+ async function localManageConnection(
258
+ active: ActiveCube,
259
+ operation: LocalManageOperation,
260
+ ): Promise<RemoteConnection> {
261
+ const trustIdentity = active.serverTrustIdentity;
262
+ if (!trustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
263
+ let authToken: string | null;
264
+ try {
265
+ authToken = await getServerCredential(active.apiUrl, trustIdentity);
266
+ } catch {
267
+ throw new LocalManageCredentialUnavailableError(
268
+ operation.operation,
269
+ operation.cubeName,
270
+ operation.noMutation,
271
+ );
272
+ }
273
+ if (!authToken) {
274
+ throw new LocalManageCredentialUnavailableError(
275
+ operation.operation,
276
+ operation.cubeName,
277
+ operation.noMutation,
278
+ );
279
+ }
280
+ return { apiUrl: active.apiUrl, authToken, serverTrustIdentity: trustIdentity };
281
+ }
282
+
283
+ async function localManageRequest<T>(
284
+ active: ActiveCube,
285
+ path: string,
286
+ method: 'POST' | 'PATCH' | 'DELETE',
287
+ operation: LocalManageOperation,
288
+ payload?: Record<string, unknown>,
289
+ decodePayload?: (value: unknown) => T,
290
+ ): Promise<T | null> {
291
+ const connection = await localManageConnection(active, operation);
292
+ try {
293
+ return await decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
294
+ method,
295
+ signal,
296
+ apiUrl: connection.apiUrl,
297
+ authToken: connection.authToken,
298
+ serverTrustIdentity: connection.serverTrustIdentity,
299
+ redirect: 'error',
300
+ ...(payload === undefined
301
+ ? { headers: { Accept: 'application/json' } }
302
+ : {
303
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
304
+ body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
305
+ }),
306
+ }), true, decodePayload);
307
+ } catch (error) {
308
+ if (
309
+ error instanceof BorgServerHttpError &&
310
+ error.status === 403 &&
311
+ error.code === ErrorCode.ACCESS_DENIED
312
+ ) {
313
+ throw new LocalManageRequiredError(
314
+ operation.operation,
315
+ operation.cubeName,
316
+ operation.noMutation,
317
+ );
318
+ }
319
+ throw error;
320
+ }
321
+ }
322
+
323
+ async function localConnectionRequest<T>(
324
+ connection: RemoteConnection,
325
+ path: string,
326
+ ): Promise<T> {
327
+ return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
328
+ method: 'GET',
329
+ signal,
330
+ apiUrl: connection.apiUrl,
331
+ authToken: connection.authToken,
332
+ serverTrustIdentity: connection.serverTrustIdentity,
333
+ redirect: 'error',
334
+ headers: { Accept: 'application/json' },
335
+ }), false) as Promise<T>;
336
+ }
337
+
338
+ async function localOwnerConnection(connection?: RemoteConnection): Promise<RemoteConnection> {
339
+ if (connection) return connection;
340
+ const active = await getActiveCube();
341
+ if (!active?.serverTrustIdentity) {
342
+ throw new Error('Selected Borg server authority state is missing or unreadable');
343
+ }
344
+ const authToken = await getServerCredential(active.apiUrl, active.serverTrustIdentity);
345
+ if (!authToken) throw new Error('No credential is stored for the selected Borg server identity');
346
+ return {
347
+ apiUrl: active.apiUrl,
348
+ authToken,
349
+ serverTrustIdentity: active.serverTrustIdentity,
350
+ };
351
+ }
352
+
353
+ async function localCubeComposition(active: ActiveCube): Promise<{
354
+ cube: any;
355
+ roles: any[];
356
+ drones: any[];
357
+ role: any;
358
+ drone: any;
359
+ }> {
360
+ const base = `/api/cubes/${active.cubeId}`;
361
+ const [cubePayload, rolePayload, dronePayload] = await Promise.all([
362
+ localServerRequest<{ cube: any }>(active, base, 'GET'),
363
+ localServerRequest<{ roles: any[] }>(active, `${base}/roles`, 'GET'),
364
+ localServerRequest<{ drones: any[] }>(active, `${base}/drones`, 'GET'),
365
+ ]);
366
+ if (!cubePayload || !rolePayload || !dronePayload) {
367
+ throw new Error('Local Borg server returned an incomplete cube response');
368
+ }
369
+ const drone = dronePayload.drones.find((candidate) => candidate.id === active.droneId);
370
+ const role = rolePayload.roles.find((candidate) => candidate.id === drone?.role_id);
371
+ if (!drone || !role) throw new Error('Local Borg server no longer recognizes this drone seat');
372
+ return {
373
+ cube: cubePayload.cube,
374
+ roles: rolePayload.roles,
375
+ drones: dronePayload.drones,
376
+ role,
377
+ drone,
378
+ };
379
+ }
380
+
381
+ function localCursorBinding(active: ActiveCube) {
382
+ return {
383
+ origin: active.apiUrl,
384
+ trustIdentity: active.serverTrustIdentity!,
385
+ cubeId: active.cubeId,
386
+ droneId: active.droneId,
387
+ };
388
+ }
389
+
390
+ async function localReadLogPage(
391
+ active: ActiveCube,
392
+ opts: { cursor?: LocalServerCursor | null; limit?: number } = {},
393
+ ): Promise<any> {
394
+ const payload = await localServerRequest<any>(
395
+ active,
396
+ `/api/cubes/${active.cubeId}/logs`,
397
+ 'PUT',
398
+ {
399
+ cursor: opts.cursor ?? null,
400
+ ...(opts.limit === undefined ? {} : { limit: opts.limit }),
401
+ },
402
+ );
403
+ if (!payload) throw new Error('Local Borg server returned an empty log response');
404
+ return payload;
405
+ }
406
+
407
+ async function resolveLocalLogCursor(
408
+ active: ActiveCube,
409
+ since: string,
410
+ ): Promise<LocalServerCursor | null> {
411
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
412
+ .test(since);
413
+ const timestamp = isUuid ? null : Date.parse(since);
414
+ if (!isUuid && (!Number.isFinite(timestamp) || new Date(timestamp!).toISOString() !== since)) {
415
+ throw new Error('Invalid local Borg server log cursor');
416
+ }
417
+
418
+ let scanCursor: LocalServerCursor | null = null;
419
+ let timestampCursor: LocalServerCursor | null = null;
420
+ for (;;) {
421
+ const page = await localReadLogPage(active, { cursor: scanCursor, limit: 500 });
422
+ for (const entry of page.entries as any[]) {
423
+ if (isUuid && entry.id === since) {
424
+ return { id: entry.id, created_at: entry.created_at };
425
+ }
426
+ if (!isUuid) {
427
+ if (entry.created_at > since) return timestampCursor;
428
+ timestampCursor = { id: entry.id, created_at: entry.created_at };
429
+ }
430
+ }
431
+ if (!page.has_more || !page.cursor) {
432
+ if (isUuid) throw new Error('Local Borg server log cursor was not found');
433
+ return timestampCursor;
434
+ }
435
+ scanCursor = page.cursor;
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Sleep for specified milliseconds
441
+ */
442
+ function sleep(ms: number): Promise<void> {
443
+ return new Promise(resolve => setTimeout(resolve, ms));
444
+ }
445
+
446
+
447
+ /**
448
+ * Authenticated fetch helper.
449
+ *
450
+ * Adds the Bearer token + optional drone-session header, parses errors
451
+ * consistently, and surfaces a re-auth recovery message ("Run: borg setup")
452
+ * on auth failure (gh#780/#794 — never `borg assimilate`, which rides the same
453
+ * broken Bearer).
454
+ *
455
+ * Accepts an optional `apiUrl` override so already-assimilated callers can
456
+ * route to the worker that issued their drone session token, regardless of
457
+ * what BORG_API_URL was set to when this process started.
458
+ */
459
+ async function authedFetch(
460
+ path: string,
461
+ init: RequestInit & {
462
+ droneSession?: string;
463
+ apiUrl?: string;
464
+ authToken?: string;
465
+ serverTrustIdentity?: string;
466
+ } = {}
467
+ ): Promise<Response> {
468
+ const {
469
+ droneSession,
470
+ apiUrl,
471
+ authToken,
472
+ serverTrustIdentity: suppliedTrustIdentity,
473
+ headers,
474
+ ...rest
475
+ } = init;
476
+ if (apiUrl === undefined) {
477
+ throw new Error('Selected Borg server authority state is missing or unreadable');
478
+ }
479
+ const baseUrl = apiUrl;
480
+ if (suppliedTrustIdentity === undefined) {
481
+ throw new Error('Selected Borg server authority state is missing or unreadable');
482
+ }
483
+ const serverTrustIdentity = suppliedTrustIdentity;
484
+ if (!/^\/api\/cubes(?:\/|$)/.test(path)) {
485
+ localUnsupported(`the ${path} capability`);
486
+ }
487
+ let requestFetch: ServerFetch;
488
+ let token: string;
489
+ {
490
+ const trust = await loadBorgServerTrust(baseUrl);
491
+ if (trust.identity !== serverTrustIdentity) {
492
+ // CR5: a TYPED terminal trust verdict — never inferred from error text.
493
+ throw new BorgServerTrustError('Borg server trust identity changed; refusing the connection');
494
+ }
495
+ requestFetch = trust.fetchImpl;
496
+ if (droneSession !== undefined) {
497
+ // Local attach credentials are already cube/drone-scoped Bearers. The
498
+ // server authenticates this single narrower principal directly.
499
+ token = droneSession;
500
+ } else if (authToken !== undefined) {
501
+ token = authToken;
502
+ } else {
503
+ const stored = await getServerCredential(baseUrl, serverTrustIdentity);
504
+ if (!stored) {
505
+ throw new Error('No credential is stored for the selected Borg server identity');
506
+ }
507
+ token = stored;
508
+ }
509
+ }
510
+
511
+ const method = ((rest.method as string | undefined) ?? 'GET').toUpperCase();
512
+
513
+ const buildRequest = async (tok: string): Promise<Response> => {
514
+ const finalHeaders: Record<string, string> = {
515
+ 'Authorization': `Bearer ${tok}`,
516
+ ...(headers as Record<string, string> | undefined),
517
+ };
518
+ // --debug / BORG_DEBUG: trace every HTTP attempt. Logs method/path/status
519
+ // ONLY — never the Authorization header or any token material.
520
+ debugLog(`→ ${method} ${path}`);
521
+ const res = await requestFetch(`${baseUrl}${path}`, {
522
+ ...rest,
523
+ headers: finalHeaders,
524
+ });
525
+ debugLog(`← ${res.status} ${method} ${path}`);
526
+ return res;
527
+ };
528
+
529
+ const response = await buildRequest(token);
530
+
531
+ if (response.status === 401) {
532
+ // Reached only after pinned-TLS trust is verified (localAuthorityContext
533
+ // fails closed otherwise). The DESTRUCTIVE worktree-seat reset is permitted
534
+ // ONLY when BOTH hold: (a) this request used the drone SESSION bearer, and
535
+ // (b) the server's bounded-decoded shared-v2 error envelope carries the EXACT
536
+ // typed code SESSION_REJECTED. A bare 401 is never sufficient. Any other or
537
+ // absent/malformed/oversized code — or a parent enrollment/client credential
538
+ // (authToken/stored) — is CREDENTIAL_REJECTED → non-destructive re-enroll
539
+ // recovery, never a seat reset. The body is read (and thus consumed) here,
540
+ // bounded to reject oversized hostile payloads (fail closed to non-reset).
541
+ let rejectedCode: ErrorCode | undefined;
542
+ try {
543
+ const body = await readBoundedResponseBody(
544
+ response,
545
+ AUTH_ERROR_ENVELOPE_LIMIT_BYTES,
546
+ 'Local Borg server auth error response exceeded the response limit',
547
+ );
548
+ rejectedCode = decodeProtocolErrorEnvelope(JSON.parse(body)).error.code;
549
+ } catch {
550
+ rejectedCode = undefined;
551
+ }
552
+ if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REJECTED) {
553
+ throw new BorgServerError(
554
+ 'SESSION_REJECTED',
555
+ 'the selected Borg server rejected this worktree session (revoked or taken over)',
556
+ );
557
+ }
558
+ throw new BorgServerError(
559
+ 'CREDENTIAL_REJECTED',
560
+ 'the selected Borg server rejected the credential',
561
+ );
562
+ }
563
+
564
+ if (!response.ok) {
565
+ // Do not copy a server response body into errors or debug output: a malicious or
566
+ // misconfigured server could reflect bearer/invitation material or inject
567
+ // terminal controls. Decode only the bounded protocol error code for typed
568
+ // branching; never surface the server-provided message or details.
569
+ let code: ErrorCode | undefined;
570
+ try {
571
+ const body = await readBoundedResponseBody(
572
+ response,
573
+ AUTH_ERROR_ENVELOPE_LIMIT_BYTES,
574
+ 'Local Borg server error response exceeded the response limit',
575
+ );
576
+ code = decodeProtocolErrorEnvelope(JSON.parse(body)).error.code;
577
+ } catch {
578
+ code = undefined;
579
+ }
580
+ debugLog(`✗ ${response.status} ${method} ${path}`);
581
+ if (droneSession !== undefined && response.status === 410 && code === DRONE_EVICTED_CODE) {
582
+ throw new DroneEvictedError();
583
+ }
584
+ throw new BorgServerHttpError(
585
+ response.status,
586
+ `Borg server request failed (HTTP ${response.status})`,
587
+ code,
588
+ );
589
+ }
590
+
591
+ return response;
592
+ }
593
+
594
+ /**
595
+ * Get the active cube's directive + role registry.
596
+ */
597
+ export async function getCubeInfo(
598
+ sessionToken: string,
599
+ apiUrl: string,
600
+ serverTrustIdentity?: string,
601
+ ): Promise<{ cube: any; roles: any[] }> {
602
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
603
+ const composed = await localCubeComposition(local);
604
+ return { cube: composed.cube, roles: composed.roles };
605
+ }
606
+
607
+ /**
608
+ * Get this drone's assigned role (with detailed_description).
609
+ */
610
+ export async function getRoleInfo(
611
+ sessionToken: string,
612
+ apiUrl: string,
613
+ serverTrustIdentity?: string,
614
+ ): Promise<{ role: any }> {
615
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
616
+ return { role: (await localCubeComposition(local)).role };
617
+ }
618
+
619
+ /**
620
+ * Get a named role's full playbook (detailed_description). Any drone in
621
+ * the cube may read any role. `role` is a role name (case-insensitive)
622
+ * or role id.
623
+ */
624
+ export async function getRoleInfoByName(
625
+ sessionToken: string,
626
+ apiUrl: string,
627
+ role: string,
628
+ serverTrustIdentity?: string,
629
+ ): Promise<{ role: any }> {
630
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
631
+ const roles = (await localCubeComposition(local)).roles;
632
+ const matched = roles.find((candidate) =>
633
+ candidate.id === role || candidate.name.toLowerCase() === role.toLowerCase()
634
+ );
635
+ if (!matched) throw new Error(`Local Borg server has no role named ${JSON.stringify(role)}`);
636
+ return { role: matched };
637
+ }
638
+
639
+ export async function whoami(
640
+ sessionToken: string,
641
+ apiUrl: string,
642
+ serverTrustIdentity?: string,
643
+ ): Promise<{ cube_id: string; cube_name: string; drone_id: string; drone_label: string; role_id: string; role_name: string }> {
644
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
645
+ const composed = await localCubeComposition(local);
646
+ return {
647
+ cube_id: composed.cube.id,
648
+ cube_name: composed.cube.name,
649
+ drone_id: composed.drone.id,
650
+ drone_label: composed.drone.label,
651
+ role_id: composed.role.id,
652
+ role_name: composed.role.name,
653
+ };
654
+ }
655
+
656
+ /**
657
+ * List all currently-connected drones in this cube.
658
+ *
659
+ * Optional `since` is the T2.1 sender-side liveness probe — pass either
660
+ * an activity_log entry id (UUID; server resolves to its `created_at`)
661
+ * OR an ISO-8601 timestamp. When provided, the response includes:
662
+ * - per-drone `seen_since: boolean` — true iff that drone's
663
+ * `last_seen` is strictly after the resolved timestamp
664
+ * - top-level `since: ISO-string | null` — the resolved timestamp
665
+ * (echoed back so the renderer can label the column accurately
666
+ * even when the caller passed an entry-id)
667
+ */
668
+ export async function getRoster(
669
+ sessionToken: string,
670
+ apiUrl: string,
671
+ since?: string,
672
+ serverTrustIdentity?: string,
673
+ ): Promise<{ drones: any[]; roles: any[]; message_taxonomy?: MessageTaxonomy | null; since?: string | null }> {
674
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
675
+ if (since !== undefined) localUnsupported('roster liveness filtering');
676
+ const composed = await localCubeComposition(local);
677
+ return { drones: composed.drones, roles: composed.roles, message_taxonomy: null };
678
+ }
679
+
680
+ /**
681
+ * Read recent log entries for the cube.
682
+ */
683
+ export async function readLog(
684
+ sessionToken: string,
685
+ apiUrl: string,
686
+ opts: {
687
+ since?: string;
688
+ limit?: number;
689
+ unreadOnly?: boolean;
690
+ serverTrustIdentity?: string;
691
+ } = {}
692
+ ): Promise<{ entries: any[]; drones: any[]; roles: any[]; behind_by?: number; has_more?: boolean }> {
693
+ const local = await localAuthorityContext(
694
+ sessionToken,
695
+ apiUrl,
696
+ opts.serverTrustIdentity,
697
+ );
698
+ let cursor: LocalServerCursor | null = null;
699
+ if (opts.unreadOnly) cursor = await getLocalServerCursor(localCursorBinding(local));
700
+ if (opts.since !== undefined) cursor = await resolveLocalLogCursor(local, opts.since);
701
+ const page = await localReadLogPage(local, { cursor, limit: opts.limit });
702
+ if (opts.unreadOnly && page.cursor) {
703
+ await advanceLocalServerCursor(localCursorBinding(local), page.cursor);
704
+ }
705
+ const composed = await localCubeComposition(local);
706
+ return {
707
+ entries: page.entries,
708
+ drones: composed.drones,
709
+ roles: composed.roles,
710
+ behind_by: page.behind_by,
711
+ has_more: page.has_more,
712
+ };
713
+ }
714
+
715
+ /**
716
+ * Sprint 25 log substrate refactor: explicit ack on a log entry.
717
+ *
718
+ * Replaces in-band `ACK: <dispatch-id>` log entries with a DB-backed
719
+ * flag on activity_log_acks. Idempotent — the server INSERT uses ON
720
+ * CONFLICT DO NOTHING. 204 No Content on success.
721
+ */
722
+ // 'claim' is advisory review-gate ownership; 'ack' preserves the original wire default.
723
+ export async function ackLogEntry(
724
+ sessionToken: string,
725
+ apiUrl: string,
726
+ entryId: string,
727
+ kind: 'ack' | 'claim' = 'ack',
728
+ serverTrustIdentity?: string,
729
+ ): Promise<void> {
730
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
731
+ await localServerRequest(
732
+ local,
733
+ `/api/cubes/${local.cubeId}/acks`,
734
+ 'POST',
735
+ { entry_id: entryId, kind },
736
+ );
737
+ }
738
+
739
+ /** Record a ratified cube decision using the local client's cube-manage grant. */
740
+ export async function recordDecision(
741
+ sessionToken: string,
742
+ apiUrl: string,
743
+ input: { topic: string; decision: string; rationale?: string },
744
+ serverTrustIdentity?: string,
745
+ ): Promise<{ decision: any }> {
746
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
747
+ const payload = await localManageRequest<{ decision: any }>(
748
+ local,
749
+ `/api/cubes/${local.cubeId}/decisions`,
750
+ 'POST',
751
+ {
752
+ operation: `record a decision in cube ${manageCopyValue(local.name)}`,
753
+ cubeName: local.name,
754
+ noMutation: 'Nothing was recorded.',
755
+ },
756
+ input,
757
+ );
758
+ if (!payload) throw new Error('Local Borg server returned an empty decision response');
759
+ return payload;
760
+ }
761
+
762
+ /**
763
+ * gh#740: list active ratified decisions for the cube (any member). With
764
+ * `topic`, returns that topic's active decision.
765
+ */
766
+ export async function listDecisions(
767
+ sessionToken: string,
768
+ apiUrl: string,
769
+ topic?: string,
770
+ serverTrustIdentity?: string,
771
+ ): Promise<{ decisions: any[] }> {
772
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
773
+ const payload = await localServerRequest<{ decisions: any[] }>(
774
+ local,
775
+ `/api/cubes/${local.cubeId}/decisions`,
776
+ 'PUT',
777
+ {},
778
+ );
779
+ if (!payload) throw new Error('Local Borg server returned an empty decisions response');
780
+ return {
781
+ decisions: topic === undefined
782
+ ? payload.decisions
783
+ : payload.decisions.filter((decision) => decision.topic === topic),
784
+ };
785
+ }
786
+
787
+ /** Remove one active ratified decision. The worker enforces the seat gate. */
788
+ export async function removeDecision(
789
+ sessionToken: string,
790
+ apiUrl: string,
791
+ selector: { topic: string } | { decision_id: string },
792
+ serverTrustIdentity?: string,
793
+ ): Promise<{ decision: any }> {
794
+ void sessionToken;
795
+ void apiUrl;
796
+ void selector;
797
+ void serverTrustIdentity;
798
+ localUnsupported('decision removal');
799
+ }
800
+
801
+ /**
802
+ * Regen: one-shot composite of everything a drone needs to be oriented.
803
+ *
804
+ * Returns the active cube's directive, the drone's own role with full
805
+ * detailed_description, the public role registry (no detailed_description
806
+ * leakage for OTHER roles), the drone roster, and the caller's unread-log
807
+ * COUNT (behind_by). gh#886: the recent-log PAYLOAD is no longer rendered
808
+ * client-side — the drone gets the count and drains via borg_read-log. Use
809
+ * on session start and before each new task to stay in sync.
810
+ *
811
+ * gh#29 Sprint C / Q3a: optional `since` cursor (entry-id UUID or
812
+ * ISO-8601 timestamp). The worker still ships `recentLog` for rollout-compat
813
+ * (a pre-gh#886 client renders it; `since` trims it to entries strictly after
814
+ * the anchor) — but the current client renders the unread COUNT, not the
815
+ * payload, so `since` no longer affects what this client shows.
816
+ */
817
+ export async function regen(
818
+ sessionToken: string,
819
+ apiUrl: string,
820
+ opts: {
821
+ since?: string;
822
+ /** Advisory self-report from the running agent; never model-routing config. */
823
+ reportedModel?: string;
824
+ /** Current cwd-derived identity; refreshed each regen to avoid stale routing data. */
825
+ workingRepo?: WorkingRepo;
826
+ /** Verified self-hosted authority from the caller's first active-state read. */
827
+ serverTrustIdentity?: string;
828
+ } = {}
829
+ ): Promise<{
830
+ cube: any;
831
+ role: any;
832
+ drone: any;
833
+ roles: any[];
834
+ drones: any[];
835
+ // gh#886: recentLog kept for rollout-compat (worker still sends it); the
836
+ // client no longer renders it. behind_by is the caller's unread count the
837
+ // client renders as a drain instruction. Optional — absent from pre-gh#886
838
+ // workers (client falls back to rendering recentLog).
839
+ recentLog?: any[];
840
+ behind_by?: number;
841
+ // gh#740: active ratified decisions for the cube, rendered by regen-format.
842
+ // Local regen composes these via listDecisions.
843
+ decisions?: any[];
844
+ }> {
845
+ const local = await localAuthorityContext(
846
+ sessionToken,
847
+ apiUrl,
848
+ opts.serverTrustIdentity,
849
+ );
850
+ const composed = await localCubeComposition(local);
851
+ const cursor = opts.since === undefined
852
+ ? await getLocalServerCursor(localCursorBinding(local))
853
+ : await resolveLocalLogCursor(local, opts.since);
854
+ const page = await localReadLogPage(local, { cursor, limit: 1 });
855
+ let decisions: any[] = [];
856
+ try {
857
+ decisions = (await listDecisions(
858
+ sessionToken,
859
+ apiUrl,
860
+ undefined,
861
+ opts.serverTrustIdentity,
862
+ )).decisions;
863
+ } catch (error) {
864
+ console.warn(
865
+ `Local regen: failed to fetch ratified decisions (${error instanceof Error ? error.message : String(error)}); continuing without them.`,
866
+ );
867
+ }
868
+ return {
869
+ cube: composed.cube,
870
+ role: composed.role,
871
+ drone: composed.drone,
872
+ roles: composed.roles,
873
+ drones: composed.drones,
874
+ recentLog: [],
875
+ behind_by: page.entries.length + page.behind_by,
876
+ decisions,
877
+ };
878
+ }
879
+
880
+ export async function roleRationale(
881
+ sessionToken: string,
882
+ apiUrl: string,
883
+ role: string,
884
+ section: string,
885
+ serverTrustIdentity?: string,
886
+ ): Promise<{ role: string; section: string; body: string }> {
887
+ void sessionToken;
888
+ void apiUrl;
889
+ void role;
890
+ void section;
891
+ void serverTrustIdentity;
892
+ localUnsupported('role rationale sections');
893
+ }
894
+
895
+ /**
896
+ * Append a message to the cube's shared activity log.
897
+ */
898
+ export async function appendLog(
899
+ sessionToken: string,
900
+ apiUrl: string,
901
+ message: string,
902
+ opts: {
903
+ visibility?: 'broadcast' | 'direct';
904
+ recipientDroneIds?: string[];
905
+ class?: string;
906
+ to?: string[];
907
+ serverTrustIdentity?: string;
908
+ } = {}
909
+ ): Promise<{
910
+ entry: {
911
+ id: string;
912
+ cube_id: string;
913
+ drone_id: string;
914
+ message: string;
915
+ visibility: 'broadcast' | 'direct';
916
+ created_at: string;
917
+ };
918
+ routing?: {
919
+ class: string | null;
920
+ recipients: string[];
921
+ fellOpen: boolean;
922
+ message: string | null;
923
+ } | null;
924
+ // gh#534: directed recipients currently unreachable via the wake path
925
+ // (wake-path:deaf). Empty/absent for broadcast or all-reachable sends.
926
+ unreachableRecipients?: { id: string; label: string }[];
927
+ }> {
928
+ if (opts.visibility === 'broadcast' && (opts.to?.length ?? 0) > 0) {
929
+ throw new Error(
930
+ "Invalid input: visibility:'broadcast' cannot be combined with non-empty to:. " +
931
+ 'Remove visibility to direct to recipients, or remove to: to broadcast.',
932
+ );
933
+ }
934
+ const local = await localAuthorityContext(
935
+ sessionToken,
936
+ apiUrl,
937
+ opts.serverTrustIdentity,
938
+ );
939
+ let visibility = opts.visibility;
940
+ let recipientDroneIds = opts.recipientDroneIds;
941
+ if (visibility !== 'broadcast' &&
942
+ (!recipientDroneIds || recipientDroneIds.length === 0) &&
943
+ opts.to !== undefined) {
944
+ const base = `/api/cubes/${local.cubeId}`;
945
+ const [rolePayload, dronePayload] = await Promise.all([
946
+ localServerRequest<{ roles: any[] }>(local, `${base}/roles`, 'GET'),
947
+ localServerRequest<{ drones: any[] }>(local, `${base}/drones`, 'GET'),
948
+ ]);
949
+ if (!rolePayload || !dronePayload) {
950
+ throw new Error('Local Borg server returned an incomplete cube roster');
951
+ }
952
+ recipientDroneIds = resolveLocalLogRecipients(
953
+ opts.to,
954
+ dronePayload.drones,
955
+ rolePayload.roles,
956
+ );
957
+ visibility = 'direct';
958
+ } else if (visibility === undefined && recipientDroneIds !== undefined) {
959
+ visibility = 'direct';
960
+ }
961
+ const payload = await localServerRequest<{ entry: any }>(
962
+ local,
963
+ `/api/cubes/${local.cubeId}/logs`,
964
+ 'POST',
965
+ {
966
+ message,
967
+ ...(visibility ? { visibility } : {}),
968
+ ...(visibility === 'direct' && recipientDroneIds
969
+ ? { recipientDroneIds }
970
+ : {}),
971
+ // server#48 append-time taxonomy routing: forward the requested class
972
+ // so the server can classify/route. It is honored only when no explicit
973
+ // visibility/recipients override it (server resolveMessageRouting).
974
+ ...(opts.class ? { class: opts.class } : {}),
975
+ },
976
+ );
977
+ if (!payload) throw new Error('Local Borg server returned an empty log response');
978
+ return { entry: payload.entry };
979
+ }
980
+
981
+ /**
982
+ * List cubes readable by the local client's live grants.
983
+ */
984
+ export async function listCubes(connection?: RemoteConnection): Promise<{ cubes: any[] }> {
985
+ return localConnectionRequest<{ cubes: any[] }>(await localOwnerConnection(connection), '/api/cubes');
986
+ }
987
+
988
+ /**
989
+ * List bundled cube templates. Used by the `borg assimilate` orchestrator
990
+ * to surface the interactive template prompt on first-drone bootstrap.
991
+ */
992
+ /**
993
+ * Create a new cube. Server-side seeds a default "Drone" role atomically
994
+ * so the cube is assimilatable immediately, OR applies the named template
995
+ * atomically when `opts.template` is set (single-withUserId transaction —
996
+ * skips the auto-Drone insert to avoid is_default partial-index conflict).
997
+ *
998
+ * Returns `{ cube, roles }` — the roles array lets the assimilate
999
+ * orchestrator pick a default role without a follow-up `getCube` call.
1000
+ * Existing callers that read `body.cube` keep working (forward-compat).
1001
+ */
1002
+ export async function createCube(
1003
+ name: string | undefined,
1004
+ cubeDirective: string,
1005
+ opts?: { template?: string; message_taxonomy?: MessageTaxonomy | null },
1006
+ connection?: RemoteConnection,
1007
+ ): Promise<{ id: string; name: string; cube_directive?: string; roles: any[]; drones?: any[]; [k: string]: any }> {
1008
+ void name;
1009
+ void cubeDirective;
1010
+ void opts;
1011
+ void connection;
1012
+ localUnsupported('cube creation');
1013
+ }
1014
+
1015
+ /**
1016
+ * Update a cube's name and/or cube_directive. Both fields are optional;
1017
+ * pass only what changes.
1018
+ */
1019
+ export async function updateCube(
1020
+ cubeId: string,
1021
+ updates: { name?: string; cube_directive?: string; message_taxonomy?: MessageTaxonomy | null }
1022
+ ): Promise<{ cube: any }> {
1023
+ assertUuidShape(cubeId, 'cube_id');
1024
+ const active = await getActiveCube();
1025
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1026
+ if (updates.name !== undefined) localUnsupported('cube rename');
1027
+ const payload: Record<string, unknown> = {};
1028
+ if (updates.cube_directive !== undefined) payload.cube_directive = updates.cube_directive;
1029
+ if (Object.prototype.hasOwnProperty.call(updates, 'message_taxonomy')) {
1030
+ payload.message_taxonomy = updates.message_taxonomy ?? null;
1031
+ }
1032
+ const result = await localManageRequest<{ cube: any }>(
1033
+ active,
1034
+ `/api/cubes/${cubeId}`,
1035
+ 'PATCH',
1036
+ {
1037
+ operation: `update cube settings in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
1038
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1039
+ noMutation: 'No cube settings were changed.',
1040
+ },
1041
+ payload,
1042
+ );
1043
+ if (!result) throw new Error('Local Borg server returned an empty cube response');
1044
+ return result;
1045
+ }
1046
+
1047
+ /**
1048
+ * gh#473 PR1 — granular per-class taxonomy patch. Add / replace-by-name
1049
+ * / remove a single class within the cube's message_taxonomy, leaving
1050
+ * other classes unchanged. The server re-validates the full resulting
1051
+ * array and requires the selected local client's live cube-manage grant.
1052
+ */
1053
+ export async function patchTaxonomyClass(
1054
+ cubeId: string,
1055
+ op:
1056
+ | { action: 'add'; class_def: MessageTaxonomyClass }
1057
+ | { action: 'replace'; class_def: MessageTaxonomyClass }
1058
+ | { action: 'remove'; class: string }
1059
+ ): Promise<{ cube: any }> {
1060
+ assertUuidShape(cubeId, 'cube_id');
1061
+ const active = await getActiveCube();
1062
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1063
+ const className = op.action === 'remove' ? op.class : op.class_def.class;
1064
+ const preposition = op.action === 'add' ? 'to' : op.action === 'replace' ? 'in' : 'from';
1065
+ const pastTense = op.action === 'add' ? 'added' : op.action === 'replace' ? 'replaced' : 'removed';
1066
+ const cubeName = cubeId === active.cubeId ? active.name : cubeId;
1067
+ const result = await localManageRequest<{ cube: any }>(
1068
+ active,
1069
+ `/api/cubes/${cubeId}/taxonomy-patch`,
1070
+ 'POST',
1071
+ {
1072
+ operation: `${op.action} message class ${manageCopyValue(className)} ${preposition} cube ${manageCopyValue(cubeName)}`,
1073
+ cubeName,
1074
+ noMutation: `No message class was ${pastTense}.`,
1075
+ },
1076
+ op,
1077
+ );
1078
+ if (!result) throw new Error('Local Borg server returned an empty taxonomy response');
1079
+ return result;
1080
+ }
1081
+
1082
+ /**
1083
+ * Delete a cube. Cascade-deletes all roles, drones, and log entries.
1084
+ * Requires a live cube-manage grant on the selected local client.
1085
+ */
1086
+ export async function deleteCube(cubeId: string): Promise<void> {
1087
+ void cubeId;
1088
+ localUnsupported('cube deletion');
1089
+ }
1090
+
1091
+ /**
1092
+ * Create a role inside a cube. is_default=true demotes the previous
1093
+ * default role; the cube always has exactly one default.
1094
+ */
1095
+ export async function createRole(
1096
+ cubeId: string,
1097
+ data: { name: string; short_description: string; detailed_description: string; is_default?: boolean; is_mandatory?: boolean; is_human_seat?: boolean; can_broadcast?: boolean; receives_all_direct?: boolean; default_model?: string; role_class?: 'queen' | 'worker' }
1098
+ ): Promise<{ role: any }> {
1099
+ assertUuidShape(cubeId, 'cube_id');
1100
+ const active = await getActiveCube();
1101
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1102
+ if (data.default_model !== undefined) localUnsupported('per-role default model');
1103
+ const result = await localManageRequest<{ role: any }>(
1104
+ active,
1105
+ `/api/cubes/${cubeId}/roles`,
1106
+ 'POST',
1107
+ {
1108
+ operation: `create role ${manageCopyValue(data.name)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
1109
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1110
+ noMutation: 'No role was created.',
1111
+ },
1112
+ buildLocalRoleFields(data),
1113
+ );
1114
+ if (!result) throw new Error('Local Borg server returned an empty role response');
1115
+ return result;
1116
+ }
1117
+
1118
+ /**
1119
+ * Update a role. All fields optional; pass only what changes.
1120
+ */
1121
+ export async function updateRole(
1122
+ roleId: string,
1123
+ updates: { name?: string; short_description?: string; detailed_description?: string; is_default?: boolean; is_mandatory?: boolean; is_human_seat?: boolean; can_broadcast?: boolean; receives_all_direct?: boolean; default_model?: string; role_class?: 'queen' | 'worker' }
1124
+ ): Promise<{ role: any }> {
1125
+ assertUuidShape(roleId, 'role_id');
1126
+ const active = await getActiveCube();
1127
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1128
+ assertUuidShape(active.cubeId, 'cube_id');
1129
+ if (updates.default_model !== undefined) localUnsupported('per-role default model');
1130
+ const result = await localManageRequest<{ role: any }>(
1131
+ active,
1132
+ `/api/cubes/${active.cubeId}/roles/${roleId}`,
1133
+ 'PATCH',
1134
+ {
1135
+ operation: `update role ${manageCopyValue(roleId)} in cube ${manageCopyValue(active.name)}`,
1136
+ cubeName: active.name,
1137
+ noMutation: 'No role was updated.',
1138
+ },
1139
+ buildLocalRoleFields(updates),
1140
+ );
1141
+ if (!result) throw new Error('Local Borg server returned an empty role response');
1142
+ return result;
1143
+ }
1144
+
1145
+ /**
1146
+ * Project a create/update-role field bag onto the exact snake_case keys the
1147
+ * self-hosted coordination API accepts, dropping undefined entries and the
1148
+ * unsupported default_model (rejected before this call). name is included only
1149
+ * when present so a partial update PATCHes just the supplied fields.
1150
+ */
1151
+ function buildLocalRoleFields(
1152
+ fields: {
1153
+ name?: string;
1154
+ short_description?: string;
1155
+ detailed_description?: string;
1156
+ is_default?: boolean;
1157
+ is_mandatory?: boolean;
1158
+ is_human_seat?: boolean;
1159
+ can_broadcast?: boolean;
1160
+ receives_all_direct?: boolean;
1161
+ role_class?: 'queen' | 'worker';
1162
+ },
1163
+ ): Record<string, unknown> {
1164
+ const payload: Record<string, unknown> = {};
1165
+ for (const key of [
1166
+ 'name',
1167
+ 'short_description',
1168
+ 'detailed_description',
1169
+ 'is_default',
1170
+ 'is_mandatory',
1171
+ 'is_human_seat',
1172
+ 'can_broadcast',
1173
+ 'receives_all_direct',
1174
+ 'role_class',
1175
+ ] as const) {
1176
+ if (fields[key] !== undefined) payload[key] = fields[key];
1177
+ }
1178
+ return payload;
1179
+ }
1180
+
1181
+ /**
1182
+ * gh#473 PR1 — granular role-text section patch. Replace / insert /
1183
+ * delete a single named section of a role's detailed_description,
1184
+ * leaving the rest of the field byte-identical. Requires the selected local
1185
+ * client's live cube-manage grant. Sections are delimited by plain-label lines (e.g.
1186
+ * `Workflow:`), NOT markdown headings.
1187
+ */
1188
+ export async function patchRoleSection(
1189
+ roleId: string,
1190
+ op:
1191
+ | { action: 'replace'; heading: string; body: string }
1192
+ | { action: 'insert'; heading: string; body: string; after?: string | null }
1193
+ | { action: 'delete'; heading: string }
1194
+ ): Promise<{ role: any }> {
1195
+ assertUuidShape(roleId, 'role_id');
1196
+ const active = await getActiveCube();
1197
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1198
+ assertUuidShape(active.cubeId, 'cube_id');
1199
+ const result = await localManageRequest<{ role: any }>(
1200
+ active,
1201
+ `/api/cubes/${active.cubeId}/roles/${roleId}/section-patch`,
1202
+ 'POST',
1203
+ {
1204
+ operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(active.name)}`,
1205
+ cubeName: active.name,
1206
+ noMutation: `No role section was ${op.action === 'insert' ? 'inserted' : op.action === 'replace' ? 'replaced' : 'deleted'}.`,
1207
+ },
1208
+ { ...op },
1209
+ );
1210
+ if (!result) throw new Error('Local Borg server returned an empty role response');
1211
+ return result;
1212
+ }
1213
+
1214
+ /**
1215
+ * Delete a role. Worker refuses if any drone is still assigned to it
1216
+ * (reassign or evict those drones first).
1217
+ */
1218
+ export async function deleteRole(roleId: string): Promise<void> {
1219
+ void roleId;
1220
+ localUnsupported('role deletion');
1221
+ }
1222
+
1223
+ /**
1224
+ * Reassign a drone to a different role within the same cube.
1225
+ * Queen-class seat cardinality is enforced server-side — attempting
1226
+ * to assign to a queen-class role when another drone already holds
1227
+ * the seat returns an error. The class-hierarchy guard also rejects
1228
+ * direct promotion from non-human-seat roles.
1229
+ */
1230
+ export async function reassignDrone(
1231
+ droneId: string,
1232
+ roleId: string,
1233
+ activeOverride?: ActiveCube,
1234
+ ): Promise<ReassignDroneResult> {
1235
+ // Validate both identifiers before credential lookup or network access.
1236
+ assertUuidShape(droneId, 'drone_id');
1237
+ assertUuidShape(roleId, 'role_id');
1238
+ const active = activeOverride ?? await getActiveCube();
1239
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1240
+ assertUuidShape(active.cubeId, 'cube_id');
1241
+ const result = await localManageRequest<ReassignDroneResult>(
1242
+ active,
1243
+ `/api/cubes/${active.cubeId}/drones/${droneId}`,
1244
+ 'PATCH',
1245
+ {
1246
+ operation: `reassign drone ${manageCopyValue(droneId)} to role ${manageCopyValue(roleId)} in cube ${manageCopyValue(active.name)}`,
1247
+ cubeName: active.name,
1248
+ noMutation: 'No drone was reassigned.',
1249
+ },
1250
+ { role_id: roleId },
1251
+ decodeReassignDroneResult,
1252
+ );
1253
+ if (!result) throw new Error('Local Borg server returned an empty drone reassignment response');
1254
+ return result;
1255
+ }
1256
+
1257
+ export interface EvictDroneOptions {
1258
+ cubeId?: string;
1259
+ cubeName?: string;
1260
+ targetReference?: string;
1261
+ active?: ActiveCube;
1262
+ }
1263
+
1264
+ export async function evictDrone(
1265
+ droneId: string,
1266
+ options: EvictDroneOptions = {},
1267
+ ): Promise<EvictDroneResult> {
1268
+ assertUuidShape(droneId, 'drone_id');
1269
+ if (options.cubeId !== undefined) assertUuidShape(options.cubeId, 'cube_id');
1270
+ const active = options.active ?? await getActiveCube();
1271
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1272
+ const cubeId = options.cubeId ?? active.cubeId;
1273
+ assertUuidShape(cubeId, 'cube_id');
1274
+ const cubeName = options.cubeName ?? (cubeId === active.cubeId ? active.name : cubeId);
1275
+ const targetReference = options.targetReference ?? droneId;
1276
+ const result = await localManageRequest<EvictDroneResult>(
1277
+ active,
1278
+ `/api/cubes/${cubeId}/drones/${droneId}`,
1279
+ 'DELETE',
1280
+ {
1281
+ operation: `remove ${manageCopyValue(targetReference)} from cube ${manageCopyValue(cubeName)}`,
1282
+ cubeName,
1283
+ noMutation: 'No drone was removed.',
1284
+ },
1285
+ {},
1286
+ decodeEvictDroneResult,
1287
+ );
1288
+ if (!result) throw new Error('Local Borg server returned an empty drone eviction response');
1289
+ return result;
1290
+ }
1291
+
1292
+ export async function listRoles(cubeId: string): Promise<any[]> {
1293
+ assertUuidShape(cubeId, 'cube_id');
1294
+ const active = await getActiveCube();
1295
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1296
+ const result = await localServerRequest<{ roles: any[] }>(
1297
+ active,
1298
+ `/api/cubes/${cubeId}/roles`,
1299
+ 'GET',
1300
+ );
1301
+ if (!result || !Array.isArray(result.roles)) {
1302
+ throw new Error('Local Borg server returned an invalid roles response');
1303
+ }
1304
+ return result.roles;
1305
+ }
1306
+
1307
+ export async function getCubeForManagement(
1308
+ cubeId: string,
1309
+ operation: LocalManageOperation,
1310
+ activeOverride?: ActiveCube,
1311
+ ): Promise<{ id: string; name: string; roles: any[]; drones: any[]; [k: string]: any }> {
1312
+ assertUuidShape(cubeId, 'cube_id');
1313
+ const active = activeOverride ?? await getActiveCube();
1314
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1315
+ return getCube(cubeId, await localManageConnection(active, operation));
1316
+ }
1317
+
1318
+ /**
1319
+ * Fetch a cube's full detail: directive, roles (with detailed
1320
+ * descriptions), and drones. Access is enforced by the local client's live
1321
+ * per-cube grant.
1322
+ */
1323
+ export async function getCube(cubeId: string, connection?: RemoteConnection): Promise<{ id: string; name: string; roles: any[]; drones: any[]; [k: string]: any }> {
1324
+ assertUuidShape(cubeId, 'cube_id');
1325
+ const resolved = await localOwnerConnection(connection);
1326
+ const base = `/api/cubes/${cubeId}`;
1327
+ const [cubePayload, rolePayload, dronePayload] = await Promise.all([
1328
+ localConnectionRequest<{ cube: any }>(resolved, base),
1329
+ localConnectionRequest<{ roles: any[] }>(resolved, `${base}/roles`),
1330
+ localConnectionRequest<{ drones: any[] }>(resolved, `${base}/drones`),
1331
+ ]);
1332
+ return {
1333
+ ...cubePayload.cube,
1334
+ roles: rolePayload.roles,
1335
+ drones: dronePayload.drones,
1336
+ };
1337
+ }
1338
+
1339
+ /**
1340
+ * gh#473 PR2 — apply a named template to an existing cube via the
1341
+ * NON-CLOBBERING server route. New roles are inserted; existing
1342
+ * template-named roles get ADD fragments auto-applied (template
1343
+ * sections/classes the cube lacks) but their EVOLVED (conflicting)
1344
+ * fragments are surfaced server-side and KEPT, never overwritten. Returns
1345
+ * `{ created, updated }` counts. To selectively take template versions of
1346
+ * conflicting fragments, use `syncRoles` with a `decisions` map instead.
1347
+ */
1348
+ export async function applyTemplate(
1349
+ cubeId: string,
1350
+ templateName: string
1351
+ ): Promise<{ created: number; updated: number }> {
1352
+ void cubeId;
1353
+ void templateName;
1354
+ localUnsupported('template application');
1355
+ }
1356
+
1357
+ /**
1358
+ * gh#473 PR2 — NON-CLOBBERING sync of a cube's roles + message_taxonomy
1359
+ * against the current built-in template. Dry-run by default classifies
1360
+ * each fragment (role-text SECTION / short_description / flags / taxonomy
1361
+ * CLASS) as ADD / UNCHANGED / CONFLICT. Pass apply=true to commit:
1362
+ * ADD fragments auto-apply (zero clobber risk); CONFLICT fragments apply
1363
+ * ONLY when their stable key appears in `decisions` as 'accept'.
1364
+ * Unspecified conflicts DEFAULT TO REJECT — the cube's evolved text is
1365
+ * never silently overwritten. Custom roles (names not in template) are
1366
+ * never touched. Returns a NonClobberSyncResult.
1367
+ */
1368
+ export async function syncRoles(
1369
+ cubeId: string,
1370
+ templateName: string = 'software-dev',
1371
+ apply: boolean = false,
1372
+ decisions?: Record<string, 'accept' | 'reject'>
1373
+ ): Promise<any> {
1374
+ void cubeId;
1375
+ void templateName;
1376
+ void apply;
1377
+ void decisions;
1378
+ localUnsupported('role synchronization');
1379
+ }