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.
- package/README.md +18 -13
- package/dist/assimilate-cmd.d.ts +1 -1
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +28 -53
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +1 -1
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/backends/launch-all-pastelist.js +2 -2
- package/dist/backends/launch-all-pastelist.js.map +1 -1
- package/dist/backends/launch-all-terminals.js +2 -2
- package/dist/backends/launch-all-terminals.js.map +1 -1
- package/dist/backends/launch-all-tmux.js +3 -3
- package/dist/backends/launch-all-tmux.js.map +1 -1
- package/dist/bare-launch-menu.d.ts +23 -38
- package/dist/bare-launch-menu.d.ts.map +1 -1
- package/dist/bare-launch-menu.js +84 -31
- package/dist/bare-launch-menu.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +27 -28
- package/dist/claude.js.map +1 -1
- package/dist/cleanup-cmd.d.ts +1 -1
- package/dist/cleanup-cmd.d.ts.map +1 -1
- package/dist/cleanup-cmd.js +10 -1
- package/dist/cleanup-cmd.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +6 -9
- package/dist/cli-help.js.map +1 -1
- package/dist/console-prefix.d.ts.map +1 -1
- package/dist/console-prefix.js +7 -2
- package/dist/console-prefix.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/launch-all-cmd.d.ts.map +1 -1
- package/dist/launch-all-cmd.js +43 -25
- package/dist/launch-all-cmd.js.map +1 -1
- package/dist/launch-all-command.d.ts +8 -2
- package/dist/launch-all-command.d.ts.map +1 -1
- package/dist/launch-all-command.js +8 -4
- package/dist/launch-all-command.js.map +1 -1
- package/dist/launch-all-deps.d.ts +2 -2
- package/dist/launch-all-deps.d.ts.map +1 -1
- package/dist/launch-all-deps.js +2 -2
- package/dist/launch-all-deps.js.map +1 -1
- package/dist/launch-all-discovery.d.ts +2 -3
- package/dist/launch-all-discovery.d.ts.map +1 -1
- package/dist/launch-all-discovery.js +1 -5
- package/dist/launch-all-discovery.js.map +1 -1
- package/dist/parse-launch-all-args.d.ts +0 -1
- package/dist/parse-launch-all-args.d.ts.map +1 -1
- package/dist/parse-launch-all-args.js +1 -9
- package/dist/parse-launch-all-args.js.map +1 -1
- package/dist/remote-client.d.ts +2 -2
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +12 -4
- package/dist/remote-client.js.map +1 -1
- package/dist/seat-probe.d.ts +2 -1
- package/dist/seat-probe.d.ts.map +1 -1
- package/dist/seat-probe.js +2 -2
- package/dist/seat-probe.js.map +1 -1
- package/dist/seat-store.d.ts.map +1 -1
- package/dist/seat-store.js +18 -0
- package/dist/seat-store.js.map +1 -1
- package/dist/worktree-lifecycle.d.ts +0 -18
- package/dist/worktree-lifecycle.d.ts.map +1 -1
- package/dist/worktree-lifecycle.js +0 -39
- package/dist/worktree-lifecycle.js.map +1 -1
- package/docs/LOCAL_SERVER.md +8 -6
- package/docs/SEAT_LIFECYCLE.md +8 -7
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +36 -68
- package/src/assimilate-deps.ts +1 -2
- package/src/backends/launch-all-pastelist.ts +2 -2
- package/src/backends/launch-all-terminals.ts +2 -2
- package/src/backends/launch-all-tmux.ts +3 -3
- package/src/bare-launch-menu.ts +89 -48
- package/src/claude.ts +31 -34
- package/src/cleanup-cmd.ts +10 -10
- package/src/cli-help.ts +6 -9
- package/src/console-prefix.ts +6 -2
- package/src/index.ts +2 -11
- package/src/launch-all-cmd.ts +55 -37
- package/src/launch-all-command.ts +9 -4
- package/src/launch-all-deps.ts +4 -10
- package/src/launch-all-discovery.ts +3 -8
- package/src/parse-launch-all-args.ts +1 -10
- package/src/remote-client.ts +13 -8
- package/src/seat-probe.ts +3 -4
- package/src/seat-store.ts +18 -0
- 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
|
|
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]
|
|
234
|
-
` borg assimilate [role] --worktree <name>
|
|
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
|
|
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>
|
|
243
|
-
` --here
|
|
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` +
|
package/src/console-prefix.ts
CHANGED
|
@@ -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,
|
package/src/launch-all-cmd.ts
CHANGED
|
@@ -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 +
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 — ${
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
* `
|
|
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 =
|
|
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
|
|
32
|
+
return `${base} || { echo "borg launch failed — press Enter to close"; read _; }`;
|
|
28
33
|
}
|
|
29
34
|
return base;
|
|
30
35
|
}
|
package/src/launch-all-deps.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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: (
|
|
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: (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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);
|
package/src/remote-client.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
91
|
-
apiUrl: string,
|
|
92
|
-
serverTrustIdentity?: string,
|
|
91
|
+
seat: ActiveCube,
|
|
93
92
|
): Promise<SeatStatus> {
|
|
94
93
|
try {
|
|
95
|
-
await whoami(
|
|
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). */
|