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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.plugin/pins.json +1 -1
- package/CHANGELOG.md +22 -0
- package/dist/cli.mjs +344 -143
- package/dist/index.d.mts +28 -48
- package/dist/index.mjs +278 -74
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/readme.md +1 -1
- package/skills/dispatch-governance/SKILL.md +16 -3
- package/skills/dispatch-governance/scripts/cyberlegion.mjs +50 -50
- package/skills/init-cyberlegion/scripts/cyberlegion.mjs +50 -50
- package/skills/legate/SKILL.md +1 -1
- package/skills/legate/scripts/cyberlegion.mjs +50 -50
- package/skills/manage-inbox/SKILL.md +1 -1
- package/skills/manage-inbox/scripts/cyberlegion.mjs +50 -50
- package/skills/relay-governance/SKILL.md +1 -1
- package/skills/relay-governance/scripts/cyberlegion.mjs +50 -50
- package/skills/subagent-backend-governance/SKILL.md +1 -1
- package/skills/subagent-backend-governance/scripts/cyberlegion.mjs +50 -50
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
430
|
-
if (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
|
|
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
|
-
/**
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
755
|
+
marked.push(rec);
|
|
576
756
|
}
|
|
577
757
|
}
|
|
578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 !== `??
|
|
2219
|
+
return !!status && status.split("\n").some((line) => line && line !== `?? .agents/cyberlegion/config.json`);
|
|
2004
2220
|
}
|
|
2005
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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": "
|
|
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
|
|
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@
|
|
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
|
|