borgmcp 1.1.14 → 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 +86 -37
  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,1864 @@
1
+ import { dirname, basename, join } from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import type { Role, RoleOccupant } from './role-resolver.js';
4
+ import {
5
+ roleSlug,
6
+ matchRoleByName,
7
+ occupiedRoleIdsForAutoRole,
8
+ pickDefaultRole,
9
+ } from './role-resolver.js';
10
+ import { deriveCubeName, parseGitRemote, sanitizeRemoteUrl } from './cube-name.js';
11
+ import { validateName } from './name-validator.js';
12
+ import { renderAssimilationWelcome } from './assimilate-welcome.js';
13
+ import { shellEscape } from './shell-escape.js';
14
+ import { withCodexCwdArg, type CodexRemoteLaunch } from './codex-remote.js';
15
+ import {
16
+ buildAgentKickoffPrompt,
17
+ buildKickoffWakePathClause,
18
+ recordCodexWakeTarget,
19
+ socketPathFromRemoteArgs,
20
+ } from './codex-launch.js';
21
+ import { perWorktreeBranchName, adoptWorktree, computeWorktreePath, localBranchExists, isMerged } from './worktree-lifecycle.js';
22
+ import { DroneEvictedError } from './drone-lifecycle.js';
23
+ import { codexBorgSessionConfigArgs } from './launch-gate.js';
24
+ import {
25
+ codexAgentKindConfigArgs,
26
+ codexRemoteWakeConfigArgs,
27
+ withAgentRuntimeEnv,
28
+ } from './agent-runtime.js';
29
+ import type { BorgCli } from './cubes.js';
30
+ import { inboxPathForDrone } from './cubes.js';
31
+ import { monitorStateRootForWorktree } from './inbox-monitor.js';
32
+ import { resolveLaunchEnv } from './model-presets.js';
33
+ import { unlinkSync } from 'node:fs';
34
+ import {
35
+ gcOrphanInboxesForCube,
36
+ defaultListInboxLogs,
37
+ defaultInboxLivenessDeps,
38
+ isInboxLive,
39
+ ORPHAN_INBOX_STALE_MS,
40
+ } from './gc-orphan-inboxes.js';
41
+ import { installBorgPlugin } from './opencode-plugin.js';
42
+ import { computeOpenCodePort, connectOpenCodeDrone, createOpenCodeLaunchKickoff, injectInitialKickoff } from './opencode-drone.js';
43
+ import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
44
+ import { normalizeServerEndpoint } from './server-endpoint.js';
45
+ import { BorgServerError } from './server-errors.js';
46
+ import type { SeatStatus } from './seat-probe.js';
47
+ import type { ServerSessionOperation } from './config.js';
48
+ import type { ExpectedBinding, FinalizeServerSeatOutcome, PersistedLocalSeat } from './cubes.js';
49
+ import type { SeatBinding, BindPendingSeatOutcome } from './seats.js';
50
+ import { createHash } from 'node:crypto';
51
+ import { buildOpenCodeLaunchArgs, type LaunchApprovalDecision } from './cli-tool-approval.js';
52
+
53
+ export interface AssimilateFlags {
54
+ worktree?: string;
55
+ template?: string;
56
+ noTemplate?: boolean;
57
+ cubeName?: string;
58
+ here?: boolean;
59
+ yes?: boolean;
60
+ cli?: BorgCli;
61
+ model?: string;
62
+ server?: string;
63
+ enroll?: boolean;
64
+ }
65
+
66
+ export interface AssimilateArgs {
67
+ role: string | undefined;
68
+ flags: AssimilateFlags;
69
+ }
70
+
71
+ export interface CubeSummary {
72
+ id: string;
73
+ name: string;
74
+ }
75
+
76
+ export interface CubeDetail {
77
+ id: string;
78
+ name: string;
79
+ roles: Role[];
80
+ // Active seats in the cube (GET /api/cubes/:id returns active-only via
81
+ // listDrones). Used to auto-pick an UNOCCUPIED worker role on a bare
82
+ // assimilate. Optional — an older worker that omits drones degrades to
83
+ // "no occupancy known" (first eligible worker / default).
84
+ drones?: RoleOccupant[];
85
+ }
86
+
87
+ export interface AssimilateResult {
88
+ cube_id: string;
89
+ drone_id: string;
90
+ drone_label: string;
91
+ session_token?: string;
92
+ role_id: string;
93
+ local_session?: {
94
+ credential_ref: string;
95
+ expires_at: string | null;
96
+ };
97
+ // Idempotent-reattach discriminant: 'reused' when the server resolved the
98
+ // client bearer to an existing seat, 'created' on a first/fresh attach.
99
+ result?: 'created' | 'reused';
100
+ // Deferred FINALIZE handles for the local-server composite (Race 2): the
101
+ // keychain pending→ACTIVE flip and the abort-scrub of the own pending record,
102
+ // run by Step 8 under the cube lock AFTER the binding is persisted. Absent for
103
+ // unit stubs that fully mock `assimilate`.
104
+ finalize?: {
105
+ activate: (binding: SeatBinding) => Promise<unknown>;
106
+ scrubPending: () => Promise<unknown>;
107
+ // CR#2: on an activation failure, bind the surviving PENDING record to the
108
+ // preserved worktree (no activation) so a rerun FROM there resumes the exact
109
+ // pending operation and re-sends the identical bearer. Absent for the (severed)
110
+ // unit stubs that fully mock `assimilate`.
111
+ bindPending?: (binding: SeatBinding) => Promise<unknown>;
112
+ };
113
+ // CR #1: set when the cube-lock-held PREPARE revalidation aborted BEFORE any
114
+ // credential was minted or sent (a reset/binding writer won before PREPARE).
115
+ // No worktree has been spawned yet at that point, so the caller exits cleanly.
116
+ prepareAborted?: boolean;
117
+ }
118
+
119
+ export interface ActiveCube {
120
+ cubeId: string;
121
+ droneId: string;
122
+ name: string;
123
+ sessionToken?: string;
124
+ droneLabel: string;
125
+ apiUrl: string;
126
+ /** Verified local-server CA identity; absent until a local server is selected. */
127
+ serverTrustIdentity?: string;
128
+ localSessionCredentialRef?: string;
129
+ localSessionExpiresAt?: string | null;
130
+ // gh#899: assimilated role, persisted for connect-time tool-surface scoping
131
+ // (mirrors cubes.ts ActiveCube; optional → backward-compatible).
132
+ roleName?: string;
133
+ roleClass?: 'queen' | 'worker';
134
+ isHumanSeat?: boolean;
135
+ }
136
+
137
+ export interface AssimilateDeps {
138
+ runSync: (cmd: string, args: string[], cwd?: string) => { status: number | null; stdout: string; stderr: string };
139
+ pathExists: (p: string) => boolean;
140
+ cwd: () => string;
141
+ chdir: (p: string) => void;
142
+
143
+ // gh#556 Part 1 — worktree relocation to ~/.borg/worktrees/<repo>/<name>.
144
+ // homedir seams $HOME (tests inject a sandbox); mkdirp is a plain recursive
145
+ // create (NO chmod — must not disturb ~/.borg's existing perms / the
146
+ // encrypted credentials file that lives there, config.ts).
147
+ homedir: () => string;
148
+ mkdirp: (dir: string) => void;
149
+ exec: (cmd: string, args: string[], cwd: string, env?: Record<string, string>) => Promise<number>;
150
+
151
+ stderr: (line: string) => void;
152
+ stdout: (line: string) => void;
153
+ prompt: (message: string) => Promise<string>;
154
+ promptSecret: (message: string) => Promise<string>;
155
+ isTTY: () => boolean;
156
+ /** Selected-harness approval inspection/consent (client#20). */
157
+ resolveCliApprovals?: (cli: BorgCli, cwd: string) => Promise<LaunchApprovalDecision>;
158
+
159
+ // CR-PD-F1 (drone-2 Phase D review 2026-05-18T04:13Z) — gh#104
160
+ // captured os.hostname() at assimilate-time as load-bearing for
161
+ // DroneCard surfacing. Seamed so tests inject + Phase F wires
162
+ // `() => os.hostname()`.
163
+ getHostname: () => string;
164
+
165
+ // CR-PD-F2 (same review) — spec rev-2 step 8 mandates terminal-title
166
+ // `borg · <drone-label> · <cube-name>`. Real wiring imports from
167
+ // src/terminal-title.ts:setTerminalTitle which is already
168
+ // TTY-safe. Seamed so tests can verify the call without touching
169
+ // process.stdout.
170
+ setTerminalTitle: (label: string, cubeName: string) => void;
171
+
172
+ // CR-PD-F3 — getActiveCube/setActiveCube are fs/promises-backed in
173
+ // src/cubes.ts (Promise<ActiveCube|null> / Promise<void>).
174
+ // The scaffold previously declared them sync; that would silently
175
+ // mis-await in Phase F wiring. Promise<...> matches the real shape.
176
+ getActiveCube: () => Promise<ActiveCube | null>;
177
+ hasPersistedActiveCube: () => Promise<boolean>;
178
+ /** Read the RAW persisted local seat for this worktree WITHOUT hydrating its
179
+ * keychain credential — used to recover a crash-in-gap PENDING seat when
180
+ * getActiveCube() returns null purely because the credential is non-hydratable
181
+ * (binding written by FINALIZE, then a crash before the pending→ACTIVE flip). */
182
+ readPersistedLocalSeat?: () => Promise<PersistedLocalSeat | null>;
183
+ /** Pure PEEK: is a resumable session RECORD (pending or active) present at the
184
+ * per-seat ref? Distinguishes a rerunnable crash-in-gap state from genuine
185
+ * keychain loss without creating or mutating anything. */
186
+ peekServerSessionRecord?: (
187
+ credentialRef: string,
188
+ binding: { origin: string; trustIdentity: string; cubeId: string },
189
+ ) => Promise<boolean>;
190
+ /** CR#3: recover an in-flight IMPLICIT-sibling attempt (a crash-orphaned UNBOUND
191
+ * pending sibling record) keyed by source repo, so a rerun re-derives the EXACT
192
+ * seat ref + re-sends the identical bearer (server reuses — no ghost). Returns the
193
+ * stored operation + role so the rerun adopts them. Absent from unit stubs that
194
+ * fully mock `assimilate`. */
195
+ findIncompleteSiblingAttempt?: (binding: {
196
+ origin: string;
197
+ trustIdentity: string;
198
+ cubeId: string;
199
+ projectRoot: string;
200
+ }) => Promise<{ operation: ServerSessionOperation; roleId: string; credentialRef: string } | null>;
201
+ probeSeat: (
202
+ sessionToken: string,
203
+ apiUrl: string,
204
+ serverTrustIdentity?: string,
205
+ ) => Promise<SeatStatus>;
206
+ setActiveCube: (a: ActiveCube) => Promise<void>;
207
+ /** COMPOSITE cube-owned FINALIZE (Race 2): under the cube lock, revalidate the
208
+ * typed expectation, persist the binding FIRST, then run `activate` (keychain
209
+ * pending→ACTIVE) LAST; on mismatch, `scrubPending` the own pending record and
210
+ * report an honest abort. Wired to the merged activate+bind FINALIZE in
211
+ * production; absent from unit stubs that fully mock `assimilate`. */
212
+ finalizeServerSeat?: (input: {
213
+ active: ActiveCube;
214
+ expected: ExpectedBinding;
215
+ activate: (binding: SeatBinding) => Promise<unknown>;
216
+ scrubPending: () => Promise<unknown>;
217
+ }) => Promise<FinalizeServerSeatOutcome>;
218
+ findProjectRoot: (cwd: string) => string;
219
+
220
+ // gh#673 P2 (WI-1): write the borg-regen SessionStart hook into the
221
+ // launch root's .claude/settings.local.json (project-local; idempotent).
222
+ // Real wiring = config-utils addProjectSessionStartHook.
223
+ installProjectSessionHook: (projectRoot: string) => void;
224
+
225
+ /** gh#27: optional test seam — when set, selectAssimilationAuthority uses
226
+ * this instead of prompting/failing. Not wired in production. */
227
+ defaultAuthority?: AssimilationAuthority;
228
+ detectLocalServer: () => Promise<string | null>;
229
+ connectServer: (
230
+ apiUrl: string,
231
+ enrollment?: { invitation: string },
232
+ ) => Promise<{
233
+ token: string;
234
+ trustIdentity: string;
235
+ serverCapabilities?: readonly string[];
236
+ }>;
237
+ resumeServerEnrollment: (
238
+ apiUrl: string,
239
+ onPending?: () => void,
240
+ ) => Promise<{
241
+ token: string;
242
+ trustIdentity: string;
243
+ serverCapabilities?: readonly string[];
244
+ } | null>;
245
+
246
+ listCubes: (apiUrl: string, token: string, serverTrustIdentity?: string) => Promise<CubeSummary[]>;
247
+ getCube: (apiUrl: string, token: string, cubeId: string, serverTrustIdentity?: string) => Promise<CubeDetail>;
248
+ createCube: (
249
+ apiUrl: string,
250
+ token: string,
251
+ params: { name?: string; template?: string; projectRoot?: string },
252
+ serverTrustIdentity?: string,
253
+ ) => Promise<CubeDetail>;
254
+ assimilate: (
255
+ apiUrl: string,
256
+ token: string,
257
+ params: { cube_id: string; role_id: string; hostname?: string | null; prior_drone_id?: string; remint_invalid_prior?: boolean; model?: string | null; agent_kind?: 'claude' | 'codex' | 'opencode' | null; session_operation?: ServerSessionOperation; session_expected?: ExpectedBinding; revalidate_at_prepare?: boolean },
258
+ serverTrustIdentity?: string,
259
+ ) => Promise<AssimilateResult>;
260
+
261
+ // CR-PE-F1 (drone-2 Phase E review 2026-05-18T04:59Z): step 8 kickoff
262
+ // must include the inbox-monitor clause that claude.ts:96-112 uses
263
+ // for the no-args path. Real wiring imports `inboxPathForDrone` from
264
+ // src/cubes.ts; tests stub a deterministic path.
265
+ getInboxPath: (cubeId: string, droneId: string) => string;
266
+
267
+ // BUG-5 / v0.9.3: probe whether the borg-mcp stdio binary starts
268
+ // cleanly + responds to `initialize` within a short timeout. Used
269
+ // before launching Claude Code in step 8 so the launched session
270
+ // doesn't race against MCP-server startup. Resolves to `true` when
271
+ // initialize succeeded within the timeout, `false` otherwise.
272
+ // Real wiring spawns `borg-mcp`, sends initialize, awaits response,
273
+ // kills child. Tests stub the result. Defense-in-depth alongside
274
+ // the kickoff text recovery clause — fast-path keeps things silent
275
+ // on healthy hosts; degraded-path surfaces a stderr warning and
276
+ // still launches claude (never blocks).
277
+ probeMcpReady: () => Promise<boolean>;
278
+ resolveCli: (explicit?: BorgCli) => Promise<BorgCli>;
279
+ prepareCodexRemoteLaunch: () => Promise<CodexRemoteLaunch>;
280
+ setCodexWakeTarget: (cubeId: string, droneId: string, target: { threadId: string; socketPath: string }) => Promise<void>;
281
+ findLoadedCodexThread: (options: {
282
+ socketPath: string;
283
+ cwd: string;
284
+ previewIncludes: string;
285
+ updatedAfter: number;
286
+ }) => Promise<string | null>;
287
+
288
+ }
289
+
290
+ type AssimilationAuthority =
291
+ | { kind: 'server'; apiUrl: string };
292
+
293
+ function affirmative(answer: string): boolean {
294
+ const normalized = answer.trim().toLowerCase();
295
+ return normalized === '' || normalized === 'y' || normalized === 'yes';
296
+ }
297
+
298
+ function isLocalCubePresentationName(name: string): boolean {
299
+ return name.length >= 1 && name.length <= 120 &&
300
+ /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name);
301
+ }
302
+
303
+ async function selectAssimilationAuthority(
304
+ flags: AssimilateFlags,
305
+ deps: AssimilateDeps,
306
+ ): Promise<AssimilationAuthority | null> {
307
+ if (flags.server !== undefined) {
308
+ try {
309
+ return { kind: 'server', apiUrl: normalizeServerEndpoint(flags.server) };
310
+ } catch (error) {
311
+ deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
312
+ return null;
313
+ }
314
+ }
315
+
316
+ // Only a local self-hosted server authority exists. Non-TTY and --yes must
317
+ // NOT infer an authority — fail closed with actionable guidance.
318
+ if (!deps.isTTY() || flags.yes) {
319
+ if (deps.defaultAuthority) return deps.defaultAuthority;
320
+ deps.stderr('No local server selected. Use `borg assimilate --host <host> --here` to select a local server.\n');
321
+ return null;
322
+ }
323
+
324
+ let detected: string | null = null;
325
+ try {
326
+ const candidate = await deps.detectLocalServer();
327
+ detected = candidate ? normalizeServerEndpoint(candidate) : null;
328
+ } catch {
329
+ // Detection is advisory. A failed probe is the same UX state as "none
330
+ // found"; an explicitly selected endpoint remains fail-closed below.
331
+ }
332
+
333
+ if (detected) {
334
+ const answer = await deps.prompt(
335
+ `Local Borg server detected at ${detected}.\nConnect this project to it? [Y/n]: `,
336
+ );
337
+ if (affirmative(answer)) return { kind: 'server', apiUrl: detected };
338
+ }
339
+
340
+ const host = await deps.prompt('Borg server host or URL: ');
341
+ try {
342
+ return { kind: 'server', apiUrl: normalizeServerEndpoint(host) };
343
+ } catch (error) {
344
+ deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
345
+ return null;
346
+ }
347
+ }
348
+
349
+ function localAssimilateCommand(apiUrl: string, enroll = false): string {
350
+ return `\`borg assimilate --host ${apiUrl}${enroll ? ' --enroll' : ''}\``;
351
+ }
352
+
353
+ function localAssimilateRoleCommand(apiUrl: string): string {
354
+ return `\`borg assimilate --host ${apiUrl} <role>\``;
355
+ }
356
+
357
+ function localAssimilateCliCommand(apiUrl: string, cli: BorgCli): string {
358
+ return `\`borg assimilate --host ${apiUrl} --cli ${cli}\``;
359
+ }
360
+
361
+ function reportServerFailure(
362
+ deps: AssimilateDeps,
363
+ apiUrl: string,
364
+ error: unknown,
365
+ enroll = false,
366
+ ): number {
367
+ const message = error instanceof Error ? error.message : String(error);
368
+ const retryCommand = localAssimilateCommand(apiUrl, enroll);
369
+ if (error instanceof BorgServerError && error.code === 'CREATE_CUBE_DENIED') {
370
+ deps.stderr(
371
+ `This enrolled client cannot create a cube on ${apiUrl}. ` +
372
+ 'Ask the server operator to grant access to a cube, then rerun ' +
373
+ `${localAssimilateCommand(apiUrl)}.\n`,
374
+ );
375
+ return 1;
376
+ }
377
+ if (error instanceof BorgServerError && error.code === 'NOT_ENROLLED') {
378
+ deps.stderr(
379
+ `No saved enrollment for ${apiUrl}. Run ` +
380
+ `${localAssimilateCommand(apiUrl, true)} from the operator’s terminal.\n`,
381
+ );
382
+ return 1;
383
+ }
384
+ if (error instanceof BorgServerError && error.code === 'CREDENTIAL_REJECTED') {
385
+ deps.stderr(
386
+ `The saved enrollment for ${apiUrl} was rejected. Re-run ` +
387
+ `${localAssimilateCommand(apiUrl, true)} from the operator’s terminal.\n`,
388
+ );
389
+ return 1;
390
+ }
391
+ // A pin-matched typed 401: the server verified its own identity but rejected
392
+ // THIS worktree's session bearer (revoked, or taken over by another session).
393
+ // Distinct from a protocol/version mismatch and from a rejected enrollment:
394
+ // only this worktree's saved local seat is affected, and recovery is scoped to
395
+ // this worktree — no server/trust-anchor/cube/other-worktree reset, no restart
396
+ // or version-alignment advice (#1082).
397
+ if (error instanceof BorgServerError && error.code === 'SESSION_REJECTED') {
398
+ // Attach is PURE DIAGNOSIS on a rejection — it mutates nothing. This copy
399
+ // claims NO mutation of its own; it points at the dedicated OFFLINE
400
+ // `borg reset-local-seat` command (the only writer that clears a seat).
401
+ deps.stderr(
402
+ `This worktree's saved local seat on ${apiUrl} is no longer accepted — it was ` +
403
+ 'revoked or taken over by another session. No Borg state changed. Run ' +
404
+ `${resetLocalSeatCommand(apiUrl)} to clear ONLY this worktree's saved seat ` +
405
+ '(offline; add `--yes` when non-interactive), then ask the server operator for a new ' +
406
+ 'invitation — the server can stay running — and re-enroll with ' +
407
+ `${localAssimilateCommand(apiUrl, true)}.\n`,
408
+ );
409
+ return 1;
410
+ }
411
+ if (error instanceof BorgServerError && error.code === 'INVITATION_REJECTED') {
412
+ deps.stderr(
413
+ `The enrollment invitation for ${apiUrl} was rejected or expired. ` +
414
+ 'Ask the server operator for a replacement invitation — the server can stay running: ' +
415
+ 'for an unclaimed owner client run `borg-mcp-server owner-invite`; for an ordinary ' +
416
+ 'client run `borg-mcp-server client-invite`. Then rerun ' +
417
+ `${localAssimilateCommand(apiUrl, true)}.\n`,
418
+ );
419
+ return 1;
420
+ }
421
+ if (/HTTP 40[13]|auth(?:entication|orization)|credential.*(?:invalid|rejected)/i.test(message)) {
422
+ deps.stderr(
423
+ `The saved enrollment for ${apiUrl} was rejected. Re-run ` +
424
+ `${localAssimilateCommand(apiUrl, true)} from the operator’s terminal.\n`,
425
+ );
426
+ return 1;
427
+ }
428
+ if (/seat store lock file .* is stale/i.test(message)) {
429
+ // RULED option (b): a lock whose recorded holder is DEAD (or whose payload is
430
+ // corrupt) is NEVER auto-removed. Surface the fail-closed guidance verbatim —
431
+ // it already names the exact lockfile path and the delete-only-if-no-borg
432
+ // instruction — so the operator can clear it by hand, then retry.
433
+ deps.stderr(
434
+ `${safeStderr(message)}\nAfter confirming no borg process is running and clearing the ` +
435
+ `stale lock, rerun ${retryCommand}.\n`,
436
+ );
437
+ return 1;
438
+ }
439
+ if (/(?:seat|credential) store is busy/i.test(message)) {
440
+ deps.stderr(
441
+ `Borg's local seat store is busy for ${apiUrl} because another Borg process is ` +
442
+ `creating or resuming saved seat state. Wait for it to finish, then rerun ${retryCommand}.\n`,
443
+ );
444
+ return 1;
445
+ }
446
+ if (/(?:local )?seat store|(?:secure )?credential (?:store|storage)/i.test(message)) {
447
+ deps.stderr(
448
+ `Borg could not access its local seat store for ${apiUrl}. ` +
449
+ `Ensure its directory on this machine is readable and writable, then rerun ${retryCommand}.\n`,
450
+ );
451
+ return 1;
452
+ }
453
+ if (/trust|certificate|\bCA\b|authority state|pinned identity|cross-authority/i.test(message)) {
454
+ deps.stderr(
455
+ `Borg could not verify the expected server identity for ${apiUrl}. ` +
456
+ 'Verify that this is the expected server. If it was re-initialized, stop it, ' +
457
+ 'run `borg-mcp-server start`, then rerun ' +
458
+ `${retryCommand}.\n`,
459
+ );
460
+ return 1;
461
+ }
462
+ if (/connect|fetch|network|timed? ?out|timeout|ECONN|ENOTFOUND|EHOST|unreachable|aborted|socket/i.test(message)) {
463
+ deps.stderr(
464
+ `Could not reach Borg server at ${apiUrl}. ` +
465
+ 'Start or restart it with `borg-mcp-server start`, then rerun ' +
466
+ `${retryCommand}.\n`,
467
+ );
468
+ return 1;
469
+ }
470
+ const safeMessage = safeStderr(message)
471
+ .replace(/[A-Za-z0-9_-]{43,}/g, '[redacted]')
472
+ .slice(0, 240);
473
+ deps.stderr(
474
+ `Borg server at ${apiUrl} returned an unexpected response: ` +
475
+ `${safeMessage || 'request failed'}. ` +
476
+ `Check that the client and server versions are compatible, then rerun ${retryCommand}.\n`,
477
+ );
478
+ return 1;
479
+ }
480
+
481
+ function resetLocalSeatCommand(apiUrl: string): string {
482
+ return `\`borg reset-local-seat --host ${apiUrl}\``;
483
+ }
484
+
485
+ /**
486
+ * Pin-matched SESSION_REJECTED diagnosis (#1082). PURE DIAGNOSIS — attach makes
487
+ * ZERO local mutation on a rejected seat (ratified client-seat-reset-state-model
488
+ * clause 1). The local server verified its OWN pinned identity (this is reached
489
+ * only after a successful pinned-TLS attach — a pin MISMATCH throws a distinct
490
+ * trust error and never lands here), then rejected THIS worktree's saved session
491
+ * bearer (revoked, or taken over by another session). We stop and recommend the
492
+ * dedicated OFFLINE `borg reset-local-seat` command, which is the ONLY writer
493
+ * that clears a worktree's saved seat. Nothing here contacts the server further,
494
+ * emits unselected egress, or touches local state. The recovery is
495
+ * live-safe: the operator is never told to stop/restart the server.
496
+ */
497
+ function diagnoseSessionRejected(
498
+ deps: AssimilateDeps,
499
+ apiUrl: string,
500
+ ): number {
501
+ const worktree = deps.findProjectRoot(deps.cwd());
502
+ deps.stderr(
503
+ `This worktree's saved local seat on ${apiUrl} is no longer accepted — it was ` +
504
+ 'revoked or taken over by another session. No Borg state was changed: your pinned ' +
505
+ 'server trust, this and every other worktree, and their cubes are all untouched.\n',
506
+ );
507
+ deps.stderr(
508
+ `To clear ONLY this worktree's saved local seat (worktree ${worktree}), run ` +
509
+ `${resetLocalSeatCommand(apiUrl)} (add \`--yes\` when non-interactive). That command is ` +
510
+ 'offline — it revokes nothing server-side. Then ask the server operator for a new ' +
511
+ 'invitation (the server can stay running) and re-enroll with ' +
512
+ `${localAssimilateCommand(apiUrl, true)}.\n`,
513
+ );
514
+ return 1;
515
+ }
516
+
517
+ export async function runAssimilate(
518
+ args: AssimilateArgs,
519
+ deps: AssimilateDeps
520
+ ): Promise<number> {
521
+ // ----- Input validation (before any subprocess work) -----
522
+ if (args.role !== undefined) {
523
+ const v = validateName(args.role);
524
+ if (!v.ok) {
525
+ deps.stderr(v.error + '\n');
526
+ return 1;
527
+ }
528
+ }
529
+ if (args.flags.worktree !== undefined) {
530
+ const v = validateName(args.flags.worktree);
531
+ if (!v.ok) {
532
+ deps.stderr(v.error + '\n');
533
+ return 1;
534
+ }
535
+ }
536
+
537
+ // ----- Step 1: Select and authenticate the local server -----
538
+ const authority = await selectAssimilationAuthority(args.flags, deps);
539
+ if (!authority) return 1;
540
+
541
+ // ----- Repository + cube-name preflight -----
542
+ // Resolve and, where necessary, confirm local presentation data before an
543
+ // owner invitation can be consumed. A declined/underivable basename must
544
+ // not leave a successfully enrolled client behind.
545
+ const projectRoot = deps.findProjectRoot(deps.cwd());
546
+ let cubeName: string | null;
547
+ if (args.flags.cubeName) {
548
+ cubeName = args.flags.cubeName;
549
+ } else {
550
+ const remoteResult = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
551
+ const remoteUrl = remoteResult.status === 0 ? remoteResult.stdout : null;
552
+ const sanitizedRemote = remoteUrl ? sanitizeRemoteUrl(remoteUrl) : null;
553
+ const parsedRepo = sanitizedRemote ? parseGitRemote(sanitizedRemote) : null;
554
+
555
+ if (!parsedRepo) {
556
+ const bareResult = deps.runSync('git', ['rev-parse', '--is-bare-repository'], projectRoot);
557
+ if (bareResult.status === 0 && bareResult.stdout.trim() === 'true') {
558
+ deps.stderr(
559
+ 'borg assimilate requires a non-bare repository worktree. ' +
560
+ (authority.kind === 'server'
561
+ ? `Clone or check out the repository, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`
562
+ : 'Clone or check out the repository, then retry.\n'),
563
+ );
564
+ return 1;
565
+ }
566
+ }
567
+
568
+ cubeName = deriveCubeName(projectRoot, remoteUrl);
569
+ if (!cubeName) {
570
+ deps.stderr(
571
+ 'Could not derive a cube name from this repository. ' +
572
+ (authority.kind === 'server'
573
+ ? `Rerun ${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\`.\n`
574
+ : 'Pass --cube-name <name> and retry.\n'),
575
+ );
576
+ return 1;
577
+ }
578
+
579
+ if (!parsedRepo) {
580
+ if (sanitizedRemote) {
581
+ deps.stderr(
582
+ `Could not parse the origin remote; using directory name '${cubeName}' as the cube name.\n`,
583
+ );
584
+ }
585
+ if (!args.flags.yes) {
586
+ if (!deps.isTTY()) {
587
+ deps.stderr(
588
+ `Using directory name '${cubeName}' as the cube name requires confirmation. ` +
589
+ (authority.kind === 'server'
590
+ ? `Rerun ${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\` or \`--yes\`.\n`
591
+ : 'Re-run with --cube-name <name> or --yes.\n'),
592
+ );
593
+ return 1;
594
+ }
595
+ const confirmed = await deps.prompt(
596
+ `No usable origin remote was found. Use directory name '${cubeName}' as the cube name? [Y/n]: `,
597
+ );
598
+ if (!affirmative(confirmed)) {
599
+ deps.stderr(
600
+ authority.kind === 'server'
601
+ ? `Cube creation for ${authority.apiUrl} was cancelled. Rerun ` +
602
+ `${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\`.\n`
603
+ : 'Cube creation cancelled. Re-run with --cube-name <name> to choose a name.\n',
604
+ );
605
+ return 1;
606
+ }
607
+ }
608
+ }
609
+ }
610
+
611
+ if (authority.kind === 'server' && !isLocalCubePresentationName(cubeName)) {
612
+ deps.stderr(
613
+ `Invalid cube name for ${authority.apiUrl}. Use 1–120 letters, digits, spaces, dots, ` +
614
+ 'underscores, or hyphens, starting with a letter or digit. Rerun ' +
615
+ `${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\`.\n`,
616
+ );
617
+ return 1;
618
+ }
619
+
620
+ let auth: { token: string; apiUrl: string; serverTrustIdentity: string };
621
+ {
622
+ try {
623
+ let serverAuth: {
624
+ token: string;
625
+ trustIdentity: string;
626
+ serverCapabilities?: readonly string[];
627
+ };
628
+ if (args.flags.enroll) {
629
+ if (!deps.isTTY()) {
630
+ deps.stderr(
631
+ 'Local enrollment requires an interactive operator terminal. ' +
632
+ `Re-run ${localAssimilateCommand(authority.apiUrl, true)} from the operator’s terminal.\n`,
633
+ );
634
+ return 1;
635
+ }
636
+ const resumed = await deps.resumeServerEnrollment(authority.apiUrl, () => {
637
+ deps.stderr(
638
+ `Resuming the pending enrollment for \`${authority.apiUrl}\`; ` +
639
+ 'do not enter another invitation.\n',
640
+ );
641
+ });
642
+ if (resumed) {
643
+ serverAuth = resumed;
644
+ } else {
645
+ let invitation = await deps.promptSecret(
646
+ `Enrollment invitation for \`${authority.apiUrl}\` (single-use; hidden input):`,
647
+ );
648
+ if (!invitation) {
649
+ deps.stderr(
650
+ `No enrollment invitation was entered for ${authority.apiUrl}. ` +
651
+ `Ask the server operator for one, then rerun ${localAssimilateCommand(authority.apiUrl, true)}.\n`,
652
+ );
653
+ return 1;
654
+ }
655
+ try {
656
+ serverAuth = await deps.connectServer(authority.apiUrl, { invitation });
657
+ } finally {
658
+ // Strings cannot be zeroized in JavaScript, but drop this command's
659
+ // reference immediately after the exchange instead of retaining the
660
+ // invitation through the rest of assimilation/agent launch.
661
+ invitation = '';
662
+ }
663
+ }
664
+ if (serverAuth.serverCapabilities?.includes('create_cube')) {
665
+ deps.stderr(
666
+ `Owner client enrolled with \`${authority.apiUrl}\`. ` +
667
+ 'Creating or joining this repository’s cube next.\n',
668
+ );
669
+ } else {
670
+ deps.stderr(
671
+ `Ordinary client enrolled with \`${authority.apiUrl}\`. ` +
672
+ 'Checking for an accessible repository cube next.\n',
673
+ );
674
+ }
675
+ } else {
676
+ serverAuth = await deps.connectServer(authority.apiUrl);
677
+ }
678
+ auth = {
679
+ token: serverAuth.token,
680
+ apiUrl: authority.apiUrl,
681
+ serverTrustIdentity: serverAuth.trustIdentity,
682
+ };
683
+ } catch (error) {
684
+ return reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true);
685
+ }
686
+ }
687
+
688
+ // ----- Sprint 19 (gh#184): Reorder for strict-rollback semantics. -----
689
+ // The previous flow created a sibling worktree (FS state) BEFORE
690
+ // role resolution + API assimilate. Any early-return between
691
+ // worktree-spawn and API success orphaned the worktree (gh#184
692
+ // canonical case: unknown role arg). The new flow defers all FS
693
+ // state until AFTER the API assimilate succeeds — early-return at
694
+ // role resolution / listCubes / createCube / template-prompt /
695
+ // template-invalid-choice is now structurally clean (no orphan
696
+ // class possible). Worktree rollback narrows to the single
697
+ // setActiveCube failure path post-worktree-creation.
698
+
699
+ // Sprint 18: capture pre-chdir cwd for the post-exit shell-cd hint
700
+ // (no chdir has happened yet; this is a stable starting point).
701
+ const originalCwd = deps.cwd();
702
+
703
+ // ----- Step 3: Cube existence check (with auto-refresh on auth failure) -----
704
+ // gh#653 B4: announce each network step. These calls take 2–5s and were
705
+ // previously silent, so a user read the wait as a hang and Ctrl-C'd mid-run.
706
+ deps.stderr('Checking your cubes…\n');
707
+ let allCubes: CubeSummary[];
708
+ try {
709
+ allCubes = await deps.listCubes(auth.apiUrl, auth.token, auth.serverTrustIdentity);
710
+ } catch (err) {
711
+ return reportServerFailure(deps, authority.apiUrl, err);
712
+ }
713
+ const existingCube = allCubes.find((c) => c.name === cubeName);
714
+
715
+ // ----- Step 4: Fetch detail OR create cube -----
716
+ let cubeDetail: CubeDetail;
717
+ let isFirstDrone: boolean;
718
+ if (existingCube) {
719
+ try {
720
+ cubeDetail = await deps.getCube(
721
+ auth.apiUrl,
722
+ auth.token,
723
+ existingCube.id,
724
+ auth.serverTrustIdentity,
725
+ );
726
+ } catch (error) {
727
+ return reportServerFailure(deps, authority.apiUrl, error);
728
+ }
729
+ isFirstDrone = false;
730
+ } else {
731
+ // ----- Step 4a: First-drone bootstrap (template selection) -----
732
+ let chosenTemplate: string | undefined;
733
+ if (args.flags.noTemplate ||
734
+ (args.flags.template !== undefined && args.flags.template !== 'default')) {
735
+ deps.stderr(
736
+ `Borg server ${authority.apiUrl} supports its default cube template only. ` +
737
+ `Rerun ${localAssimilateCommand(authority.apiUrl)} without \`--template\` or \`--no-template\`.\n`,
738
+ );
739
+ return 1;
740
+ }
741
+ chosenTemplate = 'default';
742
+
743
+ // gh#653 B4: progress for the create round-trip (silent-window stall).
744
+ deps.stderr(cubeName ? `Creating cube '${cubeName}'…\n` : 'Creating your cube…\n');
745
+ try {
746
+ const createParams = chosenTemplate
747
+ ? {
748
+ name: cubeName ?? undefined,
749
+ template: chosenTemplate,
750
+ projectRoot,
751
+ }
752
+ : { name: cubeName ?? undefined };
753
+ cubeDetail = await deps.createCube(
754
+ auth.apiUrl,
755
+ auth.token,
756
+ createParams,
757
+ auth.serverTrustIdentity,
758
+ );
759
+ } catch (error) {
760
+ return reportServerFailure(deps, authority.apiUrl, error);
761
+ }
762
+ isFirstDrone = true;
763
+ }
764
+
765
+ // Read the worktree identity before role selection. A live local seat must
766
+ // retain its original role so the attach request reuses the exact durable
767
+ // retry binding instead of selecting another unoccupied role and minting a
768
+ // duplicate seat.
769
+ const existing = await deps.getActiveCube();
770
+ const hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
771
+ const wantSibling =
772
+ args.flags.worktree !== undefined || (existing !== null && !args.flags.here);
773
+ // `let`: the bound-pending resume path (CR#2) OVERRIDES this from the stored
774
+ // operation so a rerun re-derives the EXACT original sibling seat ref.
775
+ let sessionOperation: ServerSessionOperation = {
776
+ // Capture the source repository before a successful sibling attach changes
777
+ // cwd. This is the stable seat/sibling namespace for the pending bearer, so a
778
+ // deliberate sibling never collides with the durable in-place seat's bearer.
779
+ projectRoot,
780
+ kind: wantSibling ? 'sibling' : 'seat',
781
+ // CR1(a): an implicit sibling's operation key must be COLLISION-SAFE — two
782
+ // unnamed siblings of the same (origin,trust,cube,role) must get DISTINCT seat
783
+ // refs, else prepareSeat reuses the first sibling's ACTIVE record and the
784
+ // activate+bind step overwrites its worktree (an active seat silently unseated
785
+ // and rebound). A named sibling already keys on its name; an unnamed one derives
786
+ // a per-invocation-unique key so every distinct implicit sibling target mints a
787
+ // distinct bearer / seat ref.
788
+ operationKey: wantSibling
789
+ ? (args.flags.worktree === undefined
790
+ ? `implicit-sibling:${randomUUID()}`
791
+ : `named-sibling:${args.flags.worktree}`)
792
+ : 'current-worktree',
793
+ };
794
+ let reattachPriorId: string | undefined;
795
+ let remintInvalidPrior = false;
796
+ let savedLocalRole: Role | undefined;
797
+ // Set when the pre-attach gate recovers a crash-in-gap PENDING seat: the
798
+ // composite FINALIZE must then declare EXACT-ref (the credential is pending,
799
+ // not active, so no live-bearer digest is pinned) so it re-persists the extant
800
+ // binding and flips pending→ACTIVE, rather than aborting on an ABSENT check.
801
+ let resumeCredentialRef: string | undefined;
802
+ let resumeDroneId: string | undefined;
803
+ // CR#2: 'pending' when the resumed record is a bound-PENDING sibling (activation
804
+ // failed) — it re-sends the identical bearer under an ABSENT/pending-reuse
805
+ // expectation; 'active' when resuming a live in-place seat (EXACT expectation).
806
+ let resumeState: 'pending' | 'active' | undefined;
807
+ if (existing && args.flags.here && existing.cubeId !== cubeDetail.id) {
808
+ deps.stderr(
809
+ `This directory already hosts an active drone for another cube on ${authority.apiUrl}. ` +
810
+ `Remove \`--here\` or use a fresh worktree, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
811
+ );
812
+ return 1;
813
+ }
814
+
815
+ if (authority.kind === 'server') {
816
+ // CR#3: recover an in-flight IMPLICIT-sibling attempt (persisted + collision-safe).
817
+ // An implicit sibling mints a per-invocation-unique operationKey; a crash AFTER the
818
+ // server accepts but BEFORE the worktree bind leaves an UNBOUND pending sibling
819
+ // record whose random key would otherwise be undiscoverable — a rerun would mint a
820
+ // NEW bearer and the server (digest-correlating) would create a GHOST seat. The
821
+ // unbound pending sibling record IS the persisted attempt identity, discoverable by
822
+ // source repo: adopt its EXACT operation (→ same seat ref) AND its role, and declare
823
+ // a PENDING resume so prepareSeat REUSES the identical bearer (server reuses the
824
+ // seat). Only for an IMPLICIT sibling (no --worktree name); a named sibling already
825
+ // keys collision-safe on its name. Skipped once the attempt is bound/activated (it is
826
+ // no longer an unbound pending sibling), so a completed sibling frees the key.
827
+ if (
828
+ wantSibling &&
829
+ args.flags.worktree === undefined &&
830
+ auth.serverTrustIdentity !== undefined &&
831
+ deps.findIncompleteSiblingAttempt
832
+ ) {
833
+ const inflight = await deps.findIncompleteSiblingAttempt({
834
+ origin: auth.apiUrl,
835
+ trustIdentity: auth.serverTrustIdentity,
836
+ cubeId: cubeDetail.id,
837
+ projectRoot,
838
+ });
839
+ if (inflight) {
840
+ const inflightRole = cubeDetail.roles.find((role) => role.id === inflight.roleId);
841
+ if (inflightRole) {
842
+ // Adopt the EXACT stored operation (same operationKey → same R_sib) + role, and
843
+ // resume PENDING so prepareSeat re-sends the identical bearer and converges.
844
+ sessionOperation = inflight.operation;
845
+ savedLocalRole = inflightRole;
846
+ resumeCredentialRef = inflight.credentialRef;
847
+ resumeState = 'pending';
848
+ }
849
+ }
850
+ }
851
+ if (!existing && hasPersistedIdentity) {
852
+ // getActiveCube() is null AND metadata is persisted. Two distinct states:
853
+ // (a) crash-in-gap RESUME — the composite FINALIZE wrote the binding, then
854
+ // a crash/throw preceded the pending→ACTIVE flip. The credential is
855
+ // still a PENDING record (non-hydratable → getActiveCube null), the
856
+ // binding is intact, and re-sending the identical pending bearer
857
+ // converges. Ratified clause 4: this state is RERUNNABLE and must be
858
+ // truthfully reported, NOT misdiagnosed as keychain loss.
859
+ // (b) genuine keychain loss/lock — no record at the ref. Truthful error,
860
+ // and NEVER a new seat (record-absent invariant).
861
+ // A pure PEEK (no create/mutate) at the deterministic per-seat ref
862
+ // distinguishes them. Resume only applies to an in-place attach (a
863
+ // --worktree sibling is a NEW seat, not a resume of this worktree's seat).
864
+ const persisted = deps.readPersistedLocalSeat
865
+ ? await deps.readPersistedLocalSeat()
866
+ : null;
867
+ let resumeRole: Role | undefined;
868
+ let recordPresent = false;
869
+ if (
870
+ persisted &&
871
+ !wantSibling &&
872
+ persisted.apiUrl === auth.apiUrl &&
873
+ persisted.serverTrustIdentity === auth.serverTrustIdentity
874
+ ) {
875
+ recordPresent = deps.peekServerSessionRecord
876
+ ? await deps.peekServerSessionRecord(persisted.localSessionCredentialRef, {
877
+ origin: auth.apiUrl,
878
+ trustIdentity: auth.serverTrustIdentity!,
879
+ cubeId: persisted.cubeId,
880
+ })
881
+ : false;
882
+ if (recordPresent && persisted.roleName) {
883
+ resumeRole = cubeDetail.roles.find((role) => role.name === persisted.roleName);
884
+ }
885
+ }
886
+ if (persisted && recordPresent && resumeRole) {
887
+ // RESUME: reuse the persisted role (the ref binds the role, so a resume MUST
888
+ // re-derive the exact same account) and converge on the exact stored record.
889
+ savedLocalRole = resumeRole;
890
+ resumeCredentialRef = persisted.localSessionCredentialRef;
891
+ resumeState = persisted.state;
892
+ // CR#2: re-derive the EXACT pending seat ref from the STORED operation. A
893
+ // bound-PENDING sibling's record still carries its ORIGINAL sibling operation
894
+ // (projectRoot+kind+operationKey), NOT the rerun worktree's derived
895
+ // current-worktree seat operation — overriding here makes the rerun
896
+ // re-mint-or-reuse the identical pending bearer at the original R_sib and
897
+ // converge (no ghost seat). For an ACTIVE in-place resume the stored operation
898
+ // equals the already-derived one, so this is a no-op.
899
+ sessionOperation = persisted.operation;
900
+ // Only an ACTIVE resume pins the drone id (EXACT expectation below). A
901
+ // bound-PENDING record declares ABSENT/pending-reuse and does not pin it.
902
+ if (persisted.state === 'active' && persisted.droneId !== undefined) {
903
+ resumeDroneId = persisted.droneId;
904
+ }
905
+ } else {
906
+ deps.stderr(
907
+ `This worktree has saved seat metadata for ${authority.apiUrl}, but its local session ` +
908
+ 'credential could not be loaded from the local seat store. No new seat was created. Run ' +
909
+ `${resetLocalSeatCommand(authority.apiUrl)} to clear this worktree's saved seat, then ` +
910
+ `ask the operator for a new invitation and rerun ${localAssimilateCommand(authority.apiUrl, true)}.\n`,
911
+ );
912
+ return 1;
913
+ }
914
+ }
915
+ if (
916
+ existing && args.flags.here &&
917
+ (existing.apiUrl !== auth.apiUrl ||
918
+ existing.serverTrustIdentity !== auth.serverTrustIdentity)
919
+ ) {
920
+ deps.stderr(
921
+ `This worktree's saved seat does not match ${authority.apiUrl}. ` +
922
+ 'No new seat was created. Restore the expected server identity or use a fresh ' +
923
+ `worktree, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
924
+ );
925
+ return 1;
926
+ }
927
+
928
+ // The per-seat PENDING bearer is the resume mechanism: a lost attach
929
+ // response is recovered when the next attach re-sends the identical bearer,
930
+ // so there is no separate unfinished-attach store to scan. Reattach identity
931
+ // for `--here` comes from this worktree's saved active cube below.
932
+ if (existing && args.flags.here) {
933
+ savedLocalRole = existing.roleName
934
+ ? cubeDetail.roles.find((role) => role.name === existing.roleName)
935
+ : undefined;
936
+ const status = await deps.probeSeat(
937
+ existing.sessionToken ?? '',
938
+ auth.apiUrl,
939
+ auth.serverTrustIdentity,
940
+ );
941
+ // Canonical rotated/revoked path: a pin-matched 401 on THIS worktree's
942
+ // saved bearer. PURE DIAGNOSIS — attach never mutates local state on a
943
+ // rejection; it points at the offline `borg reset-local-seat` command.
944
+ // Distinct from unreachable/404/5xx/trust-mismatch, which stay
945
+ // indeterminate below.
946
+ if (status === 'rejected') {
947
+ return diagnoseSessionRejected(deps, auth.apiUrl);
948
+ }
949
+ // CR #6: distinct causes get cause-accurate, non-destructive recovery —
950
+ // never the generic "restart the server" advice.
951
+ if (status === 'credential-rejected') {
952
+ // The saved SESSION bearer was rejected WITHOUT the typed takeover code
953
+ // (a bare/other 401). Non-destructive: re-enroll, never a seat reset.
954
+ deps.stderr(
955
+ `The saved enrollment for ${authority.apiUrl} was rejected. No new seat was created ` +
956
+ `and nothing was changed. Re-enroll with ${localAssimilateCommand(authority.apiUrl, true)} ` +
957
+ 'from the operator’s terminal.\n',
958
+ );
959
+ return 1;
960
+ }
961
+ if (status === 'trust-mismatch') {
962
+ // Terminal: the pinned identity changed. Restarting the server does NOT
963
+ // fix it — verify this is the expected server / re-initialization.
964
+ deps.stderr(
965
+ `Borg could not verify the expected server identity for ${authority.apiUrl}. ` +
966
+ 'No new seat was created. Verify that this is the expected server; if it was ' +
967
+ 're-initialized, restore the expected identity, then rerun ' +
968
+ `${localAssimilateCommand(authority.apiUrl)}.\n`,
969
+ );
970
+ return 1;
971
+ }
972
+ if (status === 'endpoint-mismatch') {
973
+ // CR5: a verified server returned 404 for the drone endpoint — a protocol /
974
+ // client-server VERSION mismatch, not a transient blip. Restarting does not
975
+ // fix it; align versions. Non-destructive: no seat created, nothing reset.
976
+ deps.stderr(
977
+ `Borg reached ${authority.apiUrl} but it did not recognize this worktree's drone ` +
978
+ 'endpoint — the client and server versions are likely incompatible. No new seat ' +
979
+ 'was created and nothing was changed. Update the Borg client and/or server so ' +
980
+ `their versions match, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
981
+ );
982
+ return 1;
983
+ }
984
+ if (status === 'server-failure') {
985
+ // CR5: a verified server returned 5xx — its own internal error. Transient:
986
+ // check the server, then retry. Non-destructive.
987
+ deps.stderr(
988
+ `Borg reached ${authority.apiUrl} but it returned a server error while verifying ` +
989
+ "this worktree's saved seat. No new seat was created. Check the server (its logs / " +
990
+ `\`borg-mcp-server start\`), then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
991
+ );
992
+ return 1;
993
+ }
994
+ if (status === 'unreachable' || status === 'indeterminate') {
995
+ // CR5: transport failure / timeout (unreachable) or a genuinely ambiguous
996
+ // failure (indeterminate) — both transient. Start or restart the server.
997
+ deps.stderr(
998
+ `Borg could not verify this worktree's saved seat on ${authority.apiUrl}. ` +
999
+ 'No new seat was created. Start or restart the server with ' +
1000
+ `\`borg-mcp-server start\`, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
1001
+ );
1002
+ return 1;
1003
+ }
1004
+ if (status === 'live' && !savedLocalRole) {
1005
+ deps.stderr(
1006
+ `Borg verified this worktree's saved seat on ${authority.apiUrl}, but its saved ` +
1007
+ 'role is unavailable. No new seat was created. Ask the server operator to restore ' +
1008
+ `the role, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
1009
+ );
1010
+ return 1;
1011
+ }
1012
+ reattachPriorId = existing.droneId;
1013
+ remintInvalidPrior = status === 'evicted';
1014
+ }
1015
+ } else if (existing && args.flags.here) {
1016
+ if (existing.serverTrustIdentity !== undefined || existing.apiUrl !== auth.apiUrl) {
1017
+ deps.stderr(
1018
+ 'This worktree\'s saved seat belongs to a different Borg authority. ' +
1019
+ 'No new seat was created; use a fresh worktree.\n',
1020
+ );
1021
+ return 1;
1022
+ }
1023
+ reattachPriorId = existing.droneId;
1024
+ }
1025
+
1026
+ // ----- Step 5: Role resolution -----
1027
+ let resolvedRole: Role | undefined;
1028
+ if (savedLocalRole) {
1029
+ resolvedRole = savedLocalRole;
1030
+ } else if (args.role !== undefined) {
1031
+ resolvedRole = matchRoleByName(cubeDetail.roles, args.role);
1032
+ if (!resolvedRole) {
1033
+ // Sprint 19 (gh#184) + drone-7 metaphor argument: include a
1034
+ // fuzzy-match "did you mean ...?" suggestion to serve Queen's
1035
+ // "more user-friendly" intent without violating the
1036
+ // Borg-collective metaphor (collective defines roles; drones
1037
+ // slot in). Levenshtein distance ≤2 on the cube's role names.
1038
+ const available = cubeDetail.roles.map((r) => r.name).join(', ');
1039
+ const suggestion = suggestRoleName(args.role, cubeDetail.roles.map((r) => r.name));
1040
+ const suggestionLine = suggestion ? ` Did you mean "${suggestion}"?` : '';
1041
+ if (authority.kind === 'server') {
1042
+ deps.stderr(
1043
+ `No role matching "${args.role}" in cube "${cubeDetail.name}" on ${authority.apiUrl}. ` +
1044
+ `Available: ${available}.${suggestionLine}\n` +
1045
+ `Rerun ${localAssimilateRoleCommand(authority.apiUrl)} with one of the available roles.\n`,
1046
+ );
1047
+ } else {
1048
+ deps.stderr(
1049
+ `no role matching "${args.role}" in cube "${cubeDetail.name}". Available: ${available}.${suggestionLine}\n` +
1050
+ `(Use --template <name> on first-drone setup or run \`borg_create-role\` from inside Claude.)\n`
1051
+ );
1052
+ }
1053
+ return 1;
1054
+ }
1055
+ } else {
1056
+ const occupiedRoleIds = occupiedRoleIdsForAutoRole(cubeDetail.drones ?? []);
1057
+ resolvedRole = pickDefaultRole(cubeDetail.roles, { isFirstDrone, occupiedRoleIds });
1058
+ if (!resolvedRole) {
1059
+ if (authority.kind === 'server') {
1060
+ deps.stderr(
1061
+ `Cube "${cubeDetail.name}" on ${authority.apiUrl} has no default or human-seat role. ` +
1062
+ `Ask the server operator to configure a role, then rerun ` +
1063
+ `${localAssimilateRoleCommand(authority.apiUrl)}.\n`,
1064
+ );
1065
+ } else {
1066
+ deps.stderr(
1067
+ `cube "${cubeDetail.name}" has no default or human-seat role; cannot infer a role. ` +
1068
+ `Either pass a role argument explicitly (e.g. \`borg assimilate builder\`) or ` +
1069
+ `run \`borg_create-role\` from inside Claude to set up roles.\n`
1070
+ );
1071
+ }
1072
+ return 1;
1073
+ }
1074
+ }
1075
+
1076
+ // ----- Step 5b: --here collision check BEFORE the API mint (gh#780) -----
1077
+ // Pre-gh#780 this check lived in Step 7 — AFTER the API assimilate — so a
1078
+ // `--here` run in a directory that already hosts a drone minted a fresh
1079
+ // drones row server-side, then aborted before Step 8 ever persisted the
1080
+ // mapping: an orphan seat with no local identity. The check must precede
1081
+ // the mint. (The full worktree DECISION stays in Step 7 by design — FS
1082
+ // state only after API success; this hoists only the abort case.)
1083
+ //
1084
+ // PR-D refinement: --here + existing + SAME authority/cube is the
1085
+ // saved-seat recovery flow. The local
1086
+ // seats first prove liveness with their keychained session, then reuse the
1087
+ // saved role/retry binding; only authoritative eviction rotates that retry.
1088
+
1089
+ // Role defaults and local launch state do not select the model. The explicit
1090
+ // Claude-only flag remains temporarily for compatibility with existing
1091
+ // invocations.
1092
+ const effectiveModel: string | null = args.flags.model ?? null;
1093
+
1094
+ // Resolve the agent CLI now so the worker learns agent_kind AT assimilate
1095
+ // time.
1096
+ const cli = await deps.resolveCli(args.flags.cli);
1097
+ try {
1098
+ ensureCliMcpConfigured(cli);
1099
+ } catch (err) {
1100
+ const message = err instanceof Error ? err.message : String(err);
1101
+ if (authority.kind === 'server') {
1102
+ deps.stderr(
1103
+ `${cli} MCP configuration failed for ${authority.apiUrl}: ${safeStderr(message)}. ` +
1104
+ `Fix the ${cli} MCP configuration, then rerun ` +
1105
+ `${localAssimilateCliCommand(authority.apiUrl, cli)}.\n`,
1106
+ );
1107
+ } else {
1108
+ deps.stderr(`${cli} MCP configuration failed: ${message}\n`);
1109
+ }
1110
+ return 1;
1111
+ }
1112
+
1113
+ // The TYPED prepare-time expectation (ratified clause 3 / CR #1). Declared HERE,
1114
+ // BEFORE the mint+send, and revalidated at BOTH the cube-lock-held PREPARE (so a
1115
+ // reset that wins before PREPARE aborts before any credential is created/sent)
1116
+ // and FINALIZE. resume/reattach/remint pin the FULL prior binding (ref + drone
1117
+ // id [+ live digest]); fresh/sibling declare ABSENT.
1118
+ let sessionExpected: ExpectedBinding;
1119
+ if (resumeCredentialRef && resumeState === 'pending') {
1120
+ // CR#2: a bound-PENDING resume (a sibling whose activation failed) re-sends the
1121
+ // identical pending bearer the server already digest-bound. A PENDING record is
1122
+ // NOT a live binding, so it declares ABSENT (pending-reuse): prepareSeat REUSES
1123
+ // the existing pending record (identical bearer). An EXACT expectation would be
1124
+ // rejected by prepareSeat's `prior.state==='active'` guard and abort the only
1125
+ // ghost-free recovery.
1126
+ sessionExpected = { kind: 'absent' };
1127
+ } else if (resumeCredentialRef) {
1128
+ sessionExpected = {
1129
+ kind: 'exact',
1130
+ credentialRef: resumeCredentialRef,
1131
+ ...(resumeDroneId ? { droneId: resumeDroneId } : {}),
1132
+ };
1133
+ } else if (remintInvalidPrior && existing?.localSessionCredentialRef) {
1134
+ sessionExpected = {
1135
+ kind: 'exact',
1136
+ credentialRef: existing.localSessionCredentialRef,
1137
+ ...(existing.droneId ? { droneId: existing.droneId } : {}),
1138
+ };
1139
+ } else if (reattachPriorId != null && existing?.localSessionCredentialRef && existing.sessionToken) {
1140
+ sessionExpected = {
1141
+ kind: 'exact',
1142
+ credentialRef: existing.localSessionCredentialRef,
1143
+ ...(existing.droneId ? { droneId: existing.droneId } : {}),
1144
+ sessionDigest: createHash('sha256').update(existing.sessionToken).digest('hex'),
1145
+ };
1146
+ } else {
1147
+ sessionExpected = { kind: 'absent' };
1148
+ }
1149
+ // CR1(b): PREPARE-time revalidation is preserved for siblings too. A sibling
1150
+ // declares an ABSENT expectation: a PENDING record at the ref (a lost-response
1151
+ // retry / crash-in-gap) stays reusable so the identical bearer is re-sent, but an
1152
+ // ACTIVE record holding the ref is a mismatch → abort (never silently reuse/move
1153
+ // a live binding). With the collision-safe sibling key above the fresh ref is
1154
+ // normally empty, so ABSENT passes and the mint proceeds; the check is the
1155
+ // defense that stops an active seat from being unseated.
1156
+ const revalidateAtPrepare = true;
1157
+
1158
+ // ----- Step 6: API assimilate (no FS state yet — clean exit on failure) -----
1159
+ // gh#653 B4: progress for the seat-mint round-trip (silent-window stall).
1160
+ deps.stderr(`Joining cube '${cubeDetail.name}' as ${resolvedRole.name}…\n`);
1161
+ let result: AssimilateResult;
1162
+ try {
1163
+ const assimilateParams = {
1164
+ cube_id: cubeDetail.id,
1165
+ role_id: resolvedRole.id,
1166
+ hostname: deps.getHostname(),
1167
+ agent_kind: cli,
1168
+ model: effectiveModel,
1169
+ ...(reattachPriorId ? { prior_drone_id: reattachPriorId } : {}),
1170
+ ...(remintInvalidPrior ? { remint_invalid_prior: true } : {}),
1171
+ session_operation: sessionOperation,
1172
+ session_expected: sessionExpected,
1173
+ revalidate_at_prepare: revalidateAtPrepare,
1174
+ };
1175
+ result = await deps.assimilate(
1176
+ auth.apiUrl,
1177
+ auth.token,
1178
+ assimilateParams,
1179
+ auth.serverTrustIdentity,
1180
+ );
1181
+ } catch (err) {
1182
+ // gh#877 follow-up: a re-attach (`--here`) whose saved seat was evicted is
1183
+ // REFUSED server-side (410 DRONE_EVICTED) rather than silently re-minting a
1184
+ // fresh drone. Surface the terminal recovery path instead of the generic
1185
+ // "assimilate failed". Only on a reattach attempt (reattachPriorId set);
1186
+ // a non-reattach DroneEvictedError falls through to the generic message.
1187
+ if (err instanceof DroneEvictedError && reattachPriorId != null) {
1188
+ deps.stderr(
1189
+ `This worktree's saved seat on ${authority.apiUrl} was evicted. ` +
1190
+ `Remove this worktree, or from a fresh worktree run ` +
1191
+ `${localAssimilateCommand(authority.apiUrl)}.\n`,
1192
+ );
1193
+ return 1;
1194
+ }
1195
+ // Pin-matched SESSION_REJECTED (revoked / taken-over seat): PURE DIAGNOSIS.
1196
+ // Reached only after a successful pinned-TLS attach, so it is pin-matched by
1197
+ // construction — a pin mismatch throws a distinct trust error and never
1198
+ // enters this branch. Attach mutates NOTHING; it recommends the offline
1199
+ // `borg reset-local-seat` command.
1200
+ if (
1201
+ err instanceof BorgServerError &&
1202
+ err.code === 'SESSION_REJECTED' &&
1203
+ reattachPriorId != null
1204
+ ) {
1205
+ return diagnoseSessionRejected(deps, authority.apiUrl);
1206
+ }
1207
+ if (authority.kind === 'server') {
1208
+ return reportServerFailure(deps, authority.apiUrl, err);
1209
+ }
1210
+ const message = err instanceof Error ? err.message : String(err);
1211
+ deps.stderr(`assimilate failed: ${message}\n`);
1212
+ return 1;
1213
+ }
1214
+
1215
+ if (authority.kind === 'server' && result.prepareAborted) {
1216
+ // CR #1: the cube-lock-held PREPARE revalidation aborted BEFORE any credential
1217
+ // was minted or sent — this worktree's saved seat changed under us (a
1218
+ // concurrent offline reset, or a competing enroll). No FS/network mutation
1219
+ // happened; never silently recreate.
1220
+ deps.stderr(
1221
+ `This worktree's saved local seat on ${authority.apiUrl} changed before the attach ` +
1222
+ '(a concurrent reset or enroll); no credential was created or sent and nothing was ' +
1223
+ `changed. Re-run ${localAssimilateCommand(authority.apiUrl)} to attach against the ` +
1224
+ 'current state.\n',
1225
+ );
1226
+ return 1;
1227
+ }
1228
+
1229
+ if (authority.kind === 'server' && result.local_session === undefined) {
1230
+ return reportServerFailure(
1231
+ deps,
1232
+ authority.apiUrl,
1233
+ new Error('Borg server did not return compatible secure session metadata'),
1234
+ );
1235
+ }
1236
+ // The server may assimilate a member into a DIFFERENT role than the client's
1237
+ // auto-picked default (gh#700 fallback: when the member's invite doesn't
1238
+ // grant the default role, the server picks one of their GRANTED roles).
1239
+ // Resolve the role the SERVER ACTUALLY assigned (result.role_id) and use it
1240
+ // for all human-facing display + naming below — not the client's pre-pick.
1241
+ // The drone label / session token are already server-truth; this aligns the
1242
+ // displayed role name + worktree slug with what was actually assigned.
1243
+ const assignedRole =
1244
+ cubeDetail.roles.find((r) => r.id === result.role_id) ?? resolvedRole;
1245
+ if (result.result === 'reused') {
1246
+ // The seat's existing role is authoritative on an idempotent reattach —
1247
+ // a role difference is expected, not a grant fallback. The bearer is
1248
+ // reused, not rotated: no new drone minted.
1249
+ deps.stderr(
1250
+ `re-attached to existing seat ${result.drone_label} (same session, no new drone minted)\n`
1251
+ );
1252
+ } else if (assignedRole.id !== resolvedRole.id) {
1253
+ deps.stderr(
1254
+ `The requested role "${resolvedRole.name}" was unavailable; ` +
1255
+ `attached to the "${assignedRole.name}" seat instead.\n`
1256
+ );
1257
+ }
1258
+
1259
+ // ----- Step 7: Worktree decision (FS state ONLY after API success) -----
1260
+ // (`existing` was read at Step 5b; a different-cube --here collision
1261
+ // already aborted there, pre-mint. The surviving --here + existing case
1262
+ // is the SAME-cube reattach — an in-place recovery, never a sibling
1263
+ // spawn.)
1264
+ let spawnedWorktreePath: string | null = null;
1265
+
1266
+ if (wantSibling) {
1267
+ // BUG-4 / gh#150 fix (v0.9.5): `git worktree add --detach <path>`
1268
+ // fails with "fatal: not a valid object name: 'HEAD'" when the
1269
+ // repo has no commits yet (unborn HEAD). Detect explicitly via
1270
+ // `git rev-parse --verify HEAD` so we surface an actionable
1271
+ // prerequisite error rather than git's cryptic internal message.
1272
+ const headProbe = deps.runSync('git', ['rev-parse', '--verify', 'HEAD'], projectRoot);
1273
+ if (headProbe.status !== 0) {
1274
+ deps.stderr(
1275
+ `sibling worktree spawn requires HEAD pointing at a commit.\n` +
1276
+ ` Fix: create at least one commit (\`git commit --allow-empty -m "initial"\`)\n` +
1277
+ ` OR: pass --here to skip the sibling spawn and use the current directory\n`
1278
+ );
1279
+ return 1;
1280
+ }
1281
+ const localHead = headProbe.stdout.trim();
1282
+ const originProbe = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
1283
+ let startRef = 'HEAD';
1284
+ if (originProbe.status === 0 && originProbe.stdout.trim().length > 0) {
1285
+ // gh#238: when origin exists, fetch it so the new worktree starts on the
1286
+ // latest remote default branch rather than a possibly stale local HEAD.
1287
+ deps.runSync('git', ['fetch', 'origin'], projectRoot);
1288
+
1289
+ const mainProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/main'], projectRoot);
1290
+ if (mainProbe.status === 0) {
1291
+ startRef = 'origin/main';
1292
+ } else {
1293
+ const masterProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/master'], projectRoot);
1294
+ if (masterProbe.status === 0) {
1295
+ startRef = 'origin/master';
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+ if (startRef === 'HEAD') {
1301
+ deps.stderr(
1302
+ `note: no usable origin; new worktree will start on local HEAD (${localHead.slice(0, 7)})\n`
1303
+ );
1304
+ } else {
1305
+ // Warn if local HEAD diverges from the remote default branch.
1306
+ const remoteHead = deps.runSync('git', ['rev-parse', startRef], projectRoot).stdout.trim();
1307
+ if (localHead !== remoteHead) {
1308
+ deps.stderr(
1309
+ `note: local HEAD (${localHead.slice(0, 7)}) differs from ${startRef} (${remoteHead.slice(0, 7)}); ` +
1310
+ `new worktree will start on ${startRef}\n`
1311
+ );
1312
+ }
1313
+ }
1314
+
1315
+ const repoBase = basename(projectRoot);
1316
+ const suffix = args.flags.worktree ?? roleSlug(assignedRole.name);
1317
+ // gh#556 Part 1: empty-suffix guard (CR-binding). roleSlug can yield '' for a
1318
+ // pathological all-special-char role name; an empty leaf would let join() collapse
1319
+ // the worktree path up to the repo-level dir (~/.borg/worktrees/<repo>) and spawn a
1320
+ // worktree at the parent-of-all-this-repo's-worktrees. Fail loud BEFORE the path calc.
1321
+ if (suffix.length === 0) {
1322
+ deps.stderr(
1323
+ `cannot derive a worktree name from role "${assignedRole.name}"; ` +
1324
+ `pass an explicit --worktree <name>\n`
1325
+ );
1326
+ return 1;
1327
+ }
1328
+ // gh#556 Part 1: NEW worktrees live under ~/.borg/worktrees/<repo>/<name>
1329
+ // (was a sibling <parent>/<repo>-<name>). Existing siblings are untouched
1330
+ // (absolute git-registered paths). Collision dedup KEPT (<name>-<n>).
1331
+ const homeDir = deps.homedir();
1332
+ let candidate = computeWorktreePath(homeDir, repoBase, suffix);
1333
+ let wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1334
+ let n = 2;
1335
+ // gh#864: dedup against an existing worktree PATH/registration AND against a
1336
+ // lingering UNMERGED per-worktree branch. `git worktree add -b <wtBranch>`
1337
+ // (below) hard-fails when <wtBranch> already exists even if its old worktree
1338
+ // was pruned — so a stale ref would block the spawn. A MERGED lingering
1339
+ // branch is safely adoptable (handled at the add), so it does NOT force a
1340
+ // suffix bump; only an UNMERGED ref (carrying un-merged commits) bumps to a
1341
+ // fresh suffix so we never reuse/clobber its work.
1342
+ while (
1343
+ deps.pathExists(candidate) ||
1344
+ worktreeRegistered(deps, projectRoot, candidate) ||
1345
+ (localBranchExists(deps.runSync, projectRoot, wtBranch) &&
1346
+ !isMerged(deps.runSync, projectRoot, wtBranch, startRef))
1347
+ ) {
1348
+ candidate = computeWorktreePath(homeDir, repoBase, suffix, n);
1349
+ wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1350
+ n++;
1351
+ }
1352
+ // gh#556 Part 1: create the intermediate ~/.borg/worktrees/<repo>/ before
1353
+ // `git worktree add` (git creates the leaf, not the parent chain). Plain
1354
+ // recursive mkdir — NO chmod of the existing ~/.borg (credentials file).
1355
+ deps.mkdirp(dirname(candidate));
1356
+ // gh#33 (Q1/Q4): spawn on a named per-worktree branch (wt-<suffix>),
1357
+ // NOT detached HEAD. The named branch is current with startRef and is
1358
+ // where the drone's feature branches get cut from. Uniform for every
1359
+ // role incl. coordinator — main is never a working branch (Q4).
1360
+ // Branch naming is UNAFFECTED by the relocation: perWorktreeBranchName's
1361
+ // as-is else-branch maps the new basename <suffix> → wt-<suffix> (== old).
1362
+ //
1363
+ // gh#864: if <wtBranch> already exists here it is MERGED (the loop bumped
1364
+ // past any unmerged ref), so ADOPT it — `git worktree add <path> <branch>`
1365
+ // (no -b) attaches the merged branch instead of failing on a create-
1366
+ // collision. A fresh suffix has no ref → create it at startRef with -b.
1367
+ const wt = localBranchExists(deps.runSync, projectRoot, wtBranch)
1368
+ ? deps.runSync('git', ['worktree', 'add', candidate, wtBranch], projectRoot)
1369
+ : deps.runSync('git', ['worktree', 'add', '-b', wtBranch, candidate, startRef], projectRoot);
1370
+ if (wt.status !== 0) {
1371
+ deps.stderr(`git worktree add failed: ${safeStderr(wt.stderr)}\n`);
1372
+ return 1;
1373
+ }
1374
+ deps.stderr(
1375
+ `spawned sibling worktree at ${candidate} on branch ${wtBranch} (${startRef}); ` +
1376
+ `the original dir keeps its active seat — run \`borg reset-local-seat\` there if that binding is stale.\n`
1377
+ );
1378
+ deps.chdir(candidate);
1379
+ deps.stderr(renderWorktreeSteeringNote(candidate, wtBranch, projectRoot));
1380
+ spawnedWorktreePath = deps.cwd();
1381
+ }
1382
+
1383
+ // ----- Step 8: persist the binding (narrow rollback — worktree exists if spawned) -----
1384
+ const activeCube: ActiveCube = {
1385
+ cubeId: result.cube_id,
1386
+ droneId: result.drone_id,
1387
+ name: cubeDetail.name,
1388
+ droneLabel: result.drone_label,
1389
+ apiUrl: auth.apiUrl,
1390
+ serverTrustIdentity: auth.serverTrustIdentity,
1391
+ localSessionCredentialRef: result.local_session!.credential_ref,
1392
+ localSessionExpiresAt: result.local_session!.expires_at,
1393
+ // gh#899: persist the assimilated role so the connect-time ListTools
1394
+ // handler can role-scope the native tool surface.
1395
+ roleName: assignedRole.name,
1396
+ isHumanSeat: assignedRole.is_human_seat,
1397
+ ...(assignedRole.role_class ? { roleClass: assignedRole.role_class } : {}),
1398
+ };
1399
+
1400
+ const rollbackWorktree = (): void => {
1401
+ if (!spawnedWorktreePath) return;
1402
+ const rm = deps.runSync('git', ['worktree', 'remove', '--force', spawnedWorktreePath], projectRoot);
1403
+ if (rm.status === 0) {
1404
+ deps.stderr(`rolled back spawned worktree at ${spawnedWorktreePath}\n`);
1405
+ } else {
1406
+ deps.stderr(
1407
+ `manual cleanup needed: \`git worktree remove --force ${spawnedWorktreePath}\` ` +
1408
+ `(rollback attempt failed: ${safeStderr(rm.stderr).trim() || 'unknown'})\n`
1409
+ );
1410
+ }
1411
+ };
1412
+
1413
+ // Local-server authority: drive the COMPOSITE cube-owned FINALIZE (Race 2).
1414
+ // The cube lock is held OUTER across revalidate → binding-write → activate; the
1415
+ // typed expectation is declared HERE at the orchestration layer (reattach =
1416
+ // EXACT prior binding with its live-bearer digest; eviction remint = EXACT ref
1417
+ // only, bearer intentionally replaced; fresh/sibling = ABSENT).
1418
+ if (result.finalize === undefined || deps.finalizeServerSeat === undefined) {
1419
+ deps.stderr('Local Borg server session metadata is incomplete; no seat was saved.\n');
1420
+ rollbackWorktree();
1421
+ return 1;
1422
+ }
1423
+ {
1424
+ // The SAME typed expectation declared before PREPARE is revalidated again at
1425
+ // FINALIZE (commit-time revalidation, ratified clause 3).
1426
+ let outcome: FinalizeServerSeatOutcome;
1427
+ try {
1428
+ outcome = await deps.finalizeServerSeat({
1429
+ active: activeCube,
1430
+ expected: sessionExpected,
1431
+ activate: result.finalize.activate,
1432
+ scrubPending: result.finalize.scrubPending,
1433
+ });
1434
+ } catch (err) {
1435
+ // A BINDING-WRITE (or revalidate) failure BEFORE the binding landed. Nothing
1436
+ // owns the spawned worktree yet, so rolling it back is safe.
1437
+ const message = err instanceof Error ? err.message : String(err);
1438
+ deps.stderr(`setActiveCube failed: ${message}\n`);
1439
+ rollbackWorktree();
1440
+ return 1;
1441
+ }
1442
+ if (!outcome.committed) {
1443
+ if (outcome.reason === 'activation-failed') {
1444
+ // CR #5: the atomic activate+bind did NOT commit (missing/replaced/threw), so
1445
+ // the record stays PENDING with no worktree of its own. CR#2/CR#4: bind that
1446
+ // exact pending record to THIS preserved worktree WITHOUT activating it — the
1447
+ // record stays pending (non-hydratable) but becomes DISCOVERABLE from here, so
1448
+ // a rerun FROM this worktree re-derives the exact original operation and
1449
+ // re-sends the identical bearer, converging on the SAME seat (no ghost).
1450
+ //
1451
+ // CR#4 (SR-seven false-success revocation): the bindPending OUTCOME is
1452
+ // load-bearing and must be BRANCHED. A blanket "safe to re-run / identical
1453
+ // seat reused" claim on a missing/replaced/thrown bind is a FALSE-SUCCESS
1454
+ // revocation failure — the worktree would NOT own a durable locator, yet the
1455
+ // operator would be told convergence is guaranteed. Preserve the spawned
1456
+ // worktree ONLY when it owns a durable locator (a `bound` outcome).
1457
+ let bindOutcome: BindPendingSeatOutcome | 'threw' | 'unavailable' = 'unavailable';
1458
+ if (result.finalize?.bindPending) {
1459
+ try {
1460
+ bindOutcome = (await result.finalize.bindPending({
1461
+ worktree: deps.findProjectRoot(deps.cwd()),
1462
+ name: activeCube.name,
1463
+ droneLabel: activeCube.droneLabel,
1464
+ ...(activeCube.roleName !== undefined ? { roleName: activeCube.roleName } : {}),
1465
+ ...(activeCube.roleClass !== undefined ? { roleClass: activeCube.roleClass } : {}),
1466
+ ...(activeCube.isHumanSeat !== undefined ? { isHumanSeat: activeCube.isHumanSeat } : {}),
1467
+ })) as BindPendingSeatOutcome;
1468
+ } catch {
1469
+ bindOutcome = 'threw';
1470
+ }
1471
+ }
1472
+ if (bindOutcome === 'bound') {
1473
+ // The worktree now owns a durable locator (the bound-pending record points
1474
+ // here). PRESERVE it. Truthful convergence copy: a rerun FROM here re-sends
1475
+ // the identical bearer (no duplicate), and `reset-local-seat` from here now
1476
+ // discovers + clears the bound-pending record.
1477
+ deps.stderr(
1478
+ `This worktree's secure session on ${auth.apiUrl} did not finish activating, but ` +
1479
+ 'its resumable seat state was PRESERVED here. This worktree was NOT removed. From ' +
1480
+ `here, re-run ${localAssimilateCommand(auth.apiUrl)} to converge (the identical seat ` +
1481
+ `is reused — no duplicate is minted), or run ${resetLocalSeatCommand(auth.apiUrl)} to ` +
1482
+ 'clear it.\n',
1483
+ );
1484
+ return 1;
1485
+ }
1486
+ // missing / replaced / threw / unavailable: the worktree owns NO durable
1487
+ // locator, so make NO convergence claim. Roll back the just-spawned worktree
1488
+ // (preserve ONLY when it owns a durable locator) and point at the offline reset.
1489
+ deps.stderr(
1490
+ `This worktree's secure session on ${auth.apiUrl} did not finish activating, and its ` +
1491
+ 'seat state could NOT be preserved to this worktree (it was concurrently reset or ' +
1492
+ 'replaced, or the local seat store could not be written). No usable seat remains here. ' +
1493
+ `Run ${resetLocalSeatCommand(auth.apiUrl)} to clear any saved seat, then re-run ` +
1494
+ `${localAssimilateCommand(auth.apiUrl)} to attach against the current state.\n`,
1495
+ );
1496
+ rollbackWorktree();
1497
+ return 1;
1498
+ }
1499
+ // 'expectation-mismatch': the binding was NEVER written (this worktree's
1500
+ // saved seat changed under us between PREPARE and FINALIZE — a concurrent
1501
+ // reset or enroll). The composite scrubbed only our own pending record — no
1502
+ // orphan ACTIVE credential — so a just-spawned worktree is safe to remove.
1503
+ deps.stderr(
1504
+ `This worktree's saved local seat on ${auth.apiUrl} changed during attach ` +
1505
+ '(a concurrent reset or enroll); no seat was created and nothing was overwritten. ' +
1506
+ `Re-run ${localAssimilateCommand(auth.apiUrl)} to attach against the current state.\n`,
1507
+ );
1508
+ rollbackWorktree();
1509
+ return 1;
1510
+ }
1511
+ }
1512
+
1513
+ // The worktree, not a reminted drone UUID, is the stable local seat identity
1514
+ // for monitor runtime state. Capture it once before the lazy GC and launch
1515
+ // paths so both use the exact same explicit root.
1516
+ const agentCwd = deps.cwd(); // post-chdir if step 3 spawned a worktree
1517
+ const seatWorktree = deps.findProjectRoot(agentCwd);
1518
+ const monitorStateRoot = monitorStateRootForWorktree(seatWorktree);
1519
+
1520
+ // gh#793: best-effort GC of orphaned inbox files (evicted/dead drones) in the
1521
+ // cube just joined — lazy-on-assimilate, no cron/new command. NEVER blocks or
1522
+ // fails the assimilate (whole call swallowed). Local-only signal (CubeDetail
1523
+ // carries no roster → droneState 'absent'; an inbox is reaped only when
1524
+ // no-live-holder AND ≥30-day stale). The live-safety gate (raw pgrep tail /
1525
+ // fresh heartbeat / live pidfile) vetoes any live holder — a wrong delete is
1526
+ // permanent deafness, a missed orphan is harmless.
1527
+ try {
1528
+ const livenessDeps = defaultInboxLivenessDeps();
1529
+ const cubeInboxDir = dirname(inboxPathForDrone(result.cube_id, result.drone_id));
1530
+ gcOrphanInboxesForCube({
1531
+ cubeInboxDir,
1532
+ selfDroneId: result.drone_id,
1533
+ deps: {
1534
+ listInboxLogs: defaultListInboxLogs,
1535
+ isLive: (p) => isInboxLive(p, livenessDeps, monitorStateRoot),
1536
+ droneState: () => 'absent',
1537
+ unlink: (p) => unlinkSync(p),
1538
+ now: livenessDeps.now,
1539
+ staleMs: ORPHAN_INBOX_STALE_MS,
1540
+ },
1541
+ monitorStateRoot,
1542
+ });
1543
+ } catch {
1544
+ /* gh#793: orphan GC is best-effort — never block or fail the assimilate */
1545
+ }
1546
+
1547
+ // ----- Step 8: Launch selected agent CLI -----
1548
+ // Mirrors the kickoff invocation from claude.ts (no-args path): the agent
1549
+ // picks up the newly-persisted ActiveCube via the MCP stdio server on
1550
+ // startup. The kickoff prompt re-enters /loop borg_regen so the new
1551
+ // drone bootstraps into the cube cleanly. The monitor clause (CR-PE-F1)
1552
+ // arms the inbox tail so the new drone wakes on peer log entries in
1553
+ // real time — without this, drones miss real-time wake events during
1554
+ // the bootstrap window and only self-heal at the /loop heartbeat.
1555
+ deps.setTerminalTitle(result.drone_label, cubeDetail.name);
1556
+
1557
+ // Pedagogical hint to stdout before Claude takes over the terminal.
1558
+ // Ink does not enter alt-screen-buffer (verified empirically via PTY
1559
+ // probe 2026-05-19), so lines printed here remain visible in the
1560
+ // user's terminal scrollback above Claude's interactive UI. Color is
1561
+ // gated on TTY + NO_COLOR/CI env-var conventions; the welcome shape
1562
+ // itself is cube-agnostic so non-default templates render identically.
1563
+ const useColor = deps.isTTY() && !process.env.NO_COLOR && !process.env.CI;
1564
+ deps.stdout(
1565
+ renderAssimilationWelcome(
1566
+ result.drone_label,
1567
+ assignedRole.name,
1568
+ cubeDetail.name,
1569
+ useColor,
1570
+ authority.kind === 'server' ? authority.apiUrl : undefined,
1571
+ ),
1572
+ );
1573
+
1574
+ // gh#673 P2 (WI-1): install the project-local SessionStart orientation
1575
+ // hook into the launch root — covers BOTH the freshly-spawned sibling
1576
+ // worktree (agentCwd = the new worktree post-chdir) and the in-place /
1577
+ // --here path. Best-effort: a hook-install failure must never block
1578
+ // the assimilate (the bare-`borg` launcher re-ensures it).
1579
+ try {
1580
+ deps.installProjectSessionHook(agentCwd);
1581
+ } catch {
1582
+ deps.stderr(`warning: could not install the project-local SessionStart hook in ${agentCwd}; it will be re-attempted on the next borg launch\n`);
1583
+ }
1584
+
1585
+ // gh#33 (Q2/Q4/Q6): in-place wt- adoption. A freshly-spawned worktree is
1586
+ // already at origin/main on a fresh wt- branch, so only the in-place /
1587
+ // --here path (running in an existing checkout) needs handling. ADOPT
1588
+ // the per-worktree branch — switch the checkout onto wt-<suffix> at
1589
+ // origin/main when clean + merged. This both moves the drone off main
1590
+ // (Q4: main is never a working branch) AND brings the branch current,
1591
+ // which a bare ff-sync would not do (it would leave a main checkout on
1592
+ // main — the gap two-of-four-CR 27af1001 + QA c7a0c615 caught). Dirty ->
1593
+ // skip + surface; unmerged HEAD -> block + surface; NEVER discards.
1594
+ // Using adoptWorktree (HEAD-merged check + explicit `switch -C wtBranch
1595
+ // ref`) also closes one-of-four-CR's compute-name-vs-current-branch NIT.
1596
+ // Best-effort: a skip/block surfaces but never blocks the launch.
1597
+ if (!spawnedWorktreePath) {
1598
+ deps.runSync('git', ['fetch', 'origin', '--prune'], agentCwd);
1599
+ const wtBranch = perWorktreeBranchName(basename(agentCwd), basename(projectRoot));
1600
+ const adopt = adoptWorktree(deps.runSync, agentCwd, wtBranch, 'origin/main');
1601
+ if (adopt.action === 'adopted') {
1602
+ deps.stderr(`worktree: adopted branch ${wtBranch} at origin/main\n`);
1603
+ deps.stderr(renderInPlaceWorktreeNote(agentCwd, wtBranch));
1604
+ } else if (adopt.message) {
1605
+ deps.stderr(`worktree sync: ${adopt.message}\n`);
1606
+ }
1607
+ }
1608
+
1609
+ // BUG-5 / v0.9.3: probe MCP readiness before launching claude so
1610
+ // the launched session sees tools at startup. Non-blocking: probe
1611
+ // failure surfaces a stderr warning but the launch proceeds (the
1612
+ // kickoff text's ToolSearch recovery clause is the second line of
1613
+ // defense).
1614
+ const mcpReady = await deps.probeMcpReady();
1615
+ if (!mcpReady) {
1616
+ deps.stderr(
1617
+ `warning: borg-mcp readiness probe did not complete within the timeout; ` +
1618
+ `launching ${cli} anyway — the kickoff prompt's ToolSearch fallback ` +
1619
+ `will recover if the MCP server takes longer to start.\n`
1620
+ );
1621
+ }
1622
+ const inboxPath = deps.getInboxPath(result.cube_id, result.drone_id);
1623
+ const codexWakeNonce = cli === 'codex' ? `borg-wake-${randomUUID()}` : null;
1624
+ // gh#929: shared wakePathArming + NEVER-TaskStop (unified with claude.ts —
1625
+ // the two call sites previously carried divergent monitorClause strings).
1626
+ const monitorClause = buildKickoffWakePathClause(
1627
+ cli,
1628
+ cli === 'claude' ? inboxPath : null,
1629
+ cli === 'claude' ? monitorStateRoot : null
1630
+ );
1631
+ let codexWakePathClause: string | undefined;
1632
+ let remoteArgs: string[] = [];
1633
+ let launchArgs: string[];
1634
+ let codexSocketPath: string | null = null;
1635
+ let codexServerCleanup: (() => void) | null = null;
1636
+ const launchApproval = deps.resolveCliApprovals
1637
+ ? await deps.resolveCliApprovals(cli, agentCwd)
1638
+ : { codexArgs: [] };
1639
+ if (launchApproval.warning) deps.stderr(`warning: ${launchApproval.warning}\n`);
1640
+
1641
+ // Temporary Claude-only model compatibility. Local/provider models are
1642
+ // configured by the selected agent CLI and are never rewritten by Borg.
1643
+ const modelEnv = resolveLaunchEnv(effectiveModel);
1644
+ const childEnv: Record<string, string> = {
1645
+ ...(withAgentRuntimeEnv(process.env, cli) as Record<string, string>),
1646
+ ...modelEnv.set,
1647
+ BORG_SESSION: '1',
1648
+ };
1649
+ if (cli === 'opencode' && launchApproval.openCodePermission) {
1650
+ childEnv.OPENCODE_PERMISSION = launchApproval.openCodePermission;
1651
+ }
1652
+ for (const key of modelEnv.unset) {
1653
+ delete childEnv[key];
1654
+ }
1655
+
1656
+ if (cli === 'codex') {
1657
+ const remote = await deps.prepareCodexRemoteLaunch();
1658
+ if (remote.warning) {
1659
+ deps.stderr(`warning: ${remote.warning}\n`);
1660
+ codexWakePathClause =
1661
+ `⚠ Codex wake-path capability check failed: remote-control is unavailable for this session. Run borg_regen manually whenever you return, and expect only fallback wakeups until relaunch.`;
1662
+ } else {
1663
+ codexWakePathClause =
1664
+ `Codex wake-path capability check passed: remote-control socket established for this session.`;
1665
+ }
1666
+ remoteArgs = remote.args;
1667
+ // Codex env takes precedence over model env when there is overlap.
1668
+ if (Object.keys(remote.env).length > 0) {
1669
+ Object.assign(childEnv, remote.env);
1670
+ }
1671
+ codexSocketPath = socketPathFromRemoteArgs(remote.args);
1672
+ codexServerCleanup = remote.server?.cleanup ?? null;
1673
+ }
1674
+ const kickoff = buildAgentKickoffPrompt({
1675
+ cli,
1676
+ codexWakeNonce,
1677
+ monitorClause,
1678
+ codexWakePathClause,
1679
+ });
1680
+ // Keep Claude and Codex on the unmodified shared kickoff. Only OpenCode
1681
+ // receives a nonce-bearing copy so the later MCP connection can identify
1682
+ // this exact launch among same-text sessions.
1683
+ let openCodeKickoff: ReturnType<typeof createOpenCodeLaunchKickoff> | null = null;
1684
+ let dronePort: number | undefined;
1685
+ launchArgs = [kickoff];
1686
+ if (cli === 'codex') {
1687
+ // gh#673 P1-codex: -c overrides deliver BORG_SESSION and the selected
1688
+ // CLI identity to the codex-spawned borg-mcp child (inherited env never
1689
+ // reaches Codex MCP children — V2/V2b probes). Explicitly pin remote wake
1690
+ // off when no socket is available, overriding legacy static configs that
1691
+ // formerly used this transport marker as Codex identity.
1692
+ launchArgs = [
1693
+ ...launchApproval.codexArgs,
1694
+ ...codexBorgSessionConfigArgs(),
1695
+ ...codexAgentKindConfigArgs(),
1696
+ ...codexRemoteWakeConfigArgs(codexSocketPath !== null),
1697
+ ...remoteArgs,
1698
+ ...withCodexCwdArg(launchArgs, agentCwd),
1699
+ ];
1700
+ } else if (cli === 'opencode') {
1701
+ // OpenCode assimilate launch: start TUI with the kickoff passed via
1702
+ // --prompt (auto-submits it as the first message). BORG_SESSION is
1703
+ // pinned in opencode.json. A unique port is assigned so the MCP child
1704
+ // can connect via the HTTP API for context-streaming (injectOpenCodeEntry).
1705
+ dronePort = computeOpenCodePort(result.drone_id);
1706
+ installBorgPlugin();
1707
+ const cwd = agentCwd;
1708
+ openCodeKickoff = createOpenCodeLaunchKickoff(kickoff);
1709
+ launchArgs = buildOpenCodeLaunchArgs(cwd, dronePort, openCodeKickoff.prompt);
1710
+ }
1711
+ // gh#673 P1: mark the launched agent session as borg-launched so the
1712
+ // MCP child + hook bins activate (launch-gate.ts). childEnv is the
1713
+ // complete child environment (process.env + model.set, minus unset
1714
+ // keys, plus BORG_SESSION + codex env). The exec seam must use it
1715
+ // directly without re-merging process.env (assimilate-deps.ts).
1716
+ const exitPromise = deps.exec(cli, launchArgs, agentCwd, childEnv);
1717
+ if (cli === 'codex' && codexSocketPath && codexWakeNonce) {
1718
+ void recordCodexWakeTarget({
1719
+ deps,
1720
+ cubeId: result.cube_id,
1721
+ droneId: result.drone_id,
1722
+ socketPath: codexSocketPath,
1723
+ cwd: agentCwd,
1724
+ previewNeedle: codexWakeNonce,
1725
+ launchedAtSeconds: Math.floor(Date.now() / 1000),
1726
+ });
1727
+ }
1728
+ // gh#opencode: inject the kickoff prompt into the TUI's first session via
1729
+ // the SDK. OpenCode doesn't accept a prompt as a CLI arg, so we do it
1730
+ // programmatically once the HTTP server is ready. Best-effort.
1731
+ if (cli === 'opencode' && openCodeKickoff) {
1732
+ const launchKickoff = openCodeKickoff;
1733
+ const serverUrl = `http://127.0.0.1:${dronePort}`;
1734
+ connectOpenCodeDrone({
1735
+ serverUrl,
1736
+ directory: agentCwd,
1737
+ droneLabel: result.drone_label,
1738
+ cubeName: cubeName ?? 'borg',
1739
+ })
1740
+ .then(() => injectInitialKickoff(launchKickoff))
1741
+ .catch(() => {});
1742
+ }
1743
+ const exitCode = await exitPromise;
1744
+ // gh#528: kill the borg-owned Codex app-server when the assimilate-launched
1745
+ // session exits, so it isn't left orphaned (live → not pruned by pid liveness).
1746
+ // OpenCode has no app-server to clean up.
1747
+ if (codexServerCleanup) {
1748
+ try {
1749
+ codexServerCleanup();
1750
+ } catch {
1751
+ // best-effort
1752
+ }
1753
+ }
1754
+
1755
+ // Sprint 18: when a sibling worktree was spawned, the user's shell
1756
+ // returns to their original cwd after Claude exits (process.chdir
1757
+ // doesn't propagate to the parent). Emit a stderr hint so they know
1758
+ // how to get back into the worktree. shellEscape defangs any shell
1759
+ // metachars in the path against paste-injection (drone-11 SR-LANE).
1760
+ // Skip the hint when no worktree was spawned (--here / no-worktree
1761
+ // flow) or when originalCwd already matches the worktree path
1762
+ // (defensive against the no-op edge case drone-9 UX-LANE flagged).
1763
+ if (spawnedWorktreePath && originalCwd !== spawnedWorktreePath) {
1764
+ deps.stderr(
1765
+ `\nAgent exited. You were working in ${spawnedWorktreePath}; your shell is back in ${originalCwd}.\n` +
1766
+ `To return:\n` +
1767
+ ` cd ${shellEscape(spawnedWorktreePath)}\n`
1768
+ );
1769
+ }
1770
+
1771
+ return exitCode;
1772
+ }
1773
+
1774
+ function renderWorktreeSteeringNote(worktreePath: string, wtBranch: string, primaryPath: string): string {
1775
+ return (
1776
+ `\nWORKTREE STEERING: You are in worktree ${worktreePath} on branch ${wtBranch}. ` +
1777
+ `Do ALL work HERE — cut your feature branch (fix/.../feat/...) off ${wtBranch} in THIS worktree, ` +
1778
+ `use relative paths / your cwd. NEVER \`git -C ${primaryPath}\` or operate on the primary checkout ${primaryPath}: ` +
1779
+ `the same branch can't be checked out in two worktrees, so work created in the primary won't reach your wt-branch ` +
1780
+ `without manual surgery (cherry-pick/merge).\n`
1781
+ );
1782
+ }
1783
+
1784
+ function renderInPlaceWorktreeNote(worktreePath: string, wtBranch: string): string {
1785
+ return (
1786
+ `\nWORKTREE STEERING: This checkout is now on branch ${wtBranch}. ` +
1787
+ `Do ALL work HERE in ${worktreePath} — cut your feature branch (fix/.../feat/...) off ${wtBranch}, ` +
1788
+ `use relative paths / your cwd.\n`
1789
+ );
1790
+ }
1791
+
1792
+ /**
1793
+ * Sprint 4 / gh#147 (drone-8 SR-PE-FINDING-1): strip ASCII control
1794
+ * characters before interpolating subprocess stderr into operator-
1795
+ * facing messages. Defense-in-depth against a local attacker editing
1796
+ * `.git/config` to embed ANSI escapes (e.g. `\x1b[2J` cursor moves,
1797
+ * `\x1b]0;...\x07` title injection) — git command stderr then carries
1798
+ * them, and unfiltered orchestrator output corrupts the terminal.
1799
+ *
1800
+ * Strips `[\x00-\x1F\x7F]` (NUL, all C0 controls, DEL). ASCII
1801
+ * whitespace inside C0 (tab, newline, CR) gets stripped too — the
1802
+ * orchestrator only ever interpolates short status fragments where
1803
+ * preserving multi-line layout isn't load-bearing; over-strip
1804
+ * trade-off accepted for shape simplicity.
1805
+ */
1806
+ export function safeStderr(msg: string): string {
1807
+ return msg.replace(/[\x00-\x1F\x7F]/g, '');
1808
+ }
1809
+
1810
+ function worktreeRegistered(deps: AssimilateDeps, projectRoot: string, candidate: string): boolean {
1811
+ const res = deps.runSync('git', ['worktree', 'list', '--porcelain'], projectRoot);
1812
+ if (res.status !== 0) return false;
1813
+ return res.stdout.split('\n').some((line) => line === `worktree ${candidate}`);
1814
+ }
1815
+
1816
+ /**
1817
+ * Sprint 19 (gh#184): suggest the closest cube-role name for a misspelled
1818
+ * CLI role argument. Levenshtein distance ≤2 against the cube's role
1819
+ * names; case-insensitive. Returns null when no close match exists.
1820
+ *
1821
+ * Serves Queen's "more user-friendly" intent without violating the
1822
+ * Borg-collective metaphor (collective defines roles; drones slot in).
1823
+ * The original strict-failure semantic is preserved; the suggestion
1824
+ * is an additive nudge in the error message, not a fallback path.
1825
+ */
1826
+ export function suggestRoleName(input: string, candidates: string[]): string | null {
1827
+ if (candidates.length === 0) return null;
1828
+ const inputLower = input.toLowerCase();
1829
+ let best: { name: string; distance: number } | null = null;
1830
+ for (const candidate of candidates) {
1831
+ const distance = levenshtein(inputLower, candidate.toLowerCase());
1832
+ if (distance <= 2 && (best === null || distance < best.distance)) {
1833
+ best = { name: candidate, distance };
1834
+ }
1835
+ }
1836
+ return best ? best.name : null;
1837
+ }
1838
+
1839
+ /**
1840
+ * Minimal Levenshtein distance implementation. Used only by
1841
+ * `suggestRoleName` for the fuzzy-match nudge; intentionally
1842
+ * unexported and not a general-purpose helper.
1843
+ */
1844
+ function levenshtein(a: string, b: string): number {
1845
+ if (a === b) return 0;
1846
+ if (a.length === 0) return b.length;
1847
+ if (b.length === 0) return a.length;
1848
+ const prev = new Array<number>(b.length + 1);
1849
+ const curr = new Array<number>(b.length + 1);
1850
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
1851
+ for (let i = 1; i <= a.length; i++) {
1852
+ curr[0] = i;
1853
+ for (let j = 1; j <= b.length; j++) {
1854
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1855
+ curr[j] = Math.min(
1856
+ curr[j - 1] + 1,
1857
+ prev[j] + 1,
1858
+ prev[j - 1] + cost,
1859
+ );
1860
+ }
1861
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
1862
+ }
1863
+ return prev[b.length];
1864
+ }