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