cyberlegion 1.9.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 +28 -0
- package/dist/cli.mjs +408 -151
- package/dist/index.d.mts +38 -48
- package/dist/index.mjs +336 -82
- 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 +58 -58
- package/skills/init-cyberlegion/scripts/cyberlegion.mjs +58 -58
- package/skills/legate/SKILL.md +1 -1
- package/skills/legate/scripts/cyberlegion.mjs +58 -58
- package/skills/manage-inbox/SKILL.md +1 -1
- package/skills/manage-inbox/scripts/cyberlegion.mjs +58 -58
- package/skills/relay-governance/SKILL.md +1 -1
- package/skills/relay-governance/scripts/cyberlegion.mjs +58 -58
- package/skills/subagent-backend-governance/SKILL.md +1 -1
- package/skills/subagent-backend-governance/scripts/cyberlegion.mjs +58 -58
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,14 +383,14 @@ 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),
|
|
248
391
|
harness,
|
|
249
392
|
cwd: process.cwd(),
|
|
250
|
-
|
|
393
|
+
...checkoutFor(ctx.store, exec, id, existing),
|
|
251
394
|
pane: cur ? paneLocator(cur, exec, env) : null,
|
|
252
395
|
status: "active",
|
|
253
396
|
createdAt: existing?.createdAt ?? ts,
|
|
@@ -276,27 +419,75 @@ 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
|
-
/** The checkout the cwd sits in: its root, its branch, and the repository's primary
|
|
283
|
-
* same `primaryRoot` spawn records, so a lookup by repository finds a unit in a linked
|
|
284
|
-
* the repository's name rather than the worktree folder's. */
|
|
285
|
-
function gitWorktree(exec) {
|
|
286
|
-
const
|
|
426
|
+
/** The checkout `dir` (else the cwd) sits in: its root, its branch, and the repository's primary
|
|
427
|
+
* checkout — the same `primaryRoot` spawn records, so a lookup by repository finds a unit in a linked
|
|
428
|
+
* worktree by the repository's name rather than the worktree folder's. */
|
|
429
|
+
function gitWorktree(exec, dir) {
|
|
430
|
+
const git = dir === void 0 ? exec : (cmd, args) => exec(cmd, cmd === "git" ? [
|
|
431
|
+
"-C",
|
|
432
|
+
dir,
|
|
433
|
+
...args
|
|
434
|
+
] : args);
|
|
435
|
+
const root = git("git", ["rev-parse", "--show-toplevel"]);
|
|
287
436
|
if (!root) return null;
|
|
288
|
-
const branch =
|
|
437
|
+
const branch = git("git", [
|
|
289
438
|
"rev-parse",
|
|
290
439
|
"--abbrev-ref",
|
|
291
440
|
"HEAD"
|
|
292
441
|
]) ?? void 0;
|
|
293
|
-
const primaryRoot = primaryRootHere(
|
|
442
|
+
const primaryRoot = primaryRootHere(git);
|
|
294
443
|
return {
|
|
295
444
|
root,
|
|
296
445
|
...branch !== void 0 ? { branch } : {},
|
|
297
446
|
...primaryRoot !== void 0 ? { primaryRoot } : {}
|
|
298
447
|
};
|
|
299
448
|
}
|
|
449
|
+
/** The id of the unit, other than `self`, whose record owns the worktree at `root`. */
|
|
450
|
+
function ownerOf(store, root, self) {
|
|
451
|
+
const at = resolve(root);
|
|
452
|
+
return listAgents(store).find((a) => a.id !== self && a.worktree?.root && resolve(a.worktree.root) === at)?.id;
|
|
453
|
+
}
|
|
454
|
+
/** `checkout` as a shared reference, naming the unit that owns it when one does. */
|
|
455
|
+
function asShared(store, checkout, self) {
|
|
456
|
+
const owner = ownerOf(store, checkout.root, self);
|
|
457
|
+
return {
|
|
458
|
+
...checkout,
|
|
459
|
+
...owner ? { owner } : {}
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/** The checkout a `--cwd` spawn opens into, as a shared reference — spawn created none, so the unit
|
|
463
|
+
* owns none, whoever does. `undefined` when `dir` is in no git checkout. */
|
|
464
|
+
function sharedCheckoutAt(store, exec, dir) {
|
|
465
|
+
const checkout = gitWorktree(exec, dir);
|
|
466
|
+
return checkout ? asShared(store, checkout) : void 0;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Which checkout a registering session owns and which it only shares. An existing owned worktree is
|
|
470
|
+
* kept. Otherwise the cwd's checkout is the session's own — unless another unit's record owns it, or
|
|
471
|
+
* this unit already records it as shared (a `--cwd` spawn): then it is a shared reference, and the
|
|
472
|
+
* session owns none, so its close can never remove another unit's worktree or release its lease.
|
|
473
|
+
*/
|
|
474
|
+
function checkoutFor(store, exec, id, existing) {
|
|
475
|
+
const kept = existing?.sharedWorktree ? { sharedWorktree: existing.sharedWorktree } : {};
|
|
476
|
+
if (existing?.worktree) return {
|
|
477
|
+
worktree: keptWorktree(existing.worktree, exec),
|
|
478
|
+
...kept
|
|
479
|
+
};
|
|
480
|
+
const here = gitWorktree(exec);
|
|
481
|
+
if (!here) return {
|
|
482
|
+
worktree: null,
|
|
483
|
+
...kept
|
|
484
|
+
};
|
|
485
|
+
if (existing?.sharedWorktree || ownerOf(store, here.root, id)) return {
|
|
486
|
+
worktree: null,
|
|
487
|
+
sharedWorktree: asShared(store, here, id)
|
|
488
|
+
};
|
|
489
|
+
return { worktree: here };
|
|
490
|
+
}
|
|
300
491
|
function primaryRootHere(exec) {
|
|
301
492
|
try {
|
|
302
493
|
return resolvePrimaryRoot$1(exec);
|
|
@@ -377,10 +568,26 @@ function bumpLastSeen(ctx, id) {
|
|
|
377
568
|
rec.lastSeen = nowIso(ctx);
|
|
378
569
|
saveAgent(ctx.store, rec);
|
|
379
570
|
}
|
|
380
|
-
/**
|
|
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
|
+
*/
|
|
381
576
|
function touch(ctx) {
|
|
382
|
-
const id =
|
|
383
|
-
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);
|
|
384
591
|
}
|
|
385
592
|
const STALE_MS = 9e5;
|
|
386
593
|
/** Standing and service records have no session of their own, so nothing about a pane or a timer
|
|
@@ -407,7 +614,9 @@ function isPrunable(rec) {
|
|
|
407
614
|
function sessionLive(ctx, rec) {
|
|
408
615
|
if (rec.status === "exited" || rec.status === "stopped") return false;
|
|
409
616
|
if (!rec.pane) return true;
|
|
410
|
-
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);
|
|
411
620
|
if (panes.length === 0) return true;
|
|
412
621
|
const id = rec.pane.id;
|
|
413
622
|
return panes.some((p) => p.id === id);
|
|
@@ -482,11 +691,12 @@ function reconcile(ctx, opts) {
|
|
|
482
691
|
if (panes.length === 0) return [];
|
|
483
692
|
const live = new Set(panes.map((p) => p.id));
|
|
484
693
|
const changed = [];
|
|
694
|
+
const cache = {};
|
|
485
695
|
for (const rec of listAgents(ctx.store)) {
|
|
486
696
|
if (!isPrunable(rec)) continue;
|
|
487
697
|
if (!rec.pane) continue;
|
|
488
698
|
if (rec.pane.mux !== cur.mux) continue;
|
|
489
|
-
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)) {
|
|
490
700
|
rec.status = "exited";
|
|
491
701
|
saveAgent(ctx.store, rec);
|
|
492
702
|
changed.push(rec);
|
|
@@ -511,27 +721,93 @@ function paneGone(exec, pane, lists) {
|
|
|
511
721
|
}
|
|
512
722
|
return panes.length > 0 && !panes.some((p) => p.id === pane.id);
|
|
513
723
|
}
|
|
514
|
-
/**
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
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
|
+
*/
|
|
518
744
|
function prune(ctx) {
|
|
519
745
|
const exec = ctx.exec ?? realExec;
|
|
520
746
|
const now = ctx.now?.() ?? Date.now();
|
|
521
|
-
const
|
|
747
|
+
const marked = reconcile(ctx);
|
|
522
748
|
const lists = /* @__PURE__ */ new Map();
|
|
749
|
+
const cache = {};
|
|
523
750
|
for (const rec of listAgents(ctx.store)) {
|
|
524
751
|
if (!isPrunable(rec)) continue;
|
|
525
|
-
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)) {
|
|
526
753
|
rec.status = "exited";
|
|
527
754
|
saveAgent(ctx.store, rec);
|
|
528
|
-
|
|
755
|
+
marked.push(rec);
|
|
529
756
|
}
|
|
530
757
|
}
|
|
531
|
-
|
|
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;
|
|
532
809
|
}
|
|
533
|
-
|
|
534
|
-
//#region src/console/prompt-guard.ts
|
|
810
|
+
// src/console/prompt-guard.ts
|
|
535
811
|
const CODEX_PLACEHOLDERS = /* @__PURE__ */ new Set([
|
|
536
812
|
"Ask Codex to do anything",
|
|
537
813
|
"Explain this codebase",
|
|
@@ -689,8 +965,7 @@ async function clearLine(adapter, exec, target, sleep) {
|
|
|
689
965
|
adapter.sendText(exec, target, KILL_LINE);
|
|
690
966
|
await sleep(KEY_SETTLE_MS);
|
|
691
967
|
}
|
|
692
|
-
|
|
693
|
-
//#region src/console/ring.ts
|
|
968
|
+
// src/console/ring.ts
|
|
694
969
|
const DEFAULT_ATTEMPTS = 10;
|
|
695
970
|
const DEFAULT_SETTLE_MS = 400;
|
|
696
971
|
/** cyber-mux's `isStaged` matches on the same prefix length. */
|
|
@@ -887,8 +1162,7 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
887
1162
|
};
|
|
888
1163
|
}
|
|
889
1164
|
}
|
|
890
|
-
|
|
891
|
-
//#region src/console/trust.ts
|
|
1165
|
+
// src/console/trust.ts
|
|
892
1166
|
/**
|
|
893
1167
|
* Each harness's folder-trust prompt, as it draws it, and the keys that accept it. Read from Claude
|
|
894
1168
|
* Code 2.1.286, Codex 0.153.4 (and its newer "Trust this folder?" wording), and cursor-agent
|
|
@@ -998,8 +1272,7 @@ function stuck(where) {
|
|
|
998
1272
|
message: `${where}, and the prompt is still showing after spawn sent the keys that accept it. A person must answer it.`
|
|
999
1273
|
};
|
|
1000
1274
|
}
|
|
1001
|
-
|
|
1002
|
-
//#region src/mux-select.ts
|
|
1275
|
+
// src/mux-select.ts
|
|
1003
1276
|
/**
|
|
1004
1277
|
* Backend selection via cyber-mux's two-mode mux probe, normalized through the transitional
|
|
1005
1278
|
* `$CYBERLEGION_MUX*` → `$CYBER_MUX*` env seam (`mux-env.ts`) — tmux/herdr map to their existing
|
|
@@ -1019,8 +1292,7 @@ function selectSessionAdapter(env, exec = realExec) {
|
|
|
1019
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`);
|
|
1020
1293
|
throw new Error("spawn requires a session backend — run inside tmux ($TMUX) or herdr ($HERDR_ENV=1)");
|
|
1021
1294
|
}
|
|
1022
|
-
|
|
1023
|
-
//#region src/workspace-label.ts
|
|
1295
|
+
// src/workspace-label.ts
|
|
1024
1296
|
/** Leading actions that tear down or revert — the `A2-` class, checked first. */
|
|
1025
1297
|
const TEARDOWN_ACTIONS = /* @__PURE__ */ new Set([
|
|
1026
1298
|
"clean",
|
|
@@ -1163,8 +1435,7 @@ function fitWords(words, budget) {
|
|
|
1163
1435
|
}
|
|
1164
1436
|
return out;
|
|
1165
1437
|
}
|
|
1166
|
-
|
|
1167
|
-
//#region src/worktree-lease.ts
|
|
1438
|
+
// src/worktree-lease.ts
|
|
1168
1439
|
/**
|
|
1169
1440
|
* The two worktree-library calls a unit's lifetime makes: `unit spawn` acquires a worktree, `unit
|
|
1170
1441
|
* close` releases it. Gathered here so a test can stand in for them and drive spawn's routing without
|
|
@@ -1188,8 +1459,7 @@ function leaseHolder(id) {
|
|
|
1188
1459
|
* cyberlegion's own file, not the unit's work, and counting it would keep every slot from reuse in a
|
|
1189
1460
|
* project that does not track it. */
|
|
1190
1461
|
const LEASE_IGNORE = [WORKTREE_MARKER];
|
|
1191
|
-
|
|
1192
|
-
//#region src/session.ts
|
|
1462
|
+
// src/session.ts
|
|
1193
1463
|
/** How each harness's own CLI is launched in the new pane. */
|
|
1194
1464
|
const LAUNCH_MAP = {
|
|
1195
1465
|
claude: "claude",
|
|
@@ -1282,6 +1552,7 @@ async function spawn(ctx, input) {
|
|
|
1282
1552
|
const from = callerPane(sessionAdapter, normalizedEnv);
|
|
1283
1553
|
let cwd;
|
|
1284
1554
|
let worktree;
|
|
1555
|
+
let sharedWorktree;
|
|
1285
1556
|
let base;
|
|
1286
1557
|
let reused;
|
|
1287
1558
|
let target;
|
|
@@ -1290,6 +1561,7 @@ async function spawn(ctx, input) {
|
|
|
1290
1561
|
cwd = resolve(input.cwd);
|
|
1291
1562
|
if (primaryRoot$1 !== void 0) assertDistinctFromPrimary$1(cwd, primaryRoot$1);
|
|
1292
1563
|
worktree = null;
|
|
1564
|
+
sharedWorktree = sharedCheckoutAt(ctx.store, exec, cwd);
|
|
1293
1565
|
const at = input.at ?? "tab";
|
|
1294
1566
|
target = sessionAdapter.open(exec, {
|
|
1295
1567
|
cwd,
|
|
@@ -1380,10 +1652,8 @@ async function spawn(ctx, input) {
|
|
|
1380
1652
|
harness,
|
|
1381
1653
|
cwd,
|
|
1382
1654
|
worktree,
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
id: target.id
|
|
1386
|
-
} : null,
|
|
1655
|
+
...sharedWorktree ? { sharedWorktree } : {},
|
|
1656
|
+
pane: openedPaneLocator(exec, muxName, target.id),
|
|
1387
1657
|
status: "active",
|
|
1388
1658
|
createdAt: ts,
|
|
1389
1659
|
lastSeen: ts,
|
|
@@ -1578,8 +1848,7 @@ function resolveBrief(input, readStdin = () => readFileSync(0, "utf8")) {
|
|
|
1578
1848
|
if (input.task != null && input.task !== "") return input.task;
|
|
1579
1849
|
return null;
|
|
1580
1850
|
}
|
|
1581
|
-
|
|
1582
|
-
//#region src/agentdef/resolve.ts
|
|
1851
|
+
// src/agentdef/resolve.ts
|
|
1583
1852
|
function parseAgentDefFile(text) {
|
|
1584
1853
|
const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/.exec(text);
|
|
1585
1854
|
if (!m) return {
|
|
@@ -1699,8 +1968,7 @@ function listAgentDefs(input = {}) {
|
|
|
1699
1968
|
}
|
|
1700
1969
|
return defs;
|
|
1701
1970
|
}
|
|
1702
|
-
|
|
1703
|
-
//#region src/agentdef/realize.ts
|
|
1971
|
+
// src/agentdef/realize.ts
|
|
1704
1972
|
const DEFAULT_HARNESS = "claude";
|
|
1705
1973
|
/** POSIX single-quote a value for safe inclusion in a shell command line. */
|
|
1706
1974
|
function shellQuote(value) {
|
|
@@ -1785,8 +2053,7 @@ function realizeLaunch(def, opts = {}) {
|
|
|
1785
2053
|
...briefInstructions ? { briefInstructions } : {}
|
|
1786
2054
|
};
|
|
1787
2055
|
}
|
|
1788
|
-
|
|
1789
|
-
//#region src/message.ts
|
|
2056
|
+
// src/message.ts
|
|
1790
2057
|
/** Write one message into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
|
|
1791
2058
|
function send(ctx, input) {
|
|
1792
2059
|
const toId = resolveRecipient(ctx.store, input.to);
|
|
@@ -1847,8 +2114,7 @@ function deleteMessage(ctx, meId, msgId) {
|
|
|
1847
2114
|
function deleteMailbox(store, id) {
|
|
1848
2115
|
store.removeMailbox(id);
|
|
1849
2116
|
}
|
|
1850
|
-
|
|
1851
|
-
//#region src/decommission.ts
|
|
2117
|
+
// src/decommission.ts
|
|
1852
2118
|
/**
|
|
1853
2119
|
* Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses a
|
|
1854
2120
|
* dirty worktree unless `--force`. Teardown always precedes reap: an already-gone worktree or pane
|
|
@@ -1884,7 +2150,7 @@ async function decommission(ctx, input) {
|
|
|
1884
2150
|
const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
|
|
1885
2151
|
const primaryRoot = worktreeExists ? await primaryRootOf(exec, worktreeRoot) : rec.worktree?.primaryRoot;
|
|
1886
2152
|
const held = rec.worktree?.lease;
|
|
1887
|
-
const onPrimary =
|
|
2153
|
+
const onPrimary = isPrimaryCheckout(worktreeRoot, primaryRoot);
|
|
1888
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`);
|
|
1889
2155
|
const removesWorktree = worktreeExists && !input.keepWorktree && !held && !onPrimary;
|
|
1890
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`);
|
|
@@ -1950,10 +2216,9 @@ function isDirty(exec, worktreeRoot) {
|
|
|
1950
2216
|
"--porcelain",
|
|
1951
2217
|
"--untracked-files=all"
|
|
1952
2218
|
]);
|
|
1953
|
-
return !!status && status.split("\n").some((line) => line && line !== `??
|
|
2219
|
+
return !!status && status.split("\n").some((line) => line && line !== `?? .agents/cyberlegion/config.json`);
|
|
1954
2220
|
}
|
|
1955
|
-
|
|
1956
|
-
//#region src/install.ts
|
|
2221
|
+
// src/install.ts
|
|
1957
2222
|
const PIN_TOKEN = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
|
|
1958
2223
|
function validatePin(pin) {
|
|
1959
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)`);
|
|
@@ -2106,8 +2371,7 @@ function removeCursor(settings, event, canonical) {
|
|
|
2106
2371
|
else delete hooks[event];
|
|
2107
2372
|
return true;
|
|
2108
2373
|
}
|
|
2109
|
-
|
|
2110
|
-
//#region src/output.ts
|
|
2374
|
+
// src/output.ts
|
|
2111
2375
|
function stringifyCell(v) {
|
|
2112
2376
|
if (v == null) return "";
|
|
2113
2377
|
const s = String(v);
|
|
@@ -2140,8 +2404,7 @@ function fail(msg) {
|
|
|
2140
2404
|
console.error(JSON.stringify({ error: msg }));
|
|
2141
2405
|
process.exit(1);
|
|
2142
2406
|
}
|
|
2143
|
-
|
|
2144
|
-
//#region src/project.ts
|
|
2407
|
+
// src/project.ts
|
|
2145
2408
|
/**
|
|
2146
2409
|
* The canonical git common dir for `dir`, or undefined outside a repository. The common dir is the
|
|
2147
2410
|
* one path every checkout of a repository shares — the default checkout and each linked worktree
|
|
@@ -2260,8 +2523,7 @@ function byPath(ctx, dir) {
|
|
|
2260
2523
|
if (!commonDir) throw new Error(`"${dir}" is not inside a git repository`);
|
|
2261
2524
|
return ctx.store.getProject(projectIdOf(commonDir)) ?? registerProject(ctx, { dir });
|
|
2262
2525
|
}
|
|
2263
|
-
|
|
2264
|
-
//#region src/runtime/inject-inbox.ts
|
|
2526
|
+
// src/runtime/inject-inbox.ts
|
|
2265
2527
|
const EVENTS = ["SessionStart"];
|
|
2266
2528
|
/**
|
|
2267
2529
|
* Resolve the calling agent, gather its unread mail (and a standing owner's, when this session is
|
|
@@ -2316,8 +2578,7 @@ function injectInbox(ctx, event) {
|
|
|
2316
2578
|
additionalContext: parts.join("\n\n")
|
|
2317
2579
|
} };
|
|
2318
2580
|
}
|
|
2319
|
-
|
|
2320
|
-
//#region src/service.ts
|
|
2581
|
+
// src/service.ts
|
|
2321
2582
|
/** A refused ownership transition. `stale` means the caller's generation, token, or holder claim is
|
|
2322
2583
|
* not the current one — the caller is not (or no longer) the authority it claims to be. */
|
|
2323
2584
|
var ServiceOwnershipError = class extends Error {
|
|
@@ -2634,8 +2895,7 @@ async function startService(ctx, projectRef, name, input) {
|
|
|
2634
2895
|
throw err;
|
|
2635
2896
|
}
|
|
2636
2897
|
}
|
|
2637
|
-
|
|
2638
|
-
//#region src/store/errors.ts
|
|
2898
|
+
// src/store/errors.ts
|
|
2639
2899
|
/** A record file exists but its content didn't parse as JSON — a torn write (crash mid-`writeFileSync`,
|
|
2640
2900
|
* pre-atomic-write code path) or on-disk tampering. Carries the file path and the original parse
|
|
2641
2901
|
* error so a caller can report exactly what's broken and where, rather than "Unexpected token" with
|
|
@@ -2648,8 +2908,7 @@ var CorruptRecordError = class extends Error {
|
|
|
2648
2908
|
this.name = "CorruptRecordError";
|
|
2649
2909
|
}
|
|
2650
2910
|
};
|
|
2651
|
-
|
|
2652
|
-
//#region src/store/process-liveness.ts
|
|
2911
|
+
// src/store/process-liveness.ts
|
|
2653
2912
|
/**
|
|
2654
2913
|
* Probe whether `pid` is alive using `process.kill(pid, 0)` — the POSIX "does this process exist"
|
|
2655
2914
|
* idiom; it sends no signal, it only tests deliverability. `ESRCH` (no such process) is the only
|
|
@@ -2669,8 +2928,7 @@ function probeProcess(pid) {
|
|
|
2669
2928
|
return err.code === "ESRCH" ? "dead" : "unknown";
|
|
2670
2929
|
}
|
|
2671
2930
|
}
|
|
2672
|
-
|
|
2673
|
-
//#region src/store/lock.ts
|
|
2931
|
+
// src/store/lock.ts
|
|
2674
2932
|
var LockTimeoutError = class extends Error {
|
|
2675
2933
|
name;
|
|
2676
2934
|
constructor(name) {
|
|
@@ -2807,8 +3065,7 @@ async function withLockAsync(root, name, fn, opts) {
|
|
|
2807
3065
|
handle.release();
|
|
2808
3066
|
}
|
|
2809
3067
|
}
|
|
2810
|
-
|
|
2811
|
-
//#region src/store/file-store.ts
|
|
3068
|
+
// src/store/file-store.ts
|
|
2812
3069
|
/** Parse a record file's content, wrapping a `JSON.parse` failure in a typed, file-named
|
|
2813
3070
|
* `CorruptRecordError` instead of letting a bare `SyntaxError` bubble up from deep inside
|
|
2814
3071
|
* `listInbox`/`listAgents`/`getAgent` with no indication of WHICH file broke. A file that doesn't
|
|
@@ -3018,8 +3275,7 @@ var FileStore = class {
|
|
|
3018
3275
|
return withLockAsync(this.root, name, fn);
|
|
3019
3276
|
}
|
|
3020
3277
|
};
|
|
3021
|
-
|
|
3022
|
-
//#region src/wake/await.ts
|
|
3278
|
+
// src/wake/await.ts
|
|
3023
3279
|
/** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
|
|
3024
3280
|
const POLL_MS$1 = 1e3;
|
|
3025
3281
|
const DEFAULT_TIMEOUT_MS = 6e5;
|
|
@@ -3064,8 +3320,7 @@ async function awaitReply(ctx, input) {
|
|
|
3064
3320
|
await sleep(POLL_MS$1);
|
|
3065
3321
|
}
|
|
3066
3322
|
}
|
|
3067
|
-
|
|
3068
|
-
//#region src/wake/watch.ts
|
|
3323
|
+
// src/wake/watch.ts
|
|
3069
3324
|
const POLL_MS = 1e3;
|
|
3070
3325
|
/**
|
|
3071
3326
|
* A continuous foreground observer: prints each NEW matching message as it arrives, polling on a
|
|
@@ -3097,5 +3352,4 @@ async function watchMail(ctx, input, onMessage, opts = {}) {
|
|
|
3097
3352
|
await sleep(POLL_MS);
|
|
3098
3353
|
}
|
|
3099
3354
|
}
|
|
3100
|
-
//#endregion
|
|
3101
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 };
|