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/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { callerPane, currentPane, herdrMuxAdapter, nodeExec, nudge, probeMultiplexer, probeMultiplexer as probeMultiplexer$1, tmuxMuxAdapter } from "cyber-mux";
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
- /** Mark agents whose pane is gone or whose last-seen is stale as exited. Reconcile-culls against the
479
- * current mux's live set first, then falls through to the per-record paneExists + staleness check
480
- * (covers the other mux and sessions outside any multiplexer pane). */
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
- const paneGone = rec.pane ? !PANE_ADAPTERS[rec.pane.mux].paneExists(exec, { id: rec.pane.id }) : false;
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, () => nudge(adapter, exec, input.target, spawnDoorbell(input.briefPath), nudgeOpts), {
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 = opened.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 = added;
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 carries effort as a bracket parameter on the model (`<model>[effort=<level>]`): merge it into
1217
- * any bracket list the model already has, replacing an `effort=` already there. */
1218
- function withCursorEffort(model, effort) {
1219
- const open = model.lastIndexOf("[");
1220
- if (open === -1 || !model.endsWith("]")) return `${model}[effort=${effort}]`;
1221
- const params = model.slice(open + 1, -1).split(",").filter((p) => p !== "" && !p.startsWith("effort="));
1222
- return `${model.slice(0, open)}[${[...params, `effort=${effort}`].join(",")}]`;
1223
- }
1224
- /** The model + effort arguments for one harness. No two harnesses spell effort alike: claude has
1225
- * `--effort`, codex only a config override, cursor only a parameter on a named model. Cursor refuses
1226
- * an effort on its default (`auto[effort=high]` is not a model it accepts), so a cursor effort with
1227
- * no model is left off and `realizeLaunch` reports it as not applied. */
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 = harness === "cursor" && effort && !model ? effort : void 0;
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
- return !!exec("git", [
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) => pin ? `npx cyberlegion@${pin} mail hook --event ${event}` : `npx cyberlegion mail hook --event ${event}`;
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
- return command.match(/^(?:npx cyberlegion(?:@[^\s]+)?|cyberlegion) (mail hook --event \S+)$/)?.[1];
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
- SessionStart: "SessionStart",
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
- SessionStart: "SessionStart",
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
- /** Register the surfacing hook into one harness's config, idempotently. */
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
- const command = hookCommand(canonical, pin);
1452
- const status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
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
- writeJson$1(file, settings);
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
- const group = { hooks: [{
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", "PostToolUse"];
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,
@@ -0,0 +1,14 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/cyberlegion.mjs\" mail hook --event SessionStart"
9
+ }
10
+ ]
11
+ }
12
+ ]
13
+ }
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberlegion",
3
- "version": "1.0.0",
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": {