cyberlegion 1.10.0 → 2.0.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
@@ -6,7 +6,7 @@ import { acquire, gitWorktreeCreator, primaryRoot, release } from "cyber-agentki
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { homedir } from "node:os";
9
- //#region src/mux-env.ts
9
+ // src/mux-env.ts
10
10
  /**
11
11
  * Transitional env-normalization seam (mux.feature: "a pane carrying only the legacy fast-path vars
12
12
  * is still honored"). cyber-mux's own fast-path reads `$CYBER_MUX`/`$CYBER_MUX_PANE` — its
@@ -35,8 +35,7 @@ function normalizeMuxEnv(env) {
35
35
  CYBER_MUX_PANE: env.CYBERLEGION_MUX_PANE
36
36
  };
37
37
  }
38
- //#endregion
39
- //#region src/paths.ts
38
+ // src/paths.ts
40
39
  /** The tracked marker file that makes a hub root initialized (see ensureMarker). */
41
40
  const MARKER_FILE = "config.json";
42
41
  /** Where `spawn` stamps that marker inside a worktree it creates, relative to the worktree root. */
@@ -166,8 +165,7 @@ const paths = {
166
165
  function sanitizePane(pane) {
167
166
  return pane.replace(/[^A-Za-z0-9_-]/g, "_");
168
167
  }
169
- //#endregion
170
- //#region src/identity.ts
168
+ // src/identity.ts
171
169
  const realExec = nodeExec;
172
170
  /** `currentPane`, narrowed to a backend the registry can actually store a locator under. A caller
173
171
  * inside a pane-carrying-but-unstorable multiplexer (wezterm/zellij) is simply unpaned here — it can
@@ -180,6 +178,128 @@ function storablePane(env) {
180
178
  pane: cur.pane
181
179
  } : void 0;
182
180
  }
181
+ /**
182
+ * This session's own pane as the multiplexer sees it now. The env names the pane the shell was
183
+ * launched in, and for herdr that goes stale: herdr gives a pane a new id when it moves to another
184
+ * tab or workspace, while the shell keeps its launch-time `$HERDR_PANE_ID`, and herdr ids recur on a
185
+ * hub, so the stale id can be another unit's pane (#154). herdr's `pane current` resolves the
186
+ * calling pane itself, so its answer wins on a herdr that answers it truly (0.8.2 on). When herdr
187
+ * gives no answer, or an older herdr disagrees with the env, the env id stands. tmux pane
188
+ * ids last as long as the pane, so tmux needs no second opinion.
189
+ */
190
+ function selfPane(cur, exec) {
191
+ if (cur?.mux !== "herdr") return cur;
192
+ const live = herdrCallingPane(exec);
193
+ if (!live) return cur;
194
+ if (live.pane === cur.pane) return {
195
+ ...cur,
196
+ ...live.terminal ? { terminal: live.terminal } : {}
197
+ };
198
+ if (!herdrReportsCallingPane(exec)) return cur;
199
+ return {
200
+ ...cur,
201
+ pane: live.pane,
202
+ ...live.terminal ? { terminal: live.terminal } : {}
203
+ };
204
+ }
205
+ /** herdr 0.8.2 fixed `pane current` to answer the calling pane; before it, the command answered
206
+ * another client's focused pane (herdr #2297), which would misattribute exactly as a stale env id
207
+ * does. A version that cannot be read is treated as too old. */
208
+ function herdrReportsCallingPane(exec) {
209
+ const m = exec("herdr", ["--version"])?.match(/(\d+)\.(\d+)\.(\d+)/);
210
+ if (!m) return false;
211
+ const [major, minor, patch] = m.slice(1).map(Number);
212
+ return major > 0 || minor > 8 || minor === 8 && patch >= 2;
213
+ }
214
+ function herdrCallingPane(exec) {
215
+ return herdrPaneAnswer(exec("herdr", ["pane", "current"]));
216
+ }
217
+ /** Parse a herdr `pane current` / `pane get` answer into its pane id and terminal id. */
218
+ function herdrPaneAnswer(out) {
219
+ if (!out) return void 0;
220
+ try {
221
+ const pane = JSON.parse(out)?.result?.pane;
222
+ const id = pane?.pane_id;
223
+ if (typeof id !== "string" || !id) return void 0;
224
+ const terminal = pane.terminal_id;
225
+ return {
226
+ pane: id,
227
+ ...typeof terminal === "string" && terminal ? { terminal } : {}
228
+ };
229
+ } catch {
230
+ return;
231
+ }
232
+ }
233
+ /**
234
+ * The herdr terminal id behind a pane id — what a unit's herdr pane record keys on besides the pane
235
+ * id. herdr gives a pane a new id when it moves to another workspace and keeps its terminal id
236
+ * (#272; herdr 0.9.5: `pane move wRD:p2 --tab wRE:t1` answered `wRE:p2` on the same `terminal_id`).
237
+ * Undefined when herdr does not answer or reports no terminal id.
238
+ */
239
+ function herdrTerminalOf(exec, paneId) {
240
+ return herdrPaneAnswer(exec("herdr", [
241
+ "pane",
242
+ "get",
243
+ paneId
244
+ ]))?.terminal;
245
+ }
246
+ /** The pane locator for a pane a spawn or restart just opened: tagged with its multiplexer so the
247
+ * unit's own `prune` runs the right liveness check, and for herdr carrying the terminal id a move
248
+ * keeps. `null` for a backend a record cannot carry. */
249
+ function openedPaneLocator(exec, mux, id) {
250
+ if (mux === "tmux") return {
251
+ mux,
252
+ id
253
+ };
254
+ if (mux !== "herdr") return null;
255
+ const terminal = herdrTerminalOf(exec, id);
256
+ return {
257
+ mux,
258
+ id,
259
+ ...terminal ? { terminal } : {}
260
+ };
261
+ }
262
+ /** herdr's live panes keyed by terminal id → current pane id. Empty when herdr gives no list, which
263
+ * says nothing about any pane. */
264
+ function herdrTerminals(exec) {
265
+ const map = /* @__PURE__ */ new Map();
266
+ const out = exec("herdr", ["pane", "list"]);
267
+ if (!out) return map;
268
+ try {
269
+ const panes = JSON.parse(out)?.result?.panes;
270
+ if (!Array.isArray(panes)) return map;
271
+ for (const p of panes) if (typeof p?.terminal_id === "string" && typeof p?.pane_id === "string") map.set(p.terminal_id, p.pane_id);
272
+ } catch {
273
+ return map;
274
+ }
275
+ return map;
276
+ }
277
+ /**
278
+ * Where a herdr record that carries its terminal id lives now: the pane id herdr lists for that
279
+ * terminal (`{ id }`), `'gone'` when herdr answered with a list that lacks the terminal, or
280
+ * `undefined` when herdr gave no list. Not called for a record without a terminal id, which keeps
281
+ * being judged by its pane id.
282
+ */
283
+ function locateTerminal(exec, terminal, cache = {}) {
284
+ cache.terminals ??= herdrTerminals(exec);
285
+ if (cache.terminals.size === 0) return void 0;
286
+ const id = cache.terminals.get(terminal);
287
+ return id ? { id } : "gone";
288
+ }
289
+ /** Point a record whose herdr pane moved at the pane id it has now, moving its pane pointer with it.
290
+ * The old pointer is dropped only while it still names this record, since herdr hands old ids out
291
+ * again. */
292
+ function followMove(ctx, rec, id) {
293
+ const pane = rec.pane;
294
+ if (!pane || pane.id === id) return;
295
+ if (ctx.store.resolvePaneId(pane.id) === rec.id) ctx.store.removePaneIndex(pane.id);
296
+ rec.pane = {
297
+ ...pane,
298
+ id
299
+ };
300
+ saveAgent(ctx.store, rec);
301
+ ctx.store.putPaneIndex(id, rec.id);
302
+ }
183
303
  const nowIso = (ctx) => new Date(ctx.now?.() ?? Date.now()).toISOString();
184
304
  function randomId() {
185
305
  return randomBytes(8).toString("hex");
@@ -225,10 +345,33 @@ function detectHarness(explicit, ctx) {
225
345
  * explicit via the env var.
226
346
  */
227
347
  function resolveSelfId(ctx) {
348
+ return resolveSelf(ctx).id;
349
+ }
350
+ function resolveSelf(ctx) {
228
351
  const env = ctx.env ?? process.env;
229
- const cur = currentPane(normalizeMuxEnv(env));
230
- if (cur) return ctx.store.resolvePaneId(cur.pane);
231
- return env.CYBERLEGION_AGENT_ID || void 0;
352
+ const cur = selfPane(currentPane(normalizeMuxEnv(env)), ctx.exec ?? realExec);
353
+ if (cur) return {
354
+ id: selfIdAt(ctx.store, cur),
355
+ cur
356
+ };
357
+ return {
358
+ id: env.CYBERLEGION_AGENT_ID || void 0,
359
+ cur
360
+ };
361
+ }
362
+ /**
363
+ * The unit a pane belongs to. The pane pointer answers first. When herdr named the calling pane's
364
+ * terminal, a pointer whose record carries a different terminal is stale (the id was handed to
365
+ * this pane after the unit's own pane moved away), and a missing pointer may mean this pane moved:
366
+ * either way the record bearing this terminal is the caller (#272). A record without a terminal id
367
+ * is trusted by its pointer as before.
368
+ */
369
+ function selfIdAt(store, cur) {
370
+ const indexed = store.resolvePaneId(cur.pane);
371
+ if (!cur.terminal) return indexed;
372
+ const terminal = indexed ? loadAgent(store, indexed)?.pane?.terminal : void 0;
373
+ if (indexed && (!terminal || terminal === cur.terminal)) return indexed;
374
+ return listAgents(store).find((a) => a.pane?.mux === "herdr" && a.pane.terminal === cur.terminal)?.id;
232
375
  }
233
376
  /** Register (or idempotently refresh) this session's identity. */
234
377
  function register(ctx, input) {
@@ -240,8 +383,8 @@ function register(ctx, input) {
240
383
  const existing = existingId ? loadAgent(ctx.store, existingId) : void 0;
241
384
  const id = existing?.id ?? existingId ?? randomId();
242
385
  const ts = nowIso(ctx);
243
- const cur = storablePane(env);
244
386
  const exec = ctx.exec ?? realExec;
387
+ const cur = selfPane(storablePane(env), exec);
245
388
  const rec = {
246
389
  id,
247
390
  handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
@@ -276,7 +419,8 @@ function paneLocator(cur, exec, env) {
276
419
  };
277
420
  return {
278
421
  mux: cur.mux,
279
- id: cur.pane
422
+ id: cur.pane,
423
+ ...cur.terminal ? { terminal: cur.terminal } : {}
280
424
  };
281
425
  }
282
426
  /** The checkout `dir` (else the cwd) sits in: its root, its branch, and the repository's primary
@@ -424,10 +568,26 @@ function bumpLastSeen(ctx, id) {
424
568
  rec.lastSeen = nowIso(ctx);
425
569
  saveAgent(ctx.store, rec);
426
570
  }
427
- /** Refresh this session's own last-seen if it is registered — best-effort, never throws. */
571
+ /**
572
+ * Refresh this session's own last-seen if it is registered — best-effort, never throws. In a herdr
573
+ * pane whose calling pane herdr reported, it also heals the record's pane to the id and terminal
574
+ * herdr gave, so a unit whose pane moved fixes its own record on its next turn (#272).
575
+ */
428
576
  function touch(ctx) {
429
- const id = resolveSelfId(ctx);
430
- if (id) bumpLastSeen(ctx, id);
577
+ const { id, cur } = resolveSelf(ctx);
578
+ if (!id) return;
579
+ bumpLastSeen(ctx, id);
580
+ if (cur?.mux !== "herdr" || !cur.terminal) return;
581
+ const rec = loadAgent(ctx.store, id);
582
+ if (rec?.pane?.mux !== "herdr") return;
583
+ if (rec.pane.terminal !== cur.terminal) {
584
+ rec.pane = {
585
+ ...rec.pane,
586
+ terminal: cur.terminal
587
+ };
588
+ saveAgent(ctx.store, rec);
589
+ }
590
+ followMove(ctx, rec, cur.pane);
431
591
  }
432
592
  const STALE_MS = 9e5;
433
593
  /** Standing and service records have no session of their own, so nothing about a pane or a timer
@@ -454,7 +614,9 @@ function isPrunable(rec) {
454
614
  function sessionLive(ctx, rec) {
455
615
  if (rec.status === "exited" || rec.status === "stopped") return false;
456
616
  if (!rec.pane) return true;
457
- const panes = PANE_ADAPTERS[rec.pane.mux].listPanes(ctx.exec ?? realExec);
617
+ const exec = ctx.exec ?? realExec;
618
+ if (rec.pane.mux === "herdr" && rec.pane.terminal) return locateTerminal(exec, rec.pane.terminal) !== "gone";
619
+ const panes = PANE_ADAPTERS[rec.pane.mux].listPanes(exec);
458
620
  if (panes.length === 0) return true;
459
621
  const id = rec.pane.id;
460
622
  return panes.some((p) => p.id === id);
@@ -529,11 +691,12 @@ function reconcile(ctx, opts) {
529
691
  if (panes.length === 0) return [];
530
692
  const live = new Set(panes.map((p) => p.id));
531
693
  const changed = [];
694
+ const cache = {};
532
695
  for (const rec of listAgents(ctx.store)) {
533
696
  if (!isPrunable(rec)) continue;
534
697
  if (!rec.pane) continue;
535
698
  if (rec.pane.mux !== cur.mux) continue;
536
- if (!live.has(rec.pane.id)) {
699
+ if (rec.pane.mux === "herdr" && rec.pane.terminal ? terminalGone(ctx, rec, rec.pane.terminal, cache) : !live.has(rec.pane.id)) {
537
700
  rec.status = "exited";
538
701
  saveAgent(ctx.store, rec);
539
702
  changed.push(rec);
@@ -558,27 +721,93 @@ function paneGone(exec, pane, lists) {
558
721
  }
559
722
  return panes.length > 0 && !panes.some((p) => p.id === pane.id);
560
723
  }
561
- /** Mark agents whose session is gone as exited. Reconcile-culls against the current mux's live set
562
- * first, then judges each remaining record (covers the other mux and sessions outside any
563
- * multiplexer pane): a pane-bound record by its pane alone — a live or unprobeable pane keeps it
564
- * whatever its last-seen — and a pane-less record by the staleness timer, its only signal. */
724
+ /**
725
+ * Whether a herdr record that carries its terminal id is positively gone: herdr listed its panes and
726
+ * that terminal is not among them. A live terminal under a new pane id is a moved pane, so the record
727
+ * follows it instead (#272). A pane id that herdr now gives another terminal does not keep the record
728
+ * alive, since herdr ids recur. No list at all decides nothing.
729
+ */
730
+ function terminalGone(ctx, rec, terminal, cache) {
731
+ const at = locateTerminal(ctx.exec ?? realExec, terminal, cache);
732
+ if (at === "gone") return true;
733
+ if (at) followMove(ctx, rec, at.id);
734
+ return false;
735
+ }
736
+ /**
737
+ * Mark agents whose session is gone as exited, then delete every exited unit with nothing left to
738
+ * work from. Reconcile-culls against the current mux's live set first, then judges each remaining
739
+ * record (covers the other mux and sessions outside any multiplexer pane): a pane-bound record by its
740
+ * pane alone — a live or unprobeable pane keeps it whatever its last-seen — and a pane-less record by
741
+ * the staleness timer, its only signal. An exited unit whose worktree is still on disk is kept, since
742
+ * `unit close` owns removing that worktree; every other exited unit is deleted with `deleteExited`.
743
+ */
565
744
  function prune(ctx) {
566
745
  const exec = ctx.exec ?? realExec;
567
746
  const now = ctx.now?.() ?? Date.now();
568
- const changed = reconcile(ctx);
747
+ const marked = reconcile(ctx);
569
748
  const lists = /* @__PURE__ */ new Map();
749
+ const cache = {};
570
750
  for (const rec of listAgents(ctx.store)) {
571
751
  if (!isPrunable(rec)) continue;
572
- if (rec.pane ? paneGone(exec, rec.pane, lists) : now - new Date(rec.lastSeen).getTime() > STALE_MS) {
752
+ if (!rec.pane ? now - new Date(rec.lastSeen).getTime() > STALE_MS : rec.pane.mux === "herdr" && rec.pane.terminal ? terminalGone(ctx, rec, rec.pane.terminal, cache) : paneGone(exec, rec.pane, lists)) {
573
753
  rec.status = "exited";
574
754
  saveAgent(ctx.store, rec);
575
- changed.push(rec);
755
+ marked.push(rec);
576
756
  }
577
757
  }
578
- return changed;
758
+ const deleted = deleteExited(ctx);
759
+ const gone = new Set(deleted.map((r) => r.id));
760
+ return [...marked.filter((r) => !gone.has(r.id)).map((r) => ({
761
+ ...r,
762
+ pruned: "exited"
763
+ })), ...deleted.map((r) => ({
764
+ ...r,
765
+ pruned: "deleted"
766
+ }))];
767
+ }
768
+ /** Whether an exited record still has a worktree on disk — the one thing it leaves behind that
769
+ * `unit close` must deal with. A record that never had a worktree has nothing on disk. */
770
+ function worktreeOnDisk(rec) {
771
+ if (rec.worktree?.root == null || !existsSync(rec.worktree.root)) return false;
772
+ return !isPrimaryCheckout(rec.worktree.root, rec.worktree.primaryRoot);
773
+ }
774
+ /** Whether a worktree root is its repository's primary checkout — the one test `unit close` and
775
+ * `unit prune` share for "a seat on the main checkout, which nothing removes". */
776
+ function isPrimaryCheckout(worktreeRoot, primaryRoot) {
777
+ return worktreeRoot != null && primaryRoot != null && resolve(worktreeRoot) === resolve(primaryRoot);
778
+ }
779
+ /**
780
+ * Delete every exited unit with no worktree left to close (none on disk, or only the primary checkout): its record, brief, mailbox, and the pane
781
+ * pointer that still names it. Nothing revives an exited unit (`unit restart` takes only a running or
782
+ * stopped one), so these records are read by nobody. A pane pointer is dropped only when it still
783
+ * resolves to the deleted unit, since a multiplexer may have handed the pane id to another unit
784
+ * since. A worktree that is gone from disk may still be registered with git, and the record is the
785
+ * last place that names its repository, so that registration is cleared first, best-effort, as
786
+ * `unit close` does. No pane is torn down: an exited unit's pane is already gone or belongs to
787
+ * someone else.
788
+ */
789
+ function deleteExited(ctx) {
790
+ const exec = ctx.exec ?? realExec;
791
+ const deleted = [];
792
+ for (const rec of listAgents(ctx.store)) {
793
+ if (rec.status !== "exited" || isSessionIndependent(rec) || worktreeOnDisk(rec)) continue;
794
+ const primaryRoot = rec.worktree?.primaryRoot;
795
+ if (primaryRoot && existsSync(primaryRoot)) exec("git", [
796
+ "-C",
797
+ primaryRoot,
798
+ "worktree",
799
+ "prune"
800
+ ]);
801
+ const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(rec.id);
802
+ if (pane && ctx.store.resolvePaneId(pane) === rec.id) ctx.store.removePaneIndex(pane);
803
+ ctx.store.removeAgent(rec.id);
804
+ ctx.store.removeAgentData(rec.id);
805
+ ctx.store.removeMailbox(rec.id);
806
+ deleted.push(rec);
807
+ }
808
+ return deleted;
579
809
  }
580
- //#endregion
581
- //#region src/console/prompt-guard.ts
810
+ // src/console/prompt-guard.ts
582
811
  const CODEX_PLACEHOLDERS = /* @__PURE__ */ new Set([
583
812
  "Ask Codex to do anything",
584
813
  "Explain this codebase",
@@ -736,8 +965,7 @@ async function clearLine(adapter, exec, target, sleep) {
736
965
  adapter.sendText(exec, target, KILL_LINE);
737
966
  await sleep(KEY_SETTLE_MS);
738
967
  }
739
- //#endregion
740
- //#region src/console/ring.ts
968
+ // src/console/ring.ts
741
969
  const DEFAULT_ATTEMPTS = 10;
742
970
  const DEFAULT_SETTLE_MS = 400;
743
971
  /** cyber-mux's `isStaged` matches on the same prefix length. */
@@ -934,8 +1162,7 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
934
1162
  };
935
1163
  }
936
1164
  }
937
- //#endregion
938
- //#region src/console/trust.ts
1165
+ // src/console/trust.ts
939
1166
  /**
940
1167
  * Each harness's folder-trust prompt, as it draws it, and the keys that accept it. Read from Claude
941
1168
  * Code 2.1.286, Codex 0.153.4 (and its newer "Trust this folder?" wording), and cursor-agent
@@ -1045,8 +1272,7 @@ function stuck(where) {
1045
1272
  message: `${where}, and the prompt is still showing after spawn sent the keys that accept it. A person must answer it.`
1046
1273
  };
1047
1274
  }
1048
- //#endregion
1049
- //#region src/mux-select.ts
1275
+ // src/mux-select.ts
1050
1276
  /**
1051
1277
  * Backend selection via cyber-mux's two-mode mux probe, normalized through the transitional
1052
1278
  * `$CYBERLEGION_MUX*` → `$CYBER_MUX*` env seam (`mux-env.ts`) — tmux/herdr map to their existing
@@ -1066,8 +1292,7 @@ function selectSessionAdapter(env, exec = realExec) {
1066
1292
  if (probe.mux === "wezterm" || probe.mux === "zellij") throw new Error(`spawn detected ${probe.mux}, a backend unit/registry cannot store a pane locator under (only tmux and herdr) — refusing before opening anything`);
1067
1293
  throw new Error("spawn requires a session backend — run inside tmux ($TMUX) or herdr ($HERDR_ENV=1)");
1068
1294
  }
1069
- //#endregion
1070
- //#region src/workspace-label.ts
1295
+ // src/workspace-label.ts
1071
1296
  /** Leading actions that tear down or revert — the `A2-` class, checked first. */
1072
1297
  const TEARDOWN_ACTIONS = /* @__PURE__ */ new Set([
1073
1298
  "clean",
@@ -1210,8 +1435,7 @@ function fitWords(words, budget) {
1210
1435
  }
1211
1436
  return out;
1212
1437
  }
1213
- //#endregion
1214
- //#region src/worktree-lease.ts
1438
+ // src/worktree-lease.ts
1215
1439
  /**
1216
1440
  * The two worktree-library calls a unit's lifetime makes: `unit spawn` acquires a worktree, `unit
1217
1441
  * close` releases it. Gathered here so a test can stand in for them and drive spawn's routing without
@@ -1235,8 +1459,7 @@ function leaseHolder(id) {
1235
1459
  * cyberlegion's own file, not the unit's work, and counting it would keep every slot from reuse in a
1236
1460
  * project that does not track it. */
1237
1461
  const LEASE_IGNORE = [WORKTREE_MARKER];
1238
- //#endregion
1239
- //#region src/session.ts
1462
+ // src/session.ts
1240
1463
  /** How each harness's own CLI is launched in the new pane. */
1241
1464
  const LAUNCH_MAP = {
1242
1465
  claude: "claude",
@@ -1430,10 +1653,7 @@ async function spawn(ctx, input) {
1430
1653
  cwd,
1431
1654
  worktree,
1432
1655
  ...sharedWorktree ? { sharedWorktree } : {},
1433
- pane: muxName === "tmux" || muxName === "herdr" ? {
1434
- mux: muxName,
1435
- id: target.id
1436
- } : null,
1656
+ pane: openedPaneLocator(exec, muxName, target.id),
1437
1657
  status: "active",
1438
1658
  createdAt: ts,
1439
1659
  lastSeen: ts,
@@ -1628,8 +1848,7 @@ function resolveBrief(input, readStdin = () => readFileSync(0, "utf8")) {
1628
1848
  if (input.task != null && input.task !== "") return input.task;
1629
1849
  return null;
1630
1850
  }
1631
- //#endregion
1632
- //#region src/agentdef/resolve.ts
1851
+ // src/agentdef/resolve.ts
1633
1852
  function parseAgentDefFile(text) {
1634
1853
  const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/.exec(text);
1635
1854
  if (!m) return {
@@ -1749,8 +1968,7 @@ function listAgentDefs(input = {}) {
1749
1968
  }
1750
1969
  return defs;
1751
1970
  }
1752
- //#endregion
1753
- //#region src/agentdef/realize.ts
1971
+ // src/agentdef/realize.ts
1754
1972
  const DEFAULT_HARNESS = "claude";
1755
1973
  /** POSIX single-quote a value for safe inclusion in a shell command line. */
1756
1974
  function shellQuote(value) {
@@ -1835,8 +2053,7 @@ function realizeLaunch(def, opts = {}) {
1835
2053
  ...briefInstructions ? { briefInstructions } : {}
1836
2054
  };
1837
2055
  }
1838
- //#endregion
1839
- //#region src/message.ts
2056
+ // src/message.ts
1840
2057
  /** Write one message into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
1841
2058
  function send(ctx, input) {
1842
2059
  const toId = resolveRecipient(ctx.store, input.to);
@@ -1897,8 +2114,7 @@ function deleteMessage(ctx, meId, msgId) {
1897
2114
  function deleteMailbox(store, id) {
1898
2115
  store.removeMailbox(id);
1899
2116
  }
1900
- //#endregion
1901
- //#region src/decommission.ts
2117
+ // src/decommission.ts
1902
2118
  /**
1903
2119
  * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses a
1904
2120
  * dirty worktree unless `--force`. Teardown always precedes reap: an already-gone worktree or pane
@@ -1934,7 +2150,7 @@ async function decommission(ctx, input) {
1934
2150
  const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
1935
2151
  const primaryRoot = worktreeExists ? await primaryRootOf(exec, worktreeRoot) : rec.worktree?.primaryRoot;
1936
2152
  const held = rec.worktree?.lease;
1937
- const onPrimary = worktreeRoot != null && primaryRoot != null && resolve(worktreeRoot) === resolve(primaryRoot);
2153
+ const onPrimary = isPrimaryCheckout(worktreeRoot, primaryRoot);
1938
2154
  if (onPrimary && input.force) throw new Error(`refusing to close "${input.id}" with --force — its worktree is the primary checkout, which close never alters; close it without --force to end its session and reap its record`);
1939
2155
  const removesWorktree = worktreeExists && !input.keepWorktree && !held && !onPrimary;
1940
2156
  if (removesWorktree && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
@@ -2000,10 +2216,9 @@ function isDirty(exec, worktreeRoot) {
2000
2216
  "--porcelain",
2001
2217
  "--untracked-files=all"
2002
2218
  ]);
2003
- return !!status && status.split("\n").some((line) => line && line !== `?? ${WORKTREE_MARKER}`);
2219
+ return !!status && status.split("\n").some((line) => line && line !== `?? .agents/cyberlegion/config.json`);
2004
2220
  }
2005
- //#endregion
2006
- //#region src/install.ts
2221
+ // src/install.ts
2007
2222
  const PIN_TOKEN = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
2008
2223
  function validatePin(pin) {
2009
2224
  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)`);
@@ -2156,8 +2371,7 @@ function removeCursor(settings, event, canonical) {
2156
2371
  else delete hooks[event];
2157
2372
  return true;
2158
2373
  }
2159
- //#endregion
2160
- //#region src/output.ts
2374
+ // src/output.ts
2161
2375
  function stringifyCell(v) {
2162
2376
  if (v == null) return "";
2163
2377
  const s = String(v);
@@ -2190,8 +2404,7 @@ function fail(msg) {
2190
2404
  console.error(JSON.stringify({ error: msg }));
2191
2405
  process.exit(1);
2192
2406
  }
2193
- //#endregion
2194
- //#region src/project.ts
2407
+ // src/project.ts
2195
2408
  /**
2196
2409
  * The canonical git common dir for `dir`, or undefined outside a repository. The common dir is the
2197
2410
  * one path every checkout of a repository shares — the default checkout and each linked worktree
@@ -2310,8 +2523,7 @@ function byPath(ctx, dir) {
2310
2523
  if (!commonDir) throw new Error(`"${dir}" is not inside a git repository`);
2311
2524
  return ctx.store.getProject(projectIdOf(commonDir)) ?? registerProject(ctx, { dir });
2312
2525
  }
2313
- //#endregion
2314
- //#region src/runtime/inject-inbox.ts
2526
+ // src/runtime/inject-inbox.ts
2315
2527
  const EVENTS = ["SessionStart"];
2316
2528
  /**
2317
2529
  * Resolve the calling agent, gather its unread mail (and a standing owner's, when this session is
@@ -2366,8 +2578,7 @@ function injectInbox(ctx, event) {
2366
2578
  additionalContext: parts.join("\n\n")
2367
2579
  } };
2368
2580
  }
2369
- //#endregion
2370
- //#region src/service.ts
2581
+ // src/service.ts
2371
2582
  /** A refused ownership transition. `stale` means the caller's generation, token, or holder claim is
2372
2583
  * not the current one — the caller is not (or no longer) the authority it claims to be. */
2373
2584
  var ServiceOwnershipError = class extends Error {
@@ -2684,8 +2895,7 @@ async function startService(ctx, projectRef, name, input) {
2684
2895
  throw err;
2685
2896
  }
2686
2897
  }
2687
- //#endregion
2688
- //#region src/store/errors.ts
2898
+ // src/store/errors.ts
2689
2899
  /** A record file exists but its content didn't parse as JSON — a torn write (crash mid-`writeFileSync`,
2690
2900
  * pre-atomic-write code path) or on-disk tampering. Carries the file path and the original parse
2691
2901
  * error so a caller can report exactly what's broken and where, rather than "Unexpected token" with
@@ -2698,8 +2908,7 @@ var CorruptRecordError = class extends Error {
2698
2908
  this.name = "CorruptRecordError";
2699
2909
  }
2700
2910
  };
2701
- //#endregion
2702
- //#region src/store/process-liveness.ts
2911
+ // src/store/process-liveness.ts
2703
2912
  /**
2704
2913
  * Probe whether `pid` is alive using `process.kill(pid, 0)` — the POSIX "does this process exist"
2705
2914
  * idiom; it sends no signal, it only tests deliverability. `ESRCH` (no such process) is the only
@@ -2719,8 +2928,7 @@ function probeProcess(pid) {
2719
2928
  return err.code === "ESRCH" ? "dead" : "unknown";
2720
2929
  }
2721
2930
  }
2722
- //#endregion
2723
- //#region src/store/lock.ts
2931
+ // src/store/lock.ts
2724
2932
  var LockTimeoutError = class extends Error {
2725
2933
  name;
2726
2934
  constructor(name) {
@@ -2857,8 +3065,7 @@ async function withLockAsync(root, name, fn, opts) {
2857
3065
  handle.release();
2858
3066
  }
2859
3067
  }
2860
- //#endregion
2861
- //#region src/store/file-store.ts
3068
+ // src/store/file-store.ts
2862
3069
  /** Parse a record file's content, wrapping a `JSON.parse` failure in a typed, file-named
2863
3070
  * `CorruptRecordError` instead of letting a bare `SyntaxError` bubble up from deep inside
2864
3071
  * `listInbox`/`listAgents`/`getAgent` with no indication of WHICH file broke. A file that doesn't
@@ -3068,8 +3275,7 @@ var FileStore = class {
3068
3275
  return withLockAsync(this.root, name, fn);
3069
3276
  }
3070
3277
  };
3071
- //#endregion
3072
- //#region src/wake/await.ts
3278
+ // src/wake/await.ts
3073
3279
  /** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
3074
3280
  const POLL_MS$1 = 1e3;
3075
3281
  const DEFAULT_TIMEOUT_MS = 6e5;
@@ -3114,8 +3320,7 @@ async function awaitReply(ctx, input) {
3114
3320
  await sleep(POLL_MS$1);
3115
3321
  }
3116
3322
  }
3117
- //#endregion
3118
- //#region src/wake/watch.ts
3323
+ // src/wake/watch.ts
3119
3324
  const POLL_MS = 1e3;
3120
3325
  /**
3121
3326
  * A continuous foreground observer: prints each NEW matching message as it arrives, polling on a
@@ -3147,5 +3352,4 @@ async function watchMail(ctx, input, onMessage, opts = {}) {
3147
3352
  await sleep(POLL_MS);
3148
3353
  }
3149
3354
  }
3150
- //#endregion
3151
3355
  export { DEFAULT_RESERVATION_TTL_MS, FileStore, ServiceOwnershipError, ack, acquireService, assertDistinctFromPrimary, awaitReply, bindService, bumpLastSeen, decommission, deleteMessage, detectHarness, emit, ensureMarker, fail, gitWorktreeAdapter, handoffService, inbox, injectInbox, install, listAgentDefs, listAgents, listProjects, loadAgent, nextStep, paths, peek, probeMultiplexer, projectRoot, prune, realExec, realizeLaunch, register, registerProject, releaseService, resolveAgent, resolveAgentDef, resolveBody, resolvePrimaryRoot, resolveProject, resolveProjectLocalRoot, resolveRecipient, resolveRoot, resolveSelfId, resolveService, selectSessionAdapter, send, serviceEndpointId, spawn, spawnAndWake, startService, toonList, toonObject, touch, verifyOwnership, watchMail, withOwnership };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberlegion",
3
- "version": "1.10.0",
3
+ "version": "2.0.0",
4
4
  "description": "Harness-agnostic agent session spawning and messaging over the filesystem",
5
5
  "keywords": [
6
6
  "agent",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "cyberlegion",
4
- "version": "1.10.0",
4
+ "version": "2.0.0",
5
5
  "description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
6
6
  "author": {
7
7
  "name": "unional"
package/readme.md CHANGED
@@ -30,7 +30,7 @@ npx cyberlegion unit register --handle scout # register/refresh this sess
30
30
  npx cyberlegion unit register --standing --handle homa # mint a standing, session-independent owner inbox
31
31
  npx cyberlegion unit whoami # print your own identity
32
32
  npx cyberlegion unit who # list addressable peers / live peer sessions
33
- npx cyberlegion unit prune # mark dead agents exited and sweep
33
+ npx cyberlegion unit prune # mark dead agents exited; delete exited ones with no worktree
34
34
  npx cyberlegion unit spawn --agent reviewer # launch a peer in its own git worktree (tmux/herdr)
35
35
  npx cyberlegion unit focus <ref> # move input focus to a peer's session
36
36
  npx cyberlegion unit nudge <ref> # ring a peer's session (doorbell)
@@ -12,12 +12,13 @@ place that judgment lives, loaded in-session by the `legate` gateway on a dispat
12
12
  realized headless by the `headless-legate` agent. Both loads run the exact same procedure below.
13
13
 
14
14
  **Input:** an intent to fulfill role `R` with brief `B`, optionally expecting a result that
15
- satisfies verdict schema `V`.
15
+ satisfies verdict schema `V`, and optionally the path `P` of an agent definition file. When the
16
+ caller supplies `P`, it is the definition to use; the by-name lookup is skipped.
16
17
 
17
18
  > **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
18
19
  > this plugin ships. The path is relative to this skill's own directory, not the working directory.
19
20
  > If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
20
- > with the same arguments: `npx -y cyberlegion@1.10.0`.
21
+ > with the same arguments: `npx -y cyberlegion@2.0.0`.
21
22
 
22
23
  ## 1. Resolve the agent definition
23
24
 
@@ -25,6 +26,18 @@ satisfies verdict schema `V`.
25
26
  node scripts/cyberlegion.mjs agent resolve <R> --format json
26
27
  ```
27
28
 
29
+ When the intent carries a definition path `P`, resolve that file instead and do not look `<R>` up by
30
+ name (a definition a plugin ships is not under the project's `.agents/agents/`, so by name it is not
31
+ found):
32
+
33
+ ```bash
34
+ node scripts/cyberlegion.mjs agent resolve --file <P> --format json
35
+ ```
36
+
37
+ Everything below reads the same resolved def either way. Wherever a step passes `--agent <R>` to
38
+ `unit spawn`, pass `--agent-file <P>` instead when `P` was given, and the subagent path builds its
39
+ instruction from the def resolved from `P`.
40
+
28
41
  Read `model`, `effort`, `harness`, `warm`, `interactive` off the resolved def. `warm` marks a role
29
42
  that should run as a live peer session (not a fire-and-forget unit); `interactive` marks a role that
30
43
  may need to grill the caller or converse over multiple rounds, not just return one result.
@@ -53,7 +66,7 @@ to open a pane in; its absence rules that strategy out regardless of what the ag
53
66
  mint a thread id, weave it (and an instruction to reply on it) into brief `B`, then:
54
67
 
55
68
  ```bash
56
- node scripts/cyberlegion.mjs unit spawn --agent <R> --brief-file <B> --at <placement>
69
+ node scripts/cyberlegion.mjs unit spawn --agent <R> --brief-file <B> --at <placement> # or --agent-file <P> when given
57
70
  node scripts/cyberlegion.mjs mail await --thread <the-minted-thread-id> [--timeout <ms>] [--max-wait <s>]
58
71
  ```
59
72