borgmcp 3.5.1 → 3.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +18 -13
  2. package/dist/assimilate-cmd.d.ts +1 -1
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +28 -53
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts.map +1 -1
  7. package/dist/assimilate-deps.js +1 -1
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/backends/launch-all-pastelist.js +2 -2
  10. package/dist/backends/launch-all-pastelist.js.map +1 -1
  11. package/dist/backends/launch-all-terminals.js +2 -2
  12. package/dist/backends/launch-all-terminals.js.map +1 -1
  13. package/dist/backends/launch-all-tmux.js +3 -3
  14. package/dist/backends/launch-all-tmux.js.map +1 -1
  15. package/dist/bare-launch-menu.d.ts +23 -38
  16. package/dist/bare-launch-menu.d.ts.map +1 -1
  17. package/dist/bare-launch-menu.js +84 -31
  18. package/dist/bare-launch-menu.js.map +1 -1
  19. package/dist/claude.d.ts.map +1 -1
  20. package/dist/claude.js +28 -29
  21. package/dist/claude.js.map +1 -1
  22. package/dist/cleanup-cmd.d.ts +1 -1
  23. package/dist/cleanup-cmd.d.ts.map +1 -1
  24. package/dist/cleanup-cmd.js +10 -1
  25. package/dist/cleanup-cmd.js.map +1 -1
  26. package/dist/cli-help.d.ts.map +1 -1
  27. package/dist/cli-help.js +6 -9
  28. package/dist/cli-help.js.map +1 -1
  29. package/dist/console-prefix.d.ts.map +1 -1
  30. package/dist/console-prefix.js +7 -2
  31. package/dist/console-prefix.js.map +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/launch-all-cmd.d.ts.map +1 -1
  36. package/dist/launch-all-cmd.js +43 -25
  37. package/dist/launch-all-cmd.js.map +1 -1
  38. package/dist/launch-all-command.d.ts +8 -2
  39. package/dist/launch-all-command.d.ts.map +1 -1
  40. package/dist/launch-all-command.js +8 -4
  41. package/dist/launch-all-command.js.map +1 -1
  42. package/dist/launch-all-deps.d.ts +2 -2
  43. package/dist/launch-all-deps.d.ts.map +1 -1
  44. package/dist/launch-all-deps.js +2 -2
  45. package/dist/launch-all-deps.js.map +1 -1
  46. package/dist/launch-all-discovery.d.ts +2 -3
  47. package/dist/launch-all-discovery.d.ts.map +1 -1
  48. package/dist/launch-all-discovery.js +1 -5
  49. package/dist/launch-all-discovery.js.map +1 -1
  50. package/dist/parse-launch-all-args.d.ts +0 -1
  51. package/dist/parse-launch-all-args.d.ts.map +1 -1
  52. package/dist/parse-launch-all-args.js +1 -9
  53. package/dist/parse-launch-all-args.js.map +1 -1
  54. package/dist/remote-client.d.ts +2 -2
  55. package/dist/remote-client.d.ts.map +1 -1
  56. package/dist/remote-client.js +12 -4
  57. package/dist/remote-client.js.map +1 -1
  58. package/dist/seat-probe.d.ts +2 -1
  59. package/dist/seat-probe.d.ts.map +1 -1
  60. package/dist/seat-probe.js +2 -2
  61. package/dist/seat-probe.js.map +1 -1
  62. package/dist/seat-store.d.ts.map +1 -1
  63. package/dist/seat-store.js +18 -0
  64. package/dist/seat-store.js.map +1 -1
  65. package/dist/worktree-lifecycle.d.ts +0 -18
  66. package/dist/worktree-lifecycle.d.ts.map +1 -1
  67. package/dist/worktree-lifecycle.js +0 -39
  68. package/dist/worktree-lifecycle.js.map +1 -1
  69. package/docs/LOCAL_SERVER.md +8 -6
  70. package/docs/SEAT_LIFECYCLE.md +8 -7
  71. package/package.json +1 -1
  72. package/src/assimilate-cmd.ts +36 -68
  73. package/src/assimilate-deps.ts +1 -2
  74. package/src/backends/launch-all-pastelist.ts +2 -2
  75. package/src/backends/launch-all-terminals.ts +2 -2
  76. package/src/backends/launch-all-tmux.ts +3 -3
  77. package/src/bare-launch-menu.ts +89 -48
  78. package/src/claude.ts +32 -35
  79. package/src/cleanup-cmd.ts +10 -10
  80. package/src/cli-help.ts +6 -9
  81. package/src/console-prefix.ts +6 -2
  82. package/src/index.ts +2 -11
  83. package/src/launch-all-cmd.ts +55 -37
  84. package/src/launch-all-command.ts +9 -4
  85. package/src/launch-all-deps.ts +4 -10
  86. package/src/launch-all-discovery.ts +3 -8
  87. package/src/parse-launch-all-args.ts +1 -10
  88. package/src/remote-client.ts +13 -8
  89. package/src/seat-probe.ts +3 -4
  90. package/src/seat-store.ts +18 -0
  91. package/src/worktree-lifecycle.ts +0 -45
@@ -17,7 +17,7 @@ import {
17
17
  recordCodexWakeTarget,
18
18
  socketPathFromRemoteArgs,
19
19
  } from './codex-launch.js';
