cyberlegion 1.1.0 → 1.3.0
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.plugin/pins.json +1 -1
- package/bin/cyberlegion +5 -0
- package/dist/cli.mjs +304 -66
- package/dist/index.d.mts +19 -5
- package/dist/index.mjs +150 -42
- package/hooks/hooks.json +1 -12
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/readme.md +5 -0
- package/skills/dispatch-governance/SKILL.md +1 -1
- package/skills/dispatch-governance/scripts/cyberlegion.mjs +1 -1
- package/skills/init-cyberlegion/README.md +5 -4
- package/skills/init-cyberlegion/SKILL.md +54 -10
- package/skills/init-cyberlegion/scripts/cyberlegion.mjs +1 -1
- package/skills/legate/SKILL.md +1 -1
- package/skills/legate/scripts/cyberlegion.mjs +1 -1
- package/skills/manage-inbox/SKILL.md +1 -1
- package/skills/manage-inbox/scripts/cyberlegion.mjs +1 -1
- package/skills/relay-governance/SKILL.md +1 -1
- package/skills/relay-governance/scripts/cyberlegion.mjs +1 -1
- package/skills/subagent-backend-governance/SKILL.md +1 -1
- package/skills/subagent-backend-governance/scripts/cyberlegion.mjs +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -6139,6 +6139,7 @@ function reconcile(ctx, opts) {
|
|
|
6139
6139
|
const cur = storablePane(ctx.env ?? process.env);
|
|
6140
6140
|
if (!cur) return [];
|
|
6141
6141
|
const panes = PANE_ADAPTERS[cur.mux].listPanes(exec);
|
|
6142
|
+
if (panes.length === 0) return [];
|
|
6142
6143
|
const live = new Set(panes.map((p) => p.id));
|
|
6143
6144
|
const changed = [];
|
|
6144
6145
|
for (const rec of listAgents(ctx.store)) {
|
|
@@ -6154,18 +6155,34 @@ function reconcile(ctx, opts) {
|
|
|
6154
6155
|
if (opts?.adopt) changed.push(...adopt(ctx, panes));
|
|
6155
6156
|
return changed;
|
|
6156
6157
|
}
|
|
6157
|
-
/**
|
|
6158
|
-
*
|
|
6159
|
-
*
|
|
6158
|
+
/**
|
|
6159
|
+
* Whether a record's pane is positively gone: its existence probe fails AND its mux answers with a
|
|
6160
|
+
* pane list that does not hold it. `paneExists` alone collapses "unreachable" into "gone", so a mux
|
|
6161
|
+
* that answers no list at all (server down, a failed query) leaves the pane undecided — never gone.
|
|
6162
|
+
* The list is read at most once per mux per call.
|
|
6163
|
+
*/
|
|
6164
|
+
function paneGone(exec, pane, lists) {
|
|
6165
|
+
const adapter = PANE_ADAPTERS[pane.mux];
|
|
6166
|
+
if (adapter.paneExists(exec, { id: pane.id })) return false;
|
|
6167
|
+
let panes = lists.get(pane.mux);
|
|
6168
|
+
if (!panes) {
|
|
6169
|
+
panes = adapter.listPanes(exec);
|
|
6170
|
+
lists.set(pane.mux, panes);
|
|
6171
|
+
}
|
|
6172
|
+
return panes.length > 0 && !panes.some((p) => p.id === pane.id);
|
|
6173
|
+
}
|
|
6174
|
+
/** Mark agents whose session is gone as exited. Reconcile-culls against the current mux's live set
|
|
6175
|
+
* first, then judges each remaining record (covers the other mux and sessions outside any
|
|
6176
|
+
* multiplexer pane): a pane-bound record by its pane alone — a live or unprobeable pane keeps it
|
|
6177
|
+
* whatever its last-seen — and a pane-less record by the staleness timer, its only signal. */
|
|
6160
6178
|
function prune(ctx) {
|
|
6161
6179
|
const exec = ctx.exec ?? realExec;
|
|
6162
6180
|
const now = ctx.now?.() ?? Date.now();
|
|
6163
6181
|
const changed = reconcile(ctx);
|
|
6182
|
+
const lists = /* @__PURE__ */ new Map();
|
|
6164
6183
|
for (const rec of listAgents(ctx.store)) {
|
|
6165
6184
|
if (!isPrunable(rec)) continue;
|
|
6166
|
-
|
|
6167
|
-
const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
|
|
6168
|
-
if (paneGone || stale) {
|
|
6185
|
+
if (rec.pane ? paneGone(exec, rec.pane, lists) : now - new Date(rec.lastSeen).getTime() > STALE_MS) {
|
|
6169
6186
|
rec.status = "exited";
|
|
6170
6187
|
saveAgent(ctx.store, rec);
|
|
6171
6188
|
changed.push(rec);
|
|
@@ -6275,6 +6292,9 @@ const KILL_LINE = "";
|
|
|
6275
6292
|
* throws without sending. A multi-row draft cannot be typed back exactly: a scrape cannot tell a
|
|
6276
6293
|
* wrapped row from a newline, and a newline typed back would submit it.
|
|
6277
6294
|
*
|
|
6295
|
+
* Text `ownText` recognizes is the caller's own, not a draft: it is cleared at once, whatever rows it
|
|
6296
|
+
* wraps onto, `send` runs, and nothing is typed back.
|
|
6297
|
+
*
|
|
6278
6298
|
* Text the clear leaves in place was never a draft — an idle placeholder this reader does not know —
|
|
6279
6299
|
* so `send` runs and nothing is typed back.
|
|
6280
6300
|
*/
|
|
@@ -6291,10 +6311,11 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
6291
6311
|
return { kind: "unknown" };
|
|
6292
6312
|
}
|
|
6293
6313
|
};
|
|
6314
|
+
const isOwn = (s) => s.kind === "draft" && options.ownText?.(s.text) === true;
|
|
6294
6315
|
let state = read();
|
|
6295
6316
|
const start = now();
|
|
6296
6317
|
let changedAt = start;
|
|
6297
|
-
while (state.kind === "draft" && now() - changedAt < idleMs) {
|
|
6318
|
+
while (state.kind === "draft" && !isOwn(state) && now() - changedAt < idleMs) {
|
|
6298
6319
|
if (now() - start + pollMs > maxWaitMs) throw new Error(`pane ${target.id} holds a draft the user is still editing — gave up after ${maxWaitMs / 1e3}s without typing`);
|
|
6299
6320
|
await sleep(pollMs);
|
|
6300
6321
|
const next = read();
|
|
@@ -6302,12 +6323,13 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
6302
6323
|
state = next;
|
|
6303
6324
|
}
|
|
6304
6325
|
if (state.kind !== "draft") return send();
|
|
6326
|
+
if (isOwn(state)) {
|
|
6327
|
+
await clearLine(adapter, exec, target, sleep);
|
|
6328
|
+
return send();
|
|
6329
|
+
}
|
|
6305
6330
|
if (state.rows > 1) throw new Error(`pane ${target.id} holds an idle draft spanning ${state.rows} rows, which cannot be typed back exactly — left it untouched`);
|
|
6306
6331
|
const draft = state.text;
|
|
6307
|
-
adapter
|
|
6308
|
-
await sleep(KEY_SETTLE_MS);
|
|
6309
|
-
adapter.sendText(exec, target, KILL_LINE);
|
|
6310
|
-
await sleep(KEY_SETTLE_MS);
|
|
6332
|
+
await clearLine(adapter, exec, target, sleep);
|
|
6311
6333
|
const after = read();
|
|
6312
6334
|
if (after.kind === "draft" && after.text === draft) return send();
|
|
6313
6335
|
try {
|
|
@@ -6317,6 +6339,12 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
6317
6339
|
adapter.sendText(exec, target, draft);
|
|
6318
6340
|
}
|
|
6319
6341
|
}
|
|
6342
|
+
async function clearLine(adapter, exec, target, sleep) {
|
|
6343
|
+
adapter.sendText(exec, target, END_OF_LINE);
|
|
6344
|
+
await sleep(KEY_SETTLE_MS);
|
|
6345
|
+
adapter.sendText(exec, target, KILL_LINE);
|
|
6346
|
+
await sleep(KEY_SETTLE_MS);
|
|
6347
|
+
}
|
|
6320
6348
|
//#endregion
|
|
6321
6349
|
//#region src/console/ring.ts
|
|
6322
6350
|
const DEFAULT_ATTEMPTS = 10;
|
|
@@ -6387,6 +6415,28 @@ const DELIVERY_DOORBELL = "You have unread mail — check your inbox.";
|
|
|
6387
6415
|
function spawnDoorbell(briefPath) {
|
|
6388
6416
|
return `Read your brief at ${briefPath}, then begin work.`;
|
|
6389
6417
|
}
|
|
6418
|
+
const SPAWN_DOORBELL = /^Readyourbriefat.+,thenbeginwork\.$/;
|
|
6419
|
+
const RESUME_DOORBELL = /^Yoursessionwasrestartedwithitsconversationresumed—continueyourwork\.Ifyouhavenoearlierconversation,readyourbriefat.+,thenbeginwork\.$/;
|
|
6420
|
+
/**
|
|
6421
|
+
* Whether `text`, read out of a peer's input box, is a ring cyberlegion typed — the delivery doorbell,
|
|
6422
|
+
* a spawn or resume doorbell, or `message`, the one about to be rung — rather than a human's draft. A harness
|
|
6423
|
+
* can take a ring and put its text back in the box (cursor-agent with a rejected login). Whitespace is
|
|
6424
|
+
* ignored, since the box wraps the text onto rows wherever it likes.
|
|
6425
|
+
*/
|
|
6426
|
+
function isRingText(text, message) {
|
|
6427
|
+
const squeeze = (s) => s.replace(/\s+/g, "");
|
|
6428
|
+
const box = squeeze(text);
|
|
6429
|
+
if (box === "") return false;
|
|
6430
|
+
return box === squeeze("You have unread mail — check your inbox.") || SPAWN_DOORBELL.test(box) || RESUME_DOORBELL.test(box) || message !== void 0 && box === squeeze(message);
|
|
6431
|
+
}
|
|
6432
|
+
/**
|
|
6433
|
+
* The first-turn instruction for a restarted session that resumed its conversation. The resume can
|
|
6434
|
+
* fall back to a fresh session (the harness no longer has the conversation), and the ring cannot tell
|
|
6435
|
+
* which one took the turn, so it names the brief for a session that finds no earlier conversation.
|
|
6436
|
+
*/
|
|
6437
|
+
function resumeDoorbell(briefPath) {
|
|
6438
|
+
return `Your session was restarted with its conversation resumed — continue your work. If you have no earlier conversation, read your brief at ${briefPath}, then begin work.`;
|
|
6439
|
+
}
|
|
6390
6440
|
/**
|
|
6391
6441
|
* A freshly-launched harness cold-boots slower than an already-running peer, so the spawn first-turn
|
|
6392
6442
|
* ring gets a wider retry budget than a plain `mail send` doorbell (nudge's own 10 × 400ms): flush the
|
|
@@ -6455,7 +6505,8 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
|
|
|
6455
6505
|
const target = { id: pane };
|
|
6456
6506
|
await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
|
|
6457
6507
|
...guardOpts,
|
|
6458
|
-
harness
|
|
6508
|
+
harness,
|
|
6509
|
+
ownText: (text) => isRingText(text)
|
|
6459
6510
|
});
|
|
6460
6511
|
return {
|
|
6461
6512
|
rung: true,
|
|
@@ -6488,9 +6539,11 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
6488
6539
|
if (input.noWake) return { rung: false };
|
|
6489
6540
|
try {
|
|
6490
6541
|
const adapter = getAdapter();
|
|
6491
|
-
|
|
6542
|
+
const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
|
|
6543
|
+
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, nudgeOpts), {
|
|
6492
6544
|
...guardOpts,
|
|
6493
|
-
harness: input.harness
|
|
6545
|
+
harness: input.harness,
|
|
6546
|
+
ownText: (text) => isRingText(text)
|
|
6494
6547
|
});
|
|
6495
6548
|
return {
|
|
6496
6549
|
rung: true,
|
|
@@ -6819,6 +6872,20 @@ function resetCommandFor(harness) {
|
|
|
6819
6872
|
if (FALSE_FRIEND_HARNESSES.has(harness)) throw new Error(`"${harness}" has no honest fresh-context command — its own "/clear" clears only the terminal screen, not the model context, so unit clear refuses to send a false-friend reset that would leave stale context behind`);
|
|
6820
6873
|
throw new Error(`"${harness}" is not in the reset map (${Object.keys(RESET_MAP).join(" | ")}) — unit clear refuses to guess a command`);
|
|
6821
6874
|
}
|
|
6875
|
+
/** The primary checkout of the repository containing `repo`, asked of git from inside it (`git -C`)
|
|
6876
|
+
* rather than from the process cwd. */
|
|
6877
|
+
function primaryRootOfRepo(exec, repo) {
|
|
6878
|
+
const dir = resolve(repo);
|
|
6879
|
+
try {
|
|
6880
|
+
return resolvePrimaryRoot((cmd, args) => exec(cmd, cmd === "git" ? [
|
|
6881
|
+
"-C",
|
|
6882
|
+
dir,
|
|
6883
|
+
...args
|
|
6884
|
+
] : args));
|
|
6885
|
+
} catch {
|
|
6886
|
+
throw new Error(`--repo ${repo} is not inside a git repository`);
|
|
6887
|
+
}
|
|
6888
|
+
}
|
|
6822
6889
|
/**
|
|
6823
6890
|
* Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
|
|
6824
6891
|
* the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
|
|
@@ -6842,9 +6909,9 @@ function spawn(ctx, input) {
|
|
|
6842
6909
|
if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
|
|
6843
6910
|
const brief = resolveBrief(input);
|
|
6844
6911
|
if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
|
|
6845
|
-
if (input.cwd && (input.branch || input.worktreePath)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path");
|
|
6912
|
+
if (input.cwd && (input.branch || input.worktreePath || input.repo)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path/--repo");
|
|
6846
6913
|
const id = randomId();
|
|
6847
|
-
const primaryRoot = resolvePrimaryRoot(exec);
|
|
6914
|
+
const primaryRoot = input.repo ? primaryRootOfRepo(exec, input.repo) : resolvePrimaryRoot(exec);
|
|
6848
6915
|
const launch = input.command ?? LAUNCH_MAP[harness];
|
|
6849
6916
|
const launchLine = () => composeLaunchLine(ctx, sessionAdapter.name, id, launch);
|
|
6850
6917
|
const from = callerPane(sessionAdapter, normalizedEnv);
|
|
@@ -6880,7 +6947,10 @@ function spawn(ctx, input) {
|
|
|
6880
6947
|
assertDistinctFromPrimary(opened.worktree.root, primaryRoot);
|
|
6881
6948
|
ensureMarker(join(opened.worktree.root, ".agents", "cyberlegion"));
|
|
6882
6949
|
cwd = opened.worktree.root;
|
|
6883
|
-
worktree =
|
|
6950
|
+
worktree = {
|
|
6951
|
+
...opened.worktree,
|
|
6952
|
+
primaryRoot
|
|
6953
|
+
};
|
|
6884
6954
|
target = opened.target;
|
|
6885
6955
|
} else {
|
|
6886
6956
|
const added = gitWorktreeAdapter.add(exec, {
|
|
@@ -6891,7 +6961,10 @@ function spawn(ctx, input) {
|
|
|
6891
6961
|
assertDistinctFromPrimary(added.root, primaryRoot);
|
|
6892
6962
|
ensureMarker(join(added.root, ".agents", "cyberlegion"));
|
|
6893
6963
|
cwd = added.root;
|
|
6894
|
-
worktree =
|
|
6964
|
+
worktree = {
|
|
6965
|
+
...added,
|
|
6966
|
+
primaryRoot
|
|
6967
|
+
};
|
|
6895
6968
|
target = sessionAdapter.open(exec, {
|
|
6896
6969
|
cwd,
|
|
6897
6970
|
launch: launchLine(),
|
|
@@ -7018,10 +7091,43 @@ function labelFor(at, input, brief, id) {
|
|
|
7018
7091
|
* supplied `ctx.self`), the multiplexer env prefix, then the harness launch command. Writes the shim
|
|
7019
7092
|
* as a side effect, so call it only past every refusal, right before the session opens. Shared by
|
|
7020
7093
|
* `spawn` and `unit restart` so a restarted unit boots exactly as a spawned one does.
|
|
7094
|
+
*
|
|
7095
|
+
* `bindSelf` has the new pane run `unit rebind <id>` through the shim before the harness starts, so
|
|
7096
|
+
* the session binds itself to the unit even when the caller dies before its own bind write. It needs
|
|
7097
|
+
* the shim, so a context with no recorded invocation gets no bind step.
|
|
7021
7098
|
*/
|
|
7022
|
-
function composeLaunchLine(ctx, muxName, id, launch) {
|
|
7023
|
-
const
|
|
7024
|
-
|
|
7099
|
+
function composeLaunchLine(ctx, muxName, id, launch, options = {}) {
|
|
7100
|
+
const shim = ctx.self ? writeSelfShim(paths.dataDir(ctx.store.root, id), ctx.self) : void 0;
|
|
7101
|
+
const bind = shim && options.bindSelf ? `${shellQuote$1(shim)} unit rebind ${shellQuote$1(id)} --space ${shellQuote$1(ctx.store.root)} >/dev/null 2>&1; ` : "";
|
|
7102
|
+
const prefix = `${shim ? shimEnvPrefix(shim) : ""}${muxEnvPrefix(muxName)}`;
|
|
7103
|
+
return `${bind}${prefix}${launch}${options.fallback ? ` || ${prefix}${options.fallback}` : ""}`;
|
|
7104
|
+
}
|
|
7105
|
+
/**
|
|
7106
|
+
* How each harness resumes a conversation by id, appended to the unit's own launch command. Cursor is
|
|
7107
|
+
* absent: its hook's id is not confirmed to be the chat id `cursor-agent --resume` takes, so a cursor
|
|
7108
|
+
* unit restarts fresh and is rebriefed.
|
|
7109
|
+
*/
|
|
7110
|
+
const RESUME_MAP = {
|
|
7111
|
+
claude: (id) => `--resume ${shellQuote$1(id)}`,
|
|
7112
|
+
codex: (id) => `resume ${shellQuote$1(id)}`
|
|
7113
|
+
};
|
|
7114
|
+
/**
|
|
7115
|
+
* The launch that resumes `conversation`, or undefined when this harness has no resume here or the
|
|
7116
|
+
* launch is not the harness's own command — a wrapper script may not take the harness's flags.
|
|
7117
|
+
*/
|
|
7118
|
+
function resumeLaunch(harness, launch, conversation) {
|
|
7119
|
+
const resume = RESUME_MAP[harness];
|
|
7120
|
+
if (!resume || launch.split(/\s+/)[0] !== LAUNCH_MAP[harness]) return void 0;
|
|
7121
|
+
return `${launch} ${resume(conversation)}`;
|
|
7122
|
+
}
|
|
7123
|
+
/**
|
|
7124
|
+
* Put the shim's directory first on PATH, so a brief's bare `cyberlegion` runs it, and name the shim
|
|
7125
|
+
* itself in `$CYBERLEGION_CLI`. The plugin's hook runs outside that PATH lookup (and a PATH lookup
|
|
7126
|
+
* cannot tell this shim from a stale global install), so it reads the variable instead: set only
|
|
7127
|
+
* here, it marks a spawned session and names the CLI that spawned it.
|
|
7128
|
+
*/
|
|
7129
|
+
function shimEnvPrefix(shim) {
|
|
7130
|
+
return `PATH=${shellQuote$1(dirname(shim))}:"$PATH" CYBERLEGION_CLI=${shellQuote$1(shim)} `;
|
|
7025
7131
|
}
|
|
7026
7132
|
/**
|
|
7027
7133
|
* The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
|
|
@@ -7050,7 +7156,7 @@ function selfInvocation() {
|
|
|
7050
7156
|
}
|
|
7051
7157
|
/**
|
|
7052
7158
|
* Write `<dataDir>/bin/cyberlegion`, a POSIX shim that execs `self` with the caller's arguments,
|
|
7053
|
-
* and return its
|
|
7159
|
+
* and return its path. A spawned session gets that directory first on its PATH, so the
|
|
7054
7160
|
* `cyberlegion` a brief tells it to run is the install that spawned it: nothing is resolved from
|
|
7055
7161
|
* the registry or the session's own PATH at report time.
|
|
7056
7162
|
*/
|
|
@@ -7060,7 +7166,7 @@ function writeSelfShim(dataDir, self) {
|
|
|
7060
7166
|
const shim = join(dir, "cyberlegion");
|
|
7061
7167
|
writeFileSync(shim, `#!/bin/sh\nexec ${self.map(shellQuote$1).join(" ")} "$@"\n`);
|
|
7062
7168
|
chmodSync(shim, 493);
|
|
7063
|
-
return
|
|
7169
|
+
return shim;
|
|
7064
7170
|
}
|
|
7065
7171
|
/** Single-quote `s` for a POSIX shell, so any path survives word splitting and expansion. */
|
|
7066
7172
|
function shellQuote$1(s) {
|
|
@@ -7112,7 +7218,8 @@ async function nudgeUnit(ctx, ref, options = {}) {
|
|
|
7112
7218
|
const adapter = selectSessionAdapter(ctx.env ?? process.env, exec);
|
|
7113
7219
|
const result = await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, message, options.nudgeOpts), {
|
|
7114
7220
|
...options.guardOpts,
|
|
7115
|
-
harness: agent.harness
|
|
7221
|
+
harness: agent.harness,
|
|
7222
|
+
ownText: (text) => isRingText(text, message)
|
|
7116
7223
|
});
|
|
7117
7224
|
return {
|
|
7118
7225
|
agent,
|
|
@@ -7151,7 +7258,8 @@ async function clearUnit(ctx, ref, options = {}) {
|
|
|
7151
7258
|
const adapter = selectSessionAdapter(env, exec);
|
|
7152
7259
|
await withDraftGuard(adapter, exec, target, async () => adapter.submit(exec, target, command), {
|
|
7153
7260
|
...options.guardOpts,
|
|
7154
|
-
harness: agent.harness
|
|
7261
|
+
harness: agent.harness,
|
|
7262
|
+
ownText: (text) => isRingText(text)
|
|
7155
7263
|
});
|
|
7156
7264
|
return {
|
|
7157
7265
|
agent,
|
|
@@ -7446,6 +7554,7 @@ function spawnCommandInput(opts, listCursorModels) {
|
|
|
7446
7554
|
branch: opts.branch,
|
|
7447
7555
|
worktreePath: opts.worktreePath,
|
|
7448
7556
|
cwd: opts.cwd,
|
|
7557
|
+
repo: opts.repo,
|
|
7449
7558
|
at: opts.at
|
|
7450
7559
|
},
|
|
7451
7560
|
noWake: opts.wake === false,
|
|
@@ -7584,12 +7693,18 @@ function decommission(ctx, input) {
|
|
|
7584
7693
|
const exec = ctx.exec ?? realExec;
|
|
7585
7694
|
const env = ctx.env ?? process.env;
|
|
7586
7695
|
const worktreeRoot = rec.worktree?.root;
|
|
7587
|
-
const primaryRoot = worktreeRoot ? resolvePrimaryRoot(exec) : void 0;
|
|
7588
|
-
if (worktreeRoot && resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error(`refusing to decommission "${input.id}" — its worktree is the primary checkout; neither --force nor --keep-worktree overrides this`);
|
|
7589
7696
|
const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
|
|
7697
|
+
const primaryRoot = worktreeExists ? primaryRootOf(exec, worktreeRoot) : rec.worktree?.primaryRoot;
|
|
7698
|
+
if (worktreeRoot && primaryRoot && resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error(`refusing to decommission "${input.id}" — its worktree is the primary checkout; neither --force nor --keep-worktree overrides this`);
|
|
7590
7699
|
const removesWorktree = worktreeExists && !input.keepWorktree;
|
|
7591
7700
|
if (removesWorktree && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
|
|
7592
7701
|
const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
|
|
7702
|
+
if (worktreeRoot && !worktreeExists && primaryRoot && existsSync(primaryRoot)) exec("git", [
|
|
7703
|
+
"-C",
|
|
7704
|
+
primaryRoot,
|
|
7705
|
+
"worktree",
|
|
7706
|
+
"prune"
|
|
7707
|
+
]);
|
|
7593
7708
|
if (removesWorktree) try {
|
|
7594
7709
|
gitWorktreeAdapter.remove(exec, worktreeRoot, { primaryRoot });
|
|
7595
7710
|
} catch (err) {
|
|
@@ -7610,6 +7725,22 @@ function decommission(ctx, input) {
|
|
|
7610
7725
|
};
|
|
7611
7726
|
}
|
|
7612
7727
|
/**
|
|
7728
|
+
* The primary checkout of the repository `worktreeRoot` belongs to — `--git-common-dir` asked of the
|
|
7729
|
+
* worktree itself, so the answer does not depend on where the caller runs. Throws for a directory git
|
|
7730
|
+
* does not know: there is no repository to remove it from.
|
|
7731
|
+
*/
|
|
7732
|
+
function primaryRootOf(exec, worktreeRoot) {
|
|
7733
|
+
const commonDir = exec("git", [
|
|
7734
|
+
"-C",
|
|
7735
|
+
worktreeRoot,
|
|
7736
|
+
"rev-parse",
|
|
7737
|
+
"--path-format=absolute",
|
|
7738
|
+
"--git-common-dir"
|
|
7739
|
+
]);
|
|
7740
|
+
if (!commonDir) throw new Error(`cannot resolve the repository of worktree ${worktreeRoot} — git does not know it`);
|
|
7741
|
+
return dirname(commonDir);
|
|
7742
|
+
}
|
|
7743
|
+
/**
|
|
7613
7744
|
* Whether the worktree holds work a removal would discard. The marker `spawn` stamps into every
|
|
7614
7745
|
* worktree it creates is cyberlegion's own file, not the unit's work: in a project that does not
|
|
7615
7746
|
* track it, it shows as untracked in every spawned worktree, and counting it would make a plain close
|
|
@@ -7644,10 +7775,7 @@ const VENDORS = {
|
|
|
7644
7775
|
claude: {
|
|
7645
7776
|
file: ".claude/settings.json",
|
|
7646
7777
|
shape: "claude",
|
|
7647
|
-
events: {
|
|
7648
|
-
SessionStart: "SessionStart",
|
|
7649
|
-
PostToolUse: "PostToolUse"
|
|
7650
|
-
},
|
|
7778
|
+
events: { SessionStart: "SessionStart" },
|
|
7651
7779
|
pluginHook: true
|
|
7652
7780
|
},
|
|
7653
7781
|
cursor: {
|
|
@@ -7659,13 +7787,11 @@ const VENDORS = {
|
|
|
7659
7787
|
codex: {
|
|
7660
7788
|
file: ".codex/hooks.json",
|
|
7661
7789
|
shape: "cursor",
|
|
7662
|
-
events: {
|
|
7663
|
-
SessionStart: "SessionStart",
|
|
7664
|
-
PostToolUse: "PostToolUse"
|
|
7665
|
-
},
|
|
7790
|
+
events: { SessionStart: "SessionStart" },
|
|
7666
7791
|
pluginHook: true
|
|
7667
7792
|
}
|
|
7668
7793
|
};
|
|
7794
|
+
const RETIRED_EVENTS = ["PostToolUse"];
|
|
7669
7795
|
function readJson(file) {
|
|
7670
7796
|
if (!existsSync(file)) return {};
|
|
7671
7797
|
try {
|
|
@@ -7709,6 +7835,17 @@ function install(harness, projectDir = process.cwd(), pin) {
|
|
|
7709
7835
|
status
|
|
7710
7836
|
});
|
|
7711
7837
|
}
|
|
7838
|
+
if (spec.pluginHook) for (const event of RETIRED_EVENTS) {
|
|
7839
|
+
if (!(spec.shape === "claude" ? removeClaude(settings, event, event) : removeCursor(settings, event, event))) continue;
|
|
7840
|
+
changed = true;
|
|
7841
|
+
results.push({
|
|
7842
|
+
harness,
|
|
7843
|
+
event,
|
|
7844
|
+
vendorEvent: event,
|
|
7845
|
+
file,
|
|
7846
|
+
status: "removed project hook"
|
|
7847
|
+
});
|
|
7848
|
+
}
|
|
7712
7849
|
if (changed) writeJson$1(file, settings);
|
|
7713
7850
|
return results;
|
|
7714
7851
|
}
|
|
@@ -7723,12 +7860,10 @@ function upsertClaude(settings, event, command) {
|
|
|
7723
7860
|
return "already present";
|
|
7724
7861
|
}
|
|
7725
7862
|
}
|
|
7726
|
-
|
|
7863
|
+
groups.push({ hooks: [{
|
|
7727
7864
|
type: "command",
|
|
7728
7865
|
command
|
|
7729
|
-
}] };
|
|
7730
|
-
if (event === "PostToolUse") group.matcher = "Write|Edit";
|
|
7731
|
-
groups.push(group);
|
|
7866
|
+
}] });
|
|
7732
7867
|
return "registered";
|
|
7733
7868
|
}
|
|
7734
7869
|
function upsertCursor(settings, event, command) {
|
|
@@ -7811,6 +7946,58 @@ function fail(msg) {
|
|
|
7811
7946
|
process.exit(1);
|
|
7812
7947
|
}
|
|
7813
7948
|
//#endregion
|
|
7949
|
+
//#region src/permission.ts
|
|
7950
|
+
const CLI_RULE = "Bash(cyberlegion *)";
|
|
7951
|
+
const COVERING = /^Bash(?:\((?:\*|cyberlegion(?: \*|:\*|\*))\))?$/;
|
|
7952
|
+
/** Claude Code's user settings file: `$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`. */
|
|
7953
|
+
function claudeUserSettingsFile(env = process.env) {
|
|
7954
|
+
return join(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json");
|
|
7955
|
+
}
|
|
7956
|
+
function readSettings(file) {
|
|
7957
|
+
if (!existsSync(file)) return {
|
|
7958
|
+
settings: {},
|
|
7959
|
+
allow: []
|
|
7960
|
+
};
|
|
7961
|
+
let settings;
|
|
7962
|
+
try {
|
|
7963
|
+
settings = JSON.parse(readFileSync(file, "utf8"));
|
|
7964
|
+
} catch {
|
|
7965
|
+
throw new Error(`cannot parse Claude Code settings ${file} — fix it by hand, then re-run`);
|
|
7966
|
+
}
|
|
7967
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) throw new Error(`Claude Code settings ${file} is not a JSON object — fix it by hand, then re-run`);
|
|
7968
|
+
const { permissions } = settings;
|
|
7969
|
+
const allow = permissions?.allow ?? [];
|
|
7970
|
+
if (permissions !== void 0 && (typeof permissions !== "object" || permissions === null) || !Array.isArray(allow)) throw new Error(`Claude Code settings ${file} has a malformed permissions.allow — fix it by hand, then re-run`);
|
|
7971
|
+
return {
|
|
7972
|
+
settings,
|
|
7973
|
+
allow
|
|
7974
|
+
};
|
|
7975
|
+
}
|
|
7976
|
+
const covers = (allow) => allow.some((rule) => typeof rule === "string" && COVERING.test(rule));
|
|
7977
|
+
/** Whether the settings file's allow list already lets `cyberlegion` subcommands run. */
|
|
7978
|
+
function permissionRuleState(file) {
|
|
7979
|
+
try {
|
|
7980
|
+
return covers(readSettings(file).allow) ? "present" : "missing";
|
|
7981
|
+
} catch {
|
|
7982
|
+
return "unreadable";
|
|
7983
|
+
}
|
|
7984
|
+
}
|
|
7985
|
+
/**
|
|
7986
|
+
* Add the CLI rule to the settings file's allow list, merged in after every existing entry. A
|
|
7987
|
+
* covering rule already there is a no-op; a file that cannot be read as settings is never rewritten.
|
|
7988
|
+
*/
|
|
7989
|
+
function addPermissionRule(file) {
|
|
7990
|
+
const { settings, allow } = readSettings(file);
|
|
7991
|
+
if (covers(allow)) return "present";
|
|
7992
|
+
settings.permissions = {
|
|
7993
|
+
...settings.permissions ?? {},
|
|
7994
|
+
allow: [...allow, CLI_RULE]
|
|
7995
|
+
};
|
|
7996
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
7997
|
+
writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
7998
|
+
return "added";
|
|
7999
|
+
}
|
|
8000
|
+
//#endregion
|
|
7814
8001
|
//#region src/project.ts
|
|
7815
8002
|
/**
|
|
7816
8003
|
* The canonical git common dir for `dir`, or undefined outside a repository. The common dir is the
|
|
@@ -7932,7 +8119,7 @@ function byPath(ctx, dir) {
|
|
|
7932
8119
|
}
|
|
7933
8120
|
//#endregion
|
|
7934
8121
|
//#region src/runtime/inject-inbox.ts
|
|
7935
|
-
const EVENTS = ["SessionStart"
|
|
8122
|
+
const EVENTS = ["SessionStart"];
|
|
7936
8123
|
/**
|
|
7937
8124
|
* Resolve the calling agent, gather its unread mail (and a standing owner's, when this session is
|
|
7938
8125
|
* the hub's main pane), and return the SessionStart-style injection payload — or null when there is
|
|
@@ -7980,9 +8167,6 @@ function injectInbox(ctx, event) {
|
|
|
7980
8167
|
}
|
|
7981
8168
|
}
|
|
7982
8169
|
} catch {}
|
|
7983
|
-
if (rec && !rec.spawnedBy) try {
|
|
7984
|
-
if (cur ? !ctx.store.getMainPane() : !listAgents(ctx.store).some((a) => a.kind === "standing")) parts.push("## Legion setup\n\nThis pane has no owner inbox bound yet — run `cyberlegion init` to register the surfacing hook and bind this pane as the owner live presence.");
|
|
7985
|
-
} catch {}
|
|
7986
8170
|
if (parts.length === 0) return null;
|
|
7987
8171
|
return { hookSpecificOutput: {
|
|
7988
8172
|
hookEventName: event,
|
|
@@ -8748,8 +8932,11 @@ function stopUnit(ctx, ref) {
|
|
|
8748
8932
|
/**
|
|
8749
8933
|
* Give a unit a fresh runtime and keep the unit: stop a still-running session (the same verified stop
|
|
8750
8934
|
* as `stopUnit`), open a new session at the unit's cwd with the launch it was spawned with, bind the
|
|
8751
|
-
* record to the new pane, and ring it
|
|
8752
|
-
*
|
|
8935
|
+
* record to the new pane, and ring it. When the record carries the harness's conversation id and the
|
|
8936
|
+
* harness can resume by id, the session resumes that conversation and is rung to continue, with the
|
|
8937
|
+
* plain launch as a shell fallback should the harness reject the id; otherwise (or with `fresh`) it
|
|
8938
|
+
* starts empty and is rung to read its brief — a rebrief. Every refusal runs before anything is torn
|
|
8939
|
+
* down.
|
|
8753
8940
|
*
|
|
8754
8941
|
* The unit is recorded `stopped` before the open, so a restart interrupted between the two leaves an
|
|
8755
8942
|
* ordinary stopped unit and a rerun recovers it; nothing needs a separate repair path.
|
|
@@ -8771,6 +8958,7 @@ async function restartUnit(ctx, ref, options = {}) {
|
|
|
8771
8958
|
const env = ctx.env ?? process.env;
|
|
8772
8959
|
const exec = ctx.exec ?? realExec;
|
|
8773
8960
|
const launch = stopped.launch ?? LAUNCH_MAP[harness];
|
|
8961
|
+
const resume = stopped.conversation && !options.fresh ? resumeLaunch(harness, launch, stopped.conversation) : void 0;
|
|
8774
8962
|
const at = stopped.worktree ? "workspace" : "tab";
|
|
8775
8963
|
let target;
|
|
8776
8964
|
let adapter;
|
|
@@ -8783,7 +8971,10 @@ async function restartUnit(ctx, ref, options = {}) {
|
|
|
8783
8971
|
}) } : {};
|
|
8784
8972
|
target = adapter.open(exec, {
|
|
8785
8973
|
cwd: stopped.cwd,
|
|
8786
|
-
launch: composeLaunchLine(ctx, adapter.name, rec.id,
|
|
8974
|
+
launch: resume ? composeLaunchLine(ctx, adapter.name, rec.id, resume, {
|
|
8975
|
+
bindSelf: true,
|
|
8976
|
+
fallback: launch
|
|
8977
|
+
}) : composeLaunchLine(ctx, adapter.name, rec.id, launch, { bindSelf: true }),
|
|
8787
8978
|
at,
|
|
8788
8979
|
from: callerPane(adapter, normalizeMuxEnv(env)),
|
|
8789
8980
|
...label
|
|
@@ -8807,13 +8998,15 @@ async function restartUnit(ctx, ref, options = {}) {
|
|
|
8807
8998
|
const wake = await wakeSpawn(() => adapter, exec, {
|
|
8808
8999
|
target,
|
|
8809
9000
|
briefPath: briefPath ?? "",
|
|
8810
|
-
noWake: options.noWake || !briefPath
|
|
9001
|
+
noWake: options.noWake || !briefPath,
|
|
9002
|
+
resumed: !!resume
|
|
8811
9003
|
}, options.nudgeOpts);
|
|
8812
9004
|
return {
|
|
8813
9005
|
agent: bound,
|
|
8814
9006
|
...previousPane ? { previousPane } : {},
|
|
8815
9007
|
pane: target.id,
|
|
8816
9008
|
launch,
|
|
9009
|
+
resumed: !!resume,
|
|
8817
9010
|
rung: wake.rung,
|
|
8818
9011
|
...wake.warning ? { warning: wake.warning } : {}
|
|
8819
9012
|
};
|
|
@@ -8911,6 +9104,29 @@ function showUnit(ctx, ref) {
|
|
|
8911
9104
|
controls: controlsFor(ctx, rec, liveness)
|
|
8912
9105
|
};
|
|
8913
9106
|
}
|
|
9107
|
+
/**
|
|
9108
|
+
* Record the harness's conversation id on the calling unit, from the JSON its SessionStart hook hands
|
|
9109
|
+
* `mail hook` on stdin (`session_id`, which Claude Code and Codex both send). The hook fires on every
|
|
9110
|
+
* session start in the pane — a fresh start, a resume, a `/clear` — so the record follows the
|
|
9111
|
+
* conversation the pane is in now. Best-effort and silent: an unparseable input, no id, or a caller
|
|
9112
|
+
* that is not a unit records nothing and never fails the harness hook.
|
|
9113
|
+
*/
|
|
9114
|
+
function recordConversation(ctx, hookInput) {
|
|
9115
|
+
let id;
|
|
9116
|
+
try {
|
|
9117
|
+
id = JSON.parse(hookInput)?.session_id;
|
|
9118
|
+
} catch {
|
|
9119
|
+
return;
|
|
9120
|
+
}
|
|
9121
|
+
if (typeof id !== "string" || id === "") return;
|
|
9122
|
+
const meId = resolveSelfId(ctx);
|
|
9123
|
+
const rec = meId ? loadAgent(ctx.store, meId) : void 0;
|
|
9124
|
+
if (!rec || rec.kind === "standing" || rec.kind === "service" || rec.conversation === id) return;
|
|
9125
|
+
saveAgent(ctx.store, {
|
|
9126
|
+
...rec,
|
|
9127
|
+
conversation: id
|
|
9128
|
+
});
|
|
9129
|
+
}
|
|
8914
9130
|
//#endregion
|
|
8915
9131
|
//#region src/wake/await.ts
|
|
8916
9132
|
/** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
|
|
@@ -9203,7 +9419,7 @@ function warnEffortNotApplied(spawnInput) {
|
|
|
9203
9419
|
}
|
|
9204
9420
|
/** The launch options `unit spawn` and `service start` share. */
|
|
9205
9421
|
function withSpawnOptions(cmd) {
|
|
9206
|
-
return withGlobals(cmd).option("--harness <h>", "claude | cursor | codex (required unless --agent/--agent-file resolves one)").option("--agent <name>", "resolve an agent def (.agents/agents/<name>.md) for harness/model/effort/instructions").option("--agent-file <path>", "read an exact agent def file instead of resolving by name").option("--model <name>", "model for this launch only (flag > agent def > harness default)").option("--effort <level>", "effort for this launch only (flag > agent def > harness default)").option("--task <text>", "brief text, or - for stdin").option("--brief-file <path>", "read the brief from a file").option("--handle <name>", "handle for the new peer").option("--branch <name>", "branch for the new worktree (default cyberlegion/unit-<id>)").option("--worktree-path <path>", "where to check out the new worktree").option("--cwd <path>", "spawn the session in an existing directory; create no worktree (mutually exclusive with --branch/--worktree-path)").addOption(new Option("--at <placement>", "where to open the new session (default: new-worktree → workspace, --cwd → tab)").choices([
|
|
9422
|
+
return withGlobals(cmd).option("--harness <h>", "claude | cursor | codex (required unless --agent/--agent-file resolves one)").option("--agent <name>", "resolve an agent def (.agents/agents/<name>.md) for harness/model/effort/instructions").option("--agent-file <path>", "read an exact agent def file instead of resolving by name").option("--model <name>", "model for this launch only (flag > agent def > harness default)").option("--effort <level>", "effort for this launch only (flag > agent def > harness default)").option("--task <text>", "brief text, or - for stdin").option("--brief-file <path>", "read the brief from a file").option("--handle <name>", "handle for the new peer").option("--branch <name>", "branch for the new worktree (default cyberlegion/unit-<id>)").option("--worktree-path <path>", "where to check out the new worktree").option("-C, --repo <path>", "create the worktree from the git repository containing <path>, not the current directory's").option("--cwd <path>", "spawn the session in an existing directory; create no worktree (mutually exclusive with --branch/--worktree-path/--repo)").addOption(new Option("--at <placement>", "where to open the new session (default: new-worktree → workspace, --cwd → tab)").choices([
|
|
9207
9423
|
"pane:right",
|
|
9208
9424
|
"pane:down",
|
|
9209
9425
|
"tab",
|
|
@@ -9303,15 +9519,19 @@ withGlobals(unit.command("stop")).description("end a unit's session and keep the
|
|
|
9303
9519
|
});
|
|
9304
9520
|
if (!res.alreadyStopped) nextStep(`cyberlegion unit restart ${res.agent.handle}`);
|
|
9305
9521
|
});
|
|
9306
|
-
withGlobals(unit.command("restart")).description("give a unit a
|
|
9522
|
+
withGlobals(unit.command("restart")).description("give a unit a new session and keep the unit — stops a running session, relaunches it resuming the harness's recorded conversation where it can, and otherwise rebriefs it").argument("<ref>", "unit id, handle, or worktree branch/CR ref").option("--no-wake", "do not ring the new session to read its brief (the caller briefs it by mail)").option("--fresh", "start an empty session and rebrief it, even when the unit's conversation could be resumed").action(async (ref, opts) => {
|
|
9307
9523
|
const ctx = ctxOf(opts);
|
|
9308
9524
|
touch(ctx);
|
|
9309
|
-
const res = await restartUnit(ctx, ref, {
|
|
9525
|
+
const res = await restartUnit(ctx, ref, {
|
|
9526
|
+
noWake: opts.wake === false,
|
|
9527
|
+
fresh: opts.fresh === true
|
|
9528
|
+
});
|
|
9310
9529
|
emit(formatOf(opts), {
|
|
9311
9530
|
toon: toonObject({
|
|
9312
9531
|
restarted: res.agent.id,
|
|
9313
9532
|
previous: res.previousPane ?? "-",
|
|
9314
9533
|
pane: res.pane,
|
|
9534
|
+
resumed: res.resumed,
|
|
9315
9535
|
rung: res.rung
|
|
9316
9536
|
}),
|
|
9317
9537
|
json: res
|
|
@@ -9792,9 +10012,12 @@ withGlobals(mail.command("watch")).description("stream new matching mail as it a
|
|
|
9792
10012
|
});
|
|
9793
10013
|
});
|
|
9794
10014
|
});
|
|
9795
|
-
withGlobals(mail.command("hook")).description("emit the harness hook injection payload (raw JSON on stdout, not TOON)").option("--event <event>", "SessionStart
|
|
10015
|
+
withGlobals(mail.command("hook")).description("emit the harness hook injection payload (raw JSON on stdout, not TOON)").option("--event <event>", "SessionStart", "SessionStart").action((opts) => {
|
|
9796
10016
|
const ctx = ctxOf(opts);
|
|
9797
10017
|
touch(ctx);
|
|
10018
|
+
if (!process.stdin.isTTY) try {
|
|
10019
|
+
recordConversation(ctx, readFileSync(0, "utf8"));
|
|
10020
|
+
} catch {}
|
|
9798
10021
|
const payload = injectInbox(ctx, opts.event);
|
|
9799
10022
|
if (payload) console.log(JSON.stringify(payload));
|
|
9800
10023
|
});
|
|
@@ -9889,30 +10112,33 @@ withGlobals(agent.command("path")).description("print the resolved def file path
|
|
|
9889
10112
|
json: { path: def.path }
|
|
9890
10113
|
});
|
|
9891
10114
|
});
|
|
10115
|
+
const ALLOW_CLI_HINT = `cyberlegion init --allow-cli to add ${CLI_RULE} to Claude Code's permissions.allow, so units can mail their results back`;
|
|
9892
10116
|
const mux = program.command("mux").description("the unit-agnostic pane layer — multiplexer detection and diagnostics");
|
|
9893
10117
|
withGlobals(mux.command("doctor")).description("probe harness, multiplexer (ancestry-discovered), hub root, and self-id").action((opts) => {
|
|
9894
10118
|
const ctx = ctxOf(opts);
|
|
9895
10119
|
const harness = detectHarness(void 0, ctx) ?? "unknown";
|
|
9896
10120
|
const probe = probeMultiplexer(ctx.exec ?? realExec, normalizeMuxEnv(ctx.env ?? process.env));
|
|
9897
10121
|
const selfId = resolveSelfId(ctx) ?? "-";
|
|
10122
|
+
const permissionRule = harness === "claude" ? permissionRuleState(claudeUserSettingsFile(ctx.env ?? process.env)) : "n/a";
|
|
10123
|
+
const report = {
|
|
10124
|
+
harness,
|
|
10125
|
+
mux: probe.mux,
|
|
10126
|
+
pane: probe.pane,
|
|
10127
|
+
via: probe.via,
|
|
10128
|
+
hubRoot: ctx.store.root,
|
|
10129
|
+
selfId
|
|
10130
|
+
};
|
|
9898
10131
|
emit(formatOf(opts), {
|
|
9899
10132
|
toon: toonObject({
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
pane: probe.pane,
|
|
9903
|
-
via: probe.via,
|
|
9904
|
-
hubRoot: ctx.store.root,
|
|
9905
|
-
selfId
|
|
10133
|
+
...report,
|
|
10134
|
+
permissionRule
|
|
9906
10135
|
}),
|
|
9907
10136
|
json: {
|
|
9908
|
-
|
|
9909
|
-
|
|
9910
|
-
pane: probe.pane,
|
|
9911
|
-
via: probe.via,
|
|
9912
|
-
hubRoot: ctx.store.root,
|
|
9913
|
-
selfId
|
|
10137
|
+
...report,
|
|
10138
|
+
permissionRule
|
|
9914
10139
|
}
|
|
9915
10140
|
});
|
|
10141
|
+
if (permissionRule === "missing") nextStep(ALLOW_CLI_HINT);
|
|
9916
10142
|
if (probe.mux !== "none") nextStep(`export CYBER_MUX=${probe.mux}${probe.pane ? ` CYBER_MUX_PANE=${probe.pane}` : ""} — pin the fast-path, skip ancestry discovery on later calls`);
|
|
9917
10143
|
});
|
|
9918
10144
|
withGlobals(mux.command("mode")).description("report the detected session-backend mode").action((opts) => {
|
|
@@ -9963,7 +10189,7 @@ withGlobals(program.command("admin").description("hub-state maintenance").comman
|
|
|
9963
10189
|
json: res
|
|
9964
10190
|
});
|
|
9965
10191
|
});
|
|
9966
|
-
withGlobals(program.command("init")).description("resolve this session harness, set up the Legion surfacing hook, and advise owner binding").option("--agent <h>", "claude | cursor | codex (else auto-detected)").option("--dir <path>", "project dir to write config into", process.cwd()).option("--pin <version>", "version the npx fallback of the cursor hook fetches (e.g. the bundled plugin version)").action((opts) => {
|
|
10192
|
+
withGlobals(program.command("init")).description("resolve this session harness, set up the Legion surfacing hook, and advise owner binding").option("--agent <h>", "claude | cursor | codex (else auto-detected)").option("--dir <path>", "project dir to write config into", process.cwd()).option("--pin <version>", "version the npx fallback of the cursor hook fetches (e.g. the bundled plugin version)").option("--allow-cli", `add ${CLI_RULE} to Claude Code's user permissions.allow (claude only)`).action((opts) => {
|
|
9967
10193
|
const ctx = ctxOf(opts);
|
|
9968
10194
|
let harness;
|
|
9969
10195
|
try {
|
|
@@ -9973,6 +10199,16 @@ withGlobals(program.command("init")).description("resolve this session harness,
|
|
|
9973
10199
|
} catch (err) {
|
|
9974
10200
|
fail(err instanceof Error ? err.message : String(err));
|
|
9975
10201
|
}
|
|
10202
|
+
if (opts.allowCli && harness !== "claude") fail(`--allow-cli applies to claude only, not ${harness}`);
|
|
10203
|
+
let permissionRule;
|
|
10204
|
+
if (harness === "claude") {
|
|
10205
|
+
const settingsFile = claudeUserSettingsFile(ctx.env ?? process.env);
|
|
10206
|
+
try {
|
|
10207
|
+
permissionRule = opts.allowCli ? addPermissionRule(settingsFile) : permissionRuleState(settingsFile);
|
|
10208
|
+
} catch (err) {
|
|
10209
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
10210
|
+
}
|
|
10211
|
+
}
|
|
9976
10212
|
let results;
|
|
9977
10213
|
try {
|
|
9978
10214
|
results = install(harness, opts.dir, opts.pin);
|
|
@@ -9993,12 +10229,14 @@ withGlobals(program.command("init")).description("resolve this session harness,
|
|
|
9993
10229
|
key: "file",
|
|
9994
10230
|
get: (r) => r.file
|
|
9995
10231
|
}
|
|
9996
|
-
], `harness ${harness}, ${results.length} hooks`),
|
|
10232
|
+
], `harness ${harness}, ${results.length} hooks${permissionRule ? `, permission rule ${permissionRule}` : ""}`),
|
|
9997
10233
|
json: {
|
|
9998
10234
|
harness,
|
|
9999
|
-
hooks: results
|
|
10235
|
+
hooks: results,
|
|
10236
|
+
...permissionRule && { permissionRule }
|
|
10000
10237
|
}
|
|
10001
10238
|
});
|
|
10239
|
+
if (permissionRule === "missing") nextStep(ALLOW_CLI_HINT);
|
|
10002
10240
|
if (!listAgents(ctx.store).some((a) => a.kind === "standing")) {
|
|
10003
10241
|
nextStep("cyberlegion unit register --standing --handle <name> to mint the durable owner inbox");
|
|
10004
10242
|
nextStep("cyberlegion attach to bind this pane as the owner live presence");
|