borgmcp 1.1.15 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (459) hide show
  1. package/CONTRIBUTING.md +15 -0
  2. package/LICENSE +201 -33
  3. package/NOTICE +2 -0
  4. package/README.md +85 -36
  5. package/SECURITY.md +7 -0
  6. package/dist/agent-runtime.d.ts.map +1 -0
  7. package/dist/agent-runtime.js +63 -1
  8. package/dist/agent-runtime.js.map +1 -0
  9. package/dist/assimilate-cmd.d.ts +96 -17
  10. package/dist/assimilate-cmd.d.ts.map +1 -0
  11. package/dist/assimilate-cmd.js +1396 -48
  12. package/dist/assimilate-cmd.js.map +1 -0
  13. package/dist/assimilate-deps.d.ts.map +1 -0
  14. package/dist/assimilate-deps.js +384 -3
  15. package/dist/assimilate-deps.js.map +1 -0
  16. package/dist/assimilate-guard.d.ts +5 -7
  17. package/dist/assimilate-guard.d.ts.map +1 -0
  18. package/dist/assimilate-guard.js +60 -2
  19. package/dist/assimilate-guard.js.map +1 -0
  20. package/dist/assimilate-welcome.d.ts +1 -1
  21. package/dist/assimilate-welcome.d.ts.map +1 -0
  22. package/dist/assimilate-welcome.js +32 -2
  23. package/dist/assimilate-welcome.js.map +1 -0
  24. package/dist/auth-env.d.ts +7 -50
  25. package/dist/auth-env.d.ts.map +1 -0
  26. package/dist/auth-env.js +22 -1
  27. package/dist/auth-env.js.map +1 -0
  28. package/dist/backends/launch-all-pastelist.d.ts.map +1 -0
  29. package/dist/backends/launch-all-pastelist.js +11 -4
  30. package/dist/backends/launch-all-pastelist.js.map +1 -0
  31. package/dist/backends/launch-all-tmux.d.ts.map +1 -0
  32. package/dist/backends/launch-all-tmux.js +49 -1
  33. package/dist/backends/launch-all-tmux.js.map +1 -0
  34. package/dist/backends/launch-all-windows.d.ts.map +1 -0
  35. package/dist/backends/launch-all-windows.js +86 -12
  36. package/dist/backends/launch-all-windows.js.map +1 -0
  37. package/dist/bare-launch-menu.d.ts.map +1 -0
  38. package/dist/bare-launch-menu.js +83 -6
  39. package/dist/bare-launch-menu.js.map +1 -0
  40. package/dist/claude-launch-args.d.ts.map +1 -0
  41. package/dist/claude-launch-args.js +34 -1
  42. package/dist/claude-launch-args.js.map +1 -0
  43. package/dist/claude.d.ts.map +1 -0
  44. package/dist/claude.js +486 -14
  45. package/dist/claude.js.map +1 -0
  46. package/dist/cleanup-cmd.d.ts +5 -5
  47. package/dist/cleanup-cmd.d.ts.map +1 -0
  48. package/dist/cleanup-cmd.js +453 -14
  49. package/dist/cleanup-cmd.js.map +1 -0
  50. package/dist/clear-rewake-core.d.ts.map +1 -0
  51. package/dist/clear-rewake-core.js +23 -2
  52. package/dist/clear-rewake-core.js.map +1 -0
  53. package/dist/clear-rewake.d.ts.map +1 -0
  54. package/dist/clear-rewake.js +19 -1
  55. package/dist/clear-rewake.js.map +1 -0
  56. package/dist/cli-help.d.ts +5 -0
  57. package/dist/cli-help.d.ts.map +1 -0
  58. package/dist/cli-help.js +104 -56
  59. package/dist/cli-help.js.map +1 -0
  60. package/dist/cli-platform.d.ts.map +1 -0
  61. package/dist/cli-platform.js +106 -4
  62. package/dist/cli-platform.js.map +1 -0
  63. package/dist/cli-tool-approval.d.ts +84 -0
  64. package/dist/cli-tool-approval.d.ts.map +1 -0
  65. package/dist/cli-tool-approval.js +583 -0
  66. package/dist/cli-tool-approval.js.map +1 -0
  67. package/dist/codex-app-server.d.ts.map +1 -0
  68. package/dist/codex-app-server.js +228 -4
  69. package/dist/codex-app-server.js.map +1 -0
  70. package/dist/codex-app-wake.d.ts.map +1 -0
  71. package/dist/codex-app-wake.js +432 -2
  72. package/dist/codex-app-wake.js.map +1 -0
  73. package/dist/codex-launch.d.ts.map +1 -0
  74. package/dist/codex-launch.js +102 -2
  75. package/dist/codex-launch.js.map +1 -0
  76. package/dist/codex-remote.d.ts +6 -1
  77. package/dist/codex-remote.d.ts.map +1 -0
  78. package/dist/codex-remote.js +394 -1
  79. package/dist/codex-remote.js.map +1 -0
  80. package/dist/codex-wake-resolve.d.ts.map +1 -0
  81. package/dist/codex-wake-resolve.js +127 -1
  82. package/dist/codex-wake-resolve.js.map +1 -0
  83. package/dist/config-utils.d.ts +2 -2
  84. package/dist/config-utils.d.ts.map +1 -0
  85. package/dist/config-utils.js +766 -3
  86. package/dist/config-utils.js.map +1 -0
  87. package/dist/config.d.ts +98 -61
  88. package/dist/config.d.ts.map +1 -0
  89. package/dist/config.js +426 -1
  90. package/dist/config.js.map +1 -0
  91. package/dist/console-prefix.d.ts.map +1 -0
  92. package/dist/console-prefix.js +96 -1
  93. package/dist/console-prefix.js.map +1 -0
  94. package/dist/cube-name.d.ts.map +1 -0
  95. package/dist/cube-name.js +67 -1
  96. package/dist/cube-name.js.map +1 -0
  97. package/dist/cubes.d.ts +126 -18
  98. package/dist/cubes.d.ts.map +1 -0
  99. package/dist/cubes.js +412 -4
  100. package/dist/cubes.js.map +1 -0
  101. package/dist/debug.d.ts.map +1 -0
  102. package/dist/debug.js +71 -1
  103. package/dist/debug.js.map +1 -0
  104. package/dist/direct-log.d.ts.map +1 -0
  105. package/dist/direct-log.js +11 -1
  106. package/dist/direct-log.js.map +1 -0
  107. package/dist/docs-sections.d.ts +11 -10
  108. package/dist/docs-sections.d.ts.map +1 -0
  109. package/dist/docs-sections.js +113 -3
  110. package/dist/docs-sections.js.map +1 -0
  111. package/dist/drone-lifecycle.d.ts +5 -21
  112. package/dist/drone-lifecycle.d.ts.map +1 -0
  113. package/dist/drone-lifecycle.js +57 -8
  114. package/dist/drone-lifecycle.js.map +1 -0
  115. package/dist/drone-management.d.ts +29 -0
  116. package/dist/drone-management.d.ts.map +1 -0
  117. package/dist/drone-management.js +109 -0
  118. package/dist/drone-management.js.map +1 -0
  119. package/dist/ensure-mcp-config.d.ts.map +1 -0
  120. package/dist/ensure-mcp-config.js +36 -1
  121. package/dist/ensure-mcp-config.js.map +1 -0
  122. package/dist/evict-drone.d.ts +10 -2
  123. package/dist/evict-drone.d.ts.map +1 -0
  124. package/dist/evict-drone.js +60 -1
  125. package/dist/evict-drone.js.map +1 -0
  126. package/dist/gc-orphan-inboxes.d.ts.map +1 -0
  127. package/dist/gc-orphan-inboxes.js +177 -1
  128. package/dist/gc-orphan-inboxes.js.map +1 -0
  129. package/dist/inbox-monitor.d.ts +1 -1
  130. package/dist/inbox-monitor.d.ts.map +1 -0
  131. package/dist/inbox-monitor.js +856 -3
  132. package/dist/inbox-monitor.js.map +1 -0
  133. package/dist/index.d.ts +6 -4
  134. package/dist/index.d.ts.map +1 -0
  135. package/dist/index.js +1034 -37
  136. package/dist/index.js.map +1 -0
  137. package/dist/launch-all-cmd.d.ts.map +1 -0
  138. package/dist/launch-all-cmd.js +377 -37
  139. package/dist/launch-all-cmd.js.map +1 -0
  140. package/dist/launch-all-command.d.ts.map +1 -0
  141. package/dist/launch-all-command.js +24 -1
  142. package/dist/launch-all-command.js.map +1 -0
  143. package/dist/launch-all-deps.d.ts +3 -8
  144. package/dist/launch-all-deps.d.ts.map +1 -0
  145. package/dist/launch-all-deps.js +99 -1
  146. package/dist/launch-all-deps.js.map +1 -0
  147. package/dist/launch-all-discovery.d.ts +1 -0
  148. package/dist/launch-all-discovery.d.ts.map +1 -0
  149. package/dist/launch-all-discovery.js +90 -4
  150. package/dist/launch-all-discovery.js.map +1 -0
  151. package/dist/launch-all-locks.d.ts.map +1 -0
  152. package/dist/launch-all-locks.js +54 -1
  153. package/dist/launch-all-locks.js.map +1 -0
  154. package/dist/launch-gate.d.ts +2 -2
  155. package/dist/launch-gate.d.ts.map +1 -0
  156. package/dist/launch-gate.js +73 -1
  157. package/dist/launch-gate.js.map +1 -0
  158. package/dist/lifecycle-log-guard.d.ts.map +1 -0
  159. package/dist/lifecycle-log-guard.js +93 -2
  160. package/dist/lifecycle-log-guard.js.map +1 -0
  161. package/dist/list-roles-render.d.ts.map +1 -0
  162. package/dist/list-roles-render.js +40 -6
  163. package/dist/list-roles-render.js.map +1 -0
  164. package/dist/local-log-routing.d.ts +19 -0
  165. package/dist/local-log-routing.d.ts.map +1 -0
  166. package/dist/local-log-routing.js +67 -0
  167. package/dist/local-log-routing.js.map +1 -0
  168. package/dist/local-manage-tool-result.d.ts +8 -0
  169. package/dist/local-manage-tool-result.d.ts.map +1 -0
  170. package/dist/local-manage-tool-result.js +10 -0
  171. package/dist/local-manage-tool-result.js.map +1 -0
  172. package/dist/local-server-cursor.d.ts +34 -0
  173. package/dist/local-server-cursor.d.ts.map +1 -0
  174. package/dist/local-server-cursor.js +151 -0
  175. package/dist/local-server-cursor.js.map +1 -0
  176. package/dist/log-audit.d.ts.map +1 -0
  177. package/dist/log-audit.js +194 -3
  178. package/dist/log-audit.js.map +1 -0
  179. package/dist/log-stream.d.ts +31 -18
  180. package/dist/log-stream.d.ts.map +1 -0
  181. package/dist/log-stream.js +1214 -12
  182. package/dist/log-stream.js.map +1 -0
  183. package/dist/model-presets.d.ts.map +1 -0
  184. package/dist/model-presets.js +19 -1
  185. package/dist/model-presets.js.map +1 -0
  186. package/dist/name-validator.d.ts.map +1 -0
  187. package/dist/name-validator.js +22 -1
  188. package/dist/name-validator.js.map +1 -0
  189. package/dist/opencode-drone.d.ts.map +1 -0
  190. package/dist/opencode-drone.js +438 -4
  191. package/dist/opencode-drone.js.map +1 -0
  192. package/dist/opencode-plugin.d.ts +1 -1
  193. package/dist/opencode-plugin.d.ts.map +1 -0
  194. package/dist/opencode-plugin.js +29 -4
  195. package/dist/opencode-plugin.js.map +1 -0
  196. package/dist/parse-assimilate-args.d.ts +1 -1
  197. package/dist/parse-assimilate-args.d.ts.map +1 -0
  198. package/dist/parse-assimilate-args.js +125 -1
  199. package/dist/parse-assimilate-args.js.map +1 -0
  200. package/dist/parse-launch-all-args.d.ts.map +1 -0
  201. package/dist/parse-launch-all-args.js +70 -1
  202. package/dist/parse-launch-all-args.js.map +1 -0
  203. package/dist/postinstall-banner.d.ts.map +1 -0
  204. package/dist/postinstall-banner.js +27 -2
  205. package/dist/postinstall-banner.js.map +1 -0
  206. package/dist/postinstall.d.ts.map +1 -0
  207. package/dist/postinstall.js +32 -5
  208. package/dist/postinstall.js.map +1 -0
  209. package/dist/readiness-probe.d.ts.map +1 -0
  210. package/dist/readiness-probe.js +9 -1
  211. package/dist/readiness-probe.js.map +1 -0
  212. package/dist/regen-format.d.ts.map +1 -0
  213. package/dist/regen-format.js +545 -13
  214. package/dist/regen-format.js.map +1 -0
  215. package/dist/regen.d.ts.map +1 -0
  216. package/dist/regen.js +141 -5
  217. package/dist/regen.js.map +1 -0
  218. package/dist/remote-client.d.ts +65 -175
  219. package/dist/remote-client.d.ts.map +1 -0
  220. package/dist/remote-client.js +942 -1
  221. package/dist/remote-client.js.map +1 -0
  222. package/dist/reset-local-seat-cmd.d.ts +50 -0
  223. package/dist/reset-local-seat-cmd.d.ts.map +1 -0
  224. package/dist/reset-local-seat-cmd.js +169 -0
  225. package/dist/reset-local-seat-cmd.js.map +1 -0
  226. package/dist/role-match.d.ts.map +1 -0
  227. package/dist/role-match.js +23 -1
  228. package/dist/role-match.js.map +1 -0
  229. package/dist/role-resolver.d.ts.map +1 -0
  230. package/dist/role-resolver.js +60 -1
  231. package/dist/role-resolver.js.map +1 -0
  232. package/dist/roster-render.d.ts.map +1 -0
  233. package/dist/roster-render.js +121 -3
  234. package/dist/roster-render.js.map +1 -0
  235. package/dist/seat-probe.d.ts +32 -13
  236. package/dist/seat-probe.d.ts.map +1 -0
  237. package/dist/seat-probe.js +78 -1
  238. package/dist/seat-probe.js.map +1 -0
  239. package/dist/seat-store.d.ts +76 -0
  240. package/dist/seat-store.d.ts.map +1 -0
  241. package/dist/seat-store.js +309 -0
  242. package/dist/seat-store.js.map +1 -0
  243. package/dist/seats.d.ts +303 -0
  244. package/dist/seats.d.ts.map +1 -0
  245. package/dist/seats.js +551 -0
  246. package/dist/seats.js.map +1 -0
  247. package/dist/self-path.d.ts +27 -0
  248. package/dist/self-path.d.ts.map +1 -0
  249. package/dist/self-path.js +43 -0
  250. package/dist/self-path.js.map +1 -0
  251. package/dist/server-endpoint.d.ts +9 -0
  252. package/dist/server-endpoint.d.ts.map +1 -0
  253. package/dist/server-endpoint.js +38 -0
  254. package/dist/server-endpoint.js.map +1 -0
  255. package/dist/server-errors.d.ts +50 -0
  256. package/dist/server-errors.d.ts.map +1 -0
  257. package/dist/server-errors.js +79 -0
  258. package/dist/server-errors.js.map +1 -0
  259. package/dist/server-handshake.d.ts +197 -0
  260. package/dist/server-handshake.d.ts.map +1 -0
  261. package/dist/server-handshake.js +505 -0
  262. package/dist/server-handshake.js.map +1 -0
  263. package/dist/server-response.d.ts +3 -0
  264. package/dist/server-response.d.ts.map +1 -0
  265. package/dist/server-response.js +67 -0
  266. package/dist/server-response.js.map +1 -0
  267. package/dist/server-trust.d.ts +14 -0
  268. package/dist/server-trust.d.ts.map +1 -0
  269. package/dist/server-trust.js +225 -0
  270. package/dist/server-trust.js.map +1 -0
  271. package/dist/setup-confirm.d.ts +3 -3
  272. package/dist/setup-confirm.d.ts.map +1 -0
  273. package/dist/setup-confirm.js +126 -2
  274. package/dist/setup-confirm.js.map +1 -0
  275. package/dist/setup.d.ts +5 -3
  276. package/dist/setup.d.ts.map +1 -0
  277. package/dist/setup.js +169 -46
  278. package/dist/setup.js.map +1 -0
  279. package/dist/shell-escape.d.ts.map +1 -0
  280. package/dist/shell-escape.js +22 -1
  281. package/dist/shell-escape.js.map +1 -0
  282. package/dist/spawn.d.ts +2 -2
  283. package/dist/spawn.d.ts.map +1 -0
  284. package/dist/spawn.js +29 -10
  285. package/dist/spawn.js.map +1 -0
  286. package/dist/startup-services.d.ts +0 -1
  287. package/dist/startup-services.d.ts.map +1 -0
  288. package/dist/startup-services.js +26 -1
  289. package/dist/startup-services.js.map +1 -0
  290. package/dist/stream-owner.d.ts +6 -2
  291. package/dist/stream-owner.d.ts.map +1 -0
  292. package/dist/stream-owner.js +547 -2
  293. package/dist/stream-owner.js.map +1 -0
  294. package/dist/stream-status.d.ts +1 -1
  295. package/dist/stream-status.d.ts.map +1 -0
  296. package/dist/stream-status.js +268 -3
  297. package/dist/stream-status.js.map +1 -0
  298. package/dist/sync-roles-render.d.ts.map +1 -0
  299. package/dist/sync-roles-render.js +118 -3
  300. package/dist/sync-roles-render.js.map +1 -0
  301. package/dist/sync.d.ts +1 -1
  302. package/dist/sync.d.ts.map +1 -0
  303. package/dist/sync.js +286 -22
  304. package/dist/sync.js.map +1 -0
  305. package/dist/terminal-title.d.ts.map +1 -0
  306. package/dist/terminal-title.js +68 -1
  307. package/dist/terminal-title.js.map +1 -0
  308. package/dist/token-store.d.ts +17 -111
  309. package/dist/token-store.d.ts.map +1 -0
  310. package/dist/token-store.js +81 -1
  311. package/dist/token-store.js.map +1 -0
  312. package/dist/tool-manifest.d.ts +4 -8
  313. package/dist/tool-manifest.d.ts.map +1 -0
  314. package/dist/tool-manifest.js +554 -1
  315. package/dist/tool-manifest.js.map +1 -0
  316. package/dist/tool-scope.d.ts +8 -12
  317. package/dist/tool-scope.d.ts.map +1 -0
  318. package/dist/tool-scope.js +97 -1
  319. package/dist/tool-scope.js.map +1 -0
  320. package/dist/types.d.ts +1 -5
  321. package/dist/types.d.ts.map +1 -0
  322. package/dist/types.js +5 -0
  323. package/dist/types.js.map +1 -0
  324. package/dist/unknown-subcommand.d.ts +1 -1
  325. package/dist/unknown-subcommand.d.ts.map +1 -0
  326. package/dist/unknown-subcommand.js +37 -1
  327. package/dist/unknown-subcommand.js.map +1 -0
  328. package/dist/version.d.ts +1 -1
  329. package/dist/version.d.ts.map +1 -0
  330. package/dist/version.js +78 -2
  331. package/dist/version.js.map +1 -0
  332. package/dist/working-repo.d.ts.map +1 -0
  333. package/dist/working-repo.js +81 -1
  334. package/dist/working-repo.js.map +1 -0
  335. package/dist/worktree-lifecycle.d.ts.map +1 -0
  336. package/dist/worktree-lifecycle.js +205 -2
  337. package/dist/worktree-lifecycle.js.map +1 -0
  338. package/docs/EXTRACTION_PROVENANCE.md +40 -0
  339. package/docs/LOCAL_SERVER.md +103 -0
  340. package/docs/RELEASING.md +120 -0
  341. package/package.json +52 -34
  342. package/src/agent-runtime.ts +69 -0
  343. package/src/assimilate-cmd.ts +1864 -0
  344. package/src/assimilate-deps.ts +444 -0
  345. package/src/assimilate-guard.ts +84 -0
  346. package/src/assimilate-welcome.ts +39 -0
  347. package/src/auth-env.ts +21 -0
  348. package/src/backends/launch-all-pastelist.ts +18 -0
  349. package/src/backends/launch-all-tmux.ts +75 -0
  350. package/src/backends/launch-all-windows.ts +110 -0
  351. package/src/bare-launch-menu.ts +148 -0
  352. package/src/claude-launch-args.ts +35 -0
  353. package/src/claude.ts +561 -0
  354. package/src/cleanup-cmd.ts +581 -0
  355. package/src/clear-rewake-core.ts +34 -0
  356. package/src/clear-rewake.ts +18 -0
  357. package/src/cli-help.ts +116 -0
  358. package/src/cli-platform.ts +127 -0
  359. package/src/cli-tool-approval.ts +716 -0
  360. package/src/codex-app-server.ts +238 -0
  361. package/src/codex-app-wake.ts +512 -0
  362. package/src/codex-launch.ts +140 -0
  363. package/src/codex-remote.ts +500 -0
  364. package/src/codex-wake-resolve.ts +155 -0
  365. package/src/config-utils.ts +811 -0
  366. package/src/config.ts +595 -0
  367. package/src/console-prefix.ts +99 -0
  368. package/src/cube-name.ts +69 -0
  369. package/src/cubes.ts +583 -0
  370. package/src/debug.ts +73 -0
  371. package/src/direct-log.ts +9 -0
  372. package/src/docs-sections.ts +129 -0
  373. package/src/drone-lifecycle.ts +62 -0
  374. package/src/drone-management.ts +162 -0
  375. package/src/ensure-mcp-config.ts +54 -0
  376. package/src/evict-drone.ts +82 -0
  377. package/src/gc-orphan-inboxes.ts +229 -0
  378. package/src/inbox-monitor.ts +1005 -0
  379. package/src/index.ts +1201 -0
  380. package/src/launch-all-cmd.ts +450 -0
  381. package/src/launch-all-command.ts +30 -0
  382. package/src/launch-all-deps.ts +188 -0
  383. package/src/launch-all-discovery.ts +118 -0
  384. package/src/launch-all-locks.ts +74 -0
  385. package/src/launch-gate.ts +80 -0
  386. package/src/lifecycle-log-guard.ts +144 -0
  387. package/src/list-roles-render.ts +52 -0
  388. package/src/local-log-routing.ts +106 -0
  389. package/src/local-manage-tool-result.ts +10 -0
  390. package/src/local-server-cursor.ts +188 -0
  391. package/src/log-audit.ts +207 -0
  392. package/src/log-stream.ts +1505 -0
  393. package/src/model-presets.ts +22 -0
  394. package/src/name-validator.ts +23 -0
  395. package/src/opencode-drone.ts +528 -0
  396. package/src/opencode-plugin.ts +38 -0
  397. package/src/parse-assimilate-args.ts +125 -0
  398. package/src/parse-launch-all-args.ts +100 -0
  399. package/src/postinstall-banner.ts +37 -0
  400. package/src/postinstall.ts +36 -0
  401. package/src/readiness-probe.ts +12 -0
  402. package/src/regen-format.ts +651 -0
  403. package/src/regen.ts +150 -0
  404. package/src/remote-client.ts +1379 -0
  405. package/src/reset-local-seat-cmd.ts +227 -0
  406. package/src/role-match.ts +29 -0
  407. package/src/role-resolver.ts +93 -0
  408. package/src/roster-render.ts +196 -0
  409. package/src/seat-probe.ts +115 -0
  410. package/src/seat-store.ts +333 -0
  411. package/src/seats.ts +741 -0
  412. package/src/self-path.ts +51 -0
  413. package/src/server-endpoint.ts +38 -0
  414. package/src/server-errors.ts +93 -0
  415. package/src/server-handshake.ts +783 -0
  416. package/src/server-response.ts +76 -0
  417. package/src/server-trust.ts +245 -0
  418. package/src/setup-confirm.ts +171 -0
  419. package/src/setup.ts +186 -0
  420. package/src/shell-escape.ts +22 -0
  421. package/src/spawn.ts +31 -0
  422. package/src/startup-services.ts +36 -0
  423. package/src/stream-owner.ts +686 -0
  424. package/src/stream-status.ts +337 -0
  425. package/src/sync-roles-render.ts +163 -0
  426. package/src/sync.ts +363 -0
  427. package/src/terminal-title.ts +76 -0
  428. package/src/token-store.ts +102 -0
  429. package/src/tool-manifest.ts +610 -0
  430. package/src/tool-scope.ts +110 -0
  431. package/src/types.ts +5 -0
  432. package/src/unknown-subcommand.ts +35 -0
  433. package/src/version.ts +82 -0
  434. package/src/working-repo.ts +99 -0
  435. package/src/worktree-lifecycle.ts +254 -0
  436. package/dist/auth-recovery.d.ts +0 -33
  437. package/dist/auth-recovery.js +0 -1
  438. package/dist/auth.d.ts +0 -103
  439. package/dist/auth.js +0 -39
  440. package/dist/device-auth.d.ts +0 -75
  441. package/dist/device-auth.js +0 -1
  442. package/dist/drone-address.d.ts +0 -26
  443. package/dist/drone-address.js +0 -1
  444. package/dist/get-started.d.ts +0 -20
  445. package/dist/get-started.js +0 -2
  446. package/dist/health-beat.d.ts +0 -134
  447. package/dist/health-beat.js +0 -1
  448. package/dist/role-section.d.ts +0 -110
  449. package/dist/role-section.js +0 -8
  450. package/dist/setup-action.d.ts +0 -20
  451. package/dist/setup-action.js +0 -1
  452. package/dist/stale-version-check.d.ts +0 -40
  453. package/dist/stale-version-check.js +0 -1
  454. package/dist/subscription-retry.d.ts +0 -40
  455. package/dist/subscription-retry.js +0 -1
  456. package/dist/templates.d.ts +0 -107
  457. package/dist/templates.js +0 -359
  458. package/dist/token-crypto.d.ts +0 -50
  459. package/dist/token-crypto.js +0 -1
