borgmcp 3.5.1 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +27 -28
  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 +31 -34
  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
package/src/cli-help.ts CHANGED
@@ -35,7 +35,6 @@ export function launchAllHelpText(version: string): string {
35
35
  ` --mode <tmux|terminals|pastelist> Select the launch backend\n` +
36
36
  ` --only <name> Launch one role or drone label\n` +
37
37
  ` --dry-run Show what would launch\n` +
38
- ` --cli <claude|codex|opencode> Select the agent CLI\n` +
39
38
  ` --no-attach Do not attach to the tmux session\n` +
40
39
  ` --yes, -y Skip the large-fleet confirmation\n` +
41
40
  ` --force Override live-session skips\n` +
@@ -126,7 +125,7 @@ export function topLevelHelpText(version: string): string {
126
125
  `Install Claude Code, Codex, or OpenCode first. Type \`borg ...\` in your terminal;\n` +
127
126
  `type \`borg_...\` inside your agent session once you've joined a cube ("assimilate").\n\n` +
128
127
  `Usage:\n` +
129
- ` borg Launch your agent CLI; in a TTY, bare borg may show the launch menu\n` +
128
+ ` borg Show the launch menu in a repository root; resume directly in a linked worktree\n` +
130
129
  ` borg setup Set up borg MCP server + agent CLI integration\n` +
131
130
  ` borg update Update the client and installed local server together\n` +
132
131
  ` borg doctor Check agent hook commands, versions, configs, and the OpenCode plugin\n` +
@@ -140,8 +139,6 @@ export function topLevelHelpText(version: string): string {
140
139
  ` borg seats List this machine's registered drones and worktrees\n` +
141
140
  ` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
142
141
  ` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
143
- ` borg launch-all [cube] --cli claude|codex|opencode\n` +
144
- ` Launch all drone worktrees with that agent CLI\n` +
145
142
  ` borg server <command> [arguments]\n` +
146
143
  ` borg --cli claude|codex|opencode Launch that agent CLI directly\n` +
147
144
  ` borg --version Show installed version\n\n` +
@@ -230,17 +227,17 @@ export function assimilateHelpText(version: string): string {
230
227
  return (
231
228
  `borg assimilate (borgmcp ${version}) — join or create a cube under a role\n\n` +
232
229
  `Usage:\n` +
233
- ` borg assimilate [role] Join the active cube under [role] (default role if omitted)\n` +
234
- ` borg assimilate [role] --worktree <name> Spawn the drone in an isolated git worktree\n` +
230
+ ` borg assimilate [role] Create the drone in a managed git worktree\n` +
231
+ ` borg assimilate [role] --worktree <name> Name the drone's managed git worktree\n` +
235
232
  ` (~/.borg/worktrees/<repo>/<name>)\n` +
236
- ` borg assimilate --here Assimilate in the current worktree (no sibling spawn)\n` +
233
+ ` borg assimilate --here Resume this worktree's saved drone\n` +
237
234
  ` borg assimilate --here --force Reattach despite a still-live inbox monitor\n` +
238
235
  ` borg assimilate --host <host> Join an authorized self-hosted cube\n` +
239
236
  ` borg assimilate --host <host> --enroll Operator-terminal enrollment, then create/join (preview)\n` +
240
237
  ` borg assimilate --help Show this help\n\n` +
241
238
  `Flags:\n` +
242
- ` --worktree <name> Create + launch the drone in a sibling git worktree\n` +
243
- ` --here Stay in the current worktree (no sibling spawn)\n` +
239
+ ` --worktree <name> Name the managed worktree created for the new drone\n` +
240
+ ` --here Resume this worktree's saved drone\n` +
244
241
  ` --force Reattach despite a still-live inbox monitor in this worktree\n` +
245
242
  ` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
246
243
  ` --host <host> Borg server host or URL (bare hosts default to HTTPS)\n` +
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { basename } from 'node:path';
28
28
  import chalk from 'chalk';
29
- import { getActiveCube } from './cubes.js';
29
+ import { getActiveCube, LaunchSeatIdentityChangedError } from './cubes.js';
30
30
  import {
31
31
  _resetDisplayIdentityForTests,
32
32
  currentDisplayIdentity,
@@ -55,7 +55,11 @@ export async function initConsolePrefix(): Promise<string> {
55
55
  initialized = true;
56
56
  return droneIdPrefix();
57
57
  }
58
- } catch {
58
+ } catch (error) {
59
+ // A targeted `borg launch` child must stop before CLI selection can write
60
+ // launch.json. This identity guard is a launch boundary, not a cosmetic
61
+ // prefix read failure, so never collapse it into the neutral fallback.
62
+ if (error instanceof LaunchSeatIdentityChangedError) throw error;
59
63
  // Fall through to unassimilated fallback.
60
64
  }
61
65
  initialized = true;
package/src/index.ts CHANGED
@@ -601,11 +601,7 @@ export async function main() {
601
601
  seedDisplayIdentity(active);
602
602
  let result: Awaited<ReturnType<typeof whoami>>;
603
603
  try {
604
- result = await whoami(
605
- active.sessionToken,
606
- active.apiUrl,
607
- active.serverTrustIdentity,
608
- );
604
+ result = await whoami(active);
609
605
  } catch (error) {
610
606
  markDisplayIdentityReadFailed(active);
611
607
  throw error;
@@ -737,12 +733,7 @@ export async function main() {
737
733
  case 'borg_roster': {
738
734
  const active = await requireActiveCube();
739
735
  const since = typeof args?.since === 'string' ? args.since : undefined;
740
- const { drones, roles, since: resolvedSince } = await getRoster(
741
- active.sessionToken,
742
- active.apiUrl,
743
- since,
744
- active.serverTrustIdentity,
745
- );
736
+ const { drones, roles, since: resolvedSince } = await getRoster(active, since);
746
737
  const text = renderRoster({
747
738
  cubeName: active.name,
748
739
  drones,
@@ -55,28 +55,60 @@ async function resolveTargetCube(
55
55
  if (matches.length === 0) {
56
56
  return { error: `no cube named '${args.cubeName}' found among this machine's saved connections — has any drone assimilated into it?` };
57
57
  }
58
+ const matchesByCubeId = new Map<string, typeof matches>();
59
+ for (const match of matches) {
60
+ const sameCube = matchesByCubeId.get(match.cube.cubeId) ?? [];
61
+ sameCube.push(match);
62
+ matchesByCubeId.set(match.cube.cubeId, sameCube);
63
+ }
58
64
  // gh#850: distinct cubes can share a name (same name across accounts/
59
65
  // environments, or a stale seat). Silently taking matches[0] could launch
60
66
  // the wrong fleet, so when the name is ambiguous, surface each match's
61
- // cubeId + the project that holds the seat and refuse to guess.
62
- if (matches.length > 1) {
63
- const list = matches
64
- .map((m) => ` ${m.cube.cubeId} (drone in ${m.projectPath})`)
67
+ // cubeId + one project that holds a seat and refuse to guess. Multiple
68
+ // seats for the SAME cube are one fleet, not ambiguity.
69
+ if (matchesByCubeId.size > 1) {
70
+ const list = [...matchesByCubeId.entries()]
71
+ .map(([cubeId, sameCube]) => ` ${cubeId} (drone in ${sameCube[0].projectPath})`)
65
72
  .join('\n');
66
73
  return {
67
74
  error:
68
- `'${args.cubeName}' is ambiguous — ${matches.length} saved connections on this machine share that name:\n${list}\n` +
75
+ `'${args.cubeName}' is ambiguous — ${matchesByCubeId.size} distinct cubes on this machine share that name:\n${list}\n` +
69
76
  'cd into the intended project and re-run as `borg launch-all` without the positional cube name (resolves the active cube), ' +
70
77
  'or clear the stale connection(s) by running `borg reset-local-connection` from the worktree that holds each.',
71
78
  };
72
79
  }
73
- return { cubeId: matches[0].cube.cubeId, name: args.cubeName };
80
+ return { cubeId: matchesByCubeId.keys().next().value!, name: args.cubeName };
74
81
  }
75
82
  const active = await deps.getActiveCube();
76
83
  if (!active) {
84
+ const identities = await deps.readAllProjectIdentities();
85
+ const knownCubeIds = [...new Set(identities.map(({ cube }) => cube.cubeId))];
86
+ const candidates = (
87
+ await Promise.all(knownCubeIds.map((cubeId) =>
88
+ discoverDroneCandidates({ targetCubeId: cubeId }, deps)
89
+ ))
90
+ ).flat();
91
+ const candidatesByCubeId = new Map<string, DroneCandidate[]>();
92
+ for (const candidate of candidates) {
93
+ const sameCube = candidatesByCubeId.get(candidate.cubeId) ?? [];
94
+ sameCube.push(candidate);
95
+ candidatesByCubeId.set(candidate.cubeId, sameCube);
96
+ }
97
+ if (candidatesByCubeId.size === 1) {
98
+ const [cubeId, sameCube] = candidatesByCubeId.entries().next().value!;
99
+ return { cubeId, name: sameCube[0].seat.name };
100
+ }
101
+ if (candidatesByCubeId.size === 0) {
102
+ return {
103
+ error:
104
+ 'no saved drone worktrees were found for this repository; pass a cube name explicitly as ' +
105
+ '`borg launch-all <cube-name>`, or cd into a drone worktree and rerun `borg launch-all`',
106
+ };
107
+ }
77
108
  return {
78
109
  error:
79
- 'no active cube in this directory; run `borg assimilate` first, or pass a cube name explicitly',
110
+ `linked drone worktrees for this repository belong to ${candidatesByCubeId.size} cubes; ` +
111
+ 'pass the intended cube name as `borg launch-all <cube-name>`, or cd into a drone worktree and rerun `borg launch-all`',
80
112
  };
81
113
  }
82
114
  return { cubeId: active.cubeId, name: active.name };
@@ -138,9 +170,7 @@ function sanitizeSessionName(cubeName: string): string {
138
170
  /** Roster reconciliation (spec §7.2). Returns droneId → 'verified'|'unconfirmed'. */
139
171
  async function reconcileRoster(
140
172
  deps: LaunchAllDeps,
141
- token: string,
142
- apiUrl: string,
143
- serverTrustIdentity: string | undefined,
173
+ seat: DroneCandidate['seat'],
144
174
  launchStartISO: string,
145
175
  launchedDroneIds: string[],
146
176
  now: () => number = Date.now,
@@ -156,10 +186,8 @@ async function reconcileRoster(
156
186
  let roster: { drones: Array<{ id: string; seen_since?: boolean }> };
157
187
  try {
158
188
  roster = await deps.getRoster(
159
- token,
160
- apiUrl,
189
+ seat,
161
190
  launchStartISO,
162
- serverTrustIdentity,
163
191
  );
164
192
  } catch (err) {
165
193
  // gh#850: the roster-reconcile token can rotate mid-launch — authedFetch
@@ -294,7 +322,7 @@ export async function runLaunchAll(
294
322
  for (const c of lockLaunchable) {
295
323
  let status: SeatStatus;
296
324
  try {
297
- status = await deps.probeSeat(c.sessionToken, c.apiUrl, c.serverTrustIdentity);
325
+ status = await deps.probeSeat(c.seat);
298
326
  } catch {
299
327
  status = 'indeterminate';
300
328
  }
@@ -310,7 +338,7 @@ export async function runLaunchAll(
310
338
  revokedCount += 1;
311
339
  deps.stderr(
312
340
  `Local session was revoked.\n` +
313
- `Next: run borg reset-local-connection, then borg assimilate --host ${c.apiUrl} --enroll.\n`
341
+ `Next: run borg reset-local-connection, then borg assimilate --host ${c.seat.apiUrl} --enroll.\n`
314
342
  );
315
343
  continue;
316
344
  }
@@ -318,7 +346,7 @@ export async function runLaunchAll(
318
346
  rejectedCount += 1;
319
347
  deps.stderr(
320
348
  `Local session was superseded by a newer enrollment.\n` +
321
- `Next: run borg reset-local-connection, then borg assimilate --host ${c.apiUrl} --enroll.\n`
349
+ `Next: run borg reset-local-connection, then borg assimilate --host ${c.seat.apiUrl} --enroll.\n`
322
350
  );
323
351
  continue;
324
352
  }
@@ -328,7 +356,7 @@ export async function runLaunchAll(
328
356
  trustMismatchCount += 1;
329
357
  deps.stderr(
330
358
  `skipping ${c.droneLabel} (${c.worktreeDir}): could not verify the server identity ` +
331
- `(pinned trust changed) — this is terminal. Confirm ${c.apiUrl} is the expected server; ` +
359
+ `(pinned trust changed) — this is terminal. Confirm ${c.seat.apiUrl} is the expected server; ` +
332
360
  'if it was re-initialized, restore the expected identity before relaunching.\n'
333
361
  );
334
362
  continue;
@@ -339,7 +367,7 @@ export async function runLaunchAll(
339
367
  credentialRejectedCount += 1;
340
368
  deps.stderr(
341
369
  `skipping ${c.droneLabel} (${c.worktreeDir}): saved credential was rejected (not a takeover) — ` +
342
- `re-enroll from that worktree with \`borg assimilate --host ${c.apiUrl} --enroll\`.\n`
370
+ `re-enroll from that worktree with \`borg assimilate --host ${c.seat.apiUrl} --enroll\`.\n`
343
371
  );
344
372
  continue;
345
373
  }
@@ -438,25 +466,15 @@ export async function runLaunchAll(
438
466
  return 1;
439
467
  }
440
468
 
441
- // 9. roster reconciliation (best-effort; uses the OLD saved token from the first candidate)
442
- const reconToken = launchable[0].sessionToken;
443
- const reconApiUrl = launchable[0].apiUrl;
444
- const reconServerTrustIdentity = launchable[0].serverTrustIdentity;
445
- let statuses: Map<string, 'verified' | 'unconfirmed'> | null = null;
446
- if (reconToken && reconApiUrl) {
447
- statuses = await reconcileRoster(
448
- deps,
449
- reconToken,
450
- reconApiUrl,
451
- reconServerTrustIdentity,
452
- launchStartISO,
453
- launchable.map((c) => c.droneId),
454
- opts.now,
455
- opts.sleep
456
- );
457
- } else {
458
- deps.stderr('roster reconciliation skipped — no session token available\n');
459
- }
469
+ // 9. roster reconciliation (best-effort; uses the first launchable seat)
470
+ const statuses = await reconcileRoster(
471
+ deps,
472
+ launchable[0].seat,
473
+ launchStartISO,
474
+ launchable.map((c) => c.droneId),
475
+ opts.now,
476
+ opts.sleep
477
+ );
460
478
 
461
479
  // 10. summary
462
480
  deps.stdout(`\nborg launch-all: launched ${launchable.length} drones for cube '${cubeName}'\n\n`);
@@ -13,18 +13,23 @@ export function resolveBorgPath(): string {
13
13
 
14
14
  /**
15
15
  * The shell command run inside each worktree's window/tab.
16
- * `keepOpenOnFail` wraps a `|| read` pause so a failed assimilate doesn't close
16
+ * `borg launch <droneId>` is a prompt-free, network-free preflight that pins the
17
+ * saved seat. The spawned child fail-closes through BORG_LAUNCH_EXPECTED_SEAT if
18
+ * the worktree's seat changed instead of silently resuming a different drone.
19
+ * `keepOpenOnFail` wraps a `|| read` pause so a failed launch doesn't close
17
20
  * the tmux window before the operator reads the error (tmux convenience only;
18
21
  * the pastelist backend omits it — the operator owns their own shell).
19
22
  */
20
23
  export function buildLaunchCommand(
21
- worktreeDir: string,
24
+ candidate: { worktreeDir: string; droneId: string },
22
25
  borgPath: string,
23
26
  opts: { keepOpenOnFail?: boolean } = {}
24
27
  ): string {
25
- const base = `cd ${shellEscape(worktreeDir)} && ${shellEscape(borgPath)} assimilate --here`;
28
+ const base =
29
+ `cd ${shellEscape(candidate.worktreeDir)} && ` +
30
+ `${shellEscape(borgPath)} launch ${shellEscape(candidate.droneId)}`;
26
31
  if (opts.keepOpenOnFail) {
27
- return `${base} || { echo "borg assimilate failed — press Enter to close"; read _; }`;
32
+ return `${base} || { echo "borg launch failed — press Enter to close"; read _; }`;
28
33
  }
29
34
  return base;
30
35
  }
@@ -62,10 +62,8 @@ export interface LaunchAllDeps {
62
62
  listDir: (p: string) => string[];
63
63
  /** Roster call (wraps getRoster from remote-client.ts). */
64
64
  getRoster: (
65
- token: string,
66
- apiUrl: string,
65
+ seat: ActiveCube,
67
66
  since?: string,
68
- serverTrustIdentity?: string,
69
67
  ) => Promise<{ drones: Array<{ id: string; seen_since?: boolean }> }>;
70
68
  /** getCube for --only tier-2 role-name resolution (best-effort). */
71
69
  getCube: (
@@ -79,9 +77,7 @@ export interface LaunchAllDeps {
79
77
  * of relaunching them (which silently re-mints a fresh drone — resurrection).
80
78
  */
81
79
  probeSeat: (
82
- sessionToken: string,
83
- apiUrl: string,
84
- serverTrustIdentity?: string,
80
+ seat: ActiveCube,
85
81
  ) => Promise<SeatStatus>;
86
82
  /** Saved CLI preference for a worktree path (launch.json). */
87
83
  getCliPreferenceForPath: (projectPath: string) => Promise<'claude' | 'codex' | 'opencode' | null>;
@@ -157,12 +153,10 @@ export function buildDefaultLaunchAllDeps(): LaunchAllDeps {
157
153
  return [];
158
154
  }
159
155
  },
160
- getRoster: (token, apiUrl, since, serverTrustIdentity) =>
161
- getRoster(token, apiUrl, since, serverTrustIdentity),
156
+ getRoster: (seat, since) => getRoster(seat, since),
162
157
  // getCube uses the drone session token via authedFetch (cubeId-only); apiUrl/token unused.
163
158
  getCube: (_apiUrl, _token, cubeId) => getCube(cubeId),
164
- probeSeat: (sessionToken, apiUrl, serverTrustIdentity) =>
165
- defaultProbeSeat(sessionToken, apiUrl, serverTrustIdentity),
159
+ probeSeat: (seat) => defaultProbeSeat(seat),
166
160
  getCliPreferenceForPath: (projectPath) => getProjectCliPreferenceForPath(projectPath),
167
161
  readAllProjectIdentities: () => cubesReadAllProjectIdentities(),
168
162
  findProjectRoot: (dir) => findProjectRoot(dir),
@@ -6,15 +6,14 @@
6
6
  // only worktrees whose saved identity has cubeId === target.
7
7
 
8
8
  import type { LaunchAllDeps, RunSyncFn } from './launch-all-deps.js';
9
+ import type { ActiveCube } from './cubes.js';
9
10
 
10
11
  export interface DroneCandidate {
11
12
  worktreeDir: string;
12
13
  cubeId: string;
13
14
  droneId: string;
14
15
  droneLabel: string;
15
- sessionToken: string;
16
- apiUrl: string;
17
- serverTrustIdentity?: string;
16
+ seat: ActiveCube;
18
17
  }
19
18
 
20
19
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -107,11 +106,7 @@ export async function discoverDroneCandidates(
107
106
  cubeId: cube.cubeId,
108
107
  droneId: cube.droneId,
109
108
  droneLabel: cube.droneLabel,
110
- sessionToken: cube.sessionToken,
111
- apiUrl: cube.apiUrl,
112
- ...(cube.serverTrustIdentity === undefined
113
- ? {}
114
- : { serverTrustIdentity: cube.serverTrustIdentity }),
109
+ seat: cube,
115
110
  });
116
111
  }
117
112
  return candidates;
@@ -5,7 +5,6 @@ export interface LaunchAllFlags {
5
5
  mode?: 'tmux' | 'terminals' | 'pastelist';
6
6
  only?: string;
7
7
  dryRun?: boolean;
8
- cli?: 'claude' | 'codex' | 'opencode';
9
8
  noAttach?: boolean;
10
9
  yes?: boolean;
11
10
  force?: boolean;
@@ -28,7 +27,7 @@ export type ParseLaunchAllResult =
28
27
  | { ok: false; error: string };
29
28
 
30
29
  const SUPPORTED =
31
- '--mode <tmux|terminals|pastelist>, --only <name>, --dry-run, --cli <claude|codex|opencode>, ' +
30
+ '--mode <tmux|terminals|pastelist>, --only <name>, --dry-run, ' +
32
31
  '--no-attach, --yes/-y, --force, --launch-delay <ms>';
33
32
 
34
33
  export function parseLaunchAllArgs(rawArgs: string[]): ParseLaunchAllResult {
@@ -54,14 +53,6 @@ export function parseLaunchAllArgs(rawArgs: string[]): ParseLaunchAllResult {
54
53
  flags.only = v;
55
54
  break;
56
55
  }
57
- case '--cli': {
58
- const v = rawArgs[++i];
59
- if (v !== 'claude' && v !== 'codex' && v !== 'opencode') {
60
- return { ok: false, error: `--cli must be one of claude|codex|opencode (got: ${v ?? '<missing>'})` };
61
- }
62
- flags.cli = v;
63
- break;
64
- }
65
56
  case '--launch-delay': {
66
57
  const v = rawArgs[++i];
67
58
  const n = v === undefined ? NaN : Number(v);
@@ -244,6 +244,15 @@ async function localAuthorityContext(
244
244
  return matched;
245
245
  }
246
246
 
247
+ function assertSeatAuthority(active: ActiveCube): ActiveCube {
248
+ if (!active.serverTrustIdentity) {
249
+ throw new Error('Selected Borg server authority state is missing or unreadable');
250
+ }
251
+ assertUuidShape(active.cubeId, 'cube_id');
252
+ assertUuidShape(active.droneId, 'drone_id');
253
+ return active;
254
+ }
255
+
247
256
  function localUnsupported(capability: string): never {
248
257
  throw new LocalUnsupportedError(capability);
249
258
  }
@@ -942,11 +951,9 @@ export async function getRoleInfoByName(
942
951
  }
943
952
 
944
953
  export async function whoami(
945
- sessionToken: string,
946
- apiUrl: string,
947
- serverTrustIdentity?: string,
954
+ active: ActiveCube,
948
955
  ): Promise<{ cube_id: string; cube_name: string; drone_id: string; drone_label: string; role_id: string; role_name: string; runtime_metadata: { agent_kind: AgentKind | null; reported_model: string | null; working_repo_name: string | null; working_repo_origin: string | null }; runtime_metadata_reported: boolean }> {
949
- const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
956
+ const local = assertSeatAuthority(active);
950
957
  const composed = await localCubeComposition(local);
951
958
  return {
952
959
  cube_id: composed.cube.id,
@@ -978,12 +985,10 @@ export async function whoami(
978
985
  * even when the caller passed an entry-id)
979
986
  */
980
987
  export async function getRoster(
981
- sessionToken: string,
982
- apiUrl: string,
988
+ active: ActiveCube,
983
989
  since?: string,
984
- serverTrustIdentity?: string,
985
990
  ): Promise<{ drones: any[]; roles: any[]; message_taxonomy?: MessageTaxonomy | null; since?: string | null }> {
986
- const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
991
+ const local = assertSeatAuthority(active);
987
992
  if (since !== undefined) {
988
993
  const [dronePayload, rolePayload, cubePayload] = await Promise.all([
989
994
  localServerRequest<{ drones: any[]; since?: string | null }>(
package/src/seat-probe.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // graph. cleanup-cmd re-exports `SeatStatus` for backwards compatibility.
7
7
 
8
8
  import { whoami } from './remote-client.js';
9
+ import type { ActiveCube } from './cubes.js';
9
10
  import { DroneEvictedError } from './drone-lifecycle.js';
10
11
  import {
11
12
  BorgServerError,
@@ -87,12 +88,10 @@ function isTransportFailure(err: unknown): boolean {
87
88
  * fail-OPEN.
88
89
  */
89
90
  export async function defaultProbeSeat(
90
- sessionToken: string,
91
- apiUrl: string,
92
- serverTrustIdentity?: string,
91
+ seat: ActiveCube,
93
92
  ): Promise<SeatStatus> {
94
93
  try {
95
- await whoami(sessionToken, apiUrl, serverTrustIdentity);
94
+ await whoami(seat);
96
95
  return 'live';
97
96
  } catch (err) {
98
97
  if (err instanceof DroneEvictedError) return 'evicted';
package/src/seat-store.ts CHANGED
@@ -408,6 +408,24 @@ export async function withStoreLock<T>(
408
408
  await new Promise((resolvePromise) => setTimeout(resolvePromise, waitMs));
409
409
  continue;
410
410
  }
411
+ // The holder may have released cleanly between our payload read and the
412
+ // liveness check. Revalidate the exact observation before diagnosing it as
413
+ // stale: a missing or changed pathname is lock turnover, so retry acquisition.
414
+ // An unchanged dead/corrupt observation still fails closed below; we never
415
+ // delete, reclaim, or steal it.
416
+ let currentRaw: string | null;
417
+ try {
418
+ currentRaw = await readStoreFile(
419
+ lockPath,
420
+ opts.secureRoot
421
+ ? { secureRoot: opts.secureRoot, rootMode: opts.rootMode }
422
+ : {},
423
+ );
424
+ } catch (readErr) {
425
+ if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') continue;
426
+ throw readErr;
427
+ }
428
+ if (currentRaw === null || currentRaw !== raw) continue;
411
429
  // Dead recorded holder, or a missing/unparseable payload → fail closed. The
412
430
  // operator must confirm no borg process is running and delete the file by hand.
413
431
  throw staleLockError(lockPath, held);
@@ -123,56 +123,11 @@ export function isMerged(runSync: RunSync, cwd: string, branch: string, ref: str
123
123
  return runSync('git', ['merge-base', '--is-ancestor', branch, ref], cwd).status === 0;
124
124
  }
125
125
 
126
- export interface AdoptResult {
127
- action: 'adopted' | 'blocked-unmerged' | 'blocked-target-unmerged' | 'skipped-dirty';
128
- message?: string;
129
- }
130
-
131
126
  /** True iff a local branch named `branch` already exists. */
132
127
  export function localBranchExists(runSync: RunSync, cwd: string, branch: string): boolean {
133
128
  return runSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], cwd).status === 0;
134
129
  }
135
130
 
136
- /**
137
- * Migration (Q4/Q5/§4.5): bring a detached/stale worktree onto
138
- * `wt-<basename>` at `ref`. Idempotent: re-running on an already-adopted
139
- * clean worktree is a lossless reset to `ref`. Never discards:
140
- * - dirty work tree -> skipped-dirty (surface)
141
- * - current HEAD unmerged -> blocked-unmerged (surface)
142
- * - TARGET `branch` exists with commits not on `ref` -> blocked-target-
143
- * unmerged (surface). This is load-bearing: the switch uses `-C`
144
- * (force-create/reset), which would ORPHAN commits on a pre-existing
145
- * `wt-` branch. The HEAD-merged check alone misses this when the
146
- * target branch != HEAD (e.g. on `main` while a prior `wt-x` holds
147
- * committed-but-unmerged work). gh#33 CR-v2 blocker 078d1630.
148
- */
149
- export function adoptWorktree(runSync: RunSync, cwd: string, branch: string, ref: string): AdoptResult {
150
- if (!isCleanTree(runSync, cwd)) {
151
- return {
152
- action: 'skipped-dirty',
153
- message: 'uncommitted changes present; not switching (nothing discarded)',
154
- };
155
- }
156
- if (!isMerged(runSync, cwd, 'HEAD', ref)) {
157
- return {
158
- action: 'blocked-unmerged',
159
- message: `current HEAD has commits not on ${ref}; commit/push or set aside before adopting`,
160
- };
161
- }
162
- // Guard the TARGET branch ref before the `switch -C` force-reset. If
163
- // `branch` already exists and is NOT an ancestor of `ref`, it carries
164
- // committed-unmerged work that `-C` would discard — block instead.
165
- // (Absent, or an ancestor of `ref` = a clean reset target → proceed.)
166
- if (localBranchExists(runSync, cwd, branch) && !isMerged(runSync, cwd, branch, ref)) {
167
- return {
168
- action: 'blocked-target-unmerged',
169
- message: `branch ${branch} exists with commits not on ${ref}; resolve before adopting (a force-switch would discard them)`,
170
- };
171
- }
172
- runSync('git', ['switch', '-C', branch, ref], cwd);
173
- return { action: 'adopted' };
174
- }
175
-
176
131
  export interface CleanupResult {
177
132
  action: 'pruned' | 'announced' | 'not-merged';
178
133
  /** The feature branch this result concerns (for the announce message). */