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/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { callerPane, currentPane, herdrMuxAdapter,
|
|
1
|
+
import { callerPane, currentPane, herdrMuxAdapter, isStaged, nodeExec, probeMultiplexer, probeMultiplexer as probeMultiplexer$1, tmuxMuxAdapter } from "cyber-mux";
|
|
2
2
|
import { assertDistinctFromPrimary, assertDistinctFromPrimary as assertDistinctFromPrimary$1, gitWorktreeAdapter, gitWorktreeAdapter as gitWorktreeAdapter$1, resolvePrimaryRoot, resolvePrimaryRoot as resolvePrimaryRoot$1 } from "cyber-mux/worktree";
|
|
3
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
@@ -38,6 +38,8 @@ function normalizeMuxEnv(env) {
|
|
|
38
38
|
//#region src/paths.ts
|
|
39
39
|
/** The tracked marker file that makes a hub root initialized (see ensureMarker). */
|
|
40
40
|
const MARKER_FILE = "config.json";
|
|
41
|
+
/** Where `spawn` stamps that marker inside a worktree it creates, relative to the worktree root. */
|
|
42
|
+
const WORKTREE_MARKER = `.agents/cyberlegion/${MARKER_FILE}`;
|
|
41
43
|
/** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
|
|
42
44
|
function projectRoot(cwd = process.cwd()) {
|
|
43
45
|
let dir = resolve(cwd);
|
|
@@ -460,6 +462,7 @@ function reconcile(ctx, opts) {
|
|
|
460
462
|
const cur = storablePane(ctx.env ?? process.env);
|
|
461
463
|
if (!cur) return [];
|
|
462
464
|
const panes = PANE_ADAPTERS[cur.mux].listPanes(exec);
|
|
465
|
+
if (panes.length === 0) return [];
|
|
463
466
|
const live = new Set(panes.map((p) => p.id));
|
|
464
467
|
const changed = [];
|
|
465
468
|
for (const rec of listAgents(ctx.store)) {
|
|
@@ -475,18 +478,34 @@ function reconcile(ctx, opts) {
|
|
|
475
478
|
if (opts?.adopt) changed.push(...adopt(ctx, panes));
|
|
476
479
|
return changed;
|
|
477
480
|
}
|
|
478
|
-
/**
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
+
/**
|
|
482
|
+
* Whether a record's pane is positively gone: its existence probe fails AND its mux answers with a
|
|
483
|
+
* pane list that does not hold it. `paneExists` alone collapses "unreachable" into "gone", so a mux
|
|
484
|
+
* that answers no list at all (server down, a failed query) leaves the pane undecided — never gone.
|
|
485
|
+
* The list is read at most once per mux per call.
|
|
486
|
+
*/
|
|
487
|
+
function paneGone(exec, pane, lists) {
|
|
488
|
+
const adapter = PANE_ADAPTERS[pane.mux];
|
|
489
|
+
if (adapter.paneExists(exec, { id: pane.id })) return false;
|
|
490
|
+
let panes = lists.get(pane.mux);
|
|
491
|
+
if (!panes) {
|
|
492
|
+
panes = adapter.listPanes(exec);
|
|
493
|
+
lists.set(pane.mux, panes);
|
|
494
|
+
}
|
|
495
|
+
return panes.length > 0 && !panes.some((p) => p.id === pane.id);
|
|
496
|
+
}
|
|
497
|
+
/** Mark agents whose session is gone as exited. Reconcile-culls against the current mux's live set
|
|
498
|
+
* first, then judges each remaining record (covers the other mux and sessions outside any
|
|
499
|
+
* multiplexer pane): a pane-bound record by its pane alone — a live or unprobeable pane keeps it
|
|
500
|
+
* whatever its last-seen — and a pane-less record by the staleness timer, its only signal. */
|
|
481
501
|
function prune(ctx) {
|
|
482
502
|
const exec = ctx.exec ?? realExec;
|
|
483
503
|
const now = ctx.now?.() ?? Date.now();
|
|
484
504
|
const changed = reconcile(ctx);
|
|
505
|
+
const lists = /* @__PURE__ */ new Map();
|
|
485
506
|
for (const rec of listAgents(ctx.store)) {
|
|
486
507
|
if (!isPrunable(rec)) continue;
|
|
487
|
-
|
|
488
|
-
const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
|
|
489
|
-
if (paneGone || stale) {
|
|
508
|
+
if (rec.pane ? paneGone(exec, rec.pane, lists) : now - new Date(rec.lastSeen).getTime() > STALE_MS) {
|
|
490
509
|
rec.status = "exited";
|
|
491
510
|
saveAgent(ctx.store, rec);
|
|
492
511
|
changed.push(rec);
|
|
@@ -639,6 +658,59 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
639
658
|
}
|
|
640
659
|
}
|
|
641
660
|
//#endregion
|
|
661
|
+
//#region src/console/ring.ts
|
|
662
|
+
const DEFAULT_ATTEMPTS = 10;
|
|
663
|
+
const DEFAULT_SETTLE_MS = 400;
|
|
664
|
+
/** cyber-mux's `isStaged` matches on the same prefix length. */
|
|
665
|
+
const NEEDLE_LEN = 40;
|
|
666
|
+
/** How many screen rows carry `message` — the transcript, a queued follow-up, the input box. */
|
|
667
|
+
function copiesOn(screen, message) {
|
|
668
|
+
if (!screen) return 0;
|
|
669
|
+
const needle = message.replace(/\s+/g, " ").trim().slice(0, NEEDLE_LEN);
|
|
670
|
+
if (needle === "") return 0;
|
|
671
|
+
return screen.split("\n").filter((row) => row.replace(/\s+/g, " ").includes(needle)).length;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Submit `message` to `target` as a taken turn: cyber-mux's `nudge`, with one more way to see that the
|
|
675
|
+
* turn was taken.
|
|
676
|
+
*
|
|
677
|
+
* `nudge` judges the turn by whether the text still sits at the bottom of the screen, and flushes
|
|
678
|
+
* with a bare Enter while it does. Some harness screens put a taken turn back in the input box —
|
|
679
|
+
* cursor-agent with a rejected login posts the ring to its transcript (or queues it as a follow-up)
|
|
680
|
+
* and restores the same text into the box — so `nudge` flushes, and each flush rings again.
|
|
681
|
+
*
|
|
682
|
+
* So the screen is counted before the ring and after each submit. A swallowed Enter adds one copy of
|
|
683
|
+
* the text, the one in the input box. Two or more new copies mean the harness also posted it above
|
|
684
|
+
* the box, so the turn was taken and nothing is flushed. Text still at the bottom with only one new
|
|
685
|
+
* copy is the swallowed Enter, and is flushed as before.
|
|
686
|
+
*/
|
|
687
|
+
async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
688
|
+
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
689
|
+
const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
|
|
690
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
691
|
+
if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
|
|
692
|
+
const before = copiesOn(adapter.read(exec, target).text, message);
|
|
693
|
+
const taken = () => {
|
|
694
|
+
const screen = adapter.read(exec, target).text;
|
|
695
|
+
return !isStaged(screen, message) || copiesOn(screen, message) >= before + 2;
|
|
696
|
+
};
|
|
697
|
+
adapter.submit(exec, target, message);
|
|
698
|
+
await sleep(settleMs);
|
|
699
|
+
if (taken()) return {
|
|
700
|
+
taken: true,
|
|
701
|
+
resubmits: 0
|
|
702
|
+
};
|
|
703
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
704
|
+
adapter.submit(exec, target);
|
|
705
|
+
await sleep(settleMs);
|
|
706
|
+
if (taken()) return {
|
|
707
|
+
taken: true,
|
|
708
|
+
resubmits: attempt
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — input still staged after ${attempts} re-submit attempts`);
|
|
712
|
+
}
|
|
713
|
+
//#endregion
|
|
642
714
|
//#region src/console/doorbell.ts
|
|
643
715
|
/**
|
|
644
716
|
* The first-turn doorbell `unit spawn` delivers to a freshly-opened paned peer — the instruction
|
|
@@ -683,7 +755,7 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
683
755
|
if (input.noWake) return { rung: false };
|
|
684
756
|
try {
|
|
685
757
|
const adapter = getAdapter();
|
|
686
|
-
await withDraftGuard(adapter, exec, input.target, () =>
|
|
758
|
+
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, spawnDoorbell(input.briefPath), nudgeOpts), {
|
|
687
759
|
...guardOpts,
|
|
688
760
|
harness: input.harness
|
|
689
761
|
});
|
|
@@ -700,6 +772,117 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
700
772
|
}
|
|
701
773
|
}
|
|
702
774
|
//#endregion
|
|
775
|
+
//#region src/console/trust.ts
|
|
776
|
+
/**
|
|
777
|
+
* Each harness's folder-trust prompt, as it draws it, and the keys that accept it. Read from Claude
|
|
778
|
+
* Code 2.1.286, Codex 0.153.4 (and its newer "Trust this folder?" wording), and cursor-agent
|
|
779
|
+
* 2026.09.26, each opened in a real tmux pane on a folder it did not trust.
|
|
780
|
+
*
|
|
781
|
+
* Claude Code lists "No, exit" first and focuses it, so a bare Enter quits the harness. Its accept is
|
|
782
|
+
* therefore Down, a check that the screen shows "Yes, I trust this folder" selected, and only then
|
|
783
|
+
* Enter. Codex highlights its first option on `1` and confirms on Enter. cursor-agent takes `a`
|
|
784
|
+
* directly.
|
|
785
|
+
*/
|
|
786
|
+
const TRUST_PROMPTS = {
|
|
787
|
+
claude: {
|
|
788
|
+
markers: [["Quick safety check", "Yes, I trust this folder"]],
|
|
789
|
+
accept: [{
|
|
790
|
+
keys: ["Down"],
|
|
791
|
+
until: /❯\s*Yes, I trust this folder/
|
|
792
|
+
}, { keys: ["Enter"] }]
|
|
793
|
+
},
|
|
794
|
+
codex: {
|
|
795
|
+
markers: [["Do you trust the contents of this directory?", "Yes, continue"], ["Trust this folder?", "Trust and continue"]],
|
|
796
|
+
accept: [{
|
|
797
|
+
text: "1",
|
|
798
|
+
until: /›\s*1\.\s*(?:Yes, continue|Trust and continue)/
|
|
799
|
+
}, { keys: ["Enter"] }]
|
|
800
|
+
},
|
|
801
|
+
cursor: {
|
|
802
|
+
markers: [["Workspace Trust Required", "Trust this workspace"]],
|
|
803
|
+
accept: [{ text: "a" }]
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
const collapse = (s) => s.replace(/\s+/g, " ");
|
|
807
|
+
/** Whether `screen` shows `harness`'s folder-trust prompt. */
|
|
808
|
+
function showsTrustPrompt(harness, screen) {
|
|
809
|
+
const flat = collapse(screen);
|
|
810
|
+
return TRUST_PROMPTS[harness].markers.some((set) => set.every((phrase) => flat.includes(collapse(phrase))));
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Watch a freshly-opened pane until its harness either shows its folder-trust prompt or settles on a
|
|
814
|
+
* screen without one, and answer the prompt by `input.policy`.
|
|
815
|
+
*
|
|
816
|
+
* "Settled" means the screen has changed from its first read (the shell, still showing the launch
|
|
817
|
+
* line while the harness loads) and then read the same `stableReads` times in a row. A harness that
|
|
818
|
+
* never draws within the timeout, or a screen that stays blank for `blankReads` reads, is
|
|
819
|
+
* `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
|
|
820
|
+
* what the harness itself decided to show.
|
|
821
|
+
*/
|
|
822
|
+
async function answerTrustPrompt(adapter, exec, target, input, opts = {}) {
|
|
823
|
+
const pollMs = opts.pollMs ?? 500;
|
|
824
|
+
const timeoutMs = opts.timeoutMs ?? 2e4;
|
|
825
|
+
const stableReads = opts.stableReads ?? 3;
|
|
826
|
+
const blankReads = opts.blankReads ?? 6;
|
|
827
|
+
const keyDelayMs = opts.keyDelayMs ?? 1e3;
|
|
828
|
+
const attempts = opts.attempts ?? 5;
|
|
829
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
830
|
+
const read = () => {
|
|
831
|
+
try {
|
|
832
|
+
return adapter.read(exec, target).text ?? "";
|
|
833
|
+
} catch {
|
|
834
|
+
return "";
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
const shown = (screen) => showsTrustPrompt(input.harness, screen);
|
|
838
|
+
const first = read();
|
|
839
|
+
let seen = shown(first);
|
|
840
|
+
let previous = first;
|
|
841
|
+
let changed = false;
|
|
842
|
+
let same = 1;
|
|
843
|
+
for (let waited = 0; !seen && waited < timeoutMs; waited += pollMs) {
|
|
844
|
+
await sleep(pollMs);
|
|
845
|
+
const screen = read();
|
|
846
|
+
if (shown(screen)) {
|
|
847
|
+
seen = true;
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
if (screen !== first) changed = true;
|
|
851
|
+
same = screen === previous ? same + 1 : 1;
|
|
852
|
+
previous = screen;
|
|
853
|
+
if (changed && same >= stableReads) return { state: "none" };
|
|
854
|
+
if (!changed && first.trim() === "" && same >= blankReads) return { state: "unsettled" };
|
|
855
|
+
}
|
|
856
|
+
if (!seen) return { state: "unsettled" };
|
|
857
|
+
const where = `${input.harness} in pane ${input.pane} is asking whether to trust ${input.folder}`;
|
|
858
|
+
if (input.policy === "ask") return {
|
|
859
|
+
state: "needs-human",
|
|
860
|
+
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.`
|
|
861
|
+
};
|
|
862
|
+
await sleep(keyDelayMs);
|
|
863
|
+
for (const move of TRUST_PROMPTS[input.harness].accept) {
|
|
864
|
+
let done = move.until ? move.until.test(read()) : false;
|
|
865
|
+
for (let attempt = 0; !done && attempt < attempts; attempt++) {
|
|
866
|
+
if (move.keys) adapter.sendKeys(exec, target, move.keys);
|
|
867
|
+
if (move.text) adapter.sendText(exec, target, move.text);
|
|
868
|
+
await sleep(pollMs);
|
|
869
|
+
done = move.until ? move.until.test(read()) : true;
|
|
870
|
+
}
|
|
871
|
+
if (!done) return stuck(where);
|
|
872
|
+
}
|
|
873
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
874
|
+
if (!shown(read())) return { state: "accepted" };
|
|
875
|
+
await sleep(pollMs);
|
|
876
|
+
}
|
|
877
|
+
return stuck(where);
|
|
878
|
+
}
|
|
879
|
+
function stuck(where) {
|
|
880
|
+
return {
|
|
881
|
+
state: "stuck",
|
|
882
|
+
message: `${where}, and the prompt is still showing after spawn sent the keys that accept it. A person must answer it.`
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
703
886
|
//#region src/mux-select.ts
|
|
704
887
|
/**
|
|
705
888
|
* Backend selection via cyber-mux's two-mode mux probe, normalized through the transitional
|
|
@@ -933,7 +1116,10 @@ function spawn(ctx, input) {
|
|
|
933
1116
|
assertDistinctFromPrimary$1(opened.worktree.root, primaryRoot);
|
|
934
1117
|
ensureMarker(join(opened.worktree.root, ".agents", "cyberlegion"));
|
|
935
1118
|
cwd = opened.worktree.root;
|
|
936
|
-
worktree =
|
|
1119
|
+
worktree = {
|
|
1120
|
+
...opened.worktree,
|
|
1121
|
+
primaryRoot
|
|
1122
|
+
};
|
|
937
1123
|
target = opened.target;
|
|
938
1124
|
} else {
|
|
939
1125
|
const added = gitWorktreeAdapter$1.add(exec, {
|
|
@@ -944,7 +1130,10 @@ function spawn(ctx, input) {
|
|
|
944
1130
|
assertDistinctFromPrimary$1(added.root, primaryRoot);
|
|
945
1131
|
ensureMarker(join(added.root, ".agents", "cyberlegion"));
|
|
946
1132
|
cwd = added.root;
|
|
947
|
-
worktree =
|
|
1133
|
+
worktree = {
|
|
1134
|
+
...added,
|
|
1135
|
+
primaryRoot
|
|
1136
|
+
};
|
|
948
1137
|
target = sessionAdapter.open(exec, {
|
|
949
1138
|
cwd,
|
|
950
1139
|
launch: launchLine(),
|
|
@@ -994,11 +1183,25 @@ function spawn(ctx, input) {
|
|
|
994
1183
|
* The ring is best-effort on top of the guaranteed spawn effect (worktree + session + registry
|
|
995
1184
|
* record): `--no-wake` rings nothing, and a ring that never completes is reported as a warning on
|
|
996
1185
|
* the result rather than thrown, so it can never fail a spawn that already landed.
|
|
1186
|
+
*
|
|
1187
|
+
* Before the ring, the harness's folder-trust prompt is answered (`answerTrustPrompt`), with or
|
|
1188
|
+
* without `--no-wake`: a unit stuck at that prompt never reads its brief, and the doorbell typed into
|
|
1189
|
+
* it would answer it wrongly (Claude Code's focused option is "No, exit"). A worktree spawn made the
|
|
1190
|
+
* folder from the caller's own repository, so its prompt is accepted. A `--cwd` folder was not made
|
|
1191
|
+
* here, so its prompt is left for a person. A prompt left showing either way rings nothing and is
|
|
1192
|
+
* reported as `trustBlocked`, a failure the caller must surface, not a best-effort warning.
|
|
997
1193
|
*/
|
|
998
1194
|
async function spawnAndWake(ctx, input, options = {}) {
|
|
999
1195
|
const res = spawn(ctx, input);
|
|
1000
1196
|
const env = ctx.env ?? process.env;
|
|
1001
1197
|
const exec = ctx.exec ?? realExec;
|
|
1198
|
+
const trust = await settleTrust(ctx, res, options.trustOpts);
|
|
1199
|
+
if (trust.state === "needs-human" || trust.state === "stuck") return {
|
|
1200
|
+
...res,
|
|
1201
|
+
rung: false,
|
|
1202
|
+
trust: trust.state,
|
|
1203
|
+
trustBlocked: trust.message
|
|
1204
|
+
};
|
|
1002
1205
|
const briefPath = res.agent.brief;
|
|
1003
1206
|
const wake = await wakeSpawn(() => selectSessionAdapter(env, exec), exec, {
|
|
1004
1207
|
target: { id: res.pane },
|
|
@@ -1009,10 +1212,31 @@ async function spawnAndWake(ctx, input, options = {}) {
|
|
|
1009
1212
|
return {
|
|
1010
1213
|
...res,
|
|
1011
1214
|
rung: wake.rung,
|
|
1215
|
+
trust: trust.state,
|
|
1012
1216
|
...wake.warning ? { warning: wake.warning } : {}
|
|
1013
1217
|
};
|
|
1014
1218
|
}
|
|
1015
1219
|
/**
|
|
1220
|
+
* Answer the new pane's folder-trust prompt, if its harness shows one. A backend that cannot be
|
|
1221
|
+
* resolved leaves nothing to read, which is no evidence of a prompt: the ring's own containment
|
|
1222
|
+
* reports that backend.
|
|
1223
|
+
*/
|
|
1224
|
+
async function settleTrust(ctx, res, opts) {
|
|
1225
|
+
const exec = ctx.exec ?? realExec;
|
|
1226
|
+
let adapter;
|
|
1227
|
+
try {
|
|
1228
|
+
adapter = selectSessionAdapter(ctx.env ?? process.env, exec);
|
|
1229
|
+
} catch {
|
|
1230
|
+
return { state: "unsettled" };
|
|
1231
|
+
}
|
|
1232
|
+
return answerTrustPrompt(adapter, exec, { id: res.pane }, {
|
|
1233
|
+
harness: res.agent.harness,
|
|
1234
|
+
folder: res.agent.cwd,
|
|
1235
|
+
pane: res.pane,
|
|
1236
|
+
policy: res.agent.worktree ? "accept" : "ask"
|
|
1237
|
+
}, opts);
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1016
1240
|
* The label a `workspace` placement opens under — the short human-scannable name that makes a unit's
|
|
1017
1241
|
* own visible space findable by eye (`console/workspace-label.ts` owns the naming rule). Only the
|
|
1018
1242
|
* `workspace` placement carries one: a pane or tab lives inside a space the caller is already
|
|
@@ -1213,25 +1437,38 @@ const DEFAULT_HARNESS = "claude";
|
|
|
1213
1437
|
function shellQuote(value) {
|
|
1214
1438
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1215
1439
|
}
|
|
1216
|
-
/** Cursor
|
|
1217
|
-
*
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1440
|
+
/** Cursor's model + effort, as the model id to launch and whether the effort rode along. Cursor has
|
|
1441
|
+
* no effort flag, and refuses the `<model>[effort=<level>]` bracket form its `--help` documents
|
|
1442
|
+
* (#72); `cursor-agent models` lists effort baked into flat ids (`claude-opus-5-high`) instead. So
|
|
1443
|
+
* the effort travels as the listed `<model>-<level>` id, or the model itself when it already is
|
|
1444
|
+
* one; with no such id listed, the model launches without the effort. Cursor also refuses an
|
|
1445
|
+
* effort on its default (`auto[effort=high]`), so with no model there is nothing to launch it on. */
|
|
1446
|
+
function cursorModel(model, effort, listCursorModels = () => []) {
|
|
1447
|
+
if (!model || !effort) return {
|
|
1448
|
+
model,
|
|
1449
|
+
effortApplied: !effort
|
|
1450
|
+
};
|
|
1451
|
+
const listed = listCursorModels();
|
|
1452
|
+
const suffix = `-${effort}`;
|
|
1453
|
+
if (model.endsWith(suffix) && listed.includes(model)) return {
|
|
1454
|
+
model,
|
|
1455
|
+
effortApplied: true
|
|
1456
|
+
};
|
|
1457
|
+
const flat = `${model}${suffix}`;
|
|
1458
|
+
if (listed.includes(flat)) return {
|
|
1459
|
+
model: flat,
|
|
1460
|
+
effortApplied: true
|
|
1461
|
+
};
|
|
1462
|
+
return {
|
|
1463
|
+
model,
|
|
1464
|
+
effortApplied: false
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
/** The model + effort arguments for claude or codex, which spell effort differently: claude has
|
|
1468
|
+
* `--effort`, codex only a config override. Cursor's effort rides on its model (`cursorModel`). */
|
|
1228
1469
|
function modelAndEffortArgs(harness, model, effort) {
|
|
1229
|
-
if (harness === "cursor") {
|
|
1230
|
-
if (!model) return [];
|
|
1231
|
-
return ["--model", shellQuote(effort ? withCursorEffort(model, effort) : model)];
|
|
1232
|
-
}
|
|
1233
1470
|
const args = model ? ["--model", shellQuote(model)] : [];
|
|
1234
|
-
if (!effort) return args;
|
|
1471
|
+
if (!effort || harness === "cursor") return args;
|
|
1235
1472
|
if (harness === "codex") return [
|
|
1236
1473
|
...args,
|
|
1237
1474
|
"-c",
|
|
@@ -1257,18 +1494,20 @@ function instructionArgs(harness, instructions) {
|
|
|
1257
1494
|
}
|
|
1258
1495
|
/** Build the harness launch invocation for a def — explicit `model`/`effort`/`harness` win over the
|
|
1259
1496
|
* def's own tags, which win over the harness default. The def's effort and instructions each go
|
|
1260
|
-
* through the harness's own control (see `modelAndEffortArgs`, `instructionArgs`). */
|
|
1497
|
+
* through the harness's own control (see `cursorModel`, `modelAndEffortArgs`, `instructionArgs`). */
|
|
1261
1498
|
function realizeLaunch(def, opts = {}) {
|
|
1262
1499
|
const harness = opts.harness ?? def.harness ?? DEFAULT_HARNESS;
|
|
1263
|
-
const model = opts.model ?? def.model;
|
|
1264
1500
|
const effort = opts.effort ?? def.effort;
|
|
1501
|
+
const asked = opts.model ?? def.model;
|
|
1502
|
+
const cursor = harness === "cursor" ? cursorModel(asked, effort, opts.listCursorModels) : void 0;
|
|
1503
|
+
const model = cursor ? cursor.model : asked;
|
|
1265
1504
|
const parts = [
|
|
1266
1505
|
LAUNCH_MAP[harness],
|
|
1267
1506
|
...modelAndEffortArgs(harness, model, effort),
|
|
1268
1507
|
...instructionArgs(harness, def.instructions)
|
|
1269
1508
|
];
|
|
1270
1509
|
const briefInstructions = harness === "cursor" && def.instructions ? def.instructions : void 0;
|
|
1271
|
-
const notApplied =
|
|
1510
|
+
const notApplied = cursor && !cursor.effortApplied ? effort : void 0;
|
|
1272
1511
|
return {
|
|
1273
1512
|
harness,
|
|
1274
1513
|
command: parts.join(" "),
|
|
@@ -1361,12 +1600,18 @@ function decommission(ctx, input) {
|
|
|
1361
1600
|
const exec = ctx.exec ?? realExec;
|
|
1362
1601
|
const env = ctx.env ?? process.env;
|
|
1363
1602
|
const worktreeRoot = rec.worktree?.root;
|
|
1364
|
-
const primaryRoot = worktreeRoot ? resolvePrimaryRoot$1(exec) : void 0;
|
|
1365
|
-
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`);
|
|
1366
1603
|
const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
|
|
1604
|
+
const primaryRoot = worktreeExists ? primaryRootOf(exec, worktreeRoot) : rec.worktree?.primaryRoot;
|
|
1605
|
+
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`);
|
|
1367
1606
|
const removesWorktree = worktreeExists && !input.keepWorktree;
|
|
1368
1607
|
if (removesWorktree && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
|
|
1369
1608
|
const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
|
|
1609
|
+
if (worktreeRoot && !worktreeExists && primaryRoot && existsSync(primaryRoot)) exec("git", [
|
|
1610
|
+
"-C",
|
|
1611
|
+
primaryRoot,
|
|
1612
|
+
"worktree",
|
|
1613
|
+
"prune"
|
|
1614
|
+
]);
|
|
1370
1615
|
if (removesWorktree) try {
|
|
1371
1616
|
gitWorktreeAdapter$1.remove(exec, worktreeRoot, { primaryRoot });
|
|
1372
1617
|
} catch (err) {
|
|
@@ -1386,13 +1631,38 @@ function decommission(ctx, input) {
|
|
|
1386
1631
|
pane
|
|
1387
1632
|
};
|
|
1388
1633
|
}
|
|
1634
|
+
/**
|
|
1635
|
+
* The primary checkout of the repository `worktreeRoot` belongs to — `--git-common-dir` asked of the
|
|
1636
|
+
* worktree itself, so the answer does not depend on where the caller runs. Throws for a directory git
|
|
1637
|
+
* does not know: there is no repository to remove it from.
|
|
1638
|
+
*/
|
|
1639
|
+
function primaryRootOf(exec, worktreeRoot) {
|
|
1640
|
+
const commonDir = exec("git", [
|
|
1641
|
+
"-C",
|
|
1642
|
+
worktreeRoot,
|
|
1643
|
+
"rev-parse",
|
|
1644
|
+
"--path-format=absolute",
|
|
1645
|
+
"--git-common-dir"
|
|
1646
|
+
]);
|
|
1647
|
+
if (!commonDir) throw new Error(`cannot resolve the repository of worktree ${worktreeRoot} — git does not know it`);
|
|
1648
|
+
return dirname(commonDir);
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Whether the worktree holds work a removal would discard. The marker `spawn` stamps into every
|
|
1652
|
+
* worktree it creates is cyberlegion's own file, not the unit's work: in a project that does not
|
|
1653
|
+
* track it, it shows as untracked in every spawned worktree, and counting it would make a plain close
|
|
1654
|
+
* of a finished unit always demand `--force`. Only that exact untracked path is set aside — listed
|
|
1655
|
+
* per file, so anything else the unit left under `.agents/` still counts.
|
|
1656
|
+
*/
|
|
1389
1657
|
function isDirty(exec, worktreeRoot) {
|
|
1390
|
-
|
|
1658
|
+
const status = exec("git", [
|
|
1391
1659
|
"-C",
|
|
1392
1660
|
worktreeRoot,
|
|
1393
1661
|
"status",
|
|
1394
|
-
"--porcelain"
|
|
1662
|
+
"--porcelain",
|
|
1663
|
+
"--untracked-files=all"
|
|
1395
1664
|
]);
|
|
1665
|
+
return !!status && status.split("\n").some((line) => line && line !== `?? ${WORKTREE_MARKER}`);
|
|
1396
1666
|
}
|
|
1397
1667
|
//#endregion
|
|
1398
1668
|
//#region src/install.ts
|
|
@@ -1400,33 +1670,35 @@ const PIN_TOKEN = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
|
|
|
1400
1670
|
function validatePin(pin) {
|
|
1401
1671
|
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)`);
|
|
1402
1672
|
}
|
|
1403
|
-
const hookCommand = (event, pin) =>
|
|
1673
|
+
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`;
|
|
1674
|
+
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$/];
|
|
1404
1675
|
function hookTarget(command) {
|
|
1405
|
-
|
|
1676
|
+
for (const generation of HOOK_GENERATIONS) {
|
|
1677
|
+
const event = command.match(generation)?.[1];
|
|
1678
|
+
if (event) return event;
|
|
1679
|
+
}
|
|
1406
1680
|
}
|
|
1407
1681
|
const VENDORS = {
|
|
1408
1682
|
claude: {
|
|
1409
1683
|
file: ".claude/settings.json",
|
|
1410
1684
|
shape: "claude",
|
|
1411
|
-
events: {
|
|
1412
|
-
|
|
1413
|
-
PostToolUse: "PostToolUse"
|
|
1414
|
-
}
|
|
1685
|
+
events: { SessionStart: "SessionStart" },
|
|
1686
|
+
pluginHook: true
|
|
1415
1687
|
},
|
|
1416
1688
|
cursor: {
|
|
1417
1689
|
file: ".cursor/hooks.json",
|
|
1418
1690
|
shape: "cursor",
|
|
1419
|
-
events: { SessionStart: "sessionStart" }
|
|
1691
|
+
events: { SessionStart: "sessionStart" },
|
|
1692
|
+
pluginHook: false
|
|
1420
1693
|
},
|
|
1421
1694
|
codex: {
|
|
1422
1695
|
file: ".codex/hooks.json",
|
|
1423
1696
|
shape: "cursor",
|
|
1424
|
-
events: {
|
|
1425
|
-
|
|
1426
|
-
PostToolUse: "PostToolUse"
|
|
1427
|
-
}
|
|
1697
|
+
events: { SessionStart: "SessionStart" },
|
|
1698
|
+
pluginHook: true
|
|
1428
1699
|
}
|
|
1429
1700
|
};
|
|
1701
|
+
const RETIRED_EVENTS = ["PostToolUse"];
|
|
1430
1702
|
function readJson(file) {
|
|
1431
1703
|
if (!existsSync(file)) return {};
|
|
1432
1704
|
try {
|
|
@@ -1439,7 +1711,10 @@ function writeJson$1(file, data) {
|
|
|
1439
1711
|
mkdirSync(dirname(file), { recursive: true });
|
|
1440
1712
|
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
1441
1713
|
}
|
|
1442
|
-
/**
|
|
1714
|
+
/**
|
|
1715
|
+
* Make sure the surfacing hook fires for one harness, idempotently: leave it to the plugin where the
|
|
1716
|
+
* plugin ships it (removing an earlier project copy), otherwise register the PATH-first project hook.
|
|
1717
|
+
*/
|
|
1443
1718
|
function install(harness, projectDir = process.cwd(), pin) {
|
|
1444
1719
|
const spec = VENDORS[harness];
|
|
1445
1720
|
if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
|
|
@@ -1447,9 +1722,18 @@ function install(harness, projectDir = process.cwd(), pin) {
|
|
|
1447
1722
|
const file = join(projectDir, spec.file);
|
|
1448
1723
|
const settings = readJson(file);
|
|
1449
1724
|
const results = [];
|
|
1725
|
+
let changed = false;
|
|
1450
1726
|
for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
|
|
1451
|
-
|
|
1452
|
-
|
|
1727
|
+
let status;
|
|
1728
|
+
if (spec.pluginHook) {
|
|
1729
|
+
const removed = spec.shape === "claude" ? removeClaude(settings, vendorEvent, canonical) : removeCursor(settings, vendorEvent, canonical);
|
|
1730
|
+
status = removed ? "removed project hook" : "provided by plugin";
|
|
1731
|
+
changed ||= removed;
|
|
1732
|
+
} else {
|
|
1733
|
+
const command = hookCommand(canonical, pin);
|
|
1734
|
+
status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
|
|
1735
|
+
changed = true;
|
|
1736
|
+
}
|
|
1453
1737
|
results.push({
|
|
1454
1738
|
harness,
|
|
1455
1739
|
event: canonical,
|
|
@@ -1458,7 +1742,18 @@ function install(harness, projectDir = process.cwd(), pin) {
|
|
|
1458
1742
|
status
|
|
1459
1743
|
});
|
|
1460
1744
|
}
|
|
1461
|
-
|
|
1745
|
+
if (spec.pluginHook) for (const event of RETIRED_EVENTS) {
|
|
1746
|
+
if (!(spec.shape === "claude" ? removeClaude(settings, event, event) : removeCursor(settings, event, event))) continue;
|
|
1747
|
+
changed = true;
|
|
1748
|
+
results.push({
|
|
1749
|
+
harness,
|
|
1750
|
+
event,
|
|
1751
|
+
vendorEvent: event,
|
|
1752
|
+
file,
|
|
1753
|
+
status: "removed project hook"
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
if (changed) writeJson$1(file, settings);
|
|
1462
1757
|
return results;
|
|
1463
1758
|
}
|
|
1464
1759
|
function upsertClaude(settings, event, command) {
|
|
@@ -1472,12 +1767,10 @@ function upsertClaude(settings, event, command) {
|
|
|
1472
1767
|
return "already present";
|
|
1473
1768
|
}
|
|
1474
1769
|
}
|
|
1475
|
-
|
|
1770
|
+
groups.push({ hooks: [{
|
|
1476
1771
|
type: "command",
|
|
1477
1772
|
command
|
|
1478
|
-
}] };
|
|
1479
|
-
if (event === "PostToolUse") group.matcher = "Write|Edit";
|
|
1480
|
-
groups.push(group);
|
|
1773
|
+
}] });
|
|
1481
1774
|
return "registered";
|
|
1482
1775
|
}
|
|
1483
1776
|
function upsertCursor(settings, event, command) {
|
|
@@ -1495,6 +1788,36 @@ function upsertCursor(settings, event, command) {
|
|
|
1495
1788
|
list.push({ command });
|
|
1496
1789
|
return "registered";
|
|
1497
1790
|
}
|
|
1791
|
+
const isOurs = (command, event) => command !== void 0 && hookTarget(command) === event;
|
|
1792
|
+
function removeClaude(settings, event, canonical) {
|
|
1793
|
+
const hooks = settings.hooks;
|
|
1794
|
+
const groups = hooks?.[event];
|
|
1795
|
+
if (!hooks || !groups) return false;
|
|
1796
|
+
let removed = false;
|
|
1797
|
+
const kept = [];
|
|
1798
|
+
for (const g of groups) {
|
|
1799
|
+
const entries = (g.hooks ?? []).filter((h) => !isOurs(h.command, canonical));
|
|
1800
|
+
if (entries.length !== (g.hooks ?? []).length) removed = true;
|
|
1801
|
+
if (entries.length > 0) kept.push({
|
|
1802
|
+
...g,
|
|
1803
|
+
hooks: entries
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
if (!removed) return false;
|
|
1807
|
+
if (kept.length > 0) hooks[event] = kept;
|
|
1808
|
+
else delete hooks[event];
|
|
1809
|
+
return true;
|
|
1810
|
+
}
|
|
1811
|
+
function removeCursor(settings, event, canonical) {
|
|
1812
|
+
const hooks = settings.hooks;
|
|
1813
|
+
const list = hooks?.[event];
|
|
1814
|
+
if (!hooks || !list) return false;
|
|
1815
|
+
const kept = list.filter((h) => !isOurs(h.command, canonical));
|
|
1816
|
+
if (kept.length === list.length) return false;
|
|
1817
|
+
if (kept.length > 0) hooks[event] = kept;
|
|
1818
|
+
else delete hooks[event];
|
|
1819
|
+
return true;
|
|
1820
|
+
}
|
|
1498
1821
|
//#endregion
|
|
1499
1822
|
//#region src/output.ts
|
|
1500
1823
|
function stringifyCell(v) {
|
|
@@ -1651,7 +1974,7 @@ function byPath(ctx, dir) {
|
|
|
1651
1974
|
}
|
|
1652
1975
|
//#endregion
|
|
1653
1976
|
//#region src/runtime/inject-inbox.ts
|
|
1654
|
-
const EVENTS = ["SessionStart"
|
|
1977
|
+
const EVENTS = ["SessionStart"];
|
|
1655
1978
|
/**
|
|
1656
1979
|
* Resolve the calling agent, gather its unread mail (and a standing owner's, when this session is
|
|
1657
1980
|
* the hub's main pane), and return the SessionStart-style injection payload — or null when there is
|
|
@@ -1699,9 +2022,6 @@ function injectInbox(ctx, event) {
|
|
|
1699
2022
|
}
|
|
1700
2023
|
}
|
|
1701
2024
|
} catch {}
|
|
1702
|
-
if (rec && !rec.spawnedBy) try {
|
|
1703
|
-
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.");
|
|
1704
|
-
} catch {}
|
|
1705
2025
|
if (parts.length === 0) return null;
|
|
1706
2026
|
return { hookSpecificOutput: {
|
|
1707
2027
|
hookEventName: event,
|
package/hooks/hooks.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cyberlegion",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Harness-agnostic agent session spawning and messaging over the filesystem",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
".codex-plugin",
|
|
37
37
|
".plugin",
|
|
38
38
|
"skills",
|
|
39
|
+
"hooks",
|
|
39
40
|
"agents"
|
|
40
41
|
],
|
|
41
42
|
"dependencies": {
|