@@ -1,48 +1,1396 @@
1
- import{dirname as G,basename as x}from"node:path";import{randomUUID as oe}from"node:crypto";import{roleSlug as ie,matchRoleByName as ae,occupiedRoleIdsForAutoRole as se,pickDefaultRole as le}from"./role-resolver.js";import{deriveCubeName as ce,parseGitRemote as ue,sanitizeRemoteUrl as me}from"./cube-name.js";import{validateName as q}from"./name-validator.js";import{renderAssimilationWelcome as fe}from"./assimilate-welcome.js";import{shellEscape as de}from"./shell-escape.js";import{withCodexCwdArg as ge}from"./codex-remote.js";import{buildAgentKickoffPrompt as he,buildKickoffWakePathClause as we,recordCodexWakeTarget as be,socketPathFromRemoteArgs as ke}from"./codex-launch.js";import{perWorktreeBranchName as U,adoptWorktree as pe,computeWorktreePath as z,localBranchExists as J,isMerged as ye}from"./worktree-lifecycle.js";import{DroneEvictedError as ve}from"./drone-lifecycle.js";import{codexBorgSessionConfigArgs as $e}from"./launch-gate.js";import{codexAgentKindConfigArgs as xe,codexRemoteWakeConfigArgs as Ce,withAgentRuntimeEnv as Se}from"./agent-runtime.js";import{inboxPathForDrone as Re}from"./cubes.js";import{monitorStateRootForWorktree as Ee}from"./inbox-monitor.js";import{resolveLaunchEnv as Ae}from"./model-presets.js";import{unlinkSync as _e}from"node:fs";import{gcOrphanInboxesForCube as Ie,defaultListInboxLogs as Ne,defaultInboxLivenessDeps as Pe,isInboxLive as Te,ORPHAN_INBOX_STALE_MS as Le}from"./gc-orphan-inboxes.js";import{installBorgPlugin as De}from"./opencode-plugin.js";import{computeOpenCodePort as Oe,connectOpenCodeDrone as We,createOpenCodeLaunchKickoff as je,injectInitialKickoff as Ue}from"./opencode-drone.js";import{ensureCliMcpConfigured as He}from"./ensure-mcp-config.js";async function ht(r,e){if(r.role!==void 0){const t=q(r.role);if(!t.ok)return e.stderr(t.error+`
2
- `),1}if(r.flags.worktree!==void 0){const t=q(r.flags.worktree);if(!t.ok)return e.stderr(t.error+`
3
- `),1}let a=await e.getCachedAuth();if(!a){if(!e.isTTY()&&!r.flags.yes)return e.stderr("borg setup required and stdin is non-interactive. Run `borg setup` first in an interactive terminal, then `borg assimilate`.\n"),1;a=await e.runSetup()}const i=e.findProjectRoot(e.cwd());let o;if(r.flags.cubeName)o=r.flags.cubeName;else{const t=e.runSync("git",["remote","get-url","origin"],i),n=t.status===0?t.stdout:null;if(o=ce(i,n),n){const c=me(n),d=c?ue(c):null;c&&!d&&o&&e.stderr(`couldn't parse git remote '${c}' \u2014 using directory name '${o}' as cube name
4
- `)}}let s=null;if(o&&o.includes("@")&&o.includes(":")){const t=o.lastIndexOf(":");s={ownerEmail:o.substring(0,t),cubeName:o.substring(t+1)},o=s.cubeName}const C=e.cwd();e.stderr(`Checking your cubes\u2026
5
- `);let I;try{I=await e.listCubes(a.apiUrl,a.token)}catch(t){const n=t instanceof Error?t.message:String(t);if(n.includes("Authentication required")||n.includes("Authentication expired"))e.stderr(`Re-authenticating...
6
- `),a=await e.runSetup(),I=await e.listCubes(a.apiUrl,a.token);else throw t}const N=I.find(t=>t.name===o);if(!N&&s)return e.stderr(`No cube named '${s.cubeName}' accessible to you owned by '${s.ownerEmail}'. Did you accept their invite? See borgmcp.ai/dashboard.
7
- `),1;let u,P;if(N)u=await e.getCube(a.apiUrl,a.token,N.id),P=!1;else{let t;if(r.flags.template)t=r.flags.template;else if(r.flags.noTemplate)t=void 0;else if(e.isTTY())if(r.flags.yes)t="starter";else{const n=await e.listTemplates(a.apiUrl,a.token),c=["First drone joining a new cube. Apply a template?"];n.forEach((y,v)=>{const _=v===0?" (default)":"";c.push(` ${v+1}) ${y.name}${_} \u2014 ${y.description}`)}),c.push(` ${n.length+1}) skip \u2014 no template`);const d=(await e.prompt(c.join(`
8
- `)+`
9
- [1]: `)).trim(),k=d===""?1:parseInt(d,10);if(Number.isNaN(k)||k<1||k>n.length+1)return e.stderr(`invalid choice "${d}"
10
- `),1;t=k<=n.length?n[k-1].name:void 0}else{if(!r.flags.yes)return e.stderr(`cube creation needs a template choice but stdin is non-interactive.
11
- Pass --template <name>, --no-template, or --yes (defaults to starter).
12
- `),1;t="starter"}e.stderr(o?`Creating cube '${o}'\u2026
13
- `:`Creating your cube\u2026
14
- `),u=await e.createCube(a.apiUrl,a.token,t?{name:o??void 0,template:t}:{name:o??void 0}),P=!0}let w;if(r.role!==void 0){if(w=ae(u.roles,r.role),!w){const t=u.roles.map(d=>d.name).join(", "),n=Be(r.role,u.roles.map(d=>d.name)),c=n?` Did you mean "${n}"?`:"";return e.stderr(`no role matching "${r.role}" in cube "${u.name}". Available: ${t}.${c}
15
- (Use --template <name> on first-drone setup or run \`borg_create-role\` from inside Claude.)
16
- `),1}}else{const t=se(u.drones??[]);if(w=le(u.roles,{isFirstDrone:P,occupiedRoleIds:t}),!w)return e.stderr(`cube "${u.name}" has no default or human-seat role; cannot infer a role. Either pass a role argument explicitly (e.g. \`borg assimilate builder\`) or run \`borg_create-role\` from inside Claude to set up roles.
17
- `),1}const S=await e.getActiveCube();let R;if(S&&r.flags.here)if(S.cubeId===u.id)R=S.droneId;else return e.stderr(`this directory already hosts an active drone; remove --here or run from a fresh worktree
18
- `),1;const H=r.flags.model??null,m=await e.resolveCli(r.flags.cli);try{He(m)}catch(t){const n=t instanceof Error?t.message:String(t);return e.stderr(`${m} MCP configuration failed: ${n}
19
- `),1}e.stderr(`Joining cube '${u.name}' as ${w.name}\u2026
20
- `);let l;try{l=await e.assimilate(a.apiUrl,a.token,{cube_id:u.id,role_id:w.id,hostname:e.getHostname(),agent_kind:m,model:H,...R?{prior_drone_id:R}:{}})}catch(t){if(t instanceof ve&&R!=null)return e.stderr(`seat evicted \u2014 this worktree's saved seat was evicted from the cube. Re-assimilate fresh from a terminal, or remove this worktree.
21
- `),1;const n=t instanceof Error?t.message:String(t);return e.stderr(`assimilate failed: ${n}
22
- `),1}const b=u.roles.find(t=>t.id===l.role_id)??w;l.reattached?e.stderr(`re-attached to existing seat ${l.drone_label} (session token rotated, no new drone minted)
23
- `):b.id!==w.id&&e.stderr(`Note: your invite didn't grant the "${w.name}" role \u2014 assimilated as "${b.name}" instead.
24
- `);const X=r.flags.worktree!==void 0||S!==null&&!r.flags.here;let h=null;if(X){const t=e.runSync("git",["rev-parse","--verify","HEAD"],i);if(t.status!==0)return e.stderr(`sibling worktree spawn requires HEAD pointing at a commit.
25
- Fix: create at least one commit (\`git commit --allow-empty -m "initial"\`)
26
- OR: pass --here to skip the sibling spawn and use the current directory
27
- `),1;e.runSync("git",["fetch","origin"],i);let n="origin/main";e.runSync("git",["rev-parse","--verify","origin/main"],i).status!==0&&e.runSync("git",["rev-parse","--verify","origin/master"],i).status===0&&(n="origin/master");const d=t.stdout.trim(),k=e.runSync("git",["rev-parse",n],i).stdout.trim();d!==k&&e.stderr(`note: local HEAD (${d.slice(0,7)}) differs from ${n} (${k.slice(0,7)}); new worktree will start on ${n}
28
- `);const y=x(i),v=r.flags.worktree??ie(b.name);if(v.length===0)return e.stderr(`cannot derive a worktree name from role "${b.name}"; pass an explicit --worktree <name>
29
- `),1;const _=e.homedir();let g=z(_,y,v),p=U(x(g),y),B=2;for(;e.pathExists(g)||Ke(e,i,g)||J(e.runSync,i,p)&&!ye(e.runSync,i,p,n);)g=z(_,y,v,B),p=U(x(g),y),B++;e.mkdirp(G(g));const Y=J(e.runSync,i,p)?e.runSync("git",["worktree","add",g,p],i):e.runSync("git",["worktree","add","-b",p,g,n],i);if(Y.status!==0)return e.stderr(`git worktree add failed: ${V(Y.stderr)}
30
- `),1;e.stderr(`spawned sibling worktree at ${g} on branch ${p} (${n}); original dir is registered as active (edit ~/.config/borgmcp/cubes.json if stale).
31
- `),e.chdir(g),e.stderr(Fe(g,p,i)),h=e.cwd()}try{await e.setActiveCube({cubeId:l.cube_id,droneId:l.drone_id,name:u.name,sessionToken:l.session_token,droneLabel:l.drone_label,apiUrl:a.apiUrl,roleName:b.name,isHumanSeat:b.is_human_seat,...b.role_class?{roleClass:b.role_class}:{}})}catch(t){const n=t instanceof Error?t.message:String(t);if(e.stderr(`setActiveCube failed: ${n}
32
- `),h){const c=e.runSync("git",["worktree","remove","--force",h],i);c.status===0?e.stderr(`rolled back spawned worktree at ${h}
33
- `):e.stderr(`manual cleanup needed: \`git worktree remove --force ${h}\` (rollback attempt failed: ${V(c.stderr).trim()||"unknown"})
34
- `)}return 1}const f=e.cwd(),Q=e.findProjectRoot(f),T=Ee(Q);try{const t=Pe(),n=G(Re(l.cube_id,l.drone_id));Ie({cubeInboxDir:n,selfDroneId:l.drone_id,deps:{listInboxLogs:Ne,isLive:c=>Te(c,t,T),droneState:()=>"absent",unlink:c=>_e(c),now:t.now,staleMs:Le},monitorStateRoot:T})}catch{}e.setTerminalTitle(l.drone_label,u.name);const Z=e.isTTY()&&!process.env.NO_COLOR&&!process.env.CI;e.stdout(fe(b.name,u.name,Z));try{e.installProjectSessionHook(f)}catch{e.stderr(`warning: could not install the project-local SessionStart hook in ${f}; it will be re-attempted on the next borg launch
35
- `)}if(!h){e.runSync("git",["fetch","origin","--prune"],f);const t=U(x(f),x(i)),n=pe(e.runSync,f,t,"origin/main");n.action==="adopted"?(e.stderr(`worktree: adopted branch ${t} at origin/main
36
- `),e.stderr(Me(f,t))):n.message&&e.stderr(`worktree sync: ${n.message}
37
- `)}await e.probeMcpReady()||e.stderr(`warning: borg-mcp readiness probe did not complete within the timeout; launching ${m} anyway \u2014 the kickoff prompt's ToolSearch fallback will recover if the MCP server takes longer to start.
38
- `);const ee=e.getInboxPath(l.cube_id,l.drone_id),L=m==="codex"?`borg-wake-${oe()}`:null,te=we(m,m==="claude"?ee:null,m==="claude"?T:null);let D,F=[],$,E=null,O=null;const M=Ae(H),W={...Se(process.env,m),...M.set,BORG_SESSION:"1"};for(const t of M.unset)delete W[t];if(m==="codex"){const t=await e.prepareCodexRemoteLaunch();t.warning?(e.stderr(`warning: ${t.warning}
39
- `),D="\u26A0 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."):D="Codex wake-path capability check passed: remote-control socket established for this session.",F=t.args,Object.keys(t.env).length>0&&Object.assign(W,t.env),E=ke(t.args),O=t.server?.cleanup??null}const K=he({cli:m,codexWakeNonce:L,monitorClause:te,codexWakePathClause:D});let A=null,j;if($=[K],m==="codex")$=[...$e(),...xe(),...Ce(E!==null),...F,...ge($,f)];else if(m==="opencode"){j=Oe(l.drone_id),De();const t=f;A=je(K),$=[t,"--port",String(j),"--auto","--prompt",A.prompt]}const re=e.exec(m,$,f,W);if(m==="codex"&&E&&L&&be({deps:e,cubeId:l.cube_id,droneId:l.drone_id,socketPath:E,cwd:f,previewNeedle:L,launchedAtSeconds:Math.floor(Date.now()/1e3)}),m==="opencode"&&A){const t=A,n=`http://127.0.0.1:${j}`;We({serverUrl:n,directory:f,droneLabel:l.drone_label,cubeName:o??"borg"}).then(()=>Ue(t)).catch(()=>{})}const ne=await re;if(O)try{O()}catch{}return h&&C!==h&&e.stderr(`
40
- Agent exited. You were working in ${h}; your shell is back in ${C}.
41
- To return:
42
- cd ${de(h)}
43
- `),ne}function Fe(r,e,a){return`
44
- WORKTREE STEERING: You are in worktree ${r} on branch ${e}. Do ALL work HERE \u2014 cut your feature branch (fix/.../feat/...) off ${e} in THIS worktree, use relative paths / your cwd. NEVER \`git -C ${a}\` or operate on the primary checkout ${a}: the same branch can't be checked out in two worktrees, so work created in the primary won't reach your wt-branch without manual surgery (cherry-pick/merge).
45
- `}function Me(r,e){return`
46
- WORKTREE STEERING: This checkout is now on branch ${e}. Do ALL work HERE in ${r} \u2014 cut your feature branch (fix/.../feat/...) off ${e}, use relative paths / your cwd.
47
- `}function V(r){return r.replace(/[\x00-\x1F\x7F]/g,"")}function Ke(r,e,a){const i=r.runSync("git",["worktree","list","--porcelain"],e);return i.status!==0?!1:i.stdout.split(`
48
- `).some(o=>o===`worktree ${a}`)}function Be(r,e){if(e.length===0)return null;const a=r.toLowerCase();let i=null;for(const o of e){const s=Ye(a,o.toLowerCase());s<=2&&(i===null||s<i.distance)&&(i={name:o,distance:s})}return i?i.name:null}function Ye(r,e){if(r===e)return 0;if(r.length===0)return e.length;if(e.length===0)return r.length;const a=new Array(e.length+1),i=new Array(e.length+1);for(let o=0;o<=e.length;o++)a[o]=o;for(let o=1;o<=r.length;o++){i[0]=o;for(let s=1;s<=e.length;s++){const C=r[o-1]===e[s-1]?0:1;i[s]=Math.min(i[s-1]+1,a[s]+1,a[s-1]+C)}for(let s=0;s<=e.length;s++)a[s]=i[s]}return a[e.length]}export{ht as runAssimilate,V as safeStderr,Be as suggestRoleName};
1
+ import { dirname, basename } from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { roleSlug, matchRoleByName, occupiedRoleIdsForAutoRole, pickDefaultRole, } from './role-resolver.js';
4
+ import { deriveCubeName, parseGitRemote, sanitizeRemoteUrl } from './cube-name.js';
5
+ import { validateName } from './name-validator.js';
6
+ import { renderAssimilationWelcome } from './assimilate-welcome.js';
7
+ import { shellEscape } from './shell-escape.js';
8
+ import { withCodexCwdArg } from './codex-remote.js';
9
+ import { buildAgentKickoffPrompt, buildKickoffWakePathClause, recordCodexWakeTarget, socketPathFromRemoteArgs, } from './codex-launch.js';
10
+ import { perWorktreeBranchName, adoptWorktree, computeWorktreePath, localBranchExists, isMerged } from './worktree-lifecycle.js';
11
+ import { DroneEvictedError } from './drone-lifecycle.js';
12
+ import { codexBorgSessionConfigArgs } from './launch-gate.js';
13
+ import { codexAgentKindConfigArgs, codexRemoteWakeConfigArgs, withAgentRuntimeEnv, } from './agent-runtime.js';
14
+ import { inboxPathForDrone } from './cubes.js';
15
+ import { monitorStateRootForWorktree } from './inbox-monitor.js';
16
+ import { resolveLaunchEnv } from './model-presets.js';
17
+ import { unlinkSync } from 'node:fs';
18
+ import { gcOrphanInboxesForCube, defaultListInboxLogs, defaultInboxLivenessDeps, isInboxLive, ORPHAN_INBOX_STALE_MS, } from './gc-orphan-inboxes.js';
19
+ import { installBorgPlugin } from './opencode-plugin.js';
20
+ import { computeOpenCodePort, connectOpenCodeDrone, createOpenCodeLaunchKickoff, injectInitialKickoff } from './opencode-drone.js';
21
+ import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
22
+ import { normalizeServerEndpoint } from './server-endpoint.js';
23
+ import { BorgServerError } from './server-errors.js';
24
+ import { createHash } from 'node:crypto';
25
+ import { buildOpenCodeLaunchArgs } from './cli-tool-approval.js';
26
+ function affirmative(answer) {
27
+ const normalized = answer.trim().toLowerCase();
28
+ return normalized === '' || normalized === 'y' || normalized === 'yes';
29
+ }
30
+ function isLocalCubePresentationName(name) {
31
+ return name.length >= 1 && name.length <= 120 &&
32
+ /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name);
33
+ }
34
+ async function selectAssimilationAuthority(flags, deps) {
35
+ if (flags.server !== undefined) {
36
+ try {
37
+ return { kind: 'server', apiUrl: normalizeServerEndpoint(flags.server) };
38
+ }
39
+ catch (error) {
40
+ deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
41
+ return null;
42
+ }
43
+ }
44
+ // Only a local self-hosted server authority exists. Non-TTY and --yes must
45
+ // NOT infer an authority — fail closed with actionable guidance.
46
+ if (!deps.isTTY() || flags.yes) {
47
+ if (deps.defaultAuthority)
48
+ return deps.defaultAuthority;
49
+ deps.stderr('No local server selected. Use `borg assimilate --host <host> --here` to select a local server.\n');
50
+ return null;
51
+ }
52
+ let detected = null;
53
+ try {
54
+ const candidate = await deps.detectLocalServer();
55
+ detected = candidate ? normalizeServerEndpoint(candidate) : null;
56
+ }
57
+ catch {
58
+ // Detection is advisory. A failed probe is the same UX state as "none
59
+ // found"; an explicitly selected endpoint remains fail-closed below.
60
+ }
61
+ if (detected) {
62
+ const answer = await deps.prompt(`Local Borg server detected at ${detected}.\nConnect this project to it? [Y/n]: `);
63
+ if (affirmative(answer))
64
+ return { kind: 'server', apiUrl: detected };
65
+ }
66
+ const host = await deps.prompt('Borg server host or URL: ');
67
+ try {
68
+ return { kind: 'server', apiUrl: normalizeServerEndpoint(host) };
69
+ }
70
+ catch (error) {
71
+ deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
72
+ return null;
73
+ }
74
+ }
75
+ function localAssimilateCommand(apiUrl, enroll = false) {
76
+ return `\`borg assimilate --host ${apiUrl}${enroll ? ' --enroll' : ''}\``;
77
+ }
78
+ function localAssimilateRoleCommand(apiUrl) {
79
+ return `\`borg assimilate --host ${apiUrl} <role>\``;
80
+ }
81
+ function localAssimilateCliCommand(apiUrl, cli) {
82
+ return `\`borg assimilate --host ${apiUrl} --cli ${cli}\``;
83
+ }
84
+ function reportServerFailure(deps, apiUrl, error, enroll = false) {
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ const retryCommand = localAssimilateCommand(apiUrl, enroll);
87
+ if (error instanceof BorgServerError && error.code === 'CREATE_CUBE_DENIED') {
88
+ deps.stderr(`This enrolled client cannot create a cube on ${apiUrl}. ` +
89
+ 'Ask the server operator to grant access to a cube, then rerun ' +
90
+ `${localAssimilateCommand(apiUrl)}.\n`);
91
+ return 1;
92
+ }
93
+ if (error instanceof BorgServerError && error.code === 'NOT_ENROLLED') {
94
+ deps.stderr(`No saved enrollment for ${apiUrl}. Run ` +
95
+ `${localAssimilateCommand(apiUrl, true)} from the operator’s terminal.\n`);
96
+ return 1;
97
+ }
98
+ if (error instanceof BorgServerError && error.code === 'CREDENTIAL_REJECTED') {
99
+ deps.stderr(`The saved enrollment for ${apiUrl} was rejected. Re-run ` +
100
+ `${localAssimilateCommand(apiUrl, true)} from the operator’s terminal.\n`);
101
+ return 1;
102
+ }
103
+ // A pin-matched typed 401: the server verified its own identity but rejected
104
+ // THIS worktree's session bearer (revoked, or taken over by another session).
105
+ // Distinct from a protocol/version mismatch and from a rejected enrollment:
106
+ // only this worktree's saved local seat is affected, and recovery is scoped to
107
+ // this worktree — no server/trust-anchor/cube/other-worktree reset, no restart
108
+ // or version-alignment advice (#1082).
109
+ if (error instanceof BorgServerError && error.code === 'SESSION_REJECTED') {
110
+ // Attach is PURE DIAGNOSIS on a rejection — it mutates nothing. This copy
111
+ // claims NO mutation of its own; it points at the dedicated OFFLINE
112
+ // `borg reset-local-seat` command (the only writer that clears a seat).
113
+ deps.stderr(`This worktree's saved local seat on ${apiUrl} is no longer accepted — it was ` +
114
+ 'revoked or taken over by another session. No Borg state changed. Run ' +
115
+ `${resetLocalSeatCommand(apiUrl)} to clear ONLY this worktree's saved seat ` +
116
+ '(offline; add `--yes` when non-interactive), then ask the server operator for a new ' +
117
+ 'invitation — the server can stay running — and re-enroll with ' +
118
+ `${localAssimilateCommand(apiUrl, true)}.\n`);
119
+ return 1;
120
+ }
121
+ if (error instanceof BorgServerError && error.code === 'INVITATION_REJECTED') {
122
+ deps.stderr(`The enrollment invitation for ${apiUrl} was rejected or expired. ` +
123
+ 'Ask the server operator for a replacement invitation — the server can stay running: ' +
124
+ 'for an unclaimed owner client run `borg-mcp-server owner-invite`; for an ordinary ' +
125
+ 'client run `borg-mcp-server client-invite`. Then rerun ' +
126
+ `${localAssimilateCommand(apiUrl, true)}.\n`);
127
+ return 1;
128
+ }
129
+ if (/HTTP 40[13]|auth(?:entication|orization)|credential.*(?:invalid|rejected)/i.test(message)) {
130
+ deps.stderr(`The saved enrollment for ${apiUrl} was rejected. Re-run ` +
131
+ `${localAssimilateCommand(apiUrl, true)} from the operator’s terminal.\n`);
132
+ return 1;
133
+ }
134
+ if (/seat store lock file .* is stale/i.test(message)) {
135
+ // RULED option (b): a lock whose recorded holder is DEAD (or whose payload is
136
+ // corrupt) is NEVER auto-removed. Surface the fail-closed guidance verbatim —
137
+ // it already names the exact lockfile path and the delete-only-if-no-borg
138
+ // instruction — so the operator can clear it by hand, then retry.
139
+ deps.stderr(`${safeStderr(message)}\nAfter confirming no borg process is running and clearing the ` +
140
+ `stale lock, rerun ${retryCommand}.\n`);
141
+ return 1;
142
+ }
143
+ if (/(?:seat|credential) store is busy/i.test(message)) {
144
+ deps.stderr(`Borg's local seat store is busy for ${apiUrl} because another Borg process is ` +
145
+ `creating or resuming saved seat state. Wait for it to finish, then rerun ${retryCommand}.\n`);
146
+ return 1;
147
+ }
148
+ if (/(?:local )?seat store|(?:secure )?credential (?:store|storage)/i.test(message)) {
149
+ deps.stderr(`Borg could not access its local seat store for ${apiUrl}. ` +
150
+ `Ensure its directory on this machine is readable and writable, then rerun ${retryCommand}.\n`);
151
+ return 1;
152
+ }
153
+ if (/trust|certificate|\bCA\b|authority state|pinned identity|cross-authority/i.test(message)) {
154
+ deps.stderr(`Borg could not verify the expected server identity for ${apiUrl}. ` +
155
+ 'Verify that this is the expected server. If it was re-initialized, stop it, ' +
156
+ 'run `borg-mcp-server start`, then rerun ' +
157
+ `${retryCommand}.\n`);
158
+ return 1;
159
+ }
160
+ if (/connect|fetch|network|timed? ?out|timeout|ECONN|ENOTFOUND|EHOST|unreachable|aborted|socket/i.test(message)) {
161
+ deps.stderr(`Could not reach Borg server at ${apiUrl}. ` +
162
+ 'Start or restart it with `borg-mcp-server start`, then rerun ' +
163
+ `${retryCommand}.\n`);
164
+ return 1;
165
+ }
166
+ const safeMessage = safeStderr(message)
167
+ .replace(/[A-Za-z0-9_-]{43,}/g, '[redacted]')
168
+ .slice(0, 240);
169
+ deps.stderr(`Borg server at ${apiUrl} returned an unexpected response: ` +
170
+ `${safeMessage || 'request failed'}. ` +
171
+ `Check that the client and server versions are compatible, then rerun ${retryCommand}.\n`);
172
+ return 1;
173
+ }
174
+ function resetLocalSeatCommand(apiUrl) {
175
+ return `\`borg reset-local-seat --host ${apiUrl}\``;
176
+ }
177
+ /**
178
+ * Pin-matched SESSION_REJECTED diagnosis (#1082). PURE DIAGNOSIS — attach makes
179
+ * ZERO local mutation on a rejected seat (ratified client-seat-reset-state-model
180
+ * clause 1). The local server verified its OWN pinned identity (this is reached
181
+ * only after a successful pinned-TLS attach — a pin MISMATCH throws a distinct
182
+ * trust error and never lands here), then rejected THIS worktree's saved session
183
+ * bearer (revoked, or taken over by another session). We stop and recommend the
184
+ * dedicated OFFLINE `borg reset-local-seat` command, which is the ONLY writer
185
+ * that clears a worktree's saved seat. Nothing here contacts the server further,
186
+ * emits unselected egress, or touches local state. The recovery is
187
+ * live-safe: the operator is never told to stop/restart the server.
188
+ */
189
+ function diagnoseSessionRejected(deps, apiUrl) {
190
+ const worktree = deps.findProjectRoot(deps.cwd());
191
+ deps.stderr(`This worktree's saved local seat on ${apiUrl} is no longer accepted — it was ` +
192
+ 'revoked or taken over by another session. No Borg state was changed: your pinned ' +
193
+ 'server trust, this and every other worktree, and their cubes are all untouched.\n');
194
+ deps.stderr(`To clear ONLY this worktree's saved local seat (worktree ${worktree}), run ` +
195
+ `${resetLocalSeatCommand(apiUrl)} (add \`--yes\` when non-interactive). That command is ` +
196
+ 'offline — it revokes nothing server-side. Then ask the server operator for a new ' +
197
+ 'invitation (the server can stay running) and re-enroll with ' +
198
+ `${localAssimilateCommand(apiUrl, true)}.\n`);
199
+ return 1;
200
+ }
201
+ export async function runAssimilate(args, deps) {
202
+ // ----- Input validation (before any subprocess work) -----
203
+ if (args.role !== undefined) {
204
+ const v = validateName(args.role);
205
+ if (!v.ok) {
206
+ deps.stderr(v.error + '\n');
207
+ return 1;
208
+ }
209
+ }
210
+ if (args.flags.worktree !== undefined) {
211
+ const v = validateName(args.flags.worktree);
212
+ if (!v.ok) {
213
+ deps.stderr(v.error + '\n');
214
+ return 1;
215
+ }
216
+ }
217
+ // ----- Step 1: Select and authenticate the local server -----
218
+ const authority = await selectAssimilationAuthority(args.flags, deps);
219
+ if (!authority)
220
+ return 1;
221
+ // ----- Repository + cube-name preflight -----
222
+ // Resolve and, where necessary, confirm local presentation data before an
223
+ // owner invitation can be consumed. A declined/underivable basename must
224
+ // not leave a successfully enrolled client behind.
225
+ const projectRoot = deps.findProjectRoot(deps.cwd());
226
+ let cubeName;
227
+ if (args.flags.cubeName) {
228
+ cubeName = args.flags.cubeName;
229
+ }
230
+ else {
231
+ const remoteResult = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
232
+ const remoteUrl = remoteResult.status === 0 ? remoteResult.stdout : null;
233
+ const sanitizedRemote = remoteUrl ? sanitizeRemoteUrl(remoteUrl) : null;
234
+ const parsedRepo = sanitizedRemote ? parseGitRemote(sanitizedRemote) : null;
235
+ if (!parsedRepo) {
236
+ const bareResult = deps.runSync('git', ['rev-parse', '--is-bare-repository'], projectRoot);
237
+ if (bareResult.status === 0 && bareResult.stdout.trim() === 'true') {
238
+ deps.stderr('borg assimilate requires a non-bare repository worktree. ' +
239
+ (authority.kind === 'server'
240
+ ? `Clone or check out the repository, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`
241
+ : 'Clone or check out the repository, then retry.\n'));
242
+ return 1;
243
+ }
244
+ }
245
+ cubeName = deriveCubeName(projectRoot, remoteUrl);
246
+ if (!cubeName) {
247
+ deps.stderr('Could not derive a cube name from this repository. ' +
248
+ (authority.kind === 'server'
249
+ ? `Rerun ${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\`.\n`
250
+ : 'Pass --cube-name <name> and retry.\n'));
251
+ return 1;
252
+ }
253
+ if (!parsedRepo) {
254
+ if (sanitizedRemote) {
255
+ deps.stderr(`Could not parse the origin remote; using directory name '${cubeName}' as the cube name.\n`);
256
+ }
257
+ if (!args.flags.yes) {
258
+ if (!deps.isTTY()) {
259
+ deps.stderr(`Using directory name '${cubeName}' as the cube name requires confirmation. ` +
260
+ (authority.kind === 'server'
261
+ ? `Rerun ${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\` or \`--yes\`.\n`
262
+ : 'Re-run with --cube-name <name> or --yes.\n'));
263
+ return 1;
264
+ }
265
+ const confirmed = await deps.prompt(`No usable origin remote was found. Use directory name '${cubeName}' as the cube name? [Y/n]: `);
266
+ if (!affirmative(confirmed)) {
267
+ deps.stderr(authority.kind === 'server'
268
+ ? `Cube creation for ${authority.apiUrl} was cancelled. Rerun ` +
269
+ `${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\`.\n`
270
+ : 'Cube creation cancelled. Re-run with --cube-name <name> to choose a name.\n');
271
+ return 1;
272
+ }
273
+ }
274
+ }
275
+ }
276
+ if (authority.kind === 'server' && !isLocalCubePresentationName(cubeName)) {
277
+ deps.stderr(`Invalid cube name for ${authority.apiUrl}. Use 1–120 letters, digits, spaces, dots, ` +
278
+ 'underscores, or hyphens, starting with a letter or digit. Rerun ' +
279
+ `${localAssimilateCommand(authority.apiUrl)} with \`--cube-name <name>\`.\n`);
280
+ return 1;
281
+ }
282
+ let auth;
283
+ {
284
+ try {
285
+ let serverAuth;
286
+ if (args.flags.enroll) {
287
+ if (!deps.isTTY()) {
288
+ deps.stderr('Local enrollment requires an interactive operator terminal. ' +
289
+ `Re-run ${localAssimilateCommand(authority.apiUrl, true)} from the operator’s terminal.\n`);
290
+ return 1;
291
+ }
292
+ const resumed = await deps.resumeServerEnrollment(authority.apiUrl, () => {
293
+ deps.stderr(`Resuming the pending enrollment for \`${authority.apiUrl}\`; ` +
294
+ 'do not enter another invitation.\n');
295
+ });
296
+ if (resumed) {
297
+ serverAuth = resumed;
298
+ }
299
+ else {
300
+ let invitation = await deps.promptSecret(`Enrollment invitation for \`${authority.apiUrl}\` (single-use; hidden input):`);
301
+ if (!invitation) {
302
+ deps.stderr(`No enrollment invitation was entered for ${authority.apiUrl}. ` +
303
+ `Ask the server operator for one, then rerun ${localAssimilateCommand(authority.apiUrl, true)}.\n`);
304
+ return 1;
305
+ }
306
+ try {
307
+ serverAuth = await deps.connectServer(authority.apiUrl, { invitation });
308
+ }
309
+ finally {
310
+ // Strings cannot be zeroized in JavaScript, but drop this command's
311
+ // reference immediately after the exchange instead of retaining the
312
+ // invitation through the rest of assimilation/agent launch.
313
+ invitation = '';
314
+ }
315
+ }
316
+ if (serverAuth.serverCapabilities?.includes('create_cube')) {
317
+ deps.stderr(`Owner client enrolled with \`${authority.apiUrl}\`. ` +
318
+ 'Creating or joining this repository’s cube next.\n');
319
+ }
320
+ else {
321
+ deps.stderr(`Ordinary client enrolled with \`${authority.apiUrl}\`. ` +
322
+ 'Checking for an accessible repository cube next.\n');
323
+ }
324
+ }
325
+ else {
326
+ serverAuth = await deps.connectServer(authority.apiUrl);
327
+ }
328
+ auth = {
329
+ token: serverAuth.token,
330
+ apiUrl: authority.apiUrl,
331
+ serverTrustIdentity: serverAuth.trustIdentity,
332
+ };
333
+ }
334
+ catch (error) {
335
+ return reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true);
336
+ }
337
+ }
338
+ // ----- Sprint 19 (gh#184): Reorder for strict-rollback semantics. -----
339
+ // The previous flow created a sibling worktree (FS state) BEFORE
340
+ // role resolution + API assimilate. Any early-return between
341
+ // worktree-spawn and API success orphaned the worktree (gh#184
342
+ // canonical case: unknown role arg). The new flow defers all FS
343
+ // state until AFTER the API assimilate succeeds — early-return at
344
+ // role resolution / listCubes / createCube / template-prompt /
345
+ // template-invalid-choice is now structurally clean (no orphan
346
+ // class possible). Worktree rollback narrows to the single
347
+ // setActiveCube failure path post-worktree-creation.
348
+ // Sprint 18: capture pre-chdir cwd for the post-exit shell-cd hint
349
+ // (no chdir has happened yet; this is a stable starting point).
350
+ const originalCwd = deps.cwd();
351
+ // ----- Step 3: Cube existence check (with auto-refresh on auth failure) -----
352
+ // gh#653 B4: announce each network step. These calls take 2–5s and were
353
+ // previously silent, so a user read the wait as a hang and Ctrl-C'd mid-run.
354
+ deps.stderr('Checking your cubes…\n');
355
+ let allCubes;
356
+ try {
357
+ allCubes = await deps.listCubes(auth.apiUrl, auth.token, auth.serverTrustIdentity);
358
+ }
359
+ catch (err) {
360
+ return reportServerFailure(deps, authority.apiUrl, err);
361
+ }
362
+ const existingCube = allCubes.find((c) => c.name === cubeName);
363
+ // ----- Step 4: Fetch detail OR create cube -----
364
+ let cubeDetail;
365
+ let isFirstDrone;
366
+ if (existingCube) {
367
+ try {
368
+ cubeDetail = await deps.getCube(auth.apiUrl, auth.token, existingCube.id, auth.serverTrustIdentity);
369
+ }
370
+ catch (error) {
371
+ return reportServerFailure(deps, authority.apiUrl, error);
372
+ }
373
+ isFirstDrone = false;
374
+ }
375
+ else {
376
+ // ----- Step 4a: First-drone bootstrap (template selection) -----
377
+ let chosenTemplate;
378
+ if (args.flags.noTemplate ||
379
+ (args.flags.template !== undefined && args.flags.template !== 'default')) {
380
+ deps.stderr(`Borg server ${authority.apiUrl} supports its default cube template only. ` +
381
+ `Rerun ${localAssimilateCommand(authority.apiUrl)} without \`--template\` or \`--no-template\`.\n`);
382
+ return 1;
383
+ }
384
+ chosenTemplate = 'default';
385
+ // gh#653 B4: progress for the create round-trip (silent-window stall).
386
+ deps.stderr(cubeName ? `Creating cube '${cubeName}'…\n` : 'Creating your cube…\n');
387
+ try {
388
+ const createParams = chosenTemplate
389
+ ? {
390
+ name: cubeName ?? undefined,
391
+ template: chosenTemplate,
392
+ projectRoot,
393
+ }
394
+ : { name: cubeName ?? undefined };
395
+ cubeDetail = await deps.createCube(auth.apiUrl, auth.token, createParams, auth.serverTrustIdentity);
396
+ }
397
+ catch (error) {
398
+ return reportServerFailure(deps, authority.apiUrl, error);
399
+ }
400
+ isFirstDrone = true;
401
+ }
402
+ // Read the worktree identity before role selection. A live local seat must
403
+ // retain its original role so the attach request reuses the exact durable
404
+ // retry binding instead of selecting another unoccupied role and minting a
405
+ // duplicate seat.
406
+ const existing = await deps.getActiveCube();
407
+ const hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
408
+ const wantSibling = args.flags.worktree !== undefined || (existing !== null && !args.flags.here);
409
+ // `let`: the bound-pending resume path (CR#2) OVERRIDES this from the stored
410
+ // operation so a rerun re-derives the EXACT original sibling seat ref.
411
+ let sessionOperation = {
412
+ // Capture the source repository before a successful sibling attach changes
413
+ // cwd. This is the stable seat/sibling namespace for the pending bearer, so a
414
+ // deliberate sibling never collides with the durable in-place seat's bearer.
415
+ projectRoot,
416
+ kind: wantSibling ? 'sibling' : 'seat',
417
+ // CR1(a): an implicit sibling's operation key must be COLLISION-SAFE — two
418
+ // unnamed siblings of the same (origin,trust,cube,role) must get DISTINCT seat
419
+ // refs, else prepareSeat reuses the first sibling's ACTIVE record and the
420
+ // activate+bind step overwrites its worktree (an active seat silently unseated
421
+ // and rebound). A named sibling already keys on its name; an unnamed one derives
422
+ // a per-invocation-unique key so every distinct implicit sibling target mints a
423
+ // distinct bearer / seat ref.
424
+ operationKey: wantSibling
425
+ ? (args.flags.worktree === undefined
426
+ ? `implicit-sibling:${randomUUID()}`
427
+ : `named-sibling:${args.flags.worktree}`)
428
+ : 'current-worktree',
429
+ };
430
+ let reattachPriorId;
431
+ let remintInvalidPrior = false;
432
+ let savedLocalRole;
433
+ // Set when the pre-attach gate recovers a crash-in-gap PENDING seat: the
434
+ // composite FINALIZE must then declare EXACT-ref (the credential is pending,
435
+ // not active, so no live-bearer digest is pinned) so it re-persists the extant
436
+ // binding and flips pending→ACTIVE, rather than aborting on an ABSENT check.
437
+ let resumeCredentialRef;
438
+ let resumeDroneId;
439
+ // CR#2: 'pending' when the resumed record is a bound-PENDING sibling (activation
440
+ // failed) — it re-sends the identical bearer under an ABSENT/pending-reuse
441
+ // expectation; 'active' when resuming a live in-place seat (EXACT expectation).
442
+ let resumeState;
443
+ if (existing && args.flags.here && existing.cubeId !== cubeDetail.id) {
444
+ deps.stderr(`This directory already hosts an active drone for another cube on ${authority.apiUrl}. ` +
445
+ `Remove \`--here\` or use a fresh worktree, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`);
446
+ return 1;
447
+ }
448
+ if (authority.kind === 'server') {
449
+ // CR#3: recover an in-flight IMPLICIT-sibling attempt (persisted + collision-safe).
450
+ // An implicit sibling mints a per-invocation-unique operationKey; a crash AFTER the
451
+ // server accepts but BEFORE the worktree bind leaves an UNBOUND pending sibling
452
+ // record whose random key would otherwise be undiscoverable — a rerun would mint a
453
+ // NEW bearer and the server (digest-correlating) would create a GHOST seat. The
454
+ // unbound pending sibling record IS the persisted attempt identity, discoverable by
455
+ // source repo: adopt its EXACT operation (→ same seat ref) AND its role, and declare
456
+ // a PENDING resume so prepareSeat REUSES the identical bearer (server reuses the
457
+ // seat). Only for an IMPLICIT sibling (no --worktree name); a named sibling already
458
+ // keys collision-safe on its name. Skipped once the attempt is bound/activated (it is
459
+ // no longer an unbound pending sibling), so a completed sibling frees the key.
460
+ if (wantSibling &&
461
+ args.flags.worktree === undefined &&
462
+ auth.serverTrustIdentity !== undefined &&
463
+ deps.findIncompleteSiblingAttempt) {
464
+ const inflight = await deps.findIncompleteSiblingAttempt({
465
+ origin: auth.apiUrl,
466
+ trustIdentity: auth.serverTrustIdentity,
467
+ cubeId: cubeDetail.id,
468
+ projectRoot,
469
+ });
470
+ if (inflight) {
471
+ const inflightRole = cubeDetail.roles.find((role) => role.id === inflight.roleId);
472
+ if (inflightRole) {
473
+ // Adopt the EXACT stored operation (same operationKey → same R_sib) + role, and
474
+ // resume PENDING so prepareSeat re-sends the identical bearer and converges.
475
+ sessionOperation = inflight.operation;
476
+ savedLocalRole = inflightRole;
477
+ resumeCredentialRef = inflight.credentialRef;
478
+ resumeState = 'pending';
479
+ }
480
+ }
481
+ }
482
+ if (!existing && hasPersistedIdentity) {
483
+ // getActiveCube() is null AND metadata is persisted. Two distinct states:
484
+ // (a) crash-in-gap RESUME — the composite FINALIZE wrote the binding, then
485
+ // a crash/throw preceded the pending→ACTIVE flip. The credential is
486
+ // still a PENDING record (non-hydratable → getActiveCube null), the
487
+ // binding is intact, and re-sending the identical pending bearer
488
+ // converges. Ratified clause 4: this state is RERUNNABLE and must be
489
+ // truthfully reported, NOT misdiagnosed as keychain loss.
490
+ // (b) genuine keychain loss/lock — no record at the ref. Truthful error,
491
+ // and NEVER a new seat (record-absent invariant).
492
+ // A pure PEEK (no create/mutate) at the deterministic per-seat ref
493
+ // distinguishes them. Resume only applies to an in-place attach (a
494
+ // --worktree sibling is a NEW seat, not a resume of this worktree's seat).
495
+ const persisted = deps.readPersistedLocalSeat
496
+ ? await deps.readPersistedLocalSeat()
497
+ : null;
498
+ let resumeRole;
499
+ let recordPresent = false;
500
+ if (persisted &&
501
+ !wantSibling &&
502
+ persisted.apiUrl === auth.apiUrl &&
503
+ persisted.serverTrustIdentity === auth.serverTrustIdentity) {
504
+ recordPresent = deps.peekServerSessionRecord
505
+ ? await deps.peekServerSessionRecord(persisted.localSessionCredentialRef, {
506
+ origin: auth.apiUrl,
507
+ trustIdentity: auth.serverTrustIdentity,
508
+ cubeId: persisted.cubeId,
509
+ })
510
+ : false;
511
+ if (recordPresent && persisted.roleName) {
512
+ resumeRole = cubeDetail.roles.find((role) => role.name === persisted.roleName);
513
+ }
514
+ }
515
+ if (persisted && recordPresent && resumeRole) {
516
+ // RESUME: reuse the persisted role (the ref binds the role, so a resume MUST
517
+ // re-derive the exact same account) and converge on the exact stored record.
518
+ savedLocalRole = resumeRole;
519
+ resumeCredentialRef = persisted.localSessionCredentialRef;
520
+ resumeState = persisted.state;
521
+ // CR#2: re-derive the EXACT pending seat ref from the STORED operation. A
522
+ // bound-PENDING sibling's record still carries its ORIGINAL sibling operation
523
+ // (projectRoot+kind+operationKey), NOT the rerun worktree's derived
524
+ // current-worktree seat operation — overriding here makes the rerun
525
+ // re-mint-or-reuse the identical pending bearer at the original R_sib and
526
+ // converge (no ghost seat). For an ACTIVE in-place resume the stored operation
527
+ // equals the already-derived one, so this is a no-op.
528
+ sessionOperation = persisted.operation;
529
+ // Only an ACTIVE resume pins the drone id (EXACT expectation below). A
530
+ // bound-PENDING record declares ABSENT/pending-reuse and does not pin it.
531
+ if (persisted.state === 'active' && persisted.droneId !== undefined) {
532
+ resumeDroneId = persisted.droneId;
533
+ }
534
+ }
535
+ else {
536
+ deps.stderr(`This worktree has saved seat metadata for ${authority.apiUrl}, but its local session ` +
537
+ 'credential could not be loaded from the local seat store. No new seat was created. Run ' +
538
+ `${resetLocalSeatCommand(authority.apiUrl)} to clear this worktree's saved seat, then ` +
539
+ `ask the operator for a new invitation and rerun ${localAssimilateCommand(authority.apiUrl, true)}.\n`);
540
+ return 1;
541
+ }
542
+ }
543
+ if (existing && args.flags.here &&
544
+ (existing.apiUrl !== auth.apiUrl ||
545
+ existing.serverTrustIdentity !== auth.serverTrustIdentity)) {
546
+ deps.stderr(`This worktree's saved seat does not match ${authority.apiUrl}. ` +
547
+ 'No new seat was created. Restore the expected server identity or use a fresh ' +
548
+ `worktree, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`);
549
+ return 1;
550
+ }
551
+ // The per-seat PENDING bearer is the resume mechanism: a lost attach
552
+ // response is recovered when the next attach re-sends the identical bearer,
553
+ // so there is no separate unfinished-attach store to scan. Reattach identity
554
+ // for `--here` comes from this worktree's saved active cube below.
555
+ if (existing && args.flags.here) {
556
+ savedLocalRole = existing.roleName
557
+ ? cubeDetail.roles.find((role) => role.name === existing.roleName)
558
+ : undefined;
559
+ const status = await deps.probeSeat(existing.sessionToken ?? '', auth.apiUrl, auth.serverTrustIdentity);
560
+ // Canonical rotated/revoked path: a pin-matched 401 on THIS worktree's
561
+ // saved bearer. PURE DIAGNOSIS — attach never mutates local state on a
562
+ // rejection; it points at the offline `borg reset-local-seat` command.
563
+ // Distinct from unreachable/404/5xx/trust-mismatch, which stay
564
+ // indeterminate below.
565
+ if (status === 'rejected') {
566
+ return diagnoseSessionRejected(deps, auth.apiUrl);
567
+ }
568
+ // CR #6: distinct causes get cause-accurate, non-destructive recovery —
569
+ // never the generic "restart the server" advice.
570
+ if (status === 'credential-rejected') {
571
+ // The saved SESSION bearer was rejected WITHOUT the typed takeover code
572
+ // (a bare/other 401). Non-destructive: re-enroll, never a seat reset.
573
+ deps.stderr(`The saved enrollment for ${authority.apiUrl} was rejected. No new seat was created ` +
574
+ `and nothing was changed. Re-enroll with ${localAssimilateCommand(authority.apiUrl, true)} ` +
575
+ 'from the operator’s terminal.\n');
576
+ return 1;
577
+ }
578
+ if (status === 'trust-mismatch') {
579
+ // Terminal: the pinned identity changed. Restarting the server does NOT
580
+ // fix it — verify this is the expected server / re-initialization.
581
+ deps.stderr(`Borg could not verify the expected server identity for ${authority.apiUrl}. ` +
582
+ 'No new seat was created. Verify that this is the expected server; if it was ' +
583
+ 're-initialized, restore the expected identity, then rerun ' +
584
+ `${localAssimilateCommand(authority.apiUrl)}.\n`);
585
+ return 1;
586
+ }
587
+ if (status === 'endpoint-mismatch') {
588
+ // CR5: a verified server returned 404 for the drone endpoint — a protocol /
589
+ // client-server VERSION mismatch, not a transient blip. Restarting does not
590
+ // fix it; align versions. Non-destructive: no seat created, nothing reset.
591
+ deps.stderr(`Borg reached ${authority.apiUrl} but it did not recognize this worktree's drone ` +
592
+ 'endpoint — the client and server versions are likely incompatible. No new seat ' +
593
+ 'was created and nothing was changed. Update the Borg client and/or server so ' +
594
+ `their versions match, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`);
595
+ return 1;
596
+ }
597
+ if (status === 'server-failure') {
598
+ // CR5: a verified server returned 5xx — its own internal error. Transient:
599
+ // check the server, then retry. Non-destructive.
600
+ deps.stderr(`Borg reached ${authority.apiUrl} but it returned a server error while verifying ` +
601
+ "this worktree's saved seat. No new seat was created. Check the server (its logs / " +
602
+ `\`borg-mcp-server start\`), then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`);
603
+ return 1;
604
+ }
605
+ if (status === 'unreachable' || status === 'indeterminate') {
606
+ // CR5: transport failure / timeout (unreachable) or a genuinely ambiguous
607
+ // failure (indeterminate) — both transient. Start or restart the server.
608
+ deps.stderr(`Borg could not verify this worktree's saved seat on ${authority.apiUrl}. ` +
609
+ 'No new seat was created. Start or restart the server with ' +
610
+ `\`borg-mcp-server start\`, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`);
611
+ return 1;
612
+ }
613
+ if (status === 'live' && !savedLocalRole) {
614
+ deps.stderr(`Borg verified this worktree's saved seat on ${authority.apiUrl}, but its saved ` +
615
+ 'role is unavailable. No new seat was created. Ask the server operator to restore ' +
616
+ `the role, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`);
617
+ return 1;
618
+ }
619
+ reattachPriorId = existing.droneId;
620
+ remintInvalidPrior = status === 'evicted';
621
+ }
622
+ }
623
+ else if (existing && args.flags.here) {
624
+ if (existing.serverTrustIdentity !== undefined || existing.apiUrl !== auth.apiUrl) {
625
+ deps.stderr('This worktree\'s saved seat belongs to a different Borg authority. ' +
626
+ 'No new seat was created; use a fresh worktree.\n');
627
+ return 1;
628
+ }
629
+ reattachPriorId = existing.droneId;
630
+ }
631
+ // ----- Step 5: Role resolution -----
632
+ let resolvedRole;
633
+ if (savedLocalRole) {
634
+ resolvedRole = savedLocalRole;
635
+ }
636
+ else if (args.role !== undefined) {
637
+ resolvedRole = matchRoleByName(cubeDetail.roles, args.role);
638
+ if (!resolvedRole) {
639
+ // Sprint 19 (gh#184) + drone-7 metaphor argument: include a
640
+ // fuzzy-match "did you mean ...?" suggestion to serve Queen's
641
+ // "more user-friendly" intent without violating the
642
+ // Borg-collective metaphor (collective defines roles; drones
643
+ // slot in). Levenshtein distance ≤2 on the cube's role names.
644
+ const available = cubeDetail.roles.map((r) => r.name).join(', ');
645
+ const suggestion = suggestRoleName(args.role, cubeDetail.roles.map((r) => r.name));
646
+ const suggestionLine = suggestion ? ` Did you mean "${suggestion}"?` : '';
647
+ if (authority.kind === 'server') {
648
+ deps.stderr(`No role matching "${args.role}" in cube "${cubeDetail.name}" on ${authority.apiUrl}. ` +
649
+ `Available: ${available}.${suggestionLine}\n` +
650
+ `Rerun ${localAssimilateRoleCommand(authority.apiUrl)} with one of the available roles.\n`);
651
+ }
652
+ else {
653
+ deps.stderr(`no role matching "${args.role}" in cube "${cubeDetail.name}". Available: ${available}.${suggestionLine}\n` +
654
+ `(Use --template <name> on first-drone setup or run \`borg_create-role\` from inside Claude.)\n`);
655
+ }
656
+ return 1;
657
+ }
658
+ }
659
+ else {
660
+ const occupiedRoleIds = occupiedRoleIdsForAutoRole(cubeDetail.drones ?? []);
661
+ resolvedRole = pickDefaultRole(cubeDetail.roles, { isFirstDrone, occupiedRoleIds });
662
+ if (!resolvedRole) {
663
+ if (authority.kind === 'server') {
664
+ deps.stderr(`Cube "${cubeDetail.name}" on ${authority.apiUrl} has no default or human-seat role. ` +
665
+ `Ask the server operator to configure a role, then rerun ` +
666
+ `${localAssimilateRoleCommand(authority.apiUrl)}.\n`);
667
+ }
668
+ else {
669
+ deps.stderr(`cube "${cubeDetail.name}" has no default or human-seat role; cannot infer a role. ` +
670
+ `Either pass a role argument explicitly (e.g. \`borg assimilate builder\`) or ` +
671
+ `run \`borg_create-role\` from inside Claude to set up roles.\n`);
672
+ }
673
+ return 1;
674
+ }
675
+ }
676
+ // ----- Step 5b: --here collision check BEFORE the API mint (gh#780) -----
677
+ // Pre-gh#780 this check lived in Step 7 — AFTER the API assimilate — so a
678
+ // `--here` run in a directory that already hosts a drone minted a fresh
679
+ // drones row server-side, then aborted before Step 8 ever persisted the
680
+ // mapping: an orphan seat with no local identity. The check must precede
681
+ // the mint. (The full worktree DECISION stays in Step 7 by design — FS
682
+ // state only after API success; this hoists only the abort case.)
683
+ //
684
+ // PR-D refinement: --here + existing + SAME authority/cube is the
685
+ // saved-seat recovery flow. The local
686
+ // seats first prove liveness with their keychained session, then reuse the
687
+ // saved role/retry binding; only authoritative eviction rotates that retry.
688
+ // Role defaults and local launch state do not select the model. The explicit
689
+ // Claude-only flag remains temporarily for compatibility with existing
690
+ // invocations.
691
+ const effectiveModel = args.flags.model ?? null;
692
+ // Resolve the agent CLI now so the worker learns agent_kind AT assimilate
693
+ // time.
694
+ const cli = await deps.resolveCli(args.flags.cli);
695
+ try {
696
+ ensureCliMcpConfigured(cli);
697
+ }
698
+ catch (err) {
699
+ const message = err instanceof Error ? err.message : String(err);
700
+ if (authority.kind === 'server') {
701
+ deps.stderr(`${cli} MCP configuration failed for ${authority.apiUrl}: ${safeStderr(message)}. ` +
702
+ `Fix the ${cli} MCP configuration, then rerun ` +
703
+ `${localAssimilateCliCommand(authority.apiUrl, cli)}.\n`);
704
+ }
705
+ else {
706
+ deps.stderr(`${cli} MCP configuration failed: ${message}\n`);
707
+ }
708
+ return 1;
709
+ }
710
+ // The TYPED prepare-time expectation (ratified clause 3 / CR #1). Declared HERE,
711
+ // BEFORE the mint+send, and revalidated at BOTH the cube-lock-held PREPARE (so a
712
+ // reset that wins before PREPARE aborts before any credential is created/sent)
713
+ // and FINALIZE. resume/reattach/remint pin the FULL prior binding (ref + drone
714
+ // id [+ live digest]); fresh/sibling declare ABSENT.
715
+ let sessionExpected;
716
+ if (resumeCredentialRef && resumeState === 'pending') {
717
+ // CR#2: a bound-PENDING resume (a sibling whose activation failed) re-sends the
718
+ // identical pending bearer the server already digest-bound. A PENDING record is
719
+ // NOT a live binding, so it declares ABSENT (pending-reuse): prepareSeat REUSES
720
+ // the existing pending record (identical bearer). An EXACT expectation would be
721
+ // rejected by prepareSeat's `prior.state==='active'` guard and abort the only
722
+ // ghost-free recovery.
723
+ sessionExpected = { kind: 'absent' };
724
+ }
725
+ else if (resumeCredentialRef) {
726
+ sessionExpected = {
727
+ kind: 'exact',
728
+ credentialRef: resumeCredentialRef,
729
+ ...(resumeDroneId ? { droneId: resumeDroneId } : {}),
730
+ };
731
+ }
732
+ else if (remintInvalidPrior && existing?.localSessionCredentialRef) {
733
+ sessionExpected = {
734
+ kind: 'exact',
735
+ credentialRef: existing.localSessionCredentialRef,
736
+ ...(existing.droneId ? { droneId: existing.droneId } : {}),
737
+ };
738
+ }
739
+ else if (reattachPriorId != null && existing?.localSessionCredentialRef && existing.sessionToken) {
740
+ sessionExpected = {
741
+ kind: 'exact',
742
+ credentialRef: existing.localSessionCredentialRef,
743
+ ...(existing.droneId ? { droneId: existing.droneId } : {}),
744
+ sessionDigest: createHash('sha256').update(existing.sessionToken).digest('hex'),
745
+ };
746
+ }
747
+ else {
748
+ sessionExpected = { kind: 'absent' };
749
+ }
750
+ // CR1(b): PREPARE-time revalidation is preserved for siblings too. A sibling
751
+ // declares an ABSENT expectation: a PENDING record at the ref (a lost-response
752
+ // retry / crash-in-gap) stays reusable so the identical bearer is re-sent, but an
753
+ // ACTIVE record holding the ref is a mismatch → abort (never silently reuse/move
754
+ // a live binding). With the collision-safe sibling key above the fresh ref is
755
+ // normally empty, so ABSENT passes and the mint proceeds; the check is the
756
+ // defense that stops an active seat from being unseated.
757
+ const revalidateAtPrepare = true;
758
+ // ----- Step 6: API assimilate (no FS state yet — clean exit on failure) -----
759
+ // gh#653 B4: progress for the seat-mint round-trip (silent-window stall).
760
+ deps.stderr(`Joining cube '${cubeDetail.name}' as ${resolvedRole.name}…\n`);
761
+ let result;
762
+ try {
763
+ const assimilateParams = {
764
+ cube_id: cubeDetail.id,
765
+ role_id: resolvedRole.id,
766
+ hostname: deps.getHostname(),
767
+ agent_kind: cli,
768
+ model: effectiveModel,
769
+ ...(reattachPriorId ? { prior_drone_id: reattachPriorId } : {}),
770
+ ...(remintInvalidPrior ? { remint_invalid_prior: true } : {}),
771
+ session_operation: sessionOperation,
772
+ session_expected: sessionExpected,
773
+ revalidate_at_prepare: revalidateAtPrepare,
774
+ };
775
+ result = await deps.assimilate(auth.apiUrl, auth.token, assimilateParams, auth.serverTrustIdentity);
776
+ }
777
+ catch (err) {
778
+ // gh#877 follow-up: a re-attach (`--here`) whose saved seat was evicted is
779
+ // REFUSED server-side (410 DRONE_EVICTED) rather than silently re-minting a
780
+ // fresh drone. Surface the terminal recovery path instead of the generic
781
+ // "assimilate failed". Only on a reattach attempt (reattachPriorId set);
782
+ // a non-reattach DroneEvictedError falls through to the generic message.
783
+ if (err instanceof DroneEvictedError && reattachPriorId != null) {
784
+ deps.stderr(`This worktree's saved seat on ${authority.apiUrl} was evicted. ` +
785
+ `Remove this worktree, or from a fresh worktree run ` +
786
+ `${localAssimilateCommand(authority.apiUrl)}.\n`);
787
+ return 1;
788
+ }
789
+ // Pin-matched SESSION_REJECTED (revoked / taken-over seat): PURE DIAGNOSIS.
790
+ // Reached only after a successful pinned-TLS attach, so it is pin-matched by
791
+ // construction — a pin mismatch throws a distinct trust error and never
792
+ // enters this branch. Attach mutates NOTHING; it recommends the offline
793
+ // `borg reset-local-seat` command.
794
+ if (err instanceof BorgServerError &&
795
+ err.code === 'SESSION_REJECTED' &&
796
+ reattachPriorId != null) {
797
+ return diagnoseSessionRejected(deps, authority.apiUrl);
798
+ }
799
+ if (authority.kind === 'server') {
800
+ return reportServerFailure(deps, authority.apiUrl, err);
801
+ }
802
+ const message = err instanceof Error ? err.message : String(err);
803
+ deps.stderr(`assimilate failed: ${message}\n`);
804
+ return 1;
805
+ }
806
+ if (authority.kind === 'server' && result.prepareAborted) {
807
+ // CR #1: the cube-lock-held PREPARE revalidation aborted BEFORE any credential
808
+ // was minted or sent — this worktree's saved seat changed under us (a
809
+ // concurrent offline reset, or a competing enroll). No FS/network mutation
810
+ // happened; never silently recreate.
811
+ deps.stderr(`This worktree's saved local seat on ${authority.apiUrl} changed before the attach ` +
812
+ '(a concurrent reset or enroll); no credential was created or sent and nothing was ' +
813
+ `changed. Re-run ${localAssimilateCommand(authority.apiUrl)} to attach against the ` +
814
+ 'current state.\n');
815
+ return 1;
816
+ }
817
+ if (authority.kind === 'server' && result.local_session === undefined) {
818
+ return reportServerFailure(deps, authority.apiUrl, new Error('Borg server did not return compatible secure session metadata'));
819
+ }
820
+ // The server may assimilate a member into a DIFFERENT role than the client's
821
+ // auto-picked default (gh#700 fallback: when the member's invite doesn't
822
+ // grant the default role, the server picks one of their GRANTED roles).
823
+ // Resolve the role the SERVER ACTUALLY assigned (result.role_id) and use it
824
+ // for all human-facing display + naming below — not the client's pre-pick.
825
+ // The drone label / session token are already server-truth; this aligns the
826
+ // displayed role name + worktree slug with what was actually assigned.
827
+ const assignedRole = cubeDetail.roles.find((r) => r.id === result.role_id) ?? resolvedRole;
828
+ if (result.result === 'reused') {
829
+ // The seat's existing role is authoritative on an idempotent reattach —
830
+ // a role difference is expected, not a grant fallback. The bearer is
831
+ // reused, not rotated: no new drone minted.
832
+ deps.stderr(`re-attached to existing seat ${result.drone_label} (same session, no new drone minted)\n`);
833
+ }
834
+ else if (assignedRole.id !== resolvedRole.id) {
835
+ deps.stderr(`The requested role "${resolvedRole.name}" was unavailable; ` +
836
+ `attached to the "${assignedRole.name}" seat instead.\n`);
837
+ }
838
+ // ----- Step 7: Worktree decision (FS state ONLY after API success) -----
839
+ // (`existing` was read at Step 5b; a different-cube --here collision
840
+ // already aborted there, pre-mint. The surviving --here + existing case
841
+ // is the SAME-cube reattach — an in-place recovery, never a sibling
842
+ // spawn.)
843
+ let spawnedWorktreePath = null;
844
+ if (wantSibling) {
845
+ // BUG-4 / gh#150 fix (v0.9.5): `git worktree add --detach <path>`
846
+ // fails with "fatal: not a valid object name: 'HEAD'" when the
847
+ // repo has no commits yet (unborn HEAD). Detect explicitly via
848
+ // `git rev-parse --verify HEAD` so we surface an actionable
849
+ // prerequisite error rather than git's cryptic internal message.
850
+ const headProbe = deps.runSync('git', ['rev-parse', '--verify', 'HEAD'], projectRoot);
851
+ if (headProbe.status !== 0) {
852
+ deps.stderr(`sibling worktree spawn requires HEAD pointing at a commit.\n` +
853
+ ` Fix: create at least one commit (\`git commit --allow-empty -m "initial"\`)\n` +
854
+ ` OR: pass --here to skip the sibling spawn and use the current directory\n`);
855
+ return 1;
856
+ }
857
+ const localHead = headProbe.stdout.trim();
858
+ const originProbe = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
859
+ let startRef = 'HEAD';
860
+ if (originProbe.status === 0 && originProbe.stdout.trim().length > 0) {
861
+ // gh#238: when origin exists, fetch it so the new worktree starts on the
862
+ // latest remote default branch rather than a possibly stale local HEAD.
863
+ deps.runSync('git', ['fetch', 'origin'], projectRoot);
864
+ const mainProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/main'], projectRoot);
865
+ if (mainProbe.status === 0) {
866
+ startRef = 'origin/main';
867
+ }
868
+ else {
869
+ const masterProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/master'], projectRoot);
870
+ if (masterProbe.status === 0) {
871
+ startRef = 'origin/master';
872
+ }
873
+ }
874
+ }
875
+ if (startRef === 'HEAD') {
876
+ deps.stderr(`note: no usable origin; new worktree will start on local HEAD (${localHead.slice(0, 7)})\n`);
877
+ }
878
+ else {
879
+ // Warn if local HEAD diverges from the remote default branch.
880
+ const remoteHead = deps.runSync('git', ['rev-parse', startRef], projectRoot).stdout.trim();
881
+ if (localHead !== remoteHead) {
882
+ deps.stderr(`note: local HEAD (${localHead.slice(0, 7)}) differs from ${startRef} (${remoteHead.slice(0, 7)}); ` +
883
+ `new worktree will start on ${startRef}\n`);
884
+ }
885
+ }
886
+ const repoBase = basename(projectRoot);
887
+ const suffix = args.flags.worktree ?? roleSlug(assignedRole.name);
888
+ // gh#556 Part 1: empty-suffix guard (CR-binding). roleSlug can yield '' for a
889
+ // pathological all-special-char role name; an empty leaf would let join() collapse
890
+ // the worktree path up to the repo-level dir (~/.borg/worktrees/<repo>) and spawn a
891
+ // worktree at the parent-of-all-this-repo's-worktrees. Fail loud BEFORE the path calc.
892
+ if (suffix.length === 0) {
893
+ deps.stderr(`cannot derive a worktree name from role "${assignedRole.name}"; ` +
894
+ `pass an explicit --worktree <name>\n`);
895
+ return 1;
896
+ }
897
+ // gh#556 Part 1: NEW worktrees live under ~/.borg/worktrees/<repo>/<name>
898
+ // (was a sibling <parent>/<repo>-<name>). Existing siblings are untouched
899
+ // (absolute git-registered paths). Collision dedup KEPT (<name>-<n>).
900
+ const homeDir = deps.homedir();
901
+ let candidate = computeWorktreePath(homeDir, repoBase, suffix);
902
+ let wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
903
+ let n = 2;
904
+ // gh#864: dedup against an existing worktree PATH/registration AND against a
905
+ // lingering UNMERGED per-worktree branch. `git worktree add -b <wtBranch>`
906
+ // (below) hard-fails when <wtBranch> already exists even if its old worktree
907
+ // was pruned — so a stale ref would block the spawn. A MERGED lingering
908
+ // branch is safely adoptable (handled at the add), so it does NOT force a
909
+ // suffix bump; only an UNMERGED ref (carrying un-merged commits) bumps to a
910
+ // fresh suffix so we never reuse/clobber its work.
911
+ while (deps.pathExists(candidate) ||
912
+ worktreeRegistered(deps, projectRoot, candidate) ||
913
+ (localBranchExists(deps.runSync, projectRoot, wtBranch) &&
914
+ !isMerged(deps.runSync, projectRoot, wtBranch, startRef))) {
915
+ candidate = computeWorktreePath(homeDir, repoBase, suffix, n);
916
+ wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
917
+ n++;
918
+ }
919
+ // gh#556 Part 1: create the intermediate ~/.borg/worktrees/<repo>/ before
920
+ // `git worktree add` (git creates the leaf, not the parent chain). Plain
921
+ // recursive mkdir — NO chmod of the existing ~/.borg (credentials file).
922
+ deps.mkdirp(dirname(candidate));
923
+ // gh#33 (Q1/Q4): spawn on a named per-worktree branch (wt-<suffix>),
924
+ // NOT detached HEAD. The named branch is current with startRef and is
925
+ // where the drone's feature branches get cut from. Uniform for every
926
+ // role incl. coordinator — main is never a working branch (Q4).
927
+ // Branch naming is UNAFFECTED by the relocation: perWorktreeBranchName's
928
+ // as-is else-branch maps the new basename <suffix> → wt-<suffix> (== old).
929
+ //
930
+ // gh#864: if <wtBranch> already exists here it is MERGED (the loop bumped
931
+ // past any unmerged ref), so ADOPT it — `git worktree add <path> <branch>`
932
+ // (no -b) attaches the merged branch instead of failing on a create-
933
+ // collision. A fresh suffix has no ref → create it at startRef with -b.
934
+ const wt = localBranchExists(deps.runSync, projectRoot, wtBranch)
935
+ ? deps.runSync('git', ['worktree', 'add', candidate, wtBranch], projectRoot)
936
+ : deps.runSync('git', ['worktree', 'add', '-b', wtBranch, candidate, startRef], projectRoot);
937
+ if (wt.status !== 0) {
938
+ deps.stderr(`git worktree add failed: ${safeStderr(wt.stderr)}\n`);
939
+ return 1;
940
+ }
941
+ deps.stderr(`spawned sibling worktree at ${candidate} on branch ${wtBranch} (${startRef}); ` +
942
+ `the original dir keeps its active seat — run \`borg reset-local-seat\` there if that binding is stale.\n`);
943
+ deps.chdir(candidate);
944
+ deps.stderr(renderWorktreeSteeringNote(candidate, wtBranch, projectRoot));
945
+ spawnedWorktreePath = deps.cwd();
946
+ }
947
+ // ----- Step 8: persist the binding (narrow rollback — worktree exists if spawned) -----
948
+ const activeCube = {
949
+ cubeId: result.cube_id,
950
+ droneId: result.drone_id,
951
+ name: cubeDetail.name,
952
+ droneLabel: result.drone_label,
953
+ apiUrl: auth.apiUrl,
954
+ serverTrustIdentity: auth.serverTrustIdentity,
955
+ localSessionCredentialRef: result.local_session.credential_ref,
956
+ localSessionExpiresAt: result.local_session.expires_at,
957
+ // gh#899: persist the assimilated role so the connect-time ListTools
958
+ // handler can role-scope the native tool surface.
959
+ roleName: assignedRole.name,
960
+ isHumanSeat: assignedRole.is_human_seat,
961
+ ...(assignedRole.role_class ? { roleClass: assignedRole.role_class } : {}),
962
+ };
963
+ const rollbackWorktree = () => {
964
+ if (!spawnedWorktreePath)
965
+ return;
966
+ const rm = deps.runSync('git', ['worktree', 'remove', '--force', spawnedWorktreePath], projectRoot);
967
+ if (rm.status === 0) {
968
+ deps.stderr(`rolled back spawned worktree at ${spawnedWorktreePath}\n`);
969
+ }
970
+ else {
971
+ deps.stderr(`manual cleanup needed: \`git worktree remove --force ${spawnedWorktreePath}\` ` +
972
+ `(rollback attempt failed: ${safeStderr(rm.stderr).trim() || 'unknown'})\n`);
973
+ }
974
+ };
975
+ // Local-server authority: drive the COMPOSITE cube-owned FINALIZE (Race 2).
976
+ // The cube lock is held OUTER across revalidate → binding-write → activate; the
977
+ // typed expectation is declared HERE at the orchestration layer (reattach =
978
+ // EXACT prior binding with its live-bearer digest; eviction remint = EXACT ref
979
+ // only, bearer intentionally replaced; fresh/sibling = ABSENT).
980
+ if (result.finalize === undefined || deps.finalizeServerSeat === undefined) {
981
+ deps.stderr('Local Borg server session metadata is incomplete; no seat was saved.\n');
982
+ rollbackWorktree();
983
+ return 1;
984
+ }
985
+ {
986
+ // The SAME typed expectation declared before PREPARE is revalidated again at
987
+ // FINALIZE (commit-time revalidation, ratified clause 3).
988
+ let outcome;
989
+ try {
990
+ outcome = await deps.finalizeServerSeat({
991
+ active: activeCube,
992
+ expected: sessionExpected,
993
+ activate: result.finalize.activate,
994
+ scrubPending: result.finalize.scrubPending,
995
+ });
996
+ }
997
+ catch (err) {
998
+ // A BINDING-WRITE (or revalidate) failure BEFORE the binding landed. Nothing
999
+ // owns the spawned worktree yet, so rolling it back is safe.
1000
+ const message = err instanceof Error ? err.message : String(err);
1001
+ deps.stderr(`setActiveCube failed: ${message}\n`);
1002
+ rollbackWorktree();
1003
+ return 1;
1004
+ }
1005
+ if (!outcome.committed) {
1006
+ if (outcome.reason === 'activation-failed') {
1007
+ // CR #5: the atomic activate+bind did NOT commit (missing/replaced/threw), so
1008
+ // the record stays PENDING with no worktree of its own. CR#2/CR#4: bind that
1009
+ // exact pending record to THIS preserved worktree WITHOUT activating it — the
1010
+ // record stays pending (non-hydratable) but becomes DISCOVERABLE from here, so
1011
+ // a rerun FROM this worktree re-derives the exact original operation and
1012
+ // re-sends the identical bearer, converging on the SAME seat (no ghost).
1013
+ //
1014
+ // CR#4 (SR-seven false-success revocation): the bindPending OUTCOME is
1015
+ // load-bearing and must be BRANCHED. A blanket "safe to re-run / identical
1016
+ // seat reused" claim on a missing/replaced/thrown bind is a FALSE-SUCCESS
1017
+ // revocation failure — the worktree would NOT own a durable locator, yet the
1018
+ // operator would be told convergence is guaranteed. Preserve the spawned
1019
+ // worktree ONLY when it owns a durable locator (a `bound` outcome).
1020
+ let bindOutcome = 'unavailable';
1021
+ if (result.finalize?.bindPending) {
1022
+ try {
1023
+ bindOutcome = (await result.finalize.bindPending({
1024
+ worktree: deps.findProjectRoot(deps.cwd()),
1025
+ name: activeCube.name,
1026
+ droneLabel: activeCube.droneLabel,
1027
+ ...(activeCube.roleName !== undefined ? { roleName: activeCube.roleName } : {}),
1028
+ ...(activeCube.roleClass !== undefined ? { roleClass: activeCube.roleClass } : {}),
1029
+ ...(activeCube.isHumanSeat !== undefined ? { isHumanSeat: activeCube.isHumanSeat } : {}),
1030
+ }));
1031
+ }
1032
+ catch {
1033
+ bindOutcome = 'threw';
1034
+ }
1035
+ }
1036
+ if (bindOutcome === 'bound') {
1037
+ // The worktree now owns a durable locator (the bound-pending record points
1038
+ // here). PRESERVE it. Truthful convergence copy: a rerun FROM here re-sends
1039
+ // the identical bearer (no duplicate), and `reset-local-seat` from here now
1040
+ // discovers + clears the bound-pending record.
1041
+ deps.stderr(`This worktree's secure session on ${auth.apiUrl} did not finish activating, but ` +
1042
+ 'its resumable seat state was PRESERVED here. This worktree was NOT removed. From ' +
1043
+ `here, re-run ${localAssimilateCommand(auth.apiUrl)} to converge (the identical seat ` +
1044
+ `is reused — no duplicate is minted), or run ${resetLocalSeatCommand(auth.apiUrl)} to ` +
1045
+ 'clear it.\n');
1046
+ return 1;
1047
+ }
1048
+ // missing / replaced / threw / unavailable: the worktree owns NO durable
1049
+ // locator, so make NO convergence claim. Roll back the just-spawned worktree
1050
+ // (preserve ONLY when it owns a durable locator) and point at the offline reset.
1051
+ deps.stderr(`This worktree's secure session on ${auth.apiUrl} did not finish activating, and its ` +
1052
+ 'seat state could NOT be preserved to this worktree (it was concurrently reset or ' +
1053
+ 'replaced, or the local seat store could not be written). No usable seat remains here. ' +
1054
+ `Run ${resetLocalSeatCommand(auth.apiUrl)} to clear any saved seat, then re-run ` +
1055
+ `${localAssimilateCommand(auth.apiUrl)} to attach against the current state.\n`);
1056
+ rollbackWorktree();
1057
+ return 1;
1058
+ }
1059
+ // 'expectation-mismatch': the binding was NEVER written (this worktree's
1060
+ // saved seat changed under us between PREPARE and FINALIZE — a concurrent
1061
+ // reset or enroll). The composite scrubbed only our own pending record — no
1062
+ // orphan ACTIVE credential — so a just-spawned worktree is safe to remove.
1063
+ deps.stderr(`This worktree's saved local seat on ${auth.apiUrl} changed during attach ` +
1064
+ '(a concurrent reset or enroll); no seat was created and nothing was overwritten. ' +
1065
+ `Re-run ${localAssimilateCommand(auth.apiUrl)} to attach against the current state.\n`);
1066
+ rollbackWorktree();
1067
+ return 1;
1068
+ }
1069
+ }
1070
+ // The worktree, not a reminted drone UUID, is the stable local seat identity
1071
+ // for monitor runtime state. Capture it once before the lazy GC and launch
1072
+ // paths so both use the exact same explicit root.
1073
+ const agentCwd = deps.cwd(); // post-chdir if step 3 spawned a worktree
1074
+ const seatWorktree = deps.findProjectRoot(agentCwd);
1075
+ const monitorStateRoot = monitorStateRootForWorktree(seatWorktree);
1076
+ // gh#793: best-effort GC of orphaned inbox files (evicted/dead drones) in the
1077
+ // cube just joined — lazy-on-assimilate, no cron/new command. NEVER blocks or
1078
+ // fails the assimilate (whole call swallowed). Local-only signal (CubeDetail
1079
+ // carries no roster → droneState 'absent'; an inbox is reaped only when
1080
+ // no-live-holder AND ≥30-day stale). The live-safety gate (raw pgrep tail /
1081
+ // fresh heartbeat / live pidfile) vetoes any live holder — a wrong delete is
1082
+ // permanent deafness, a missed orphan is harmless.
1083
+ try {
1084
+ const livenessDeps = defaultInboxLivenessDeps();
1085
+ const cubeInboxDir = dirname(inboxPathForDrone(result.cube_id, result.drone_id));
1086
+ gcOrphanInboxesForCube({
1087
+ cubeInboxDir,
1088
+ selfDroneId: result.drone_id,
1089
+ deps: {
1090
+ listInboxLogs: defaultListInboxLogs,
1091
+ isLive: (p) => isInboxLive(p, livenessDeps, monitorStateRoot),
1092
+ droneState: () => 'absent',
1093
+ unlink: (p) => unlinkSync(p),
1094
+ now: livenessDeps.now,
1095
+ staleMs: ORPHAN_INBOX_STALE_MS,
1096
+ },
1097
+ monitorStateRoot,
1098
+ });
1099
+ }
1100
+ catch {
1101
+ /* gh#793: orphan GC is best-effort — never block or fail the assimilate */
1102
+ }
1103
+ // ----- Step 8: Launch selected agent CLI -----
1104
+ // Mirrors the kickoff invocation from claude.ts (no-args path): the agent
1105
+ // picks up the newly-persisted ActiveCube via the MCP stdio server on
1106
+ // startup. The kickoff prompt re-enters /loop borg_regen so the new
1107
+ // drone bootstraps into the cube cleanly. The monitor clause (CR-PE-F1)
1108
+ // arms the inbox tail so the new drone wakes on peer log entries in
1109
+ // real time — without this, drones miss real-time wake events during
1110
+ // the bootstrap window and only self-heal at the /loop heartbeat.
1111
+ deps.setTerminalTitle(result.drone_label, cubeDetail.name);
1112
+ // Pedagogical hint to stdout before Claude takes over the terminal.
1113
+ // Ink does not enter alt-screen-buffer (verified empirically via PTY
1114
+ // probe 2026-05-19), so lines printed here remain visible in the
1115
+ // user's terminal scrollback above Claude's interactive UI. Color is
1116
+ // gated on TTY + NO_COLOR/CI env-var conventions; the welcome shape
1117
+ // itself is cube-agnostic so non-default templates render identically.
1118
+ const useColor = deps.isTTY() && !process.env.NO_COLOR && !process.env.CI;
1119
+ deps.stdout(renderAssimilationWelcome(result.drone_label, assignedRole.name, cubeDetail.name, useColor, authority.kind === 'server' ? authority.apiUrl : undefined));
1120
+ // gh#673 P2 (WI-1): install the project-local SessionStart orientation
1121
+ // hook into the launch root — covers BOTH the freshly-spawned sibling
1122
+ // worktree (agentCwd = the new worktree post-chdir) and the in-place /
1123
+ // --here path. Best-effort: a hook-install failure must never block
1124
+ // the assimilate (the bare-`borg` launcher re-ensures it).
1125
+ try {
1126
+ deps.installProjectSessionHook(agentCwd);
1127
+ }
1128
+ catch {
1129
+ deps.stderr(`warning: could not install the project-local SessionStart hook in ${agentCwd}; it will be re-attempted on the next borg launch\n`);
1130
+ }
1131
+ // gh#33 (Q2/Q4/Q6): in-place wt- adoption. A freshly-spawned worktree is
1132
+ // already at origin/main on a fresh wt- branch, so only the in-place /
1133
+ // --here path (running in an existing checkout) needs handling. ADOPT
1134
+ // the per-worktree branch — switch the checkout onto wt-<suffix> at
1135
+ // origin/main when clean + merged. This both moves the drone off main
1136
+ // (Q4: main is never a working branch) AND brings the branch current,
1137
+ // which a bare ff-sync would not do (it would leave a main checkout on
1138
+ // main — the gap two-of-four-CR 27af1001 + QA c7a0c615 caught). Dirty ->
1139
+ // skip + surface; unmerged HEAD -> block + surface; NEVER discards.
1140
+ // Using adoptWorktree (HEAD-merged check + explicit `switch -C wtBranch
1141
+ // ref`) also closes one-of-four-CR's compute-name-vs-current-branch NIT.
1142
+ // Best-effort: a skip/block surfaces but never blocks the launch.
1143
+ if (!spawnedWorktreePath) {
1144
+ deps.runSync('git', ['fetch', 'origin', '--prune'], agentCwd);
1145
+ const wtBranch = perWorktreeBranchName(basename(agentCwd), basename(projectRoot));
1146
+ const adopt = adoptWorktree(deps.runSync, agentCwd, wtBranch, 'origin/main');
1147
+ if (adopt.action === 'adopted') {
1148
+ deps.stderr(`worktree: adopted branch ${wtBranch} at origin/main\n`);
1149
+ deps.stderr(renderInPlaceWorktreeNote(agentCwd, wtBranch));
1150
+ }
1151
+ else if (adopt.message) {
1152
+ deps.stderr(`worktree sync: ${adopt.message}\n`);
1153
+ }
1154
+ }
1155
+ // BUG-5 / v0.9.3: probe MCP readiness before launching claude so
1156
+ // the launched session sees tools at startup. Non-blocking: probe
1157
+ // failure surfaces a stderr warning but the launch proceeds (the
1158
+ // kickoff text's ToolSearch recovery clause is the second line of
1159
+ // defense).
1160
+ const mcpReady = await deps.probeMcpReady();
1161
+ if (!mcpReady) {
1162
+ deps.stderr(`warning: borg-mcp readiness probe did not complete within the timeout; ` +
1163
+ `launching ${cli} anyway — the kickoff prompt's ToolSearch fallback ` +
1164
+ `will recover if the MCP server takes longer to start.\n`);
1165
+ }
1166
+ const inboxPath = deps.getInboxPath(result.cube_id, result.drone_id);
1167
+ const codexWakeNonce = cli === 'codex' ? `borg-wake-${randomUUID()}` : null;
1168
+ // gh#929: shared wakePathArming + NEVER-TaskStop (unified with claude.ts —
1169
+ // the two call sites previously carried divergent monitorClause strings).
1170
+ const monitorClause = buildKickoffWakePathClause(cli, cli === 'claude' ? inboxPath : null, cli === 'claude' ? monitorStateRoot : null);
1171
+ let codexWakePathClause;
1172
+ let remoteArgs = [];
1173
+ let launchArgs;
1174
+ let codexSocketPath = null;
1175
+ let codexServerCleanup = null;
1176
+ const launchApproval = deps.resolveCliApprovals
1177
+ ? await deps.resolveCliApprovals(cli, agentCwd)
1178
+ : { codexArgs: [] };
1179
+ if (launchApproval.warning)
1180
+ deps.stderr(`warning: ${launchApproval.warning}\n`);
1181
+ // Temporary Claude-only model compatibility. Local/provider models are
1182
+ // configured by the selected agent CLI and are never rewritten by Borg.
1183
+ const modelEnv = resolveLaunchEnv(effectiveModel);
1184
+ const childEnv = {
1185
+ ...withAgentRuntimeEnv(process.env, cli),
1186
+ ...modelEnv.set,
1187
+ BORG_SESSION: '1',
1188
+ };
1189
+ if (cli === 'opencode' && launchApproval.openCodePermission) {
1190
+ childEnv.OPENCODE_PERMISSION = launchApproval.openCodePermission;
1191
+ }
1192
+ for (const key of modelEnv.unset) {
1193
+ delete childEnv[key];
1194
+ }
1195
+ if (cli === 'codex') {
1196
+ const remote = await deps.prepareCodexRemoteLaunch();
1197
+ if (remote.warning) {
1198
+ deps.stderr(`warning: ${remote.warning}\n`);
1199
+ codexWakePathClause =
1200
+ `⚠ 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.`;
1201
+ }
1202
+ else {
1203
+ codexWakePathClause =
1204
+ `Codex wake-path capability check passed: remote-control socket established for this session.`;
1205
+ }
1206
+ remoteArgs = remote.args;
1207
+ // Codex env takes precedence over model env when there is overlap.
1208
+ if (Object.keys(remote.env).length > 0) {
1209
+ Object.assign(childEnv, remote.env);
1210
+ }
1211
+ codexSocketPath = socketPathFromRemoteArgs(remote.args);
1212
+ codexServerCleanup = remote.server?.cleanup ?? null;
1213
+ }
1214
+ const kickoff = buildAgentKickoffPrompt({
1215
+ cli,
1216
+ codexWakeNonce,
1217
+ monitorClause,
1218
+ codexWakePathClause,
1219
+ });
1220
+ // Keep Claude and Codex on the unmodified shared kickoff. Only OpenCode
1221
+ // receives a nonce-bearing copy so the later MCP connection can identify
1222
+ // this exact launch among same-text sessions.
1223
+ let openCodeKickoff = null;
1224
+ let dronePort;
1225
+ launchArgs = [kickoff];
1226
+ if (cli === 'codex') {
1227
+ // gh#673 P1-codex: -c overrides deliver BORG_SESSION and the selected
1228
+ // CLI identity to the codex-spawned borg-mcp child (inherited env never
1229
+ // reaches Codex MCP children — V2/V2b probes). Explicitly pin remote wake
1230
+ // off when no socket is available, overriding legacy static configs that
1231
+ // formerly used this transport marker as Codex identity.
1232
+ launchArgs = [
1233
+ ...launchApproval.codexArgs,
1234
+ ...codexBorgSessionConfigArgs(),
1235
+ ...codexAgentKindConfigArgs(),
1236
+ ...codexRemoteWakeConfigArgs(codexSocketPath !== null),
1237
+ ...remoteArgs,
1238
+ ...withCodexCwdArg(launchArgs, agentCwd),
1239
+ ];
1240
+ }
1241
+ else if (cli === 'opencode') {
1242
+ // OpenCode assimilate launch: start TUI with the kickoff passed via
1243
+ // --prompt (auto-submits it as the first message). BORG_SESSION is
1244
+ // pinned in opencode.json. A unique port is assigned so the MCP child
1245
+ // can connect via the HTTP API for context-streaming (injectOpenCodeEntry).
1246
+ dronePort = computeOpenCodePort(result.drone_id);
1247
+ installBorgPlugin();
1248
+ const cwd = agentCwd;
1249
+ openCodeKickoff = createOpenCodeLaunchKickoff(kickoff);
1250
+ launchArgs = buildOpenCodeLaunchArgs(cwd, dronePort, openCodeKickoff.prompt);
1251
+ }
1252
+ // gh#673 P1: mark the launched agent session as borg-launched so the
1253
+ // MCP child + hook bins activate (launch-gate.ts). childEnv is the
1254
+ // complete child environment (process.env + model.set, minus unset
1255
+ // keys, plus BORG_SESSION + codex env). The exec seam must use it
1256
+ // directly without re-merging process.env (assimilate-deps.ts).
1257
+ const exitPromise = deps.exec(cli, launchArgs, agentCwd, childEnv);
1258
+ if (cli === 'codex' && codexSocketPath && codexWakeNonce) {
1259
+ void recordCodexWakeTarget({
1260
+ deps,
1261
+ cubeId: result.cube_id,
1262
+ droneId: result.drone_id,
1263
+ socketPath: codexSocketPath,
1264
+ cwd: agentCwd,
1265
+ previewNeedle: codexWakeNonce,
1266
+ launchedAtSeconds: Math.floor(Date.now() / 1000),
1267
+ });
1268
+ }
1269
+ // gh#opencode: inject the kickoff prompt into the TUI's first session via
1270
+ // the SDK. OpenCode doesn't accept a prompt as a CLI arg, so we do it
1271
+ // programmatically once the HTTP server is ready. Best-effort.
1272
+ if (cli === 'opencode' && openCodeKickoff) {
1273
+ const launchKickoff = openCodeKickoff;
1274
+ const serverUrl = `http://127.0.0.1:${dronePort}`;
1275
+ connectOpenCodeDrone({
1276
+ serverUrl,
1277
+ directory: agentCwd,
1278
+ droneLabel: result.drone_label,
1279
+ cubeName: cubeName ?? 'borg',
1280
+ })
1281
+ .then(() => injectInitialKickoff(launchKickoff))
1282
+ .catch(() => { });
1283
+ }
1284
+ const exitCode = await exitPromise;
1285
+ // gh#528: kill the borg-owned Codex app-server when the assimilate-launched
1286
+ // session exits, so it isn't left orphaned (live → not pruned by pid liveness).
1287
+ // OpenCode has no app-server to clean up.
1288
+ if (codexServerCleanup) {
1289
+ try {
1290
+ codexServerCleanup();
1291
+ }
1292
+ catch {
1293
+ // best-effort
1294
+ }
1295
+ }
1296
+ // Sprint 18: when a sibling worktree was spawned, the user's shell
1297
+ // returns to their original cwd after Claude exits (process.chdir
1298
+ // doesn't propagate to the parent). Emit a stderr hint so they know
1299
+ // how to get back into the worktree. shellEscape defangs any shell
1300
+ // metachars in the path against paste-injection (drone-11 SR-LANE).
1301
+ // Skip the hint when no worktree was spawned (--here / no-worktree
1302
+ // flow) or when originalCwd already matches the worktree path
1303
+ // (defensive against the no-op edge case drone-9 UX-LANE flagged).
1304
+ if (spawnedWorktreePath && originalCwd !== spawnedWorktreePath) {
1305
+ deps.stderr(`\nAgent exited. You were working in ${spawnedWorktreePath}; your shell is back in ${originalCwd}.\n` +
1306
+ `To return:\n` +
1307
+ ` cd ${shellEscape(spawnedWorktreePath)}\n`);
1308
+ }
1309
+ return exitCode;
1310
+ }
1311
+ function renderWorktreeSteeringNote(worktreePath, wtBranch, primaryPath) {
1312
+ return (`\nWORKTREE STEERING: You are in worktree ${worktreePath} on branch ${wtBranch}. ` +
1313
+ `Do ALL work HERE — cut your feature branch (fix/.../feat/...) off ${wtBranch} in THIS worktree, ` +
1314
+ `use relative paths / your cwd. NEVER \`git -C ${primaryPath}\` or operate on the primary checkout ${primaryPath}: ` +
1315
+ `the same branch can't be checked out in two worktrees, so work created in the primary won't reach your wt-branch ` +
1316
+ `without manual surgery (cherry-pick/merge).\n`);
1317
+ }
1318
+ function renderInPlaceWorktreeNote(worktreePath, wtBranch) {
1319
+ return (`\nWORKTREE STEERING: This checkout is now on branch ${wtBranch}. ` +
1320
+ `Do ALL work HERE in ${worktreePath} — cut your feature branch (fix/.../feat/...) off ${wtBranch}, ` +
1321
+ `use relative paths / your cwd.\n`);
1322
+ }
1323
+ /**
1324
+ * Sprint 4 / gh#147 (drone-8 SR-PE-FINDING-1): strip ASCII control
1325
+ * characters before interpolating subprocess stderr into operator-
1326
+ * facing messages. Defense-in-depth against a local attacker editing
1327
+ * `.git/config` to embed ANSI escapes (e.g. `\x1b[2J` cursor moves,
1328
+ * `\x1b]0;...\x07` title injection) — git command stderr then carries
1329
+ * them, and unfiltered orchestrator output corrupts the terminal.
1330
+ *
1331
+ * Strips `[\x00-\x1F\x7F]` (NUL, all C0 controls, DEL). ASCII
1332
+ * whitespace inside C0 (tab, newline, CR) gets stripped too — the
1333
+ * orchestrator only ever interpolates short status fragments where
1334
+ * preserving multi-line layout isn't load-bearing; over-strip
1335
+ * trade-off accepted for shape simplicity.
1336
+ */
1337
+ export function safeStderr(msg) {
1338
+ return msg.replace(/[\x00-\x1F\x7F]/g, '');
1339
+ }
1340
+ function worktreeRegistered(deps, projectRoot, candidate) {
1341
+ const res = deps.runSync('git', ['worktree', 'list', '--porcelain'], projectRoot);
1342
+ if (res.status !== 0)
1343
+ return false;
1344
+ return res.stdout.split('\n').some((line) => line === `worktree ${candidate}`);
1345
+ }
1346
+ /**
1347
+ * Sprint 19 (gh#184): suggest the closest cube-role name for a misspelled
1348
+ * CLI role argument. Levenshtein distance ≤2 against the cube's role
1349
+ * names; case-insensitive. Returns null when no close match exists.
1350
+ *
1351
+ * Serves Queen's "more user-friendly" intent without violating the
1352
+ * Borg-collective metaphor (collective defines roles; drones slot in).
1353
+ * The original strict-failure semantic is preserved; the suggestion
1354
+ * is an additive nudge in the error message, not a fallback path.
1355
+ */
1356
+ export function suggestRoleName(input, candidates) {
1357
+ if (candidates.length === 0)
1358
+ return null;
1359
+ const inputLower = input.toLowerCase();
1360
+ let best = null;
1361
+ for (const candidate of candidates) {
1362
+ const distance = levenshtein(inputLower, candidate.toLowerCase());
1363
+ if (distance <= 2 && (best === null || distance < best.distance)) {
1364
+ best = { name: candidate, distance };
1365
+ }
1366
+ }
1367
+ return best ? best.name : null;
1368
+ }
1369
+ /**
1370
+ * Minimal Levenshtein distance implementation. Used only by
1371
+ * `suggestRoleName` for the fuzzy-match nudge; intentionally
1372
+ * unexported and not a general-purpose helper.
1373
+ */
1374
+ function levenshtein(a, b) {
1375
+ if (a === b)
1376
+ return 0;
1377
+ if (a.length === 0)
1378
+ return b.length;
1379
+ if (b.length === 0)
1380
+ return a.length;
1381
+ const prev = new Array(b.length + 1);
1382
+ const curr = new Array(b.length + 1);
1383
+ for (let j = 0; j <= b.length; j++)
1384
+ prev[j] = j;
1385
+ for (let i = 1; i <= a.length; i++) {
1386
+ curr[0] = i;
1387
+ for (let j = 1; j <= b.length; j++) {
1388
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1389
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
1390
+ }
1391
+ for (let j = 0; j <= b.length; j++)
1392
+ prev[j] = curr[j];
1393
+ }
1394
+ return prev[b.length];
1395
+ }
1396
+ //# sourceMappingURL=assimilate-cmd.js.map