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