cyberlegion 1.0.0 → 1.2.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 +3 -2
- package/.codex-plugin/plugin.json +3 -2
- package/.plugin/pins.json +1 -1
- package/dist/cli.mjs +443 -116
- package/dist/index.d.mts +74 -10
- package/dist/index.mjs +377 -57
- package/hooks/hooks.json +14 -0
- package/package.json +2 -1
- package/plugin.json +2 -1
- package/readme.md +1 -1
- package/skills/dispatch-governance/SKILL.md +1 -1
- package/skills/dispatch-governance/scripts/cyberlegion.mjs +1 -1
- package/skills/init-cyberlegion/SKILL.md +6 -4
- 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/README.md +10 -10
- package/skills/relay-governance/SKILL.md +63 -40
- package/skills/relay-governance/scripts/cyberlegion.mjs +1 -1
- package/skills/subagent-backend-governance/README.md +3 -2
- package/skills/subagent-backend-governance/SKILL.md +4 -3
- package/skills/subagent-backend-governance/scripts/cyberlegion.mjs +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -5527,12 +5527,8 @@ function discoverByAncestry(exec, env) {
|
|
|
5527
5527
|
via: "ancestry"
|
|
5528
5528
|
};
|
|
5529
5529
|
}
|
|
5530
|
-
/** Max flush re-submits after the initial submit, before nudge fails loud. */
|
|
5531
|
-
const DEFAULT_ATTEMPTS = 10;
|
|
5532
|
-
/** Wait after a submit before reading the pane back, in ms. */
|
|
5533
|
-
const DEFAULT_SETTLE_MS = 400;
|
|
5534
5530
|
/** Length of the message prefix used as the staged-text needle. */
|
|
5535
|
-
const NEEDLE_LEN = 40;
|
|
5531
|
+
const NEEDLE_LEN$1 = 40;
|
|
5536
5532
|
/**
|
|
5537
5533
|
* Whether `message` is still sitting staged in `visible`'s input box (not yet submitted). A real
|
|
5538
5534
|
* submit scrolls the message into the transcript, leaving the bottom input box empty, so the
|
|
@@ -5542,52 +5538,11 @@ const NEEDLE_LEN = 40;
|
|
|
5542
5538
|
*/
|
|
5543
5539
|
function isStaged(visible, message) {
|
|
5544
5540
|
if (!visible) return true;
|
|
5545
|
-
const needle = message.replace(/\s+/g, " ").trim().slice(0, NEEDLE_LEN);
|
|
5541
|
+
const needle = message.replace(/\s+/g, " ").trim().slice(0, NEEDLE_LEN$1);
|
|
5546
5542
|
if (needle === "") return true;
|
|
5547
5543
|
return visible.split("\n").filter((l) => l.trim() !== "").slice(-5).join(" ").replace(/\s+/g, " ").trim().includes(needle);
|
|
5548
5544
|
}
|
|
5549
5545
|
/**
|
|
5550
|
-
* Submit `message` to `target` and verify the peer actually took the turn — a booting harness can
|
|
5551
|
-
* swallow the Enter of the initial `submit`, leaving the text staged unsent while nudge would
|
|
5552
|
-
* otherwise report false success. Types the message exactly once; a swallowed Enter is recovered by
|
|
5553
|
-
* flushing the staged buffer (`adapter.submit` with no text, a bare Enter) — never re-typing the
|
|
5554
|
-
* message — up to a bounded number of attempts. Throws if the turn is never taken within the cap.
|
|
5555
|
-
*
|
|
5556
|
-
* A pane that no longer exists is rejected up front rather than retried: a gone pane and a booting
|
|
5557
|
-
* one both read back empty, so without the liveness probe the retry loop reports a dead peer as
|
|
5558
|
-
* "never took the turn" — a boot-race shape — and buries the real cause.
|
|
5559
|
-
*
|
|
5560
|
-
* **Not built on `waitForOutput`, deliberately.** The two look alike and wait on opposite conditions:
|
|
5561
|
-
* `waitForOutput` returns when a pattern APPEARS anywhere in the snapshot, while nudge returns when the
|
|
5562
|
-
* message DISAPPEARS from the input box at the bottom (`isStaged`) — a negative, position-sensitive
|
|
5563
|
-
* condition the wait primitive cannot express, and one that must not be satisfied by the same text
|
|
5564
|
-
* sitting up in the transcript, which is exactly where a submitted message ends up. Nor is the loop body
|
|
5565
|
-
* the same: nudge does not merely observe between polls, it re-submits, so its "poll" is a corrective
|
|
5566
|
-
* action with its own attempt budget rather than a read. What the two DO share is the liveness rule —
|
|
5567
|
-
* a gone pane throws instead of being reported as a quiet one — and `pollForOutput` adopts it from here.
|
|
5568
|
-
*/
|
|
5569
|
-
async function nudge(adapter, exec, target, message, opts = {}) {
|
|
5570
|
-
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
5571
|
-
const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
|
|
5572
|
-
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
5573
|
-
if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
|
|
5574
|
-
adapter.submit(exec, target, message);
|
|
5575
|
-
await sleep(settleMs);
|
|
5576
|
-
if (!isStaged(adapter.read(exec, target).text, message)) return {
|
|
5577
|
-
taken: true,
|
|
5578
|
-
resubmits: 0
|
|
5579
|
-
};
|
|
5580
|
-
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
5581
|
-
adapter.submit(exec, target);
|
|
5582
|
-
await sleep(settleMs);
|
|
5583
|
-
if (!isStaged(adapter.read(exec, target).text, message)) return {
|
|
5584
|
-
taken: true,
|
|
5585
|
-
resubmits: attempt
|
|
5586
|
-
};
|
|
5587
|
-
}
|
|
5588
|
-
throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — input still staged after ${attempts} re-submit attempts`);
|
|
5589
|
-
}
|
|
5590
|
-
/**
|
|
5591
5546
|
* This process's own pane, as something `adapter` can address — `MuxOpenOptions.from`'s intended
|
|
5592
5547
|
* argument for a `pane:*` open, so a split lands on the caller rather than on whichever pane the
|
|
5593
5548
|
* user is looking at (see `from`'s note for why each backend's default gets that wrong).
|
|
@@ -5643,6 +5598,8 @@ function migrateStore(from, to) {
|
|
|
5643
5598
|
//#region src/paths.ts
|
|
5644
5599
|
/** The tracked marker file that makes a hub root initialized (see ensureMarker). */
|
|
5645
5600
|
const MARKER_FILE = "config.json";
|
|
5601
|
+
/** Where `spawn` stamps that marker inside a worktree it creates, relative to the worktree root. */
|
|
5602
|
+
const WORKTREE_MARKER = `.agents/cyberlegion/${MARKER_FILE}`;
|
|
5646
5603
|
/** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
|
|
5647
5604
|
function projectRoot(cwd = process.cwd()) {
|
|
5648
5605
|
let dir = resolve(cwd);
|
|
@@ -6182,6 +6139,7 @@ function reconcile(ctx, opts) {
|
|
|
6182
6139
|
const cur = storablePane(ctx.env ?? process.env);
|
|
6183
6140
|
if (!cur) return [];
|
|
6184
6141
|
const panes = PANE_ADAPTERS[cur.mux].listPanes(exec);
|
|
6142
|
+
if (panes.length === 0) return [];
|
|
6185
6143
|
const live = new Set(panes.map((p) => p.id));
|
|
6186
6144
|
const changed = [];
|
|
6187
6145
|
for (const rec of listAgents(ctx.store)) {
|
|
@@ -6197,18 +6155,34 @@ function reconcile(ctx, opts) {
|
|
|
6197
6155
|
if (opts?.adopt) changed.push(...adopt(ctx, panes));
|
|
6198
6156
|
return changed;
|
|
6199
6157
|
}
|
|
6200
|
-
/**
|
|
6201
|
-
*
|
|
6202
|
-
*
|
|
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. */
|
|
6203
6178
|
function prune(ctx) {
|
|
6204
6179
|
const exec = ctx.exec ?? realExec;
|
|
6205
6180
|
const now = ctx.now?.() ?? Date.now();
|
|
6206
6181
|
const changed = reconcile(ctx);
|
|
6182
|
+
const lists = /* @__PURE__ */ new Map();
|
|
6207
6183
|
for (const rec of listAgents(ctx.store)) {
|
|
6208
6184
|
if (!isPrunable(rec)) continue;
|
|
6209
|
-
|
|
6210
|
-
const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
|
|
6211
|
-
if (paneGone || stale) {
|
|
6185
|
+
if (rec.pane ? paneGone(exec, rec.pane, lists) : now - new Date(rec.lastSeen).getTime() > STALE_MS) {
|
|
6212
6186
|
rec.status = "exited";
|
|
6213
6187
|
saveAgent(ctx.store, rec);
|
|
6214
6188
|
changed.push(rec);
|
|
@@ -6361,6 +6335,59 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
6361
6335
|
}
|
|
6362
6336
|
}
|
|
6363
6337
|
//#endregion
|
|
6338
|
+
//#region src/console/ring.ts
|
|
6339
|
+
const DEFAULT_ATTEMPTS = 10;
|
|
6340
|
+
const DEFAULT_SETTLE_MS = 400;
|
|
6341
|
+
/** cyber-mux's `isStaged` matches on the same prefix length. */
|
|
6342
|
+
const NEEDLE_LEN = 40;
|
|
6343
|
+
/** How many screen rows carry `message` — the transcript, a queued follow-up, the input box. */
|
|
6344
|
+
function copiesOn(screen, message) {
|
|
6345
|
+
if (!screen) return 0;
|
|
6346
|
+
const needle = message.replace(/\s+/g, " ").trim().slice(0, NEEDLE_LEN);
|
|
6347
|
+
if (needle === "") return 0;
|
|
6348
|
+
return screen.split("\n").filter((row) => row.replace(/\s+/g, " ").includes(needle)).length;
|
|
6349
|
+
}
|
|
6350
|
+
/**
|
|
6351
|
+
* Submit `message` to `target` as a taken turn: cyber-mux's `nudge`, with one more way to see that the
|
|
6352
|
+
* turn was taken.
|
|
6353
|
+
*
|
|
6354
|
+
* `nudge` judges the turn by whether the text still sits at the bottom of the screen, and flushes
|
|
6355
|
+
* with a bare Enter while it does. Some harness screens put a taken turn back in the input box —
|
|
6356
|
+
* cursor-agent with a rejected login posts the ring to its transcript (or queues it as a follow-up)
|
|
6357
|
+
* and restores the same text into the box — so `nudge` flushes, and each flush rings again.
|
|
6358
|
+
*
|
|
6359
|
+
* So the screen is counted before the ring and after each submit. A swallowed Enter adds one copy of
|
|
6360
|
+
* the text, the one in the input box. Two or more new copies mean the harness also posted it above
|
|
6361
|
+
* the box, so the turn was taken and nothing is flushed. Text still at the bottom with only one new
|
|
6362
|
+
* copy is the swallowed Enter, and is flushed as before.
|
|
6363
|
+
*/
|
|
6364
|
+
async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
6365
|
+
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
6366
|
+
const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
|
|
6367
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6368
|
+
if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
|
|
6369
|
+
const before = copiesOn(adapter.read(exec, target).text, message);
|
|
6370
|
+
const taken = () => {
|
|
6371
|
+
const screen = adapter.read(exec, target).text;
|
|
6372
|
+
return !isStaged(screen, message) || copiesOn(screen, message) >= before + 2;
|
|
6373
|
+
};
|
|
6374
|
+
adapter.submit(exec, target, message);
|
|
6375
|
+
await sleep(settleMs);
|
|
6376
|
+
if (taken()) return {
|
|
6377
|
+
taken: true,
|
|
6378
|
+
resubmits: 0
|
|
6379
|
+
};
|
|
6380
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
6381
|
+
adapter.submit(exec, target);
|
|
6382
|
+
await sleep(settleMs);
|
|
6383
|
+
if (taken()) return {
|
|
6384
|
+
taken: true,
|
|
6385
|
+
resubmits: attempt
|
|
6386
|
+
};
|
|
6387
|
+
}
|
|
6388
|
+
throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — input still staged after ${attempts} re-submit attempts`);
|
|
6389
|
+
}
|
|
6390
|
+
//#endregion
|
|
6364
6391
|
//#region src/console/doorbell.ts
|
|
6365
6392
|
/** The doorbell text delivered to a woken recipient; also the standalone `unit nudge` default. */
|
|
6366
6393
|
const DELIVERY_DOORBELL = "You have unread mail — check your inbox.";
|
|
@@ -6443,7 +6470,7 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
|
|
|
6443
6470
|
try {
|
|
6444
6471
|
const adapter = getAdapter();
|
|
6445
6472
|
const target = { id: pane };
|
|
6446
|
-
await withDraftGuard(adapter, exec, target, () =>
|
|
6473
|
+
await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
|
|
6447
6474
|
...guardOpts,
|
|
6448
6475
|
harness
|
|
6449
6476
|
});
|
|
@@ -6478,7 +6505,7 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
6478
6505
|
if (input.noWake) return { rung: false };
|
|
6479
6506
|
try {
|
|
6480
6507
|
const adapter = getAdapter();
|
|
6481
|
-
await withDraftGuard(adapter, exec, input.target, () =>
|
|
6508
|
+
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, spawnDoorbell(input.briefPath), nudgeOpts), {
|
|
6482
6509
|
...guardOpts,
|
|
6483
6510
|
harness: input.harness
|
|
6484
6511
|
});
|
|
@@ -6495,6 +6522,117 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
6495
6522
|
}
|
|
6496
6523
|
}
|
|
6497
6524
|
//#endregion
|
|
6525
|
+
//#region src/console/trust.ts
|
|
6526
|
+
/**
|
|
6527
|
+
* Each harness's folder-trust prompt, as it draws it, and the keys that accept it. Read from Claude
|
|
6528
|
+
* Code 2.1.286, Codex 0.153.4 (and its newer "Trust this folder?" wording), and cursor-agent
|
|
6529
|
+
* 2026.09.26, each opened in a real tmux pane on a folder it did not trust.
|
|
6530
|
+
*
|
|
6531
|
+
* Claude Code lists "No, exit" first and focuses it, so a bare Enter quits the harness. Its accept is
|
|
6532
|
+
* therefore Down, a check that the screen shows "Yes, I trust this folder" selected, and only then
|
|
6533
|
+
* Enter. Codex highlights its first option on `1` and confirms on Enter. cursor-agent takes `a`
|
|
6534
|
+
* directly.
|
|
6535
|
+
*/
|
|
6536
|
+
const TRUST_PROMPTS = {
|
|
6537
|
+
claude: {
|
|
6538
|
+
markers: [["Quick safety check", "Yes, I trust this folder"]],
|
|
6539
|
+
accept: [{
|
|
6540
|
+
keys: ["Down"],
|
|
6541
|
+
until: /❯\s*Yes, I trust this folder/
|
|
6542
|
+
}, { keys: ["Enter"] }]
|
|
6543
|
+
},
|
|
6544
|
+
codex: {
|
|
6545
|
+
markers: [["Do you trust the contents of this directory?", "Yes, continue"], ["Trust this folder?", "Trust and continue"]],
|
|
6546
|
+
accept: [{
|
|
6547
|
+
text: "1",
|
|
6548
|
+
until: /›\s*1\.\s*(?:Yes, continue|Trust and continue)/
|
|
6549
|
+
}, { keys: ["Enter"] }]
|
|
6550
|
+
},
|
|
6551
|
+
cursor: {
|
|
6552
|
+
markers: [["Workspace Trust Required", "Trust this workspace"]],
|
|
6553
|
+
accept: [{ text: "a" }]
|
|
6554
|
+
}
|
|
6555
|
+
};
|
|
6556
|
+
const collapse = (s) => s.replace(/\s+/g, " ");
|
|
6557
|
+
/** Whether `screen` shows `harness`'s folder-trust prompt. */
|
|
6558
|
+
function showsTrustPrompt(harness, screen) {
|
|
6559
|
+
const flat = collapse(screen);
|
|
6560
|
+
return TRUST_PROMPTS[harness].markers.some((set) => set.every((phrase) => flat.includes(collapse(phrase))));
|
|
6561
|
+
}
|
|
6562
|
+
/**
|
|
6563
|
+
* Watch a freshly-opened pane until its harness either shows its folder-trust prompt or settles on a
|
|
6564
|
+
* screen without one, and answer the prompt by `input.policy`.
|
|
6565
|
+
*
|
|
6566
|
+
* "Settled" means the screen has changed from its first read (the shell, still showing the launch
|
|
6567
|
+
* line while the harness loads) and then read the same `stableReads` times in a row. A harness that
|
|
6568
|
+
* never draws within the timeout, or a screen that stays blank for `blankReads` reads, is
|
|
6569
|
+
* `unsettled`: no evidence of a prompt, so the caller rings as it would have before. Only the screen is read; no harness config file is consulted, so what counts is
|
|
6570
|
+
* what the harness itself decided to show.
|
|
6571
|
+
*/
|
|
6572
|
+
async function answerTrustPrompt(adapter, exec, target, input, opts = {}) {
|
|
6573
|
+
const pollMs = opts.pollMs ?? 500;
|
|
6574
|
+
const timeoutMs = opts.timeoutMs ?? 2e4;
|
|
6575
|
+
const stableReads = opts.stableReads ?? 3;
|
|
6576
|
+
const blankReads = opts.blankReads ?? 6;
|
|
6577
|
+
const keyDelayMs = opts.keyDelayMs ?? 1e3;
|
|
6578
|
+
const attempts = opts.attempts ?? 5;
|
|
6579
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6580
|
+
const read = () => {
|
|
6581
|
+
try {
|
|
6582
|
+
return adapter.read(exec, target).text ?? "";
|
|
6583
|
+
} catch {
|
|
6584
|
+
return "";
|
|
6585
|
+
}
|
|
6586
|
+
};
|
|
6587
|
+
const shown = (screen) => showsTrustPrompt(input.harness, screen);
|
|
6588
|
+
const first = read();
|
|
6589
|
+
let seen = shown(first);
|
|
6590
|
+
let previous = first;
|
|
6591
|
+
let changed = false;
|
|
6592
|
+
let same = 1;
|
|
6593
|
+
for (let waited = 0; !seen && waited < timeoutMs; waited += pollMs) {
|
|
6594
|
+
await sleep(pollMs);
|
|
6595
|
+
const screen = read();
|
|
6596
|
+
if (shown(screen)) {
|
|
6597
|
+
seen = true;
|
|
6598
|
+
break;
|
|
6599
|
+
}
|
|
6600
|
+
if (screen !== first) changed = true;
|
|
6601
|
+
same = screen === previous ? same + 1 : 1;
|
|
6602
|
+
previous = screen;
|
|
6603
|
+
if (changed && same >= stableReads) return { state: "none" };
|
|
6604
|
+
if (!changed && first.trim() === "" && same >= blankReads) return { state: "unsettled" };
|
|
6605
|
+
}
|
|
6606
|
+
if (!seen) return { state: "unsettled" };
|
|
6607
|
+
const where = `${input.harness} in pane ${input.pane} is asking whether to trust ${input.folder}`;
|
|
6608
|
+
if (input.policy === "ask") return {
|
|
6609
|
+
state: "needs-human",
|
|
6610
|
+
message: `${where}. unit spawn does not trust a folder it did not create, so a person must answer that prompt. The unit is registered and its brief written; once the prompt is answered, nudge the unit to its brief.`
|
|
6611
|
+
};
|
|
6612
|
+
await sleep(keyDelayMs);
|
|
6613
|
+
for (const move of TRUST_PROMPTS[input.harness].accept) {
|
|
6614
|
+
let done = move.until ? move.until.test(read()) : false;
|
|
6615
|
+
for (let attempt = 0; !done && attempt < attempts; attempt++) {
|
|
6616
|
+
if (move.keys) adapter.sendKeys(exec, target, move.keys);
|
|
6617
|
+
if (move.text) adapter.sendText(exec, target, move.text);
|
|
6618
|
+
await sleep(pollMs);
|
|
6619
|
+
done = move.until ? move.until.test(read()) : true;
|
|
6620
|
+
}
|
|
6621
|
+
if (!done) return stuck(where);
|
|
6622
|
+
}
|
|
6623
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
6624
|
+
if (!shown(read())) return { state: "accepted" };
|
|
6625
|
+
await sleep(pollMs);
|
|
6626
|
+
}
|
|
6627
|
+
return stuck(where);
|
|
6628
|
+
}
|
|
6629
|
+
function stuck(where) {
|
|
6630
|
+
return {
|
|
6631
|
+
state: "stuck",
|
|
6632
|
+
message: `${where}, and the prompt is still showing after spawn sent the keys that accept it. A person must answer it.`
|
|
6633
|
+
};
|
|
6634
|
+
}
|
|
6635
|
+
//#endregion
|
|
6498
6636
|
//#region src/mux-select.ts
|
|
6499
6637
|
/**
|
|
6500
6638
|
* Backend selection via cyber-mux's two-mode mux probe, normalized through the transitional
|
|
@@ -6759,7 +6897,10 @@ function spawn(ctx, input) {
|
|
|
6759
6897
|
assertDistinctFromPrimary(opened.worktree.root, primaryRoot);
|
|
6760
6898
|
ensureMarker(join(opened.worktree.root, ".agents", "cyberlegion"));
|
|
6761
6899
|
cwd = opened.worktree.root;
|
|
6762
|
-
worktree =
|
|
6900
|
+
worktree = {
|
|
6901
|
+
...opened.worktree,
|
|
6902
|
+
primaryRoot
|
|
6903
|
+
};
|
|
6763
6904
|
target = opened.target;
|
|
6764
6905
|
} else {
|
|
6765
6906
|
const added = gitWorktreeAdapter.add(exec, {
|
|
@@ -6770,7 +6911,10 @@ function spawn(ctx, input) {
|
|
|
6770
6911
|
assertDistinctFromPrimary(added.root, primaryRoot);
|
|
6771
6912
|
ensureMarker(join(added.root, ".agents", "cyberlegion"));
|
|
6772
6913
|
cwd = added.root;
|
|
6773
|
-
worktree =
|
|
6914
|
+
worktree = {
|
|
6915
|
+
...added,
|
|
6916
|
+
primaryRoot
|
|
6917
|
+
};
|
|
6774
6918
|
target = sessionAdapter.open(exec, {
|
|
6775
6919
|
cwd,
|
|
6776
6920
|
launch: launchLine(),
|
|
@@ -6820,11 +6964,25 @@ function spawn(ctx, input) {
|
|
|
6820
6964
|
* The ring is best-effort on top of the guaranteed spawn effect (worktree + session + registry
|
|
6821
6965
|
* record): `--no-wake` rings nothing, and a ring that never completes is reported as a warning on
|
|
6822
6966
|
* the result rather than thrown, so it can never fail a spawn that already landed.
|
|
6967
|
+
*
|
|
6968
|
+
* Before the ring, the harness's folder-trust prompt is answered (`answerTrustPrompt`), with or
|
|
6969
|
+
* without `--no-wake`: a unit stuck at that prompt never reads its brief, and the doorbell typed into
|
|
6970
|
+
* it would answer it wrongly (Claude Code's focused option is "No, exit"). A worktree spawn made the
|
|
6971
|
+
* folder from the caller's own repository, so its prompt is accepted. A `--cwd` folder was not made
|
|
6972
|
+
* here, so its prompt is left for a person. A prompt left showing either way rings nothing and is
|
|
6973
|
+
* reported as `trustBlocked`, a failure the caller must surface, not a best-effort warning.
|
|
6823
6974
|
*/
|
|
6824
6975
|
async function spawnAndWake(ctx, input, options = {}) {
|
|
6825
6976
|
const res = spawn(ctx, input);
|
|
6826
6977
|
const env = ctx.env ?? process.env;
|
|
6827
6978
|
const exec = ctx.exec ?? realExec;
|
|
6979
|
+
const trust = await settleTrust(ctx, res, options.trustOpts);
|
|
6980
|
+
if (trust.state === "needs-human" || trust.state === "stuck") return {
|
|
6981
|
+
...res,
|
|
6982
|
+
rung: false,
|
|
6983
|
+
trust: trust.state,
|
|
6984
|
+
trustBlocked: trust.message
|
|
6985
|
+
};
|
|
6828
6986
|
const briefPath = res.agent.brief;
|
|
6829
6987
|
const wake = await wakeSpawn(() => selectSessionAdapter(env, exec), exec, {
|
|
6830
6988
|
target: { id: res.pane },
|
|
@@ -6835,10 +6993,31 @@ async function spawnAndWake(ctx, input, options = {}) {
|
|
|
6835
6993
|
return {
|
|
6836
6994
|
...res,
|
|
6837
6995
|
rung: wake.rung,
|
|
6996
|
+
trust: trust.state,
|
|
6838
6997
|
...wake.warning ? { warning: wake.warning } : {}
|
|
6839
6998
|
};
|
|
6840
6999
|
}
|
|
6841
7000
|
/**
|
|
7001
|
+
* Answer the new pane's folder-trust prompt, if its harness shows one. A backend that cannot be
|
|
7002
|
+
* resolved leaves nothing to read, which is no evidence of a prompt: the ring's own containment
|
|
7003
|
+
* reports that backend.
|
|
7004
|
+
*/
|
|
7005
|
+
async function settleTrust(ctx, res, opts) {
|
|
7006
|
+
const exec = ctx.exec ?? realExec;
|
|
7007
|
+
let adapter;
|
|
7008
|
+
try {
|
|
7009
|
+
adapter = selectSessionAdapter(ctx.env ?? process.env, exec);
|
|
7010
|
+
} catch {
|
|
7011
|
+
return { state: "unsettled" };
|
|
7012
|
+
}
|
|
7013
|
+
return answerTrustPrompt(adapter, exec, { id: res.pane }, {
|
|
7014
|
+
harness: res.agent.harness,
|
|
7015
|
+
folder: res.agent.cwd,
|
|
7016
|
+
pane: res.pane,
|
|
7017
|
+
policy: res.agent.worktree ? "accept" : "ask"
|
|
7018
|
+
}, opts);
|
|
7019
|
+
}
|
|
7020
|
+
/**
|
|
6842
7021
|
* The label a `workspace` placement opens under — the short human-scannable name that makes a unit's
|
|
6843
7022
|
* own visible space findable by eye (`console/workspace-label.ts` owns the naming rule). Only the
|
|
6844
7023
|
* `workspace` placement carries one: a pane or tab lives inside a space the caller is already
|
|
@@ -6954,7 +7133,7 @@ async function nudgeUnit(ctx, ref, options = {}) {
|
|
|
6954
7133
|
const exec = ctx.exec ?? realExec;
|
|
6955
7134
|
const message = options.message || "You have unread mail — check your inbox.";
|
|
6956
7135
|
const adapter = selectSessionAdapter(ctx.env ?? process.env, exec);
|
|
6957
|
-
const result = await withDraftGuard(adapter, exec, target, () =>
|
|
7136
|
+
const result = await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, message, options.nudgeOpts), {
|
|
6958
7137
|
...options.guardOpts,
|
|
6959
7138
|
harness: agent.harness
|
|
6960
7139
|
});
|
|
@@ -7145,25 +7324,38 @@ const DEFAULT_HARNESS = "claude";
|
|
|
7145
7324
|
function shellQuote(value) {
|
|
7146
7325
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7147
7326
|
}
|
|
7148
|
-
/** Cursor
|
|
7149
|
-
*
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7327
|
+
/** Cursor's model + effort, as the model id to launch and whether the effort rode along. Cursor has
|
|
7328
|
+
* no effort flag, and refuses the `<model>[effort=<level>]` bracket form its `--help` documents
|
|
7329
|
+
* (#72); `cursor-agent models` lists effort baked into flat ids (`claude-opus-5-high`) instead. So
|
|
7330
|
+
* the effort travels as the listed `<model>-<level>` id, or the model itself when it already is
|
|
7331
|
+
* one; with no such id listed, the model launches without the effort. Cursor also refuses an
|
|
7332
|
+
* effort on its default (`auto[effort=high]`), so with no model there is nothing to launch it on. */
|
|
7333
|
+
function cursorModel(model, effort, listCursorModels = () => []) {
|
|
7334
|
+
if (!model || !effort) return {
|
|
7335
|
+
model,
|
|
7336
|
+
effortApplied: !effort
|
|
7337
|
+
};
|
|
7338
|
+
const listed = listCursorModels();
|
|
7339
|
+
const suffix = `-${effort}`;
|
|
7340
|
+
if (model.endsWith(suffix) && listed.includes(model)) return {
|
|
7341
|
+
model,
|
|
7342
|
+
effortApplied: true
|
|
7343
|
+
};
|
|
7344
|
+
const flat = `${model}${suffix}`;
|
|
7345
|
+
if (listed.includes(flat)) return {
|
|
7346
|
+
model: flat,
|
|
7347
|
+
effortApplied: true
|
|
7348
|
+
};
|
|
7349
|
+
return {
|
|
7350
|
+
model,
|
|
7351
|
+
effortApplied: false
|
|
7352
|
+
};
|
|
7353
|
+
}
|
|
7354
|
+
/** The model + effort arguments for claude or codex, which spell effort differently: claude has
|
|
7355
|
+
* `--effort`, codex only a config override. Cursor's effort rides on its model (`cursorModel`). */
|
|
7160
7356
|
function modelAndEffortArgs(harness, model, effort) {
|
|
7161
|
-
if (harness === "cursor") {
|
|
7162
|
-
if (!model) return [];
|
|
7163
|
-
return ["--model", shellQuote(effort ? withCursorEffort(model, effort) : model)];
|
|
7164
|
-
}
|
|
7165
7357
|
const args = model ? ["--model", shellQuote(model)] : [];
|
|
7166
|
-
if (!effort) return args;
|
|
7358
|
+
if (!effort || harness === "cursor") return args;
|
|
7167
7359
|
if (harness === "codex") return [
|
|
7168
7360
|
...args,
|
|
7169
7361
|
"-c",
|
|
@@ -7189,18 +7381,20 @@ function instructionArgs(harness, instructions) {
|
|
|
7189
7381
|
}
|
|
7190
7382
|
/** Build the harness launch invocation for a def — explicit `model`/`effort`/`harness` win over the
|
|
7191
7383
|
* def's own tags, which win over the harness default. The def's effort and instructions each go
|
|
7192
|
-
* through the harness's own control (see `modelAndEffortArgs`, `instructionArgs`). */
|
|
7384
|
+
* through the harness's own control (see `cursorModel`, `modelAndEffortArgs`, `instructionArgs`). */
|
|
7193
7385
|
function realizeLaunch(def, opts = {}) {
|
|
7194
7386
|
const harness = opts.harness ?? def.harness ?? DEFAULT_HARNESS;
|
|
7195
|
-
const model = opts.model ?? def.model;
|
|
7196
7387
|
const effort = opts.effort ?? def.effort;
|
|
7388
|
+
const asked = opts.model ?? def.model;
|
|
7389
|
+
const cursor = harness === "cursor" ? cursorModel(asked, effort, opts.listCursorModels) : void 0;
|
|
7390
|
+
const model = cursor ? cursor.model : asked;
|
|
7197
7391
|
const parts = [
|
|
7198
7392
|
LAUNCH_MAP[harness],
|
|
7199
7393
|
...modelAndEffortArgs(harness, model, effort),
|
|
7200
7394
|
...instructionArgs(harness, def.instructions)
|
|
7201
7395
|
];
|
|
7202
7396
|
const briefInstructions = harness === "cursor" && def.instructions ? def.instructions : void 0;
|
|
7203
|
-
const notApplied =
|
|
7397
|
+
const notApplied = cursor && !cursor.effortApplied ? effort : void 0;
|
|
7204
7398
|
return {
|
|
7205
7399
|
harness,
|
|
7206
7400
|
command: parts.join(" "),
|
|
@@ -7224,7 +7418,8 @@ function resolveSpawnLaunch(input) {
|
|
|
7224
7418
|
const overrides = {
|
|
7225
7419
|
harness: input.harness,
|
|
7226
7420
|
model: input.model,
|
|
7227
|
-
effort: input.effort
|
|
7421
|
+
effort: input.effort,
|
|
7422
|
+
listCursorModels: input.listCursorModels
|
|
7228
7423
|
};
|
|
7229
7424
|
if (!input.agent && !input.agentFile) {
|
|
7230
7425
|
if (!input.harness || !input.model && !input.effort) return { harness: input.harness };
|
|
@@ -7248,15 +7443,19 @@ function resolveSpawnLaunch(input) {
|
|
|
7248
7443
|
* `--agent`/`--agent-file` def into the harness and composed launch command, with an explicit
|
|
7249
7444
|
* `--harness`/`--model`/`--effort` overriding the def's own.
|
|
7250
7445
|
*
|
|
7446
|
+
* `listCursorModels` is the cursor model listing a cursor effort is carried through (see `realizeLaunch`);
|
|
7447
|
+
* the CLI passes the live `cursor-agent models` probe.
|
|
7448
|
+
*
|
|
7251
7449
|
* Throws when no harness can be resolved, so the CLI's own `fail()` still renders it.
|
|
7252
7450
|
*/
|
|
7253
|
-
function spawnCommandInput(opts) {
|
|
7451
|
+
function spawnCommandInput(opts, listCursorModels) {
|
|
7254
7452
|
const { harness, command, model, effort, effortNotApplied, briefInstructions } = resolveSpawnLaunch({
|
|
7255
7453
|
agent: opts.agent,
|
|
7256
7454
|
agentFile: opts.agentFile,
|
|
7257
7455
|
harness: opts.harness,
|
|
7258
7456
|
model: opts.model,
|
|
7259
|
-
effort: opts.effort
|
|
7457
|
+
effort: opts.effort,
|
|
7458
|
+
listCursorModels
|
|
7260
7459
|
});
|
|
7261
7460
|
if (!harness) throw new Error("unit spawn needs --harness, or --agent/--agent-file resolving one");
|
|
7262
7461
|
return {
|
|
@@ -7285,6 +7484,30 @@ function readCommandOutput(format, result) {
|
|
|
7285
7484
|
return format === "json" ? JSON.stringify(result, null, 2) : result.output;
|
|
7286
7485
|
}
|
|
7287
7486
|
//#endregion
|
|
7487
|
+
//#region src/cursor-models.ts
|
|
7488
|
+
/** The model ids in `cursor-agent models` output: the first token of each line, ANSI colour and list
|
|
7489
|
+
* bullets stripped. Headings and tips come along too; callers only test a specific id for membership. */
|
|
7490
|
+
function parseCursorModels(output) {
|
|
7491
|
+
return stripVTControlCharacters(output).split("\n").map((line) => line.trim().replace(/^[-*•]\s+/, "").split(/\s+/)[0] ?? "").filter((id) => id !== "");
|
|
7492
|
+
}
|
|
7493
|
+
/** The model ids this account's `cursor-agent` lists, or none when the probe fails (not installed,
|
|
7494
|
+
* signed out, timed out) — the caller then launches without the effort and warns. */
|
|
7495
|
+
function listCursorModels() {
|
|
7496
|
+
try {
|
|
7497
|
+
return parseCursorModels(execFileSync("cursor-agent", ["models"], {
|
|
7498
|
+
encoding: "utf8",
|
|
7499
|
+
stdio: [
|
|
7500
|
+
"ignore",
|
|
7501
|
+
"pipe",
|
|
7502
|
+
"ignore"
|
|
7503
|
+
],
|
|
7504
|
+
timeout: 15e3
|
|
7505
|
+
}));
|
|
7506
|
+
} catch {
|
|
7507
|
+
return [];
|
|
7508
|
+
}
|
|
7509
|
+
}
|
|
7510
|
+
//#endregion
|
|
7288
7511
|
//#region src/message.ts
|
|
7289
7512
|
/** Write one message into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
|
|
7290
7513
|
function send(ctx, input) {
|
|
@@ -7384,12 +7607,18 @@ function decommission(ctx, input) {
|
|
|
7384
7607
|
const exec = ctx.exec ?? realExec;
|
|
7385
7608
|
const env = ctx.env ?? process.env;
|
|
7386
7609
|
const worktreeRoot = rec.worktree?.root;
|
|
7387
|
-
const primaryRoot = worktreeRoot ? resolvePrimaryRoot(exec) : void 0;
|
|
7388
|
-
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`);
|
|
7389
7610
|
const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
|
|
7611
|
+
const primaryRoot = worktreeExists ? primaryRootOf(exec, worktreeRoot) : rec.worktree?.primaryRoot;
|
|
7612
|
+
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`);
|
|
7390
7613
|
const removesWorktree = worktreeExists && !input.keepWorktree;
|
|
7391
7614
|
if (removesWorktree && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
|
|
7392
7615
|
const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
|
|
7616
|
+
if (worktreeRoot && !worktreeExists && primaryRoot && existsSync(primaryRoot)) exec("git", [
|
|
7617
|
+
"-C",
|
|
7618
|
+
primaryRoot,
|
|
7619
|
+
"worktree",
|
|
7620
|
+
"prune"
|
|
7621
|
+
]);
|
|
7393
7622
|
if (removesWorktree) try {
|
|
7394
7623
|
gitWorktreeAdapter.remove(exec, worktreeRoot, { primaryRoot });
|
|
7395
7624
|
} catch (err) {
|
|
@@ -7409,13 +7638,38 @@ function decommission(ctx, input) {
|
|
|
7409
7638
|
pane
|
|
7410
7639
|
};
|
|
7411
7640
|
}
|
|
7641
|
+
/**
|
|
7642
|
+
* The primary checkout of the repository `worktreeRoot` belongs to — `--git-common-dir` asked of the
|
|
7643
|
+
* worktree itself, so the answer does not depend on where the caller runs. Throws for a directory git
|
|
7644
|
+
* does not know: there is no repository to remove it from.
|
|
7645
|
+
*/
|
|
7646
|
+
function primaryRootOf(exec, worktreeRoot) {
|
|
7647
|
+
const commonDir = exec("git", [
|
|
7648
|
+
"-C",
|
|
7649
|
+
worktreeRoot,
|
|
7650
|
+
"rev-parse",
|
|
7651
|
+
"--path-format=absolute",
|
|
7652
|
+
"--git-common-dir"
|
|
7653
|
+
]);
|
|
7654
|
+
if (!commonDir) throw new Error(`cannot resolve the repository of worktree ${worktreeRoot} — git does not know it`);
|
|
7655
|
+
return dirname(commonDir);
|
|
7656
|
+
}
|
|
7657
|
+
/**
|
|
7658
|
+
* Whether the worktree holds work a removal would discard. The marker `spawn` stamps into every
|
|
7659
|
+
* worktree it creates is cyberlegion's own file, not the unit's work: in a project that does not
|
|
7660
|
+
* track it, it shows as untracked in every spawned worktree, and counting it would make a plain close
|
|
7661
|
+
* of a finished unit always demand `--force`. Only that exact untracked path is set aside — listed
|
|
7662
|
+
* per file, so anything else the unit left under `.agents/` still counts.
|
|
7663
|
+
*/
|
|
7412
7664
|
function isDirty(exec, worktreeRoot) {
|
|
7413
|
-
|
|
7665
|
+
const status = exec("git", [
|
|
7414
7666
|
"-C",
|
|
7415
7667
|
worktreeRoot,
|
|
7416
7668
|
"status",
|
|
7417
|
-
"--porcelain"
|
|
7669
|
+
"--porcelain",
|
|
7670
|
+
"--untracked-files=all"
|
|
7418
7671
|
]);
|
|
7672
|
+
return !!status && status.split("\n").some((line) => line && line !== `?? ${WORKTREE_MARKER}`);
|
|
7419
7673
|
}
|
|
7420
7674
|
//#endregion
|
|
7421
7675
|
//#region src/install.ts
|
|
@@ -7423,33 +7677,35 @@ const PIN_TOKEN = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
|
|
|
7423
7677
|
function validatePin(pin) {
|
|
7424
7678
|
if (!PIN_TOKEN.test(pin)) throw new Error(`invalid --pin "${pin}" — expected a version or dist-tag token like 0.2.0 or latest (no spaces, ranges, or shell metacharacters)`);
|
|
7425
7679
|
}
|
|
7426
|
-
const hookCommand = (event, pin) =>
|
|
7680
|
+
const hookCommand = (event, pin) => `if command -v cyberlegion >/dev/null 2>&1; then cyberlegion mail hook --event ${event}; else npx -y cyberlegion${pin ? `@${pin}` : ""} mail hook --event ${event}; fi`;
|
|
7681
|
+
const HOOK_GENERATIONS = [/^(?:npx (?:-y )?cyberlegion(?:@\S+)?|cyberlegion) mail hook --event (\w+)$/, /^if command -v cyberlegion >\/dev\/null 2>&1; then cyberlegion mail hook --event (\w+); else npx -y cyberlegion(?:@\S+)? mail hook --event \1; fi$/];
|
|
7427
7682
|
function hookTarget(command) {
|
|
7428
|
-
|
|
7683
|
+
for (const generation of HOOK_GENERATIONS) {
|
|
7684
|
+
const event = command.match(generation)?.[1];
|
|
7685
|
+
if (event) return event;
|
|
7686
|
+
}
|
|
7429
7687
|
}
|
|
7430
7688
|
const VENDORS = {
|
|
7431
7689
|
claude: {
|
|
7432
7690
|
file: ".claude/settings.json",
|
|
7433
7691
|
shape: "claude",
|
|
7434
|
-
events: {
|
|
7435
|
-
|
|
7436
|
-
PostToolUse: "PostToolUse"
|
|
7437
|
-
}
|
|
7692
|
+
events: { SessionStart: "SessionStart" },
|
|
7693
|
+
pluginHook: true
|
|
7438
7694
|
},
|
|
7439
7695
|
cursor: {
|
|
7440
7696
|
file: ".cursor/hooks.json",
|
|
7441
7697
|
shape: "cursor",
|
|
7442
|
-
events: { SessionStart: "sessionStart" }
|
|
7698
|
+
events: { SessionStart: "sessionStart" },
|
|
7699
|
+
pluginHook: false
|
|
7443
7700
|
},
|
|
7444
7701
|
codex: {
|
|
7445
7702
|
file: ".codex/hooks.json",
|
|
7446
7703
|
shape: "cursor",
|
|
7447
|
-
events: {
|
|
7448
|
-
|
|
7449
|
-
PostToolUse: "PostToolUse"
|
|
7450
|
-
}
|
|
7704
|
+
events: { SessionStart: "SessionStart" },
|
|
7705
|
+
pluginHook: true
|
|
7451
7706
|
}
|
|
7452
7707
|
};
|
|
7708
|
+
const RETIRED_EVENTS = ["PostToolUse"];
|
|
7453
7709
|
function readJson(file) {
|
|
7454
7710
|
if (!existsSync(file)) return {};
|
|
7455
7711
|
try {
|
|
@@ -7462,7 +7718,10 @@ function writeJson$1(file, data) {
|
|
|
7462
7718
|
mkdirSync(dirname(file), { recursive: true });
|
|
7463
7719
|
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
7464
7720
|
}
|
|
7465
|
-
/**
|
|
7721
|
+
/**
|
|
7722
|
+
* Make sure the surfacing hook fires for one harness, idempotently: leave it to the plugin where the
|
|
7723
|
+
* plugin ships it (removing an earlier project copy), otherwise register the PATH-first project hook.
|
|
7724
|
+
*/
|
|
7466
7725
|
function install(harness, projectDir = process.cwd(), pin) {
|
|
7467
7726
|
const spec = VENDORS[harness];
|
|
7468
7727
|
if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
|
|
@@ -7470,9 +7729,18 @@ function install(harness, projectDir = process.cwd(), pin) {
|
|
|
7470
7729
|
const file = join(projectDir, spec.file);
|
|
7471
7730
|
const settings = readJson(file);
|
|
7472
7731
|
const results = [];
|
|
7732
|
+
let changed = false;
|
|
7473
7733
|
for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
|
|
7474
|
-
|
|
7475
|
-
|
|
7734
|
+
let status;
|
|
7735
|
+
if (spec.pluginHook) {
|
|
7736
|
+
const removed = spec.shape === "claude" ? removeClaude(settings, vendorEvent, canonical) : removeCursor(settings, vendorEvent, canonical);
|
|
7737
|
+
status = removed ? "removed project hook" : "provided by plugin";
|
|
7738
|
+
changed ||= removed;
|
|
7739
|
+
} else {
|
|
7740
|
+
const command = hookCommand(canonical, pin);
|
|
7741
|
+
status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
|
|
7742
|
+
changed = true;
|
|
7743
|
+
}
|
|
7476
7744
|
results.push({
|
|
7477
7745
|
harness,
|
|
7478
7746
|
event: canonical,
|
|
@@ -7481,7 +7749,18 @@ function install(harness, projectDir = process.cwd(), pin) {
|
|
|
7481
7749
|
status
|
|
7482
7750
|
});
|
|
7483
7751
|
}
|
|
7484
|
-
|
|
7752
|
+
if (spec.pluginHook) for (const event of RETIRED_EVENTS) {
|
|
7753
|
+
if (!(spec.shape === "claude" ? removeClaude(settings, event, event) : removeCursor(settings, event, event))) continue;
|
|
7754
|
+
changed = true;
|
|
7755
|
+
results.push({
|
|
7756
|
+
harness,
|
|
7757
|
+
event,
|
|
7758
|
+
vendorEvent: event,
|
|
7759
|
+
file,
|
|
7760
|
+
status: "removed project hook"
|
|
7761
|
+
});
|
|
7762
|
+
}
|
|
7763
|
+
if (changed) writeJson$1(file, settings);
|
|
7485
7764
|
return results;
|
|
7486
7765
|
}
|
|
7487
7766
|
function upsertClaude(settings, event, command) {
|
|
@@ -7495,12 +7774,10 @@ function upsertClaude(settings, event, command) {
|
|
|
7495
7774
|
return "already present";
|
|
7496
7775
|
}
|
|
7497
7776
|
}
|
|
7498
|
-
|
|
7777
|
+
groups.push({ hooks: [{
|
|
7499
7778
|
type: "command",
|
|
7500
7779
|
command
|
|
7501
|
-
}] };
|
|
7502
|
-
if (event === "PostToolUse") group.matcher = "Write|Edit";
|
|
7503
|
-
groups.push(group);
|
|
7780
|
+
}] });
|
|
7504
7781
|
return "registered";
|
|
7505
7782
|
}
|
|
7506
7783
|
function upsertCursor(settings, event, command) {
|
|
@@ -7518,6 +7795,36 @@ function upsertCursor(settings, event, command) {
|
|
|
7518
7795
|
list.push({ command });
|
|
7519
7796
|
return "registered";
|
|
7520
7797
|
}
|
|
7798
|
+
const isOurs = (command, event) => command !== void 0 && hookTarget(command) === event;
|
|
7799
|
+
function removeClaude(settings, event, canonical) {
|
|
7800
|
+
const hooks = settings.hooks;
|
|
7801
|
+
const groups = hooks?.[event];
|
|
7802
|
+
if (!hooks || !groups) return false;
|
|
7803
|
+
let removed = false;
|
|
7804
|
+
const kept = [];
|
|
7805
|
+
for (const g of groups) {
|
|
7806
|
+
const entries = (g.hooks ?? []).filter((h) => !isOurs(h.command, canonical));
|
|
7807
|
+
if (entries.length !== (g.hooks ?? []).length) removed = true;
|
|
7808
|
+
if (entries.length > 0) kept.push({
|
|
7809
|
+
...g,
|
|
7810
|
+
hooks: entries
|
|
7811
|
+
});
|
|
7812
|
+
}
|
|
7813
|
+
if (!removed) return false;
|
|
7814
|
+
if (kept.length > 0) hooks[event] = kept;
|
|
7815
|
+
else delete hooks[event];
|
|
7816
|
+
return true;
|
|
7817
|
+
}
|
|
7818
|
+
function removeCursor(settings, event, canonical) {
|
|
7819
|
+
const hooks = settings.hooks;
|
|
7820
|
+
const list = hooks?.[event];
|
|
7821
|
+
if (!hooks || !list) return false;
|
|
7822
|
+
const kept = list.filter((h) => !isOurs(h.command, canonical));
|
|
7823
|
+
if (kept.length === list.length) return false;
|
|
7824
|
+
if (kept.length > 0) hooks[event] = kept;
|
|
7825
|
+
else delete hooks[event];
|
|
7826
|
+
return true;
|
|
7827
|
+
}
|
|
7521
7828
|
//#endregion
|
|
7522
7829
|
//#region src/output.ts
|
|
7523
7830
|
function stringifyCell(v) {
|
|
@@ -7674,7 +7981,7 @@ function byPath(ctx, dir) {
|
|
|
7674
7981
|
}
|
|
7675
7982
|
//#endregion
|
|
7676
7983
|
//#region src/runtime/inject-inbox.ts
|
|
7677
|
-
const EVENTS = ["SessionStart"
|
|
7984
|
+
const EVENTS = ["SessionStart"];
|
|
7678
7985
|
/**
|
|
7679
7986
|
* Resolve the calling agent, gather its unread mail (and a standing owner's, when this session is
|
|
7680
7987
|
* the hub's main pane), and return the SessionStart-style injection payload — or null when there is
|
|
@@ -7722,9 +8029,6 @@ function injectInbox(ctx, event) {
|
|
|
7722
8029
|
}
|
|
7723
8030
|
}
|
|
7724
8031
|
} catch {}
|
|
7725
|
-
if (rec && !rec.spawnedBy) try {
|
|
7726
|
-
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.");
|
|
7727
|
-
} catch {}
|
|
7728
8032
|
if (parts.length === 0) return null;
|
|
7729
8033
|
return { hookSpecificOutput: {
|
|
7730
8034
|
hookEventName: event,
|
|
@@ -8932,10 +9236,15 @@ function reportedEffort(launched) {
|
|
|
8932
9236
|
if (launched.effortNotApplied) return `${launched.effortNotApplied} (not applied)`;
|
|
8933
9237
|
return launched.effort ?? HARNESS_DEFAULT;
|
|
8934
9238
|
}
|
|
8935
|
-
/** Warn on stderr when the launch dropped an effort it could not carry
|
|
9239
|
+
/** Warn on stderr when the launch dropped an effort it could not carry: cursor with no model, or with
|
|
9240
|
+
* no `<model>-<level>` id in `cursor-agent models`. */
|
|
8936
9241
|
function warnEffortNotApplied(spawnInput) {
|
|
8937
|
-
const level = spawnInput.launched
|
|
9242
|
+
const { effortNotApplied: level, model } = spawnInput.launched;
|
|
8938
9243
|
if (!level) return;
|
|
9244
|
+
if (model) {
|
|
9245
|
+
console.error(`${spawnInput.input.harness} carries effort only in a model id that cursor-agent models lists, and it lists no "${model}-${level}"; effort "${level}" not applied — launching ${model} at its default effort (run cursor-agent models to pick an id)`);
|
|
9246
|
+
return;
|
|
9247
|
+
}
|
|
8939
9248
|
console.error(`${spawnInput.input.harness} carries effort only as a parameter on a named model and none was set; effort "${level}" not applied — launching at the harness default model and effort (pass --model to apply it)`);
|
|
8940
9249
|
}
|
|
8941
9250
|
/** The launch options `unit spawn` and `service start` share. */
|
|
@@ -8947,19 +9256,34 @@ function withSpawnOptions(cmd) {
|
|
|
8947
9256
|
"workspace"
|
|
8948
9257
|
])).option("--no-wake", "suppress the first-turn doorbell (spawn idle; the caller drives the first turn itself)");
|
|
8949
9258
|
}
|
|
9259
|
+
/**
|
|
9260
|
+
* A spawn left at its harness's folder-trust prompt is a failure the caller must see, not a
|
|
9261
|
+
* best-effort warning: the unit cannot read its brief until a person answers the prompt. The spawn
|
|
9262
|
+
* still landed, so the report names the step that finishes it, and the exit code is non-zero.
|
|
9263
|
+
*/
|
|
9264
|
+
function reportTrustBlocked(res) {
|
|
9265
|
+
if (!res.trustBlocked) return;
|
|
9266
|
+
console.error(res.trustBlocked);
|
|
9267
|
+
if (res.agent.brief) {
|
|
9268
|
+
const doorbell = spawnDoorbell(res.agent.brief).replaceAll("'", "'\\''");
|
|
9269
|
+
console.error(`then: cyberlegion unit nudge ${res.agent.handle} --message '${doorbell}'`);
|
|
9270
|
+
}
|
|
9271
|
+
process.exitCode = 1;
|
|
9272
|
+
}
|
|
8950
9273
|
function defineSpawn(cmd) {
|
|
8951
9274
|
return withSpawnOptions(cmd).description("launch a new peer session in its own git worktree (tmux or herdr)").action(async (opts) => {
|
|
8952
9275
|
const ctx = ctxOf(opts);
|
|
8953
9276
|
touch(ctx);
|
|
8954
9277
|
let spawnInput;
|
|
8955
9278
|
try {
|
|
8956
|
-
spawnInput = spawnCommandInput(opts);
|
|
9279
|
+
spawnInput = spawnCommandInput(opts, listCursorModels);
|
|
8957
9280
|
} catch (err) {
|
|
8958
9281
|
fail(err instanceof Error ? err.message : String(err));
|
|
8959
9282
|
}
|
|
8960
9283
|
warnEffortNotApplied(spawnInput);
|
|
8961
9284
|
const res = await spawnAndWake(ctx, spawnInput.input, { noWake: spawnInput.noWake });
|
|
8962
9285
|
if (res.warning) console.error(`first-turn doorbell not confirmed (peer still spawned; nudge it manually): ${res.warning}`);
|
|
9286
|
+
reportTrustBlocked(res);
|
|
8963
9287
|
emit(formatOf(opts), {
|
|
8964
9288
|
toon: toonObject({
|
|
8965
9289
|
spawned: res.agent.id,
|
|
@@ -8969,7 +9293,8 @@ function defineSpawn(cmd) {
|
|
|
8969
9293
|
effort: reportedEffort(spawnInput.launched),
|
|
8970
9294
|
worktree: res.agent.worktree?.root,
|
|
8971
9295
|
pane: res.pane,
|
|
8972
|
-
rung: res.rung
|
|
9296
|
+
rung: res.rung,
|
|
9297
|
+
trust: res.trust
|
|
8973
9298
|
}),
|
|
8974
9299
|
json: {
|
|
8975
9300
|
agent: res.agent,
|
|
@@ -8977,7 +9302,8 @@ function defineSpawn(cmd) {
|
|
|
8977
9302
|
launch: res.launch,
|
|
8978
9303
|
model: spawnInput.launched.model ?? HARNESS_DEFAULT,
|
|
8979
9304
|
effort: reportedEffort(spawnInput.launched),
|
|
8980
|
-
rung: res.rung
|
|
9305
|
+
rung: res.rung,
|
|
9306
|
+
trust: res.trust
|
|
8981
9307
|
}
|
|
8982
9308
|
});
|
|
8983
9309
|
nextStep(`cyberlegion unit read ${res.agent.id}`);
|
|
@@ -9303,7 +9629,7 @@ withSpawnOptions(service.command("start")).description("resolve the healthy owne
|
|
|
9303
9629
|
touch(ctx);
|
|
9304
9630
|
let spawnInput;
|
|
9305
9631
|
try {
|
|
9306
|
-
spawnInput = spawnCommandInput(opts);
|
|
9632
|
+
spawnInput = spawnCommandInput(opts, listCursorModels);
|
|
9307
9633
|
} catch (err) {
|
|
9308
9634
|
fail(err instanceof Error ? err.message : String(err));
|
|
9309
9635
|
}
|
|
@@ -9317,6 +9643,7 @@ withSpawnOptions(service.command("start")).description("resolve the healthy owne
|
|
|
9317
9643
|
launch: async () => {
|
|
9318
9644
|
const spawned = await spawnAndWake(ctx, spawnInput.input, { noWake: spawnInput.noWake });
|
|
9319
9645
|
warning = spawned.warning;
|
|
9646
|
+
reportTrustBlocked(spawned);
|
|
9320
9647
|
return {
|
|
9321
9648
|
unit: spawned.agent.id,
|
|
9322
9649
|
pane: spawned.pane
|
|
@@ -9511,7 +9838,7 @@ withGlobals(mail.command("watch")).description("stream new matching mail as it a
|
|
|
9511
9838
|
});
|
|
9512
9839
|
});
|
|
9513
9840
|
});
|
|
9514
|
-
withGlobals(mail.command("hook")).description("emit the harness hook injection payload (raw JSON on stdout, not TOON)").option("--event <event>", "SessionStart
|
|
9841
|
+
withGlobals(mail.command("hook")).description("emit the harness hook injection payload (raw JSON on stdout, not TOON)").option("--event <event>", "SessionStart", "SessionStart").action((opts) => {
|
|
9515
9842
|
const ctx = ctxOf(opts);
|
|
9516
9843
|
touch(ctx);
|
|
9517
9844
|
const payload = injectInbox(ctx, opts.event);
|
|
@@ -9682,7 +10009,7 @@ withGlobals(program.command("admin").description("hub-state maintenance").comman
|
|
|
9682
10009
|
json: res
|
|
9683
10010
|
});
|
|
9684
10011
|
});
|
|
9685
|
-
withGlobals(program.command("init")).description("resolve this session harness,
|
|
10012
|
+
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) => {
|
|
9686
10013
|
const ctx = ctxOf(opts);
|
|
9687
10014
|
let harness;
|
|
9688
10015
|
try {
|