borgmcp 3.5.0 → 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 (108) 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/log-stream.d.ts.map +1 -1
  51. package/dist/log-stream.js +5 -8
  52. package/dist/log-stream.js.map +1 -1
  53. package/dist/opencode-drone.d.ts +3 -4
  54. package/dist/opencode-drone.d.ts.map +1 -1
  55. package/dist/opencode-drone.js +15 -10
  56. package/dist/opencode-drone.js.map +1 -1
  57. package/dist/opencode-plugin.d.ts +1 -0
  58. package/dist/opencode-plugin.d.ts.map +1 -1
  59. package/dist/opencode-plugin.js +2 -1
  60. package/dist/opencode-plugin.js.map +1 -1
  61. package/dist/opencode-wake-copy.js +1 -1
  62. package/dist/opencode-wake-copy.js.map +1 -1
  63. package/dist/parse-launch-all-args.d.ts +0 -1
  64. package/dist/parse-launch-all-args.d.ts.map +1 -1
  65. package/dist/parse-launch-all-args.js +1 -9
  66. package/dist/parse-launch-all-args.js.map +1 -1
  67. package/dist/remote-client.d.ts +2 -2
  68. package/dist/remote-client.d.ts.map +1 -1
  69. package/dist/remote-client.js +12 -4
  70. package/dist/remote-client.js.map +1 -1
  71. package/dist/seat-probe.d.ts +2 -1
  72. package/dist/seat-probe.d.ts.map +1 -1
  73. package/dist/seat-probe.js +2 -2
  74. package/dist/seat-probe.js.map +1 -1
  75. package/dist/seat-store.d.ts.map +1 -1
  76. package/dist/seat-store.js +18 -0
  77. package/dist/seat-store.js.map +1 -1
  78. package/dist/worktree-lifecycle.d.ts +0 -18
  79. package/dist/worktree-lifecycle.d.ts.map +1 -1
  80. package/dist/worktree-lifecycle.js +0 -39
  81. package/dist/worktree-lifecycle.js.map +1 -1
  82. package/docs/LOCAL_SERVER.md +8 -6
  83. package/docs/SEAT_LIFECYCLE.md +8 -7
  84. package/package.json +1 -1
  85. package/src/assimilate-cmd.ts +36 -68
  86. package/src/assimilate-deps.ts +1 -2
  87. package/src/backends/launch-all-pastelist.ts +2 -2
  88. package/src/backends/launch-all-terminals.ts +2 -2
  89. package/src/backends/launch-all-tmux.ts +3 -3
  90. package/src/bare-launch-menu.ts +89 -48
  91. package/src/claude.ts +31 -34
  92. package/src/cleanup-cmd.ts +10 -10
  93. package/src/cli-help.ts +6 -9
  94. package/src/console-prefix.ts +6 -2
  95. package/src/index.ts +2 -11
  96. package/src/launch-all-cmd.ts +55 -37
  97. package/src/launch-all-command.ts +9 -4
  98. package/src/launch-all-deps.ts +4 -10
  99. package/src/launch-all-discovery.ts +3 -8
  100. package/src/log-stream.ts +5 -8
  101. package/src/opencode-drone.ts +24 -10
  102. package/src/opencode-plugin.ts +2 -1
  103. package/src/opencode-wake-copy.ts +1 -1
  104. package/src/parse-launch-all-args.ts +1 -10
  105. package/src/remote-client.ts +13 -8
  106. package/src/seat-probe.ts +3 -4
  107. package/src/seat-store.ts +18 -0
  108. 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;