20
- import { perWorktreeBranchName, adoptWorktree, computeWorktreePath, localBranchExists, isMerged } from './worktree-lifecycle.js';
20
+ import { perWorktreeBranchName, computeWorktreePath, localBranchExists, isMerged } from './worktree-lifecycle.js';
21
21
  import { DroneEvictedError } from './drone-lifecycle.js';
22
22
  import { codexBorgSessionConfigArgs } from './launch-gate.js';
23
23
  import {
@@ -269,11 +269,7 @@ export interface AssimilateDeps {
269
269
  cubeId: string;
270
270
  projectRoot: string;
271
271
  }) => Promise<{ operation: ServerSessionOperation; roleId: string; credentialRef: string } | null>;
272
- probeSeat: (
273
- sessionToken: string,
274
- apiUrl: string,
275
- serverTrustIdentity?: string,
276
- ) => Promise<SeatStatus>;
272
+ probeSeat: (seat: ActiveCube) => Promise<SeatStatus>;
277
273
  setActiveCube: (a: ActiveCube) => Promise<void>;
278
274
  /** COMPOSITE cube-owned FINALIZE (Race 2): under the cube lock, revalidate the
279
275
  * typed expectation, persist the binding FIRST, then run `activate` (keychain
@@ -910,6 +906,30 @@ export async function runAssimilate(
910
906
  }
911
907
 
912
908
  const projectRoot = repositoryContext.root;
909
+ const wantSibling =
910
+ args.flags.worktree !== undefined ||
911
+ (!args.flags.here && !(existing === null && hasPersistedIdentity));
912
+ let verifiedHead = '';
913
+
914
+ if (mode !== 'cube-init' && args.flags.here && existing === null && !hasPersistedIdentity) {
915
+ deps.stderr(
916
+ '`borg assimilate --here` resumes this worktree\'s saved drone, but no saved drone was found.\n' +
917
+ 'Run `borg assimilate` to create a new drone in a managed worktree.\n',
918
+ );
919
+ return 1;
920
+ }
921
+
922
+ if (mode !== 'cube-init' && wantSibling) {
923
+ const headProbe = deps.runSync('git', ['rev-parse', '--verify', 'HEAD'], projectRoot);
924
+ if (headProbe.status !== 0) {
925
+ deps.stderr(
926
+ 'sibling worktree spawn requires HEAD pointing at a commit.\n' +
927
+ 'Create an initial commit (for example: `git commit --allow-empty -m "Initial commit"`), then rerun `borg assimilate`.\n',
928
+ );
929
+ return 1;
930
+ }
931
+ verifiedHead = headProbe.stdout.trim();
932
+ }
913
933
 
914
934
  let auth: { token: string; apiUrl: string; serverTrustIdentity: string; serverCapabilities: readonly string[] };
915
935
  {
@@ -1145,8 +1165,6 @@ export async function runAssimilate(
1145
1165
  // retain its original role so the attach request reuses the exact durable
1146
1166
  // retry binding instead of selecting another unoccupied role and minting a
1147
1167
  // duplicate seat.
1148
- const wantSibling =
1149
- args.flags.worktree !== undefined || (existing !== null && !args.flags.here);
1150
1168
  // `let`: the bound-pending resume path (CR#2) OVERRIDES this from the stored
1151
1169
  // operation so a rerun re-derives the EXACT original sibling seat ref.
1152
1170
  let sessionOperation: ServerSessionOperation = {
@@ -1154,7 +1172,7 @@ export async function runAssimilate(
1154
1172
  // cwd. This is the stable seat/sibling namespace for the pending bearer, so a
1155
1173
  // deliberate sibling never collides with the durable in-place seat's bearer.
1156
1174
  projectRoot,
1157
- kind: wantSibling ? 'sibling' : 'seat',
1175
+ kind: 'sibling',
1158
1176
  // CR1(a): an implicit sibling's operation key must be COLLISION-SAFE — two
1159
1177
  // unnamed siblings of the same (origin,trust,cube,role) must get DISTINCT seat
1160
1178
  // refs, else prepareSeat reuses the first sibling's ACTIVE record and the
@@ -1162,11 +1180,9 @@ export async function runAssimilate(
1162
1180
  // and rebound). A named sibling already keys on its name; an unnamed one derives
1163
1181
  // a per-invocation-unique key so every distinct implicit sibling target mints a
1164
1182
  // distinct bearer / seat ref.
1165
- operationKey: wantSibling
1166
- ? (args.flags.worktree === undefined
1167
- ? `implicit-sibling:${randomUUID()}`
1168
- : `named-sibling:${args.flags.worktree}`)
1169
- : 'current-worktree',
1183
+ operationKey: args.flags.worktree === undefined
1184
+ ? `implicit-sibling:${randomUUID()}`
1185
+ : `named-sibling:${args.flags.worktree}`,
1170
1186
  };
1171
1187
  // A selected sibling can be the surviving live seat for this worktree (#63).
1172
1188
  // `--here` must re-send that seat's durable operation, not reconstruct the
@@ -1316,11 +1332,7 @@ export async function runAssimilate(
1316
1332
  savedLocalRole = existing.roleName
1317
1333
  ? cubeDetail.roles.find((role) => role.name === existing.roleName)
1318
1334
  : undefined;
1319
- const status = await deps.probeSeat(
1320
- existing.sessionToken ?? '',
1321
- auth.apiUrl,
1322
- auth.serverTrustIdentity,
1323
- );
1335
+ const status = await deps.probeSeat(existing);
1324
1336
  // Canonical rotated/revoked path: a pin-matched 401 on THIS worktree's
1325
1337
  // saved bearer. PURE DIAGNOSIS — attach never mutates local state on a
1326
1338
  // rejection; it points at the offline `borg reset-local-connection` command.
@@ -1662,21 +1674,7 @@ export async function runAssimilate(
1662
1674
  let spawnedWorktreePath: string | null = null;
1663
1675
 
1664
1676
  if (wantSibling) {
1665
- // BUG-4 / gh#150 fix (v0.9.5): `git worktree add --detach <path>`
1666
- // fails with "fatal: not a valid object name: 'HEAD'" when the
1667
- // repo has no commits yet (unborn HEAD). Detect explicitly via
1668
- // `git rev-parse --verify HEAD` so we surface an actionable
1669
- // prerequisite error rather than git's cryptic internal message.
1670
- const headProbe = deps.runSync('git', ['rev-parse', '--verify', 'HEAD'], projectRoot);
1671
- if (headProbe.status !== 0) {
1672
- deps.stderr(
1673
- `sibling worktree spawn requires HEAD pointing at a commit.\n` +
1674
- ` Fix: create at least one commit (\`git commit --allow-empty -m "initial"\`)\n` +
1675
- ` OR: pass --here to skip the sibling spawn and use the current directory\n`
1676
- );
1677
- return 1;
1678
- }
1679
- const localHead = headProbe.stdout.trim();
1677
+ const localHead = verifiedHead;
1680
1678
  const originProbe = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
1681
1679
  let startRef = 'HEAD';
1682
1680
  if (originProbe.status === 0 && originProbe.stdout.trim().length > 0) {
@@ -1813,8 +1811,10 @@ export async function runAssimilate(
1813
1811
  return 1;
1814
1812
  }
1815
1813
  deps.stderr(
1816
- `spawned sibling worktree at ${candidate} on branch ${wtBranch} (${startRef}); ` +
1817
- `the original dir keeps its active drone binding — run \`borg reset-local-connection\` there if that binding is stale.\n`
1814
+ `spawned sibling worktree at ${candidate} on branch ${wtBranch} (${startRef})` +
1815
+ (existing !== null
1816
+ ? `; the original dir keeps its active drone binding — run \`borg reset-local-connection\` there if that binding is stale.\n`
1817
+ : '.\n'),
1818
1818
  );
1819
1819
  deps.chdir(candidate);
1820
1820
  deps.stderr(renderWorktreeSteeringNote(candidate, wtBranch, projectRoot));
@@ -2071,30 +2071,6 @@ export async function runAssimilate(
2071
2071
  deps.stderr(`warning: could not install the project-local SessionStart hook in ${agentCwd}; it will be re-attempted on the next borg launch\n`);
2072
2072
  }
2073
2073
 
2074
- // gh#33 (Q2/Q4/Q6): in-place wt- adoption. A freshly-spawned worktree is
2075
- // already at origin/main on a fresh wt- branch, so only the in-place /
2076
- // --here path (running in an existing checkout) needs handling. ADOPT
2077
- // the per-worktree branch — switch the checkout onto wt-<suffix> at
2078
- // origin/main when clean + merged. This both moves the drone off main
2079
- // (Q4: main is never a working branch) AND brings the branch current,
2080
- // which a bare ff-sync would not do (it would leave a main checkout on
2081
- // main — the gap two-of-four-CR 27af1001 + QA c7a0c615 caught). Dirty ->
2082
- // skip + surface; unmerged HEAD -> block + surface; NEVER discards.
2083
- // Using adoptWorktree (HEAD-merged check + explicit `switch -C wtBranch
2084
- // ref`) also closes one-of-four-CR's compute-name-vs-current-branch NIT.
2085
- // Best-effort: a skip/block surfaces but never blocks the launch.
2086
- if (!spawnedWorktreePath) {
2087
- deps.runSync('git', ['fetch', 'origin', '--prune'], agentCwd);
2088
- const wtBranch = perWorktreeBranchName(basename(agentCwd), basename(projectRoot));
2089
- const adopt = adoptWorktree(deps.runSync, agentCwd, wtBranch, 'origin/main');
2090
- if (adopt.action === 'adopted') {
2091
- deps.stderr(`worktree: adopted branch ${wtBranch} at origin/main\n`);
2092
- deps.stderr(renderInPlaceWorktreeNote(agentCwd, wtBranch));
2093
- } else if (adopt.message) {
2094
- deps.stderr(`worktree sync: ${adopt.message}\n`);
2095
- }
2096
- }
2097
-
2098
2074
  // BUG-5 / v0.9.3: probe MCP readiness before launching claude so
2099
2075
  // the launched session sees tools at startup. Non-blocking: probe
2100
2076
  // failure surfaces a stderr warning but the launch proceeds (the
@@ -2280,14 +2256,6 @@ function renderWorktreeSteeringNote(worktreePath: string, wtBranch: string, prim
2280
2256
  );
2281
2257
  }
2282
2258
 
2283
- function renderInPlaceWorktreeNote(worktreePath: string, wtBranch: string): string {
2284
- return (
2285
- `\nWORKTREE STEERING: This checkout is now on branch ${wtBranch}. ` +
2286
- `Do ALL work HERE in ${worktreePath} — cut your feature branch (fix/.../feat/...) off ${wtBranch}, ` +
2287
- `use relative paths / your cwd.\n`
2288
- );
2289
- }
2290
-
2291
2259
  /**
2292
2260
  * Sprint 4 / gh#147 (drone-8 SR-PE-FINDING-1): strip ASCII control
2293
2261
  * characters before interpolating subprocess stderr into operator-
@@ -203,8 +203,7 @@ export function buildDefaultAssimilateDeps(
203
203
  if (!record) return null;
204
204
  return { operation: record.operation, roleId: record.roleId, credentialRef: seatRef(record) };
205
205
  },
206
- probeSeat: (sessionToken, apiUrl, serverTrustIdentity) =>
207
- defaultProbeSeat(sessionToken, apiUrl, serverTrustIdentity),
206
+ probeSeat: (seat) => defaultProbeSeat(seat as import('./cubes.js').ActiveCube),
208
207
  setActiveCube: (a) => cubesSetActive(a),
209
208
  // Single-store FINALIZE: the merged activate+bind (reached via the injected
210
209
  // `activate` thunk from sendBorgServerAttach) stamps the exact
@@ -1,5 +1,5 @@
1
1
  // gh#556 Part 2 — pastelist backend (never-fail floor, spec §4.3).
2
- // Prints copy-pasteable `cd <dir> && borg assimilate --here` lines.
2
+ // Prints copy-pasteable `cd <dir> && borg launch <droneId>` lines.
3
3
 
4
4
  import type { DroneCandidate } from '../launch-all-discovery.js';
5
5
  import type { LaunchAllDeps } from '../launch-all-deps.js';
@@ -13,6 +13,6 @@ export function runPastelistBackend(
13
13
  deps.stdout('# borg launch-all: open each worktree in a terminal window and run:\n\n');
14
14
  for (const c of candidates) {
15
15
  // No keep-open-on-fail: the operator owns their own shell in pastelist mode.
16
- deps.stdout(buildLaunchCommand(c.worktreeDir, borgPath) + '\n');
16
+ deps.stdout(buildLaunchCommand(c, borgPath) + '\n');
17
17
  }
18
18
  }
@@ -53,7 +53,7 @@ async function launchMacOS(candidates: DroneCandidate[], opts: TerminalsOpts, de
53
53
  for (let i = 0; i < candidates.length; i++) {
54
54
  const c = candidates[i];
55
55
  if (i > 0 && opts.launchDelayMs > 0) await opts.sleep(opts.launchDelayMs); // rate-limit stagger
56
- const cmd = buildLaunchCommand(c.worktreeDir, opts.borgPath);
56
+ const cmd = buildLaunchCommand(c, opts.borgPath);
57
57
  const title = composeTerminalTitle({ label: c.droneLabel, cubeName: opts.cubeName }, '');
58
58
  const script = hasITerm
59
59
  ? `tell application "iTerm"\n if (count of windows) = 0 then\n set launchedWindow to (create window with default profile command "${appleScriptEscape(cmd)}")\n tell current session of launchedWindow to set name to "${appleScriptEscape(title)}"\n else\n tell current window\n set launchedTab to (create tab with default profile command "${appleScriptEscape(cmd)}")\n tell current session of launchedTab to set name to "${appleScriptEscape(title)}"\n end tell\n end if\nend tell`
@@ -88,7 +88,7 @@ async function launchLinux(candidates: DroneCandidate[], opts: TerminalsOpts, de
88
88
  const title = composeTerminalTitle({ label: c.droneLabel, cubeName: opts.cubeName }, '');
89
89
  const cmd =
90
90
  `printf '\\033]0;%s\\007' ${shellEscape(title)}; ` +
91
- buildLaunchCommand(c.worktreeDir, opts.borgPath, { keepOpenOnFail: true });
91
+ buildLaunchCommand(c, opts.borgPath, { keepOpenOnFail: true });
92
92
  // `<term> -e sh -c '<cmd>'` opens a new window running the command.
93
93
  deps.runSync(term, ['-e', 'sh', '-c', cmd]);
94
94
  writeLockMarker(deps, c.cubeId, c.droneLabel, c.worktreeDir, opts.launchedAtISO);
@@ -1,6 +1,6 @@
1
1
  // gh#556 Part 2 — tmux backend (primary, spec §4.1).
2
2
  // One detached `borg-<cube>` session, one window per drone, each running
3
- // `borg assimilate --here`. Idempotent: reuses an existing session (adds windows).
3
+ // `borg launch <droneId>`. Idempotent: reuses an existing session (adds windows).
4
4
 
5
5
  import type { DroneCandidate } from '../launch-all-discovery.js';
6
6
  import type { LaunchAllDeps } from '../launch-all-deps.js';
@@ -33,7 +33,7 @@ export async function runTmuxBackend(
33
33
  const c = candidates[i];
34
34
 
35
35
  // Rate-limit stagger: wait BEFORE each launch after the first, so a fleet's
36
- // agents don't all bootstrap (assimilate + regen + roster) simultaneously and
36
+ // agents don't all bootstrap (launch + regen + roster) simultaneously and
37
37
  // trip the per-user/IP limiter. Window creation is ~instant otherwise, so
38
38
  // without this N agents start within ~100ms. 0 → no delay (first never waits).
39
39
  if (i > 0 && launchDelayMs > 0) {
@@ -59,7 +59,7 @@ export async function runTmuxBackend(
59
59
  // tmux-level window name (status bar). The agent's own OSC-0 may update it later.
60
60
  deps.runSync('tmux', ['rename-window', '-t', windowId, c.droneLabel]);
61
61
 
62
- const cmd = buildLaunchCommand(c.worktreeDir, borgPath, { keepOpenOnFail: true });
62
+ const cmd = buildLaunchCommand(c, borgPath, { keepOpenOnFail: true });
63
63
  deps.runSync('tmux', ['send-keys', '-t', windowId, cmd, 'Enter']);
64
64
 
65
65
  // Lock marker written immediately after dispatch (best-effort liveness signal).
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * gh#853 — bare `borg` (no-args) interactive launch menu.
3
3
  *
4
- * When `borg` is run with NO arguments in a TTY outside an active seat, offer
5
- * a small launch selector. In a repository with live sibling drones, those
6
- * drones come first; otherwise the existing agent choices remain unchanged.
4
+ * When `borg` is run with NO arguments in a TTY from a repository's main
5
+ * worktree, offer a small launch selector. A linked worktree still resumes its
6
+ * own drone directly, so selecting a sibling from the menu cannot recurse.
7
7
  *
8
8
  * The option-set, the selection→action mapping, and the show/collapse decision
9
9
  * are pure functions so they're unit-testable without a real TTY. claude.ts
@@ -11,10 +11,11 @@
11
11
  * gates on shouldShowLaunchMenu, runs the orchestrator with the real readline
12
12
  * prompt, then dispatches the returned action.
13
13
  *
14
- * Load-bearing safety: TTY-only + bare-args-only + no-active-seat
14
+ * Load-bearing safety: TTY-only + bare-args-only + main-worktree-only
15
15
  * (shouldShowLaunchMenu), so scripted/programmatic invocations and direct
16
- * worktree resumes are untouched.
16
+ * linked-worktree resumes are untouched.
17
17
  */
