cyberlegion 1.8.0 → 1.10.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 +16 -0
- package/dist/cli.mjs +208 -16
- package/dist/index.d.mts +13 -0
- package/dist/index.mjs +108 -14
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/dispatch-governance/SKILL.md +1 -1
- package/skills/dispatch-governance/scripts/cyberlegion.mjs +58 -57
- package/skills/init-cyberlegion/scripts/cyberlegion.mjs +58 -57
- package/skills/legate/SKILL.md +1 -1
- package/skills/legate/scripts/cyberlegion.mjs +58 -57
- package/skills/manage-inbox/SKILL.md +1 -1
- package/skills/manage-inbox/scripts/cyberlegion.mjs +58 -57
- package/skills/relay-governance/SKILL.md +1 -1
- package/skills/relay-governance/scripts/cyberlegion.mjs +58 -57
- package/skills/subagent-backend-governance/SKILL.md +1 -1
- package/skills/subagent-backend-governance/scripts/cyberlegion.mjs +58 -57
package/.plugin/pins.json
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# cyberlegion
|
|
2
2
|
|
|
3
|
+
## 1.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7a1c849: A unit now records a worktree it works in but does not own as `sharedWorktree` (`root`, `branch`, `primaryRoot`, and `owner`, the id of the unit that owns it). `unit spawn --cwd` records the checkout its directory sits in, and `unit register` records a worktree another unit's record owns this way instead of taking it as its own `worktree`. `unit close` never removes, prunes, or releases a shared worktree, so closing such a unit can no longer remove another unit's worktree or release its lease. `unit who` gains a `shared` column and `unit show` a `shared` field (`sharedWorktree` in JSON), so a sweep can judge the unit by that checkout. Records without the field read as before.
|
|
8
|
+
|
|
9
|
+
## 1.9.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- deed34d: `mail inbox --unit <handle|id>` lists another unit's mail without acking it and without the caller taking that unit's identity. Exited units resolve too, so a cleanup sweep can check whether a dead unit left unread mail without reading the hub's files itself. The output carries a `mailbox` state of `none` (no mail was ever delivered), `present`, or `unreadable`. An unreadable mailbox exits 1, so a caller never reads it as empty.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- a7a2d57: `unit spawn` no longer leaves several unsent "Read your brief" lines in a new session's input box. The first-turn ring now waits for the harness to draw its input box before typing; keys typed while a harness boots are taken as one paste, so each Enter became a newline and re-typed copies piled up. Copies of the doorbell sitting in the input box are also no longer counted as a posted turn, so a staged doorbell is flushed with Enter instead of reported rung.
|
|
18
|
+
|
|
3
19
|
## 1.8.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -10517,7 +10517,7 @@ function register(ctx, input) {
|
|
|
10517
10517
|
handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
|
|
10518
10518
|
harness,
|
|
10519
10519
|
cwd: process.cwd(),
|
|
10520
|
-
|
|
10520
|
+
...checkoutFor(ctx.store, exec, id, existing),
|
|
10521
10521
|
pane: cur ? paneLocator(cur, exec, env) : null,
|
|
10522
10522
|
status: "active",
|
|
10523
10523
|
createdAt: existing?.createdAt ?? ts,
|
|
@@ -10584,24 +10584,71 @@ function writeStanding(ctx, id, input) {
|
|
|
10584
10584
|
saveAgent(ctx.store, rec);
|
|
10585
10585
|
return rec;
|
|
10586
10586
|
}
|
|
10587
|
-
/** The checkout the cwd sits in: its root, its branch, and the repository's primary
|
|
10588
|
-
* same `primaryRoot` spawn records, so a lookup by repository finds a unit in a linked
|
|
10589
|
-
* the repository's name rather than the worktree folder's. */
|
|
10590
|
-
function gitWorktree(exec) {
|
|
10591
|
-
const
|
|
10587
|
+
/** The checkout `dir` (else the cwd) sits in: its root, its branch, and the repository's primary
|
|
10588
|
+
* checkout — the same `primaryRoot` spawn records, so a lookup by repository finds a unit in a linked
|
|
10589
|
+
* worktree by the repository's name rather than the worktree folder's. */
|
|
10590
|
+
function gitWorktree(exec, dir) {
|
|
10591
|
+
const git = dir === void 0 ? exec : (cmd, args) => exec(cmd, cmd === "git" ? [
|
|
10592
|
+
"-C",
|
|
10593
|
+
dir,
|
|
10594
|
+
...args
|
|
10595
|
+
] : args);
|
|
10596
|
+
const root = git("git", ["rev-parse", "--show-toplevel"]);
|
|
10592
10597
|
if (!root) return null;
|
|
10593
|
-
const branch =
|
|
10598
|
+
const branch = git("git", [
|
|
10594
10599
|
"rev-parse",
|
|
10595
10600
|
"--abbrev-ref",
|
|
10596
10601
|
"HEAD"
|
|
10597
10602
|
]) ?? void 0;
|
|
10598
|
-
const primaryRoot = primaryRootHere(
|
|
10603
|
+
const primaryRoot = primaryRootHere(git);
|
|
10599
10604
|
return {
|
|
10600
10605
|
root,
|
|
10601
10606
|
...branch !== void 0 ? { branch } : {},
|
|
10602
10607
|
...primaryRoot !== void 0 ? { primaryRoot } : {}
|
|
10603
10608
|
};
|
|
10604
10609
|
}
|
|
10610
|
+
/** The id of the unit, other than `self`, whose record owns the worktree at `root`. */
|
|
10611
|
+
function ownerOf(store, root, self) {
|
|
10612
|
+
const at = resolve(root);
|
|
10613
|
+
return listAgents(store).find((a) => a.id !== self && a.worktree?.root && resolve(a.worktree.root) === at)?.id;
|
|
10614
|
+
}
|
|
10615
|
+
/** `checkout` as a shared reference, naming the unit that owns it when one does. */
|
|
10616
|
+
function asShared(store, checkout, self) {
|
|
10617
|
+
const owner = ownerOf(store, checkout.root, self);
|
|
10618
|
+
return {
|
|
10619
|
+
...checkout,
|
|
10620
|
+
...owner ? { owner } : {}
|
|
10621
|
+
};
|
|
10622
|
+
}
|
|
10623
|
+
/** The checkout a `--cwd` spawn opens into, as a shared reference — spawn created none, so the unit
|
|
10624
|
+
* owns none, whoever does. `undefined` when `dir` is in no git checkout. */
|
|
10625
|
+
function sharedCheckoutAt(store, exec, dir) {
|
|
10626
|
+
const checkout = gitWorktree(exec, dir);
|
|
10627
|
+
return checkout ? asShared(store, checkout) : void 0;
|
|
10628
|
+
}
|
|
10629
|
+
/**
|
|
10630
|
+
* Which checkout a registering session owns and which it only shares. An existing owned worktree is
|
|
10631
|
+
* kept. Otherwise the cwd's checkout is the session's own — unless another unit's record owns it, or
|
|
10632
|
+
* this unit already records it as shared (a `--cwd` spawn): then it is a shared reference, and the
|
|
10633
|
+
* session owns none, so its close can never remove another unit's worktree or release its lease.
|
|
10634
|
+
*/
|
|
10635
|
+
function checkoutFor(store, exec, id, existing) {
|
|
10636
|
+
const kept = existing?.sharedWorktree ? { sharedWorktree: existing.sharedWorktree } : {};
|
|
10637
|
+
if (existing?.worktree) return {
|
|
10638
|
+
worktree: keptWorktree(existing.worktree, exec),
|
|
10639
|
+
...kept
|
|
10640
|
+
};
|
|
10641
|
+
const here = gitWorktree(exec);
|
|
10642
|
+
if (!here) return {
|
|
10643
|
+
worktree: null,
|
|
10644
|
+
...kept
|
|
10645
|
+
};
|
|
10646
|
+
if (existing?.sharedWorktree || ownerOf(store, here.root, id)) return {
|
|
10647
|
+
worktree: null,
|
|
10648
|
+
sharedWorktree: asShared(store, here, id)
|
|
10649
|
+
};
|
|
10650
|
+
return { worktree: here };
|
|
10651
|
+
}
|
|
10605
10652
|
function primaryRootHere(exec) {
|
|
10606
10653
|
try {
|
|
10607
10654
|
return resolvePrimaryRoot(exec);
|
|
@@ -10671,6 +10718,20 @@ function resolveOwnerMailbox(store, ref) {
|
|
|
10671
10718
|
if (services.length > 1) throw new Error(`"${ref}" names ${services.length} service endpoints — pass an endpoint id (${services.map((a) => a.id).join(", ")})`);
|
|
10672
10719
|
return resolveStandingOwner(store, ref);
|
|
10673
10720
|
}
|
|
10721
|
+
/** Resolve the unit whose mailbox `mail inbox --unit` reads, by id or handle. Unlike a recipient, an
|
|
10722
|
+
* exited unit resolves: the caller is deciding about that unit (a sweep asking whether a dead unit left
|
|
10723
|
+
* unread mail), not writing to it. A live holder of the handle wins; several exited holders and no live
|
|
10724
|
+
* one fail rather than picking one. */
|
|
10725
|
+
function resolveUnitMailbox(store, ref) {
|
|
10726
|
+
const byId = loadAgent(store, ref);
|
|
10727
|
+
if (byId) return byId;
|
|
10728
|
+
const { live, exited } = matchHandle(listAgents(store), ref);
|
|
10729
|
+
const byHandle = pickLive(ref, live);
|
|
10730
|
+
if (byHandle) return byHandle;
|
|
10731
|
+
if (exited.length > 1) throw new Error(`"${ref}" names ${exited.length} exited units — pass an id (${exited.map((a) => a.id).join(", ")})`);
|
|
10732
|
+
if (exited[0]) return exited[0];
|
|
10733
|
+
throw new Error(`no unit addressable as "${ref}" (tried id and handle)`);
|
|
10734
|
+
}
|
|
10674
10735
|
/**
|
|
10675
10736
|
* Bind the caller's own unit as a standing owner's presence — the live unit standing in for a
|
|
10676
10737
|
* durable record that has no session of its own. Order matters: resolve the standing record first
|
|
@@ -11016,6 +11077,10 @@ function readShape(rows, shape) {
|
|
|
11016
11077
|
}
|
|
11017
11078
|
return { kind: "unknown" };
|
|
11018
11079
|
}
|
|
11080
|
+
/** Whether this reader knows `harness`'s input box, so a missing box on screen means it is not drawn yet. */
|
|
11081
|
+
function knowsPromptOf(harness) {
|
|
11082
|
+
return harness !== void 0 && SHAPES[harness] !== void 0;
|
|
11083
|
+
}
|
|
11019
11084
|
/**
|
|
11020
11085
|
* Read the input box off `screen` — a plain-text scrape of the pane — with the shape of `harness`,
|
|
11021
11086
|
* or, when the harness is not known, with each known shape in turn until one recognizes the box.
|
|
@@ -11116,6 +11181,27 @@ function copiesOn(screen, message) {
|
|
|
11116
11181
|
return screen.split("\n").filter((row) => row.replace(/\s+/g, " ").includes(needle)).length;
|
|
11117
11182
|
}
|
|
11118
11183
|
/**
|
|
11184
|
+
* Read `screen` for the ring: whether `message` sits in the input box, and how many copies of it
|
|
11185
|
+
* are on screen outside the box. With the box read by its harness's shape, copies inside the box are
|
|
11186
|
+
* left out of the count: a booting harness can take several typed copies as one paste, and those
|
|
11187
|
+
* copies are staged text, not posted turns. A screen with no input box the reader knows falls back to
|
|
11188
|
+
* cyber-mux's `isStaged`, and the box's copy is counted with the rest.
|
|
11189
|
+
*/
|
|
11190
|
+
function readRing(screen, message, harness) {
|
|
11191
|
+
const box = screen ? readPrompt(screen, harness) : { kind: "unknown" };
|
|
11192
|
+
if (box.kind === "unknown") return {
|
|
11193
|
+
boxKnown: false,
|
|
11194
|
+
staged: isStaged(screen, message),
|
|
11195
|
+
outside: copiesOn(screen, message)
|
|
11196
|
+
};
|
|
11197
|
+
const inBox = box.kind === "draft" ? copiesOn(box.text, message) : 0;
|
|
11198
|
+
return {
|
|
11199
|
+
boxKnown: true,
|
|
11200
|
+
staged: inBox > 0,
|
|
11201
|
+
outside: copiesOn(screen, message) - inBox
|
|
11202
|
+
};
|
|
11203
|
+
}
|
|
11204
|
+
/**
|
|
11119
11205
|
* Submit `message` to `target` as a taken turn: cyber-mux's `nudge`, with one more way to see that the
|
|
11120
11206
|
* turn was taken.
|
|
11121
11207
|
*
|
|
@@ -11135,18 +11221,32 @@ function copiesOn(screen, message) {
|
|
|
11135
11221
|
* queue. Text that is neither staged nor posted is lost: after one more settle for a slow redraw, it
|
|
11136
11222
|
* is typed again. It is opt-in because it needs the posted copy to stay on screen: a busy peer's
|
|
11137
11223
|
* output can scroll earlier copies away, so a ring to it could read as lost and be typed twice.
|
|
11224
|
+
*
|
|
11225
|
+
* With `requirePosted` and a harness whose input box this reader knows, nothing is typed until the
|
|
11226
|
+
* harness has drawn that box, for up to the
|
|
11227
|
+
* whole retry budget. Keys typed while a harness boots are buffered, and the harness takes them as one
|
|
11228
|
+
* paste once it draws the box: each Enter becomes a newline, so re-typed copies pile up in the box
|
|
11229
|
+
* unsent. A box that never shows within the budget is typed into anyway, as before.
|
|
11230
|
+
*
|
|
11231
|
+
* Where the input box can be read, copies of the text inside it are not counted as posted: only a
|
|
11232
|
+
* copy outside the box means the harness took the turn.
|
|
11138
11233
|
*/
|
|
11139
11234
|
async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
11140
11235
|
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
11141
11236
|
const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
|
|
11142
11237
|
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
11143
11238
|
if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
|
|
11144
|
-
|
|
11239
|
+
let first = adapter.read(exec, target).text;
|
|
11240
|
+
if (opts.requirePosted && knowsPromptOf(opts.harness)) for (let wait = 0; wait < attempts && !readRing(first, message, opts.harness).boxKnown; wait++) {
|
|
11241
|
+
await sleep(settleMs);
|
|
11242
|
+
first = adapter.read(exec, target).text;
|
|
11243
|
+
}
|
|
11244
|
+
const before = readRing(first, message, opts.harness).outside;
|
|
11145
11245
|
const check = () => {
|
|
11146
|
-
const
|
|
11147
|
-
const
|
|
11148
|
-
if (
|
|
11149
|
-
return
|
|
11246
|
+
const seen = readRing(adapter.read(exec, target).text, message, opts.harness);
|
|
11247
|
+
const posted = seen.outside >= before + (seen.boxKnown || !seen.staged ? 1 : 2);
|
|
11248
|
+
if (seen.staged) return posted ? "taken" : "staged";
|
|
11249
|
+
return posted || !opts.requirePosted ? "taken" : "lost";
|
|
11150
11250
|
};
|
|
11151
11251
|
adapter.submit(exec, target, message);
|
|
11152
11252
|
await sleep(settleMs);
|
|
@@ -11306,7 +11406,10 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
|
|
|
11306
11406
|
try {
|
|
11307
11407
|
const adapter = getAdapter();
|
|
11308
11408
|
const target = { id: pane };
|
|
11309
|
-
await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL,
|
|
11409
|
+
await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, {
|
|
11410
|
+
...nudgeOpts,
|
|
11411
|
+
harness
|
|
11412
|
+
}), {
|
|
11310
11413
|
...guardOpts,
|
|
11311
11414
|
harness,
|
|
11312
11415
|
ownText: (text) => isRingText(text)
|
|
@@ -11351,7 +11454,8 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
11351
11454
|
const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
|
|
11352
11455
|
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, {
|
|
11353
11456
|
...nudgeOpts,
|
|
11354
|
-
requirePosted: true
|
|
11457
|
+
requirePosted: true,
|
|
11458
|
+
harness: input.harness
|
|
11355
11459
|
}), {
|
|
11356
11460
|
...guardOpts,
|
|
11357
11461
|
harness: input.harness,
|
|
@@ -11795,6 +11899,7 @@ async function spawn(ctx, input) {
|
|
|
11795
11899
|
const from = callerPane(sessionAdapter, normalizedEnv);
|
|
11796
11900
|
let cwd;
|
|
11797
11901
|
let worktree;
|
|
11902
|
+
let sharedWorktree;
|
|
11798
11903
|
let base;
|
|
11799
11904
|
let reused;
|
|
11800
11905
|
let target;
|
|
@@ -11803,6 +11908,7 @@ async function spawn(ctx, input) {
|
|
|
11803
11908
|
cwd = resolve(input.cwd);
|
|
11804
11909
|
if (primaryRoot$1 !== void 0) assertDistinctFromPrimary(cwd, primaryRoot$1);
|
|
11805
11910
|
worktree = null;
|
|
11911
|
+
sharedWorktree = sharedCheckoutAt(ctx.store, exec, cwd);
|
|
11806
11912
|
const at = input.at ?? "tab";
|
|
11807
11913
|
target = sessionAdapter.open(exec, {
|
|
11808
11914
|
cwd,
|
|
@@ -11893,6 +11999,7 @@ async function spawn(ctx, input) {
|
|
|
11893
11999
|
harness,
|
|
11894
12000
|
cwd,
|
|
11895
12001
|
worktree,
|
|
12002
|
+
...sharedWorktree ? { sharedWorktree } : {},
|
|
11896
12003
|
pane: muxName === "tmux" || muxName === "herdr" ? {
|
|
11897
12004
|
mux: muxName,
|
|
11898
12005
|
id: target.id
|
|
@@ -12575,6 +12682,26 @@ function inbox(ctx, q) {
|
|
|
12575
12682
|
if (q.thread) items = items.filter((m) => m.thread === q.thread);
|
|
12576
12683
|
return items.sort((a, b) => a.id.localeCompare(b.id));
|
|
12577
12684
|
}
|
|
12685
|
+
/** List another unit's mail read-only, for a caller deciding about that unit (a cleanup sweep): never
|
|
12686
|
+
* acks, and reports a missing or unreadable mailbox as its own state rather than as an empty one. */
|
|
12687
|
+
function unitInbox(ctx, q) {
|
|
12688
|
+
if (!ctx.store.hasMailbox(q.meId)) return {
|
|
12689
|
+
mailbox: "none",
|
|
12690
|
+
messages: []
|
|
12691
|
+
};
|
|
12692
|
+
try {
|
|
12693
|
+
return {
|
|
12694
|
+
mailbox: "present",
|
|
12695
|
+
messages: inbox(ctx, q)
|
|
12696
|
+
};
|
|
12697
|
+
} catch (err) {
|
|
12698
|
+
return {
|
|
12699
|
+
mailbox: "unreadable",
|
|
12700
|
+
messages: [],
|
|
12701
|
+
error: err instanceof Error ? err.message : String(err)
|
|
12702
|
+
};
|
|
12703
|
+
}
|
|
12704
|
+
}
|
|
12578
12705
|
/** Peek at a message (unread or already-acked) without changing its state. */
|
|
12579
12706
|
function peek(ctx, meId, msgId) {
|
|
12580
12707
|
const snap = ctx.store.listInbox(meId);
|
|
@@ -14008,6 +14135,10 @@ var FileStore = class {
|
|
|
14008
14135
|
read: read ? readMessages(read) : []
|
|
14009
14136
|
};
|
|
14010
14137
|
}
|
|
14138
|
+
hasMailbox(id) {
|
|
14139
|
+
const dir = readPathOrUndefined(() => paths.inboxDir(this.root, id));
|
|
14140
|
+
return dir !== void 0 && existsSync(dir);
|
|
14141
|
+
}
|
|
14011
14142
|
ackMessage(id, msgId) {
|
|
14012
14143
|
const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
|
|
14013
14144
|
if (!src || !existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
|
|
@@ -14379,6 +14510,7 @@ function showUnit(ctx, ref) {
|
|
|
14379
14510
|
} : null,
|
|
14380
14511
|
cwd: rec.cwd,
|
|
14381
14512
|
worktree: rec.worktree ?? null,
|
|
14513
|
+
sharedWorktree: rec.sharedWorktree ?? null,
|
|
14382
14514
|
lastSeen: rec.lastSeen,
|
|
14383
14515
|
controls: controlsFor(ctx, rec, liveness)
|
|
14384
14516
|
};
|
|
@@ -14678,6 +14810,10 @@ function runWho(opts) {
|
|
|
14678
14810
|
{
|
|
14679
14811
|
key: "pane",
|
|
14680
14812
|
get: (a) => a.pane?.id ?? "-"
|
|
14813
|
+
},
|
|
14814
|
+
{
|
|
14815
|
+
key: "shared",
|
|
14816
|
+
get: (a) => a.sharedWorktree?.root ?? "-"
|
|
14681
14817
|
}
|
|
14682
14818
|
], `${agents.length} units`),
|
|
14683
14819
|
json: agents
|
|
@@ -14877,6 +15013,7 @@ withGlobals(unit.command("show")).description("show a unit's runtime: where it i
|
|
|
14877
15013
|
pane: view.pane ? `${view.pane.mux}:${view.pane.id}` : "-",
|
|
14878
15014
|
cwd: view.cwd,
|
|
14879
15015
|
worktree: view.worktree?.root ?? "-",
|
|
15016
|
+
shared: view.sharedWorktree?.root ?? "-",
|
|
14880
15017
|
lastSeen: view.lastSeen,
|
|
14881
15018
|
controls: view.controls.join(",") || "-"
|
|
14882
15019
|
}),
|
|
@@ -15238,12 +15375,67 @@ function emitInbox(ctx, opts, meId, readCmd) {
|
|
|
15238
15375
|
const firstUnread = items.find((m) => !m.read);
|
|
15239
15376
|
if (firstUnread) nextStep(`${readCmd} ${firstUnread.id}`);
|
|
15240
15377
|
}
|
|
15378
|
+
/** List another unit's mailbox read-only (`--unit`). Its `mailbox` state tells a missing mailbox
|
|
15379
|
+
* from an empty one; an unreadable one is reported on stdout and exits 1, never read as empty. */
|
|
15380
|
+
function emitUnitInbox(ctx, opts, ref) {
|
|
15381
|
+
let unit;
|
|
15382
|
+
try {
|
|
15383
|
+
unit = resolveUnitMailbox(ctx.store, ref);
|
|
15384
|
+
} catch (err) {
|
|
15385
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
15386
|
+
}
|
|
15387
|
+
const result = unitInbox({ store: ctx.store }, {
|
|
15388
|
+
meId: unit.id,
|
|
15389
|
+
unread: opts.unread,
|
|
15390
|
+
from: opts.from,
|
|
15391
|
+
thread: opts.thread
|
|
15392
|
+
});
|
|
15393
|
+
const unreadCount = result.messages.filter((m) => !m.read).length;
|
|
15394
|
+
const summary = result.mailbox === "none" ? "no mailbox" : result.mailbox === "unreadable" ? `mailbox unreadable: ${result.error}` : `${result.messages.length} messages (${unreadCount} unread)`;
|
|
15395
|
+
emit(formatOf(opts), {
|
|
15396
|
+
toon: [toonObject({
|
|
15397
|
+
unit: unit.id,
|
|
15398
|
+
handle: unit.handle,
|
|
15399
|
+
mailbox: result.mailbox
|
|
15400
|
+
}), toonList("messages", result.messages, [
|
|
15401
|
+
{
|
|
15402
|
+
key: "id",
|
|
15403
|
+
get: (m) => m.id
|
|
15404
|
+
},
|
|
15405
|
+
{
|
|
15406
|
+
key: "from",
|
|
15407
|
+
get: (m) => m.fromHandle
|
|
15408
|
+
},
|
|
15409
|
+
{
|
|
15410
|
+
key: "subject",
|
|
15411
|
+
get: (m) => m.subject ?? ""
|
|
15412
|
+
},
|
|
15413
|
+
{
|
|
15414
|
+
key: "read",
|
|
15415
|
+
get: (m) => m.read
|
|
15416
|
+
}
|
|
15417
|
+
], summary)].join("\n"),
|
|
15418
|
+
json: {
|
|
15419
|
+
unit: unit.id,
|
|
15420
|
+
handle: unit.handle,
|
|
15421
|
+
...result
|
|
15422
|
+
}
|
|
15423
|
+
});
|
|
15424
|
+
if (result.mailbox === "unreadable") {
|
|
15425
|
+
console.error(JSON.stringify({ error: `mailbox of ${unit.id} is unreadable: ${result.error}` }));
|
|
15426
|
+
process.exitCode = 1;
|
|
15427
|
+
}
|
|
15428
|
+
}
|
|
15241
15429
|
function runInbox(opts) {
|
|
15242
15430
|
const ctx = ctxOf(opts);
|
|
15243
15431
|
touch(ctx);
|
|
15432
|
+
if (opts.unit) {
|
|
15433
|
+
emitUnitInbox(ctx, opts, opts.unit);
|
|
15434
|
+
return;
|
|
15435
|
+
}
|
|
15244
15436
|
emitInbox(ctx, opts, opts.owner ? resolveOwnerMailbox(ctx.store, opts.owner) : requireSelf(ctx), "cyberlegion mail read");
|
|
15245
15437
|
}
|
|
15246
|
-
withGlobals(mail.command("inbox")).description("list your mail").option("--unread", "only un-acked mail").option("--from <id>", "filter by sender").option("--thread <id>", "filter to messages carrying this thread id").option("--owner <handle>", "target a standing owner's mailbox instead of this session's own").action(runInbox);
|
|
15438
|
+
withGlobals(mail.command("inbox")).description("list your mail").option("--unread", "only un-acked mail").option("--from <id>", "filter by sender").option("--thread <id>", "filter to messages carrying this thread id").option("--owner <handle>", "target a standing owner's mailbox instead of this session's own").addOption(new Option("--unit <handle|id>", "list another unit's mailbox read-only (exited units too); never acks, reports mailbox: none|present|unreadable").conflicts("owner")).action(runInbox);
|
|
15247
15439
|
withGlobals(mail.command("read")).description("read a message; --ack consumes it in the same step, otherwise it only peeks").argument("<msg-id>", "message id").option("--ack", "acknowledge the message in the same step (idempotent — no error if already acked)").option("--owner <handle>", "read a standing owner's mailbox instead of this session's own").action((msgId, opts) => {
|
|
15248
15440
|
const ctx = ctxOf(opts);
|
|
15249
15441
|
const meId = opts.owner ? resolveOwnerMailbox(ctx.store, opts.owner) : requireSelf(ctx);
|
package/dist/index.d.mts
CHANGED
|
@@ -40,6 +40,16 @@ interface AgentRecord {
|
|
|
40
40
|
holder: string;
|
|
41
41
|
};
|
|
42
42
|
} | null;
|
|
43
|
+
/** A checkout the unit works in but does not own: a `--cwd` spawn into an existing checkout, or a
|
|
44
|
+
* session registered in a worktree another unit's record owns. `owner` is that unit's id, when one
|
|
45
|
+
* owned the root at the time of recording. Close never removes, prunes, or releases it — it is read
|
|
46
|
+
* only, so a sweep can judge the unit by the work there. Absent on records from before it existed. */
|
|
47
|
+
sharedWorktree?: {
|
|
48
|
+
root: string;
|
|
49
|
+
branch?: string;
|
|
50
|
+
primaryRoot?: string;
|
|
51
|
+
owner?: string;
|
|
52
|
+
};
|
|
43
53
|
/** Where this session lives, tagged with its multiplexer so `prune` runs the right liveness check.
|
|
44
54
|
* `null` for a session in no pane and for a standing record. `window`/`session` are tmux-only. */
|
|
45
55
|
pane?: {
|
|
@@ -162,6 +172,8 @@ interface MailboxStore {
|
|
|
162
172
|
putMessage(toId: string, msg: Message): void;
|
|
163
173
|
/** The full unread/read split for one agent's inbox. */
|
|
164
174
|
listInbox(id: string): InboxSnapshot;
|
|
175
|
+
/** Whether `id` has a mailbox at all — one exists once mail was first delivered to it. */
|
|
176
|
+
hasMailbox(id: string): boolean;
|
|
165
177
|
/** Ack a message by moving it out of the unread set; throws if it is not currently unread. */
|
|
166
178
|
ackMessage(id: string, msgId: string): Message;
|
|
167
179
|
/** Permanently remove a message (unread or already-acked) from `id`'s inbox; throws if absent. */
|
|
@@ -864,6 +876,7 @@ export declare class FileStore implements Store {
|
|
|
864
876
|
ensureMarker(): void;
|
|
865
877
|
putMessage(toId: string, msg: Message): void;
|
|
866
878
|
listInbox(id: string): InboxSnapshot;
|
|
879
|
+
hasMailbox(id: string): boolean;
|
|
867
880
|
ackMessage(id: string, msgId: string): Message;
|
|
868
881
|
removeMessage(id: string, msgId: string): void;
|
|
869
882
|
removeMailbox(id: string): void;
|
package/dist/index.mjs
CHANGED
|
@@ -247,7 +247,7 @@ function register(ctx, input) {
|
|
|
247
247
|
handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
|
|
248
248
|
harness,
|
|
249
249
|
cwd: process.cwd(),
|
|
250
|
-
|
|
250
|
+
...checkoutFor(ctx.store, exec, id, existing),
|
|
251
251
|
pane: cur ? paneLocator(cur, exec, env) : null,
|
|
252
252
|
status: "active",
|
|
253
253
|
createdAt: existing?.createdAt ?? ts,
|
|
@@ -279,24 +279,71 @@ function paneLocator(cur, exec, env) {
|
|
|
279
279
|
id: cur.pane
|
|
280
280
|
};
|
|
281
281
|
}
|
|
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
|
|
282
|
+
/** The checkout `dir` (else the cwd) sits in: its root, its branch, and the repository's primary
|
|
283
|
+
* checkout — the same `primaryRoot` spawn records, so a lookup by repository finds a unit in a linked
|
|
284
|
+
* worktree by the repository's name rather than the worktree folder's. */
|
|
285
|
+
function gitWorktree(exec, dir) {
|
|
286
|
+
const git = dir === void 0 ? exec : (cmd, args) => exec(cmd, cmd === "git" ? [
|
|
287
|
+
"-C",
|
|
288
|
+
dir,
|
|
289
|
+
...args
|
|
290
|
+
] : args);
|
|
291
|
+
const root = git("git", ["rev-parse", "--show-toplevel"]);
|
|
287
292
|
if (!root) return null;
|
|
288
|
-
const branch =
|
|
293
|
+
const branch = git("git", [
|
|
289
294
|
"rev-parse",
|
|
290
295
|
"--abbrev-ref",
|
|
291
296
|
"HEAD"
|
|
292
297
|
]) ?? void 0;
|
|
293
|
-
const primaryRoot = primaryRootHere(
|
|
298
|
+
const primaryRoot = primaryRootHere(git);
|
|
294
299
|
return {
|
|
295
300
|
root,
|
|
296
301
|
...branch !== void 0 ? { branch } : {},
|
|
297
302
|
...primaryRoot !== void 0 ? { primaryRoot } : {}
|
|
298
303
|
};
|
|
299
304
|
}
|
|
305
|
+
/** The id of the unit, other than `self`, whose record owns the worktree at `root`. */
|
|
306
|
+
function ownerOf(store, root, self) {
|
|
307
|
+
const at = resolve(root);
|
|
308
|
+
return listAgents(store).find((a) => a.id !== self && a.worktree?.root && resolve(a.worktree.root) === at)?.id;
|
|
309
|
+
}
|
|
310
|
+
/** `checkout` as a shared reference, naming the unit that owns it when one does. */
|
|
311
|
+
function asShared(store, checkout, self) {
|
|
312
|
+
const owner = ownerOf(store, checkout.root, self);
|
|
313
|
+
return {
|
|
314
|
+
...checkout,
|
|
315
|
+
...owner ? { owner } : {}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/** The checkout a `--cwd` spawn opens into, as a shared reference — spawn created none, so the unit
|
|
319
|
+
* owns none, whoever does. `undefined` when `dir` is in no git checkout. */
|
|
320
|
+
function sharedCheckoutAt(store, exec, dir) {
|
|
321
|
+
const checkout = gitWorktree(exec, dir);
|
|
322
|
+
return checkout ? asShared(store, checkout) : void 0;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Which checkout a registering session owns and which it only shares. An existing owned worktree is
|
|
326
|
+
* kept. Otherwise the cwd's checkout is the session's own — unless another unit's record owns it, or
|
|
327
|
+
* this unit already records it as shared (a `--cwd` spawn): then it is a shared reference, and the
|
|
328
|
+
* session owns none, so its close can never remove another unit's worktree or release its lease.
|
|
329
|
+
*/
|
|
330
|
+
function checkoutFor(store, exec, id, existing) {
|
|
331
|
+
const kept = existing?.sharedWorktree ? { sharedWorktree: existing.sharedWorktree } : {};
|
|
332
|
+
if (existing?.worktree) return {
|
|
333
|
+
worktree: keptWorktree(existing.worktree, exec),
|
|
334
|
+
...kept
|
|
335
|
+
};
|
|
336
|
+
const here = gitWorktree(exec);
|
|
337
|
+
if (!here) return {
|
|
338
|
+
worktree: null,
|
|
339
|
+
...kept
|
|
340
|
+
};
|
|
341
|
+
if (existing?.sharedWorktree || ownerOf(store, here.root, id)) return {
|
|
342
|
+
worktree: null,
|
|
343
|
+
sharedWorktree: asShared(store, here, id)
|
|
344
|
+
};
|
|
345
|
+
return { worktree: here };
|
|
346
|
+
}
|
|
300
347
|
function primaryRootHere(exec) {
|
|
301
348
|
try {
|
|
302
349
|
return resolvePrimaryRoot$1(exec);
|
|
@@ -599,6 +646,10 @@ function readShape(rows, shape) {
|
|
|
599
646
|
}
|
|
600
647
|
return { kind: "unknown" };
|
|
601
648
|
}
|
|
649
|
+
/** Whether this reader knows `harness`'s input box, so a missing box on screen means it is not drawn yet. */
|
|
650
|
+
function knowsPromptOf(harness) {
|
|
651
|
+
return harness !== void 0 && SHAPES[harness] !== void 0;
|
|
652
|
+
}
|
|
602
653
|
/**
|
|
603
654
|
* Read the input box off `screen` — a plain-text scrape of the pane — with the shape of `harness`,
|
|
604
655
|
* or, when the harness is not known, with each known shape in turn until one recognizes the box.
|
|
@@ -699,6 +750,27 @@ function copiesOn(screen, message) {
|
|
|
699
750
|
return screen.split("\n").filter((row) => row.replace(/\s+/g, " ").includes(needle)).length;
|
|
700
751
|
}
|
|
701
752
|
/**
|
|
753
|
+
* Read `screen` for the ring: whether `message` sits in the input box, and how many copies of it
|
|
754
|
+
* are on screen outside the box. With the box read by its harness's shape, copies inside the box are
|
|
755
|
+
* left out of the count: a booting harness can take several typed copies as one paste, and those
|
|
756
|
+
* copies are staged text, not posted turns. A screen with no input box the reader knows falls back to
|
|
757
|
+
* cyber-mux's `isStaged`, and the box's copy is counted with the rest.
|
|
758
|
+
*/
|
|
759
|
+
function readRing(screen, message, harness) {
|
|
760
|
+
const box = screen ? readPrompt(screen, harness) : { kind: "unknown" };
|
|
761
|
+
if (box.kind === "unknown") return {
|
|
762
|
+
boxKnown: false,
|
|
763
|
+
staged: isStaged(screen, message),
|
|
764
|
+
outside: copiesOn(screen, message)
|
|
765
|
+
};
|
|
766
|
+
const inBox = box.kind === "draft" ? copiesOn(box.text, message) : 0;
|
|
767
|
+
return {
|
|
768
|
+
boxKnown: true,
|
|
769
|
+
staged: inBox > 0,
|
|
770
|
+
outside: copiesOn(screen, message) - inBox
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
702
774
|
* Submit `message` to `target` as a taken turn: cyber-mux's `nudge`, with one more way to see that the
|
|
703
775
|
* turn was taken.
|
|
704
776
|
*
|
|
@@ -718,18 +790,32 @@ function copiesOn(screen, message) {
|
|
|
718
790
|
* queue. Text that is neither staged nor posted is lost: after one more settle for a slow redraw, it
|
|
719
791
|
* is typed again. It is opt-in because it needs the posted copy to stay on screen: a busy peer's
|
|
720
792
|
* output can scroll earlier copies away, so a ring to it could read as lost and be typed twice.
|
|
793
|
+
*
|
|
794
|
+
* With `requirePosted` and a harness whose input box this reader knows, nothing is typed until the
|
|
795
|
+
* harness has drawn that box, for up to the
|
|
796
|
+
* whole retry budget. Keys typed while a harness boots are buffered, and the harness takes them as one
|
|
797
|
+
* paste once it draws the box: each Enter becomes a newline, so re-typed copies pile up in the box
|
|
798
|
+
* unsent. A box that never shows within the budget is typed into anyway, as before.
|
|
799
|
+
*
|
|
800
|
+
* Where the input box can be read, copies of the text inside it are not counted as posted: only a
|
|
801
|
+
* copy outside the box means the harness took the turn.
|
|
721
802
|
*/
|
|
722
803
|
async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
723
804
|
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
724
805
|
const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
|
|
725
806
|
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
726
807
|
if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
|
|
727
|
-
|
|
808
|
+
let first = adapter.read(exec, target).text;
|
|
809
|
+
if (opts.requirePosted && knowsPromptOf(opts.harness)) for (let wait = 0; wait < attempts && !readRing(first, message, opts.harness).boxKnown; wait++) {
|
|
810
|
+
await sleep(settleMs);
|
|
811
|
+
first = adapter.read(exec, target).text;
|
|
812
|
+
}
|
|
813
|
+
const before = readRing(first, message, opts.harness).outside;
|
|
728
814
|
const check = () => {
|
|
729
|
-
const
|
|
730
|
-
const
|
|
731
|
-
if (
|
|
732
|
-
return
|
|
815
|
+
const seen = readRing(adapter.read(exec, target).text, message, opts.harness);
|
|
816
|
+
const posted = seen.outside >= before + (seen.boxKnown || !seen.staged ? 1 : 2);
|
|
817
|
+
if (seen.staged) return posted ? "taken" : "staged";
|
|
818
|
+
return posted || !opts.requirePosted ? "taken" : "lost";
|
|
733
819
|
};
|
|
734
820
|
adapter.submit(exec, target, message);
|
|
735
821
|
await sleep(settleMs);
|
|
@@ -829,7 +915,8 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
829
915
|
const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
|
|
830
916
|
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, {
|
|
831
917
|
...nudgeOpts,
|
|
832
|
-
requirePosted: true
|
|
918
|
+
requirePosted: true,
|
|
919
|
+
harness: input.harness
|
|
833
920
|
}), {
|
|
834
921
|
...guardOpts,
|
|
835
922
|
harness: input.harness,
|
|
@@ -1242,6 +1329,7 @@ async function spawn(ctx, input) {
|
|
|
1242
1329
|
const from = callerPane(sessionAdapter, normalizedEnv);
|
|
1243
1330
|
let cwd;
|
|
1244
1331
|
let worktree;
|
|
1332
|
+
let sharedWorktree;
|
|
1245
1333
|
let base;
|
|
1246
1334
|
let reused;
|
|
1247
1335
|
let target;
|
|
@@ -1250,6 +1338,7 @@ async function spawn(ctx, input) {
|
|
|
1250
1338
|
cwd = resolve(input.cwd);
|
|
1251
1339
|
if (primaryRoot$1 !== void 0) assertDistinctFromPrimary$1(cwd, primaryRoot$1);
|
|
1252
1340
|
worktree = null;
|
|
1341
|
+
sharedWorktree = sharedCheckoutAt(ctx.store, exec, cwd);
|
|
1253
1342
|
const at = input.at ?? "tab";
|
|
1254
1343
|
target = sessionAdapter.open(exec, {
|
|
1255
1344
|
cwd,
|
|
@@ -1340,6 +1429,7 @@ async function spawn(ctx, input) {
|
|
|
1340
1429
|
harness,
|
|
1341
1430
|
cwd,
|
|
1342
1431
|
worktree,
|
|
1432
|
+
...sharedWorktree ? { sharedWorktree } : {},
|
|
1343
1433
|
pane: muxName === "tmux" || muxName === "herdr" ? {
|
|
1344
1434
|
mux: muxName,
|
|
1345
1435
|
id: target.id
|
|
@@ -2857,6 +2947,10 @@ var FileStore = class {
|
|
|
2857
2947
|
read: read ? readMessages(read) : []
|
|
2858
2948
|
};
|
|
2859
2949
|
}
|
|
2950
|
+
hasMailbox(id) {
|
|
2951
|
+
const dir = readPathOrUndefined(() => paths.inboxDir(this.root, id));
|
|
2952
|
+
return dir !== void 0 && existsSync(dir);
|
|
2953
|
+
}
|
|
2860
2954
|
ackMessage(id, msgId) {
|
|
2861
2955
|
const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
|
|
2862
2956
|
if (!src || !existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
|
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": "1.
|
|
4
|
+
"version": "1.10.0",
|
|
5
5
|
"description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "unional"
|