package/src/log-stream.ts CHANGED
@@ -142,11 +142,8 @@ export function setModuleInjectOpenCode(
142
142
  */
143
143
  const RECENT_IDS_CAP = 50;
144
144
 
145
- function formatOpenCodeWakePrompt(line: string, wakeNonce?: string): string {
146
- const prompt = formatCubeActivityWakeMessage(line);
147
- return wakeNonce === undefined
148
- ? prompt
149
- : `${prompt}\n\n<!-- borg-wake-nonce:${wakeNonce} -->`;
145
+ function formatOpenCodeWakePrompt(line: string): string {
146
+ return formatCubeActivityWakeMessage(line);
150
147
  }
151
148
 
152
149
  export const INBOX_TAIL_LINES_CAP = 512;
@@ -875,7 +872,7 @@ export async function streamOnce(
875
872
  // exception: each wake nonce is a new delivery identity even when the
876
873
  // underlying inbox line is already durable.
877
874
  if (isReping) {
878
- await injectOpenCode(formatOpenCodeWakePrompt(line, ev.wake_nonce), deliveryId, true);
875
+ await injectOpenCode(formatOpenCodeWakePrompt(line), deliveryId, true);
879
876
  }
880
877
  markEventPersisted(ev.id, ev.data?.created_at ?? '');
881
878
  return 'persisted-skip';
@@ -883,7 +880,7 @@ export async function streamOnce(
883
880
  // The inbox append is the durable record. OpenCode injection is only the
884
881
  // wake attempt and may return before the agent finishes processing.
885
882
  await appendLine(active.cubeId, active.droneId, line);
886
- if (!(await injectOpenCode(formatOpenCodeWakePrompt(line, ev.wake_nonce), deliveryId, true))) {
883
+ if (!(await injectOpenCode(formatOpenCodeWakePrompt(line), deliveryId, true))) {
887
884
  if (ev.wake_nonce === undefined) wakeCodex(formatCodexWakePrompt(line));
888
885
  else wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce);
889
886
  }
@@ -1201,7 +1198,7 @@ export async function streamOnce(
1201
1198
  if (isRecentWakeReplay) {
1202
1199
  const line = formatInboxLine(withSseEventId(event.data, event.id));
1203
1200
  const wakeNonce = event.wake_nonce;
1204
- if (wakeNonce !== undefined && !(await injectOpenCode(formatOpenCodeWakePrompt(line, wakeNonce), wakeNonce, true))) {
1201
+ if (wakeNonce !== undefined && !(await injectOpenCode(formatOpenCodeWakePrompt(line), wakeNonce, true))) {
1205
1202
  wakeCodex(formatCodexWakePrompt(line), wakeNonce);
1206
1203
  }
1207
1204
  continue;
@@ -3,7 +3,10 @@ import { createHash, randomUUID } from 'crypto';
3
3
  import { createServer } from 'node:net';
4
4
  import { join } from 'path';
5
5
  import { tmpdir } from 'os';
6
- import { OPENCODE_INJECTED_ENTRY_METADATA_KEY } from './opencode-plugin.js';
6
+ import {
7
+ OPENCODE_INJECTED_ENTRY_METADATA_KEY,
8
+ OPENCODE_WAKE_IDENTITY_METADATA_KEY,
9
+ } from './opencode-plugin.js';
7
10
 
8
11
  const LOG_FILE = join(tmpdir(), 'borg-opencode-drone.log');
9
12
  function log(msg: string) {
@@ -57,6 +60,7 @@ interface OCMessage {
57
60
  parts?: Array<{
58
61
  type?: string;
59
62
  text?: string;
63
+ metadata?: Record<string, unknown>;
60
64
  }>;
61
65
  }
62
66
 
@@ -213,12 +217,20 @@ async function listSessionMessages(id: string): Promise<OCMessage[]> {
213
217
  return JSON.parse(body);
214
218
  }
215
219
 
216
- async function findInjectedMessage(sessionId: string, text: string): Promise<string | null> {
220
+ async function findInjectedMessage(
221
+ sessionId: string,
222
+ expectedWakeIdentity: string,
223
+ ): Promise<string | null> {
217
224
  const messages = await listSessionMessages(sessionId);
218
225
  for (let index = messages.length - 1; index >= 0; index--) {
219
226
  const message = messages[index];
220
227
  if (message?.info?.role !== 'user' || typeof message.info.id !== 'string') continue;
221
- if (message.parts?.some((part) => part.type === 'text' && part.text === text)) {
228
+ const part = message.parts?.[0];
229
+ if (
230
+ part?.type === 'text' &&
231
+ part.metadata?.[OPENCODE_INJECTED_ENTRY_METADATA_KEY] === true &&
232
+ part.metadata?.[OPENCODE_WAKE_IDENTITY_METADATA_KEY] === expectedWakeIdentity
233
+ ) {
222
234
  return message.info.id;
223
235
  }
224
236
  }
@@ -493,7 +505,7 @@ async function deliverOpenCodeEntry(
493
505
  }
494
506
 
495
507
  try {
496
- if (await findInjectedMessage(target.id, delivery.text)) {
508
+ if (await findInjectedMessage(target.id, delivery.entryId)) {
497
509
  log(`entry ${delivery.entryId} already present in session ${target.id}`);
498
510
  return 'delivered';
499
511
  }
@@ -515,7 +527,10 @@ async function deliverOpenCodeEntry(
515
527
  parts: [{
516
528
  type: 'text',
517
529
  text: delivery.text,
518
- metadata: { [OPENCODE_INJECTED_ENTRY_METADATA_KEY]: true },
530
+ metadata: {
531
+ [OPENCODE_INJECTED_ENTRY_METADATA_KEY]: true,
532
+ [OPENCODE_WAKE_IDENTITY_METADATA_KEY]: delivery.entryId,
533
+ },
519
534
  }],
520
535
  });
521
536
  } catch (err) {
@@ -541,7 +556,7 @@ async function deliverOpenCodeEntry(
541
556
  delivery.state = 'delivered-unconfirmed';
542
557
  }
543
558
  try {
544
- if (await findInjectedMessage(target.id, delivery.text)) {
559
+ if (await findInjectedMessage(target.id, delivery.entryId)) {
545
560
  return 'delivered';
546
561
  }
547
562
  } catch (err) {
@@ -640,10 +655,9 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
640
655
 
641
656
  /**
642
657
  * Queue one durable inbox entry for delivery into the bound OpenCode session.
643
- * The injected text identifies the OpenCode-generated user message, so retries
644
- * and replay can confirm an earlier ambiguous submission without supplying an
645
- * ordering-breaking caller message ID or running it twice. Normal delivery uses
646
- * canonical inbox text; wake re-pings include their stable nonce marker.
658
+ * The delivery identity is stored in TextPart metadata, so retries and replay
659
+ * can confirm an earlier ambiguous submission without supplying an
660
+ * ordering-breaking caller message ID or exposing the identity in delivered text.
647
661
  */
648
662
  export function injectOpenCodeEntry(
649
663
  text: string,
@@ -18,6 +18,7 @@ const COMPACT_FALLBACK =
18
18
  '## Borg Cube\nYou are in a Borg MCP multi-agent coordination cube. ' +
19
19
  'Use MCP tool borg_regen to get full context and recent activity.';
20
20
  export const OPENCODE_INJECTED_ENTRY_METADATA_KEY = 'borgOpenCodeInjectedEntry';
21
+ export const OPENCODE_WAKE_IDENTITY_METADATA_KEY = 'borgOpenCodeWakeIdentity';
21
22
  export const OPENCODE_RECOVERY_METADATA_KEY = 'borgOpenCodeSessionOrientation';
22
23
  const PLUGIN_REL_PATH = path.join('.config', 'opencode', 'plugins', 'borg-orient.js');
23
24
 
@@ -146,7 +147,7 @@ export function createOpenCodePluginCore(
146
147
  }
147
148
 
148
149
  export function buildBorgPluginSource(version: string): string {
149
- const marker = `borgmcp-opencode-plugin:${version};opencode=${OPENCODE_COMPATIBILITY.opencode};sdk=${OPENCODE_COMPATIBILITY.sdk};textpart-metadata=exists+persisted+tui-hidden+model-hidden`;
150
+ const marker = `borgmcp-opencode-plugin:${version};opencode=${OPENCODE_COMPATIBILITY.opencode};sdk=${OPENCODE_COMPATIBILITY.sdk};textpart-metadata=exists+persisted+tui-hidden+model-hidden;wake-identity-key=${OPENCODE_WAKE_IDENTITY_METADATA_KEY}`;
150
151
  return `// ${marker}
151
152
  // Generated by borgmcp. Self-contained; do not edit.
152
153
  const createCore = ${createOpenCodePluginCore.toString()};
@@ -3,7 +3,7 @@ import { CUBE_ACTIVITY_RESUME_WAKE_MESSAGE } from './cube-activity-wake-copy.js'
3
3
  export const OPENCODE_WAKE_PATH_GUIDANCE =
4
4
  'OpenCode wakes through HTTP entry injection into its session through the local HTTP API. ' +
5
5
  'Borg writes each entry to the durable inbox before one injection attempt and ' +
6
- 'confirms delivery by the unique persisted entry text; no inbox Monitor or secondary wake loop is used. ' +
6
+ 'confirms delivery by its unique persisted metadata identity; no inbox Monitor or secondary wake loop is used. ' +
7
7
  'If injection is rejected or cannot be ' +
8
8
  'confirmed, the durable entry remains available and `borg_stream-status` reports ' +
9
9
  'it as failed or delivered-unconfirmed; run `borg_read-log unread_only=true` and ' +
@@ -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);