18
+ import { isAbsolute } from 'node:path';
18
19
  import type { ActiveCube, BorgCli } from './cubes.js';
19
20
  import type { DroneCandidate } from './launch-all-discovery.js';
20
21
  import type { SeatStatus } from './seat-probe.js';
@@ -44,6 +45,8 @@ export interface LaunchMenuInputs {
44
45
  otherConfiguredClis: BorgCli[];
45
46
  /** True iff the current menu context has launch-all targets. */
46
47
  hasLaunchAllTargets: boolean;
48
+ /** Legacy drone saved in the repository's main worktree, resumed in this process. */
49
+ currentDrone?: { droneLabel: string; worktree: string; status: SeatStatus };
47
50
  /** Live sibling drones offered before the unattached launch choices. */
48
51
  droneCandidates?: LaunchMenuDroneCandidate[];
49
52
  /** Cube selected by the sibling-drone context for its launch-all action. */
@@ -60,7 +63,12 @@ interface LaunchMenuCandidateDeps {
60
63
  probeSeat: (candidate: DroneCandidate) => Promise<SeatStatus>;
61
64
  }
62
65
 
63
- const TERMINAL_SEAT_STATUSES = new Set<SeatStatus>([
66
+ export type TerminalLaunchMenuSeatStatus = Extract<
67
+ SeatStatus,
68
+ 'evicted' | 'revoked' | 'rejected' | 'credential-rejected' | 'trust-mismatch'
69
+ >;
70
+
71
+ const TERMINAL_SEAT_STATUSES = new Set<TerminalLaunchMenuSeatStatus>([
64
72
  'evicted',
65
73
  'revoked',
66
74
  'rejected',
@@ -68,6 +76,29 @@ const TERMINAL_SEAT_STATUSES = new Set<SeatStatus>([
68
76
  'trust-mismatch',
69
77
  ]);
70
78
 
79
+ export function isTerminalLaunchMenuSeatStatus(
80
+ status: SeatStatus,
81
+ ): status is TerminalLaunchMenuSeatStatus {
82
+ return TERMINAL_SEAT_STATUSES.has(status as TerminalLaunchMenuSeatStatus);
83
+ }
84
+
85
+ export function terminalLaunchMenuSeatNotice(status: TerminalLaunchMenuSeatStatus): string {
86
+ const reason: Record<TerminalLaunchMenuSeatStatus, string> = {
87
+ evicted: 'the server reports that it was evicted',
88
+ revoked: 'its local session was revoked',
89
+ rejected: 'its local session was superseded by a newer enrollment',
90
+ 'credential-rejected': 'its saved credential was rejected',
91
+ 'trust-mismatch': 'the saved server identity no longer matches',
92
+ };
93
+ const recovery = status === 'trust-mismatch'
94
+ ? 'Verify that this is the expected server; if it was re-initialized, restore the expected identity before relaunching.\n'
95
+ : 'Run `borg reset-local-connection`, then `borg assimilate` to start a replacement managed-worktree drone.\n';
96
+ return (
97
+ `This worktree's saved drone cannot be resumed because ${reason[status]}. ` +
98
+ `It is not offered in the menu below. ${recovery}`
99
+ );
100
+ }
101
+
71
102
  /**
72
103
  * Find linked sibling worktrees that still own their preferred active seat.
73
104
  * Authoritative terminal probe results are omitted; transient/unknown probe
@@ -99,7 +130,7 @@ export async function discoverLiveLaunchMenuCandidates(
99
130
  } catch {
100
131
  status = 'indeterminate';
101
132
  }
102
- if (TERMINAL_SEAT_STATUSES.has(status)) continue;
133
+ if (isTerminalLaunchMenuSeatStatus(status)) continue;
103
134
 
104
135
  const key = `${candidate.cubeId}\0${candidate.droneId}\0${candidate.worktreeDir}`;
105
136
  if (seen.has(key)) continue;
@@ -145,41 +176,37 @@ export function configureSelectedLaunchCli(
145
176
  const PRETTY: Record<BorgCli, string> = { claude: 'Claude', codex: 'Codex', opencode: 'OpenCode' };
146
177
 
147
178
  /**
148
- * Gate: the menu fires ONLY for bare `borg` (no args) in a TTY without an
149
- * active seat. Explicit invocations, non-TTY launches, and direct worktree
150
- * resumes fall straight through to the existing launch path.
179
+ * Git reports the main worktree with identical absolute git-dir/common-dir
180
+ * paths. Linked worktrees instead use <common>/worktrees/<name> as git-dir.
181
+ * Fail closed when either probe is absent or malformed.
182
+ */
183
+ export function isMainGitWorktree(
184
+ readGitPath: (args: string[]) => string,
185
+ ): boolean {
186
+ try {
187
+ const gitDir = readGitPath(['rev-parse', '--path-format=absolute', '--git-dir']).trim();
188
+ const commonDir = readGitPath(['rev-parse', '--path-format=absolute', '--git-common-dir']).trim();
189
+ return isAbsolute(gitDir) && isAbsolute(commonDir) && gitDir === commonDir;
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Gate: the menu fires ONLY for bare `borg` (no args) in a TTY from the main
197
+ * repository worktree. Explicit invocations, non-TTY launches, and linked
198
+ * worktree resumes fall straight through to the existing launch path.
151
199
  */
152
200
  export function shouldShowLaunchMenu(args: {
153
201
  extraArgs: string[];
154
202
  stdinIsTTY: boolean;
155
203
  stdoutIsTTY: boolean;
156
- hasActiveSeat?: boolean;
204
+ isMainWorktree: boolean;
157
205
  }): boolean {
158
206
  return args.extraArgs.length === 0
159
207
  && args.stdinIsTTY
160
208
  && args.stdoutIsTTY
161
- && !args.hasActiveSeat;
162
- }
163
-
164
- export function explicitCliLaunchHint(args: {
165
- explicitCli: BorgCli | undefined;
166
- stdinIsTTY: boolean;
167
- stdoutIsTTY: boolean;
168
- hasActiveCube: boolean;
169
- hasLaunchAllTargets: boolean;
170
- }): string | null {
171
- if (!args.explicitCli || !args.stdinIsTTY || !args.stdoutIsTTY) return null;
172
- if (!args.hasActiveCube || !args.hasLaunchAllTargets) return null;
173
- return `borg --cli ${args.explicitCli} launches ${PRETTY[args.explicitCli]} directly; use bare borg for the launch menu or borg launch-all --cli ${args.explicitCli} for all drone worktrees.\n`;
174
- }
175
-
176
- export function shouldResolveExplicitCliLaunchHintTargets(args: {
177
- explicitCli: BorgCli | undefined;
178
- stdinIsTTY: boolean;
179
- stdoutIsTTY: boolean;
180
- hasActiveCube: boolean;
181
- }): boolean {
182
- return Boolean(args.explicitCli && args.stdinIsTTY && args.stdoutIsTTY && args.hasActiveCube);
209
+ && args.isMainWorktree;
183
210
  }
184
211
 
185
212
  /**
@@ -188,14 +215,25 @@ export function shouldResolveExplicitCliLaunchHintTargets(args: {
188
215
  * middle option never produces a "1) … 3) …" gap menu.
189
216
  */
190
217
  export function buildLaunchMenuOptions(inputs: LaunchMenuInputs): LaunchMenuOption[] {
191
- if (inputs.droneCandidates && inputs.droneCandidates.length > 0) {
192
- const options: LaunchMenuOption[] = [...inputs.droneCandidates]
218
+ const currentDrone = inputs.currentDrone && !isTerminalLaunchMenuSeatStatus(inputs.currentDrone.status)
219
+ ? inputs.currentDrone
220
+ : undefined;
221
+ if (currentDrone || (inputs.droneCandidates && inputs.droneCandidates.length > 0)) {
222
+ const options: LaunchMenuOption[] = [];
223
+ if (currentDrone) {
224
+ options.push({
225
+ key: '1',
226
+ label: `Resume ${currentDrone.droneLabel} (${currentDrone.worktree})`,
227
+ action: { kind: 'launch', cli: inputs.defaultCli },
228
+ });
229
+ }
230
+ options.push(...[...(inputs.droneCandidates ?? [])]
193
231
  .sort((a, b) => a.droneLabel.localeCompare(b.droneLabel) || a.worktree.localeCompare(b.worktree))
194
232
  .map((candidate, index) => ({
195
- key: String(index + 1),
233
+ key: String(options.length + index + 1),
196
234
  label: `Resume ${candidate.droneLabel} (${candidate.worktree})`,
197
235
  action: { kind: 'launch-seat' as const, target: candidate.target },
198
- }));
236
+ })));
199
237
  if (inputs.hasLaunchAllTargets) {
200
238
  options.push({
201
239
  key: String(options.length + 1),
@@ -206,15 +244,19 @@ export function buildLaunchMenuOptions(inputs: LaunchMenuInputs): LaunchMenuOpti
206
244
  },
207
245
  });
208
246
  }
209
- options.push({
210
- key: String(options.length + 1),
211
- label: `Launch ${PRETTY[inputs.defaultCli]} here without a drone`,
212
- action: { kind: 'launch', cli: inputs.defaultCli },
213
- });
247
+ if (!currentDrone) {
248
+ options.push({
249
+ key: String(options.length + 1),
250
+ label: `Launch ${PRETTY[inputs.defaultCli]} here without a drone`,
251
+ action: { kind: 'launch', cli: inputs.defaultCli },
252
+ });
253
+ }
214
254
  for (const cli of inputs.otherConfiguredClis) {
215
255
  options.push({
216
256
  key: String(options.length + 1),
217
- label: `Launch with ${PRETTY[cli]} here without a drone (one-shot)`,
257
+ label: currentDrone
258
+ ? `Resume ${currentDrone.droneLabel} with ${PRETTY[cli]} (one-shot)`
259
+ : `Launch with ${PRETTY[cli]} here without a drone (one-shot)`,
218
260
  action: { kind: 'launch', cli },
219
261
  });
220
262
  }
@@ -263,10 +305,10 @@ export function renderLaunchMenu(options: LaunchMenuOption[]): string {
263
305
  }
264
306
 
265
307
  /**
266
- * Orchestrate the menu with an injected readline-style prompt. Collapses to a
267
- * direct default launch (no render, no prompt) when only option 1 applies.
268
- * Re-prompts on invalid input up to `maxAttempts`, then falls back to the safe
269
- * default (option 1) so a fat-fingered session still launches.
308
+ * Orchestrate the menu with an injected readline-style prompt. The caller has
309
+ * already established that this is a bare interactive main-worktree launch,
310
+ * so even a one-item selector is rendered. Re-prompts on invalid input up to
311
+ * `maxAttempts`, then falls back to the safe default (option 1).
270
312
  */
271
313
  export async function runBareLaunchMenu(
272
314
  inputs: LaunchMenuInputs,
@@ -274,7 +316,6 @@ export async function runBareLaunchMenu(
274
316
  opts: { maxAttempts?: number; warn?: (message: string) => void } = {}
275
317
  ): Promise<LaunchMenuAction> {
276
318
  const options = buildLaunchMenuOptions(inputs);
277
- if (options.length === 1) return options[0].action; // collapse — never a 1-item menu
278
319
 
279
320
  const maxAttempts = opts.maxAttempts ?? 3;
280
321
  const menu = renderLaunchMenu(options);
package/src/claude.ts CHANGED
@@ -65,12 +65,14 @@ import { discoverDroneCandidates } from './launch-all-discovery.js';
65
65
  import {
66
66
  configureSelectedLaunchCli,
67
67
  discoverLiveLaunchMenuCandidates,
68
- explicitCliLaunchHint,
68
+ isMainGitWorktree,
69
+ isTerminalLaunchMenuSeatStatus,
69
70
  runBareLaunchMenu,
70
- shouldResolveExplicitCliLaunchHintTargets,
71
71
  shouldShowLaunchMenu,
72
+ terminalLaunchMenuSeatNotice,
72
73
  type LaunchMenuAction,
73
74
  } from './bare-launch-menu.js';
75
+ import type { SeatStatus } from './seat-probe.js';
74
76
  import { setTerminalTitle } from './terminal-title.js';
75
77
  import { initConsolePrefix, consolePrefix } from './console-prefix.js';
76
78
  import { initDebugFromArgv } from './debug.js';
@@ -371,35 +373,14 @@ async function main() {
371
373
 
372
374
  // Active cube for this directory — needed for the launch menu's option-3
373
375
  // availability, the terminal title, and the inbox-Monitor clause below.
374
- const active = await getActiveCube();
375
- let launchAllTargetsAvailable: boolean | undefined;
376
- const hasLaunchAllTargets = async (): Promise<boolean> => {
377
- if (!active) return false;
378
- if (launchAllTargetsAvailable === undefined) {
379
- const candidates = await discoverDroneCandidates(
380
- { targetCubeId: active.cubeId },
381
- buildDefaultLaunchAllDeps()
382
- );
383
- launchAllTargetsAvailable = candidates.length > 0;
384
- }
385
- return launchAllTargetsAvailable;
386
- };
376
+ let active = await getActiveCube();
377
+ const launchAllDeps = buildDefaultLaunchAllDeps();
378
+ const isMainWorktree = isMainGitWorktree((args) =>
379
+ launchAllDeps.runSync('git', args, { cwd: process.cwd() })
380
+ );
387
381
 
388
382
  const stdinIsTTY = process.stdin.isTTY === true;
389
383
  const stdoutIsTTY = process.stdout.isTTY === true;
390
- const explicitCliHint = explicitCliLaunchHint({
391
- explicitCli: parsedCli.cli,
392
- stdinIsTTY,
393
- stdoutIsTTY,
394
- hasActiveCube: active !== null,
395
- hasLaunchAllTargets: shouldResolveExplicitCliLaunchHintTargets({
396
- explicitCli: parsedCli.cli,
397
- stdinIsTTY,
398
- stdoutIsTTY,
399
- hasActiveCube: active !== null,
400
- }) ? await hasLaunchAllTargets() : false,
401
- });
402
- if (explicitCliHint) process.stderr.write(explicitCliHint);
403
384
 
404
385
  // gh#853: bare `borg` (no args) interactive launch menu. TTY-only + bare-args-
405
386
  // only (shouldShowLaunchMenu) so every scripted/programmatic `borg` and every
@@ -412,11 +393,22 @@ async function main() {
412
393
  extraArgs: process.argv.slice(2),
413
394
  stdinIsTTY,
414
395
  stdoutIsTTY,
415
- hasActiveSeat: active !== null,
396
+ isMainWorktree,
416
397
  })
417
398
  ) {
418
- const launchAllDeps = buildDefaultLaunchAllDeps();
419
399
  const seatCommandDeps = buildDefaultSeatCommandDeps();
400
+ let currentDroneStatus: SeatStatus | null = null;
401
+ if (active) {
402
+ try {
403
+ currentDroneStatus = await launchAllDeps.probeSeat(active);
404
+ } catch {
405
+ currentDroneStatus = 'indeterminate';
406
+ }
407
+ if (isTerminalLaunchMenuSeatStatus(currentDroneStatus)) {
408
+ process.stderr.write(terminalLaunchMenuSeatNotice(currentDroneStatus));
409
+ active = null;
410
+ }
411
+ }
420
412
  const siblingContext = await discoverLiveLaunchMenuCandidates({
421
413
  readAllProjectIdentities: launchAllDeps.readAllProjectIdentities,
422
414
  discoverDroneCandidates: (cubeId) => discoverDroneCandidates(
@@ -425,11 +417,7 @@ async function main() {
425
417
  ),
426
418
  getActiveSeatForWorktree: seatCommandDeps.getActiveSeatForWorktree,
427
419
  pathExists: launchAllDeps.pathExists,
428
- probeSeat: (candidate) => launchAllDeps.probeSeat(
429
- candidate.sessionToken,
430
- candidate.apiUrl,
431
- candidate.serverTrustIdentity,
432
- ),
420
+ probeSeat: (candidate) => launchAllDeps.probeSeat(candidate.seat),
433
421
  });
434
422
  const otherConfiguredClis = configuredCliNames(
435
423
  detectCliAvailability(),
@@ -440,6 +428,15 @@ async function main() {
440
428
  defaultCli: cli,
441
429
  otherConfiguredClis,
442
430
  hasLaunchAllTargets: siblingContext.launchAllCubeId !== undefined,
431
+ ...(active && currentDroneStatus
432
+ ? {
433
+ currentDrone: {
434
+ droneLabel: active.droneLabel,
435
+ worktree: active.worktree ?? findProjectRoot(process.cwd()),
436
+ status: currentDroneStatus,
437
+ },
438
+ }
439
+ : {}),
443
440
  droneCandidates: siblingContext.candidates,
444
441
  ...(siblingContext.launchAllCubeId
445
442
  ? { launchAllCubeId: siblingContext.launchAllCubeId }
@@ -97,11 +97,7 @@ export interface CleanupDeps {
97
97
  /** Enumerate borg-managed seats from the central cubes.json. */
98
98
  listSeats?: () => Promise<Array<{ projectPath: string; cube: ActiveCube }>>;
99
99
  /** Probe ONE seat's eviction status using ITS OWN token (gh#877). */
100
- probeSeat?: (
101
- sessionToken: string,
102
- apiUrl: string,
103
- serverTrustIdentity?: string,
104
- ) => Promise<SeatStatus>;
100
+ probeSeat?: (seat: ActiveCube) => Promise<SeatStatus>;
105
101
  /** realpath resolver — injected so tests can model symlink escape. */
106
102
  realpath?: (p: string) => string;
107
103
  stdout?: (line: string) => void;
@@ -337,11 +333,15 @@ async function classifyWorktree(
337
333
  }
338
334
 
339
335
  // (5) ONLY now probe eviction with the worktree's OWN seat token (S1).
340
- const status = await deps.probeSeat(
341
- seat.sessionToken,
342
- seat.apiUrl,
343
- seat.serverTrustIdentity,
344
- );
336
+ let status: SeatStatus;
337
+ try {
338
+ status = await deps.probeSeat(seat);
339
+ } catch {
340
+ return {
341
+ reason: 'UNKNOWN-indeterminate',
342
+ detail: 'probe failed — not deleting',
343
+ };
344
+ }
345
345
  switch (status) {
346
346
  case 'evicted':
347
347
  return { reason: 'PRUNABLE', detail: '410 DRONE_EVICTED (clean + merged)' };