cyberlegion 1.3.0 → 1.4.1
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/dist/cli.mjs +334 -63
- package/dist/index.d.mts +28 -3
- package/dist/index.mjs +101 -23
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/skills/dispatch-governance/SKILL.md +1 -1
- package/skills/dispatch-governance/scripts/cyberlegion.mjs +1 -1
- package/skills/init-cyberlegion/README.md +4 -3
- package/skills/init-cyberlegion/SKILL.md +88 -6
- package/skills/init-cyberlegion/scripts/cyberlegion.mjs +1 -1
- package/skills/legate/SKILL.md +1 -1
- package/skills/legate/scripts/cyberlegion.mjs +1 -1
- package/skills/manage-inbox/SKILL.md +1 -1
- package/skills/manage-inbox/scripts/cyberlegion.mjs +1 -1
- package/skills/relay-governance/SKILL.md +1 -1
- package/skills/relay-governance/scripts/cyberlegion.mjs +1 -1
- package/skills/subagent-backend-governance/SKILL.md +1 -1
- package/skills/subagent-backend-governance/scripts/cyberlegion.mjs +1 -1
package/.plugin/pins.json
CHANGED
package/dist/cli.mjs
CHANGED
|
@@ -5861,6 +5861,9 @@ function standingId(handle) {
|
|
|
5861
5861
|
function registerStanding(ctx, input) {
|
|
5862
5862
|
ctx.store.ensureMarker();
|
|
5863
5863
|
const id = standingId(input.handle);
|
|
5864
|
+
return ctx.store.withLock(`presence:${id}`, () => writeStanding(ctx, id, input));
|
|
5865
|
+
}
|
|
5866
|
+
function writeStanding(ctx, id, input) {
|
|
5864
5867
|
const existing = loadAgent(ctx.store, id);
|
|
5865
5868
|
const ts = nowIso(ctx);
|
|
5866
5869
|
const rec = {
|
|
@@ -5874,6 +5877,9 @@ function registerStanding(ctx, input) {
|
|
|
5874
5877
|
createdAt: existing?.createdAt ?? ts,
|
|
5875
5878
|
lastSeen: ts
|
|
5876
5879
|
};
|
|
5880
|
+
if (existing?.presence !== void 0) rec.presence = existing.presence;
|
|
5881
|
+
const home = input.home === void 0 ? existing?.home : input.home ?? void 0;
|
|
5882
|
+
if (home) rec.home = home;
|
|
5877
5883
|
saveAgent(ctx.store, rec);
|
|
5878
5884
|
return rec;
|
|
5879
5885
|
}
|
|
@@ -5889,10 +5895,15 @@ function gitWorktree(exec) {
|
|
|
5889
5895
|
]) ?? void 0
|
|
5890
5896
|
};
|
|
5891
5897
|
}
|
|
5892
|
-
/**
|
|
5893
|
-
*
|
|
5894
|
-
|
|
5895
|
-
|
|
5898
|
+
/** Pick the one live record a handle names. A standing record wins over the rest — an owner report
|
|
5899
|
+
* must land in the durable standing inbox, not a dying session's. Otherwise two or more matches are
|
|
5900
|
+
* ambiguous (two same-named repos give their service endpoints the same handle), and silently taking
|
|
5901
|
+
* the first could deliver to the wrong one, so fail loud naming the ids, as `resolveOwnerMailbox` does. */
|
|
5902
|
+
function pickLive(ref, live) {
|
|
5903
|
+
const standing = live.find((a) => a.kind === "standing");
|
|
5904
|
+
if (standing) return standing;
|
|
5905
|
+
if (live.length > 1) throw new Error(`"${ref}" names ${live.length} units — pass an id (${live.map((a) => a.id).join(", ")})`);
|
|
5906
|
+
return live[0];
|
|
5896
5907
|
}
|
|
5897
5908
|
/** Split a handle's matches into live and exited. An exited unit's pane is gone and its inbox has
|
|
5898
5909
|
* no reader, so a *name* must never resolve to one — a handle is reusable across units, and the
|
|
@@ -6010,7 +6021,7 @@ function resolvePresence(store, handle) {
|
|
|
6010
6021
|
function resolveRecipient(store, to) {
|
|
6011
6022
|
if (loadAgent(store, to)) return to;
|
|
6012
6023
|
const { live, exited } = matchHandle(listAgents(store), to);
|
|
6013
|
-
const match =
|
|
6024
|
+
const match = pickLive(to, live);
|
|
6014
6025
|
if (!match) throw unaddressable(to, exited, "id and handle");
|
|
6015
6026
|
return match.id;
|
|
6016
6027
|
}
|
|
@@ -6024,7 +6035,7 @@ function resolveAgent(store, ref) {
|
|
|
6024
6035
|
if (byId) return byId;
|
|
6025
6036
|
const agents = listAgents(store);
|
|
6026
6037
|
const { live, exited } = matchHandle(agents, ref);
|
|
6027
|
-
const byHandle =
|
|
6038
|
+
const byHandle = pickLive(ref, live);
|
|
6028
6039
|
if (byHandle) return byHandle;
|
|
6029
6040
|
const byBranchAll = agents.filter((a) => a.worktree?.branch === ref);
|
|
6030
6041
|
const byBranch = byBranchAll.find((a) => a.status !== "exited");
|
|
@@ -6371,6 +6382,13 @@ function copiesOn(screen, message) {
|
|
|
6371
6382
|
* the text, the one in the input box. Two or more new copies mean the harness also posted it above
|
|
6372
6383
|
* the box, so the turn was taken and nothing is flushed. Text still at the bottom with only one new
|
|
6373
6384
|
* copy is the swallowed Enter, and is flushed as before.
|
|
6385
|
+
*
|
|
6386
|
+
* With `requirePosted`, text gone from the box is not enough on its own: text typed before the harness
|
|
6387
|
+
* drew its input box, or wiped by a redraw, is gone too, and no turn was taken. The turn then counts
|
|
6388
|
+
* as taken only when a new copy of the text is on screen — the harness posted it to its transcript or
|
|
6389
|
+
* queue. Text that is neither staged nor posted is lost: after one more settle for a slow redraw, it
|
|
6390
|
+
* is typed again. It is opt-in because it needs the posted copy to stay on screen: a busy peer's
|
|
6391
|
+
* output can scroll earlier copies away, so a ring to it could read as lost and be typed twice.
|
|
6374
6392
|
*/
|
|
6375
6393
|
async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
6376
6394
|
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
@@ -6378,25 +6396,36 @@ async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
|
6378
6396
|
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6379
6397
|
if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
|
|
6380
6398
|
const before = copiesOn(adapter.read(exec, target).text, message);
|
|
6381
|
-
const
|
|
6399
|
+
const check = () => {
|
|
6382
6400
|
const screen = adapter.read(exec, target).text;
|
|
6383
|
-
|
|
6401
|
+
const copies = copiesOn(screen, message);
|
|
6402
|
+
if (isStaged(screen, message)) return copies >= before + 2 ? "taken" : "staged";
|
|
6403
|
+
return copies > before || !opts.requirePosted ? "taken" : "lost";
|
|
6384
6404
|
};
|
|
6385
6405
|
adapter.submit(exec, target, message);
|
|
6386
6406
|
await sleep(settleMs);
|
|
6387
|
-
|
|
6407
|
+
let state = check();
|
|
6408
|
+
if (state === "taken") return {
|
|
6388
6409
|
taken: true,
|
|
6389
6410
|
resubmits: 0
|
|
6390
6411
|
};
|
|
6412
|
+
let lostReads = state === "lost" ? 1 : 0;
|
|
6391
6413
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
6392
|
-
adapter.submit(exec, target);
|
|
6414
|
+
if (state === "staged") adapter.submit(exec, target);
|
|
6415
|
+
else if (lostReads >= 2) {
|
|
6416
|
+
adapter.submit(exec, target, message);
|
|
6417
|
+
lostReads = 0;
|
|
6418
|
+
}
|
|
6393
6419
|
await sleep(settleMs);
|
|
6394
|
-
|
|
6420
|
+
state = check();
|
|
6421
|
+
if (state === "taken") return {
|
|
6395
6422
|
taken: true,
|
|
6396
6423
|
resubmits: attempt
|
|
6397
6424
|
};
|
|
6425
|
+
lostReads = state === "lost" ? lostReads + 1 : 0;
|
|
6398
6426
|
}
|
|
6399
|
-
|
|
6427
|
+
const why = state === "lost" ? "the text vanished without the harness posting it" : `input still staged after ${attempts} re-submit attempts`;
|
|
6428
|
+
throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — ${why}`);
|
|
6400
6429
|
}
|
|
6401
6430
|
//#endregion
|
|
6402
6431
|
//#region src/console/doorbell.ts
|
|
@@ -6448,6 +6477,11 @@ const SPAWN_NUDGE_OPTS = {
|
|
|
6448
6477
|
attempts: 20,
|
|
6449
6478
|
settleMs: 400
|
|
6450
6479
|
};
|
|
6480
|
+
/** What a sender reads when a standing owner's home could not give it a presence. */
|
|
6481
|
+
function homeSpawnWarning(handle, err) {
|
|
6482
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
6483
|
+
return `standing owner "${handle}" has a home, but no presence could be spawned: ${reason}${/session backend/.test(reason) ? " — a sender outside any multiplexer pane can reach a running one by setting CYBER_MUX=herdr or CYBER_MUX=tmux" : ""}`;
|
|
6484
|
+
}
|
|
6451
6485
|
/** Resolve an agent's live session pane: its recorded pane, else a pane pointer keyed by its id. */
|
|
6452
6486
|
function paneOf(store, id) {
|
|
6453
6487
|
return loadAgent(store, id)?.pane?.id ?? store.findPaneByAgentId(id);
|
|
@@ -6473,8 +6507,24 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
|
|
|
6473
6507
|
let pane;
|
|
6474
6508
|
let harness;
|
|
6475
6509
|
let focusGated = false;
|
|
6510
|
+
let spawnWarning;
|
|
6476
6511
|
if (recipient.kind === "standing") {
|
|
6477
|
-
|
|
6512
|
+
let presenceUnit = presenceOf(store, recipient);
|
|
6513
|
+
if (!presenceUnit && recipient.home && input.spawnHome) try {
|
|
6514
|
+
const outcome = await input.spawnHome(recipient);
|
|
6515
|
+
if (outcome.kind === "existing") presenceUnit = outcome.presence;
|
|
6516
|
+
else {
|
|
6517
|
+
const warning = outcome.trustBlocked ?? outcome.warning;
|
|
6518
|
+
return {
|
|
6519
|
+
rung: outcome.rung,
|
|
6520
|
+
pane: outcome.pane,
|
|
6521
|
+
spawned: outcome.presence.id,
|
|
6522
|
+
...warning ? { warning } : {}
|
|
6523
|
+
};
|
|
6524
|
+
}
|
|
6525
|
+
} catch (err) {
|
|
6526
|
+
spawnWarning = homeSpawnWarning(recipient.handle, err);
|
|
6527
|
+
}
|
|
6478
6528
|
if (presenceUnit) {
|
|
6479
6529
|
pane = paneOf(store, presenceUnit.id);
|
|
6480
6530
|
harness = presenceUnit.harness;
|
|
@@ -6486,38 +6536,46 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
|
|
|
6486
6536
|
pane = paneOf(store, recipient.id);
|
|
6487
6537
|
harness = recipient.harness;
|
|
6488
6538
|
}
|
|
6489
|
-
|
|
6490
|
-
if (
|
|
6491
|
-
|
|
6492
|
-
|
|
6539
|
+
const result = await ringResolved();
|
|
6540
|
+
if (!spawnWarning) return result;
|
|
6541
|
+
return {
|
|
6542
|
+
...result,
|
|
6543
|
+
warning: result.warning ? `${spawnWarning}; ${result.warning}` : spawnWarning
|
|
6544
|
+
};
|
|
6545
|
+
async function ringResolved() {
|
|
6546
|
+
if (!pane) return { rung: false };
|
|
6547
|
+
if (pane === paneOf(store, input.fromId)) return { rung: false };
|
|
6548
|
+
if (focusGated) {
|
|
6549
|
+
let focused;
|
|
6550
|
+
try {
|
|
6551
|
+
focused = getAdapter().isPaneFocused(exec, { id: pane });
|
|
6552
|
+
} catch {
|
|
6553
|
+
focused = void 0;
|
|
6554
|
+
}
|
|
6555
|
+
if (focused === false) return {
|
|
6556
|
+
rung: false,
|
|
6557
|
+
pane
|
|
6558
|
+
};
|
|
6559
|
+
}
|
|
6493
6560
|
try {
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6561
|
+
const adapter = getAdapter();
|
|
6562
|
+
const target = { id: pane };
|
|
6563
|
+
await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
|
|
6564
|
+
...guardOpts,
|
|
6565
|
+
harness,
|
|
6566
|
+
ownText: (text) => isRingText(text)
|
|
6567
|
+
});
|
|
6568
|
+
return {
|
|
6569
|
+
rung: true,
|
|
6570
|
+
pane
|
|
6571
|
+
};
|
|
6572
|
+
} catch (err) {
|
|
6573
|
+
return {
|
|
6574
|
+
rung: false,
|
|
6575
|
+
pane,
|
|
6576
|
+
warning: err instanceof Error ? err.message : String(err)
|
|
6577
|
+
};
|
|
6497
6578
|
}
|
|
6498
|
-
if (focused === false) return {
|
|
6499
|
-
rung: false,
|
|
6500
|
-
pane
|
|
6501
|
-
};
|
|
6502
|
-
}
|
|
6503
|
-
try {
|
|
6504
|
-
const adapter = getAdapter();
|
|
6505
|
-
const target = { id: pane };
|
|
6506
|
-
await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
|
|
6507
|
-
...guardOpts,
|
|
6508
|
-
harness,
|
|
6509
|
-
ownText: (text) => isRingText(text)
|
|
6510
|
-
});
|
|
6511
|
-
return {
|
|
6512
|
-
rung: true,
|
|
6513
|
-
pane
|
|
6514
|
-
};
|
|
6515
|
-
} catch (err) {
|
|
6516
|
-
return {
|
|
6517
|
-
rung: false,
|
|
6518
|
-
pane,
|
|
6519
|
-
warning: err instanceof Error ? err.message : String(err)
|
|
6520
|
-
};
|
|
6521
6579
|
}
|
|
6522
6580
|
}
|
|
6523
6581
|
/**
|
|
@@ -6531,6 +6589,11 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
|
|
|
6531
6589
|
* ring that never completes within the retry budget is swallowed into a warning. This never throws —
|
|
6532
6590
|
* it can never fail the spawn.
|
|
6533
6591
|
*
|
|
6592
|
+
* The ring counts only once the harness posts the doorbell (`requirePosted`), not merely once it
|
|
6593
|
+
* leaves the input box: a doorbell typed before a booting harness drew its box vanishes the same way,
|
|
6594
|
+
* and nothing else will tell the peer its brief exists. A fresh session has no earlier copy to
|
|
6595
|
+
* scroll away, so the posted copy is a reliable sign here.
|
|
6596
|
+
*
|
|
6534
6597
|
* The adapter is resolved lazily via `getAdapter` inside the same swallowing try, so even a session
|
|
6535
6598
|
* whose backend has since gone away (where `selectSessionAdapter` would throw) degrades to a warned
|
|
6536
6599
|
* no-op rather than a failed spawn.
|
|
@@ -6540,7 +6603,10 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
6540
6603
|
try {
|
|
6541
6604
|
const adapter = getAdapter();
|
|
6542
6605
|
const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
|
|
6543
|
-
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell,
|
|
6606
|
+
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, {
|
|
6607
|
+
...nudgeOpts,
|
|
6608
|
+
requirePosted: true
|
|
6609
|
+
}), {
|
|
6544
6610
|
...guardOpts,
|
|
6545
6611
|
harness: input.harness,
|
|
6546
6612
|
ownText: (text) => isRingText(text)
|
|
@@ -6886,6 +6952,16 @@ function primaryRootOfRepo(exec, repo) {
|
|
|
6886
6952
|
throw new Error(`--repo ${repo} is not inside a git repository`);
|
|
6887
6953
|
}
|
|
6888
6954
|
}
|
|
6955
|
+
/** The primary checkout of the repository containing `dir`, or `undefined` when `dir` is in no git
|
|
6956
|
+
* repository — for a folder that may sit outside any repository, where there is no primary checkout
|
|
6957
|
+
* to keep a unit out of. */
|
|
6958
|
+
function primaryRootOf$1(exec, dir) {
|
|
6959
|
+
try {
|
|
6960
|
+
return primaryRootOfRepo(exec, dir);
|
|
6961
|
+
} catch {
|
|
6962
|
+
return;
|
|
6963
|
+
}
|
|
6964
|
+
}
|
|
6889
6965
|
/**
|
|
6890
6966
|
* Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
|
|
6891
6967
|
* the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
|
|
@@ -6911,7 +6987,7 @@ function spawn(ctx, input) {
|
|
|
6911
6987
|
if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
|
|
6912
6988
|
if (input.cwd && (input.branch || input.worktreePath || input.repo)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path/--repo");
|
|
6913
6989
|
const id = randomId();
|
|
6914
|
-
const primaryRoot = input.repo ? primaryRootOfRepo(exec, input.repo) : resolvePrimaryRoot(exec);
|
|
6990
|
+
const primaryRoot = input.cwd !== void 0 && input.cwdOwnRepo === true ? primaryRootOf$1(exec, input.cwd) : input.repo ? primaryRootOfRepo(exec, input.repo) : resolvePrimaryRoot(exec);
|
|
6915
6991
|
const launch = input.command ?? LAUNCH_MAP[harness];
|
|
6916
6992
|
const launchLine = () => composeLaunchLine(ctx, sessionAdapter.name, id, launch);
|
|
6917
6993
|
const from = callerPane(sessionAdapter, normalizedEnv);
|
|
@@ -6921,7 +6997,7 @@ function spawn(ctx, input) {
|
|
|
6921
6997
|
if (input.cwd) {
|
|
6922
6998
|
if (!existsSync(input.cwd)) throw new Error(`--cwd directory must already exist: ${input.cwd}`);
|
|
6923
6999
|
cwd = resolve(input.cwd);
|
|
6924
|
-
assertDistinctFromPrimary(cwd, primaryRoot);
|
|
7000
|
+
if (primaryRoot !== void 0) assertDistinctFromPrimary(cwd, primaryRoot);
|
|
6925
7001
|
worktree = null;
|
|
6926
7002
|
const at = input.at ?? "tab";
|
|
6927
7003
|
target = sessionAdapter.open(exec, {
|
|
@@ -6932,6 +7008,7 @@ function spawn(ctx, input) {
|
|
|
6932
7008
|
...labelFor(at, input, brief, id)
|
|
6933
7009
|
});
|
|
6934
7010
|
} else {
|
|
7011
|
+
if (primaryRoot === void 0) throw new Error("a worktree spawn needs a primary checkout");
|
|
6935
7012
|
const branch = input.branch ?? `cyberlegion/unit-${id}`;
|
|
6936
7013
|
const at = input.at ?? "workspace";
|
|
6937
7014
|
const worktreePath = input.worktreePath ?? resolveUnitWorktreePath(primaryRoot, id.slice(0, 6));
|
|
@@ -7024,15 +7101,39 @@ function spawn(ctx, input) {
|
|
|
7024
7101
|
*/
|
|
7025
7102
|
async function spawnAndWake(ctx, input, options = {}) {
|
|
7026
7103
|
const res = spawn(ctx, input);
|
|
7027
|
-
const
|
|
7028
|
-
|
|
7029
|
-
const trust = await settleTrust(ctx, res, options.trustOpts);
|
|
7030
|
-
if (trust.state === "needs-human" || trust.state === "stuck") return {
|
|
7104
|
+
const trust = await settleSpawnTrust(ctx, res, options);
|
|
7105
|
+
if (trust.trustBlocked) return {
|
|
7031
7106
|
...res,
|
|
7107
|
+
...trust
|
|
7108
|
+
};
|
|
7109
|
+
return {
|
|
7110
|
+
...res,
|
|
7111
|
+
...await ringSpawnFirstTurn(ctx, res, trust, options)
|
|
7112
|
+
};
|
|
7113
|
+
}
|
|
7114
|
+
/**
|
|
7115
|
+
* Settle a just-spawned unit's folder-trust prompt — the first half of `spawnAndWake` after the spawn
|
|
7116
|
+
* itself. Split from the ring so `spawnPresence` (`presence-spawn.ts`) can hold the presence lock
|
|
7117
|
+
* across this step and release it before the ring. `trustFolder` says a person already vouched for
|
|
7118
|
+
* this folder (a standing owner's registered home), so its prompt is accepted as a worktree spawn's is.
|
|
7119
|
+
*/
|
|
7120
|
+
async function settleSpawnTrust(ctx, res, options) {
|
|
7121
|
+
const trust = await settleTrust(ctx, res, options.trustOpts, options.trustFolder);
|
|
7122
|
+
if (trust.state === "needs-human" || trust.state === "stuck") return {
|
|
7032
7123
|
rung: false,
|
|
7033
7124
|
trust: trust.state,
|
|
7034
7125
|
trustBlocked: trust.message
|
|
7035
7126
|
};
|
|
7127
|
+
return {
|
|
7128
|
+
rung: false,
|
|
7129
|
+
trust: trust.state
|
|
7130
|
+
};
|
|
7131
|
+
}
|
|
7132
|
+
/** Ring a just-spawned unit's first turn once its trust prompt has settled — the second half of
|
|
7133
|
+
* `spawnAndWake`. */
|
|
7134
|
+
async function ringSpawnFirstTurn(ctx, res, trust, options) {
|
|
7135
|
+
const env = ctx.env ?? process.env;
|
|
7136
|
+
const exec = ctx.exec ?? realExec;
|
|
7036
7137
|
const briefPath = res.agent.brief;
|
|
7037
7138
|
const wake = await wakeSpawn(() => selectSessionAdapter(env, exec), exec, {
|
|
7038
7139
|
target: { id: res.pane },
|
|
@@ -7041,9 +7142,8 @@ async function spawnAndWake(ctx, input, options = {}) {
|
|
|
7041
7142
|
harness: res.agent.harness
|
|
7042
7143
|
}, options.nudgeOpts);
|
|
7043
7144
|
return {
|
|
7044
|
-
...res,
|
|
7045
7145
|
rung: wake.rung,
|
|
7046
|
-
trust: trust.
|
|
7146
|
+
trust: trust.trust,
|
|
7047
7147
|
...wake.warning ? { warning: wake.warning } : {}
|
|
7048
7148
|
};
|
|
7049
7149
|
}
|
|
@@ -7052,7 +7152,7 @@ async function spawnAndWake(ctx, input, options = {}) {
|
|
|
7052
7152
|
* resolved leaves nothing to read, which is no evidence of a prompt: the ring's own containment
|
|
7053
7153
|
* reports that backend.
|
|
7054
7154
|
*/
|
|
7055
|
-
async function settleTrust(ctx, res, opts) {
|
|
7155
|
+
async function settleTrust(ctx, res, opts, trustFolder = false) {
|
|
7056
7156
|
const exec = ctx.exec ?? realExec;
|
|
7057
7157
|
let adapter;
|
|
7058
7158
|
try {
|
|
@@ -7064,7 +7164,7 @@ async function settleTrust(ctx, res, opts) {
|
|
|
7064
7164
|
harness: res.agent.harness,
|
|
7065
7165
|
folder: res.agent.cwd,
|
|
7066
7166
|
pane: res.pane,
|
|
7067
|
-
policy: res.agent.worktree ? "accept" : "ask"
|
|
7167
|
+
policy: res.agent.worktree || trustFolder ? "accept" : "ask"
|
|
7068
7168
|
}, opts);
|
|
7069
7169
|
}
|
|
7070
7170
|
/**
|
|
@@ -7998,6 +8098,102 @@ function addPermissionRule(file) {
|
|
|
7998
8098
|
return "added";
|
|
7999
8099
|
}
|
|
8000
8100
|
//#endregion
|
|
8101
|
+
//#region src/presence-spawn.ts
|
|
8102
|
+
/** The brief a presence spawned into a standing owner's home starts from: whom it stands in for, and
|
|
8103
|
+
* where that owner's mail is. What to *do* with the mail is the home's agent definition's business. */
|
|
8104
|
+
function presenceBrief(handle) {
|
|
8105
|
+
return [
|
|
8106
|
+
`You stand in for the standing owner "${handle}": you are its presence, spawned because mail was delivered to it and no unit was standing in.`,
|
|
8107
|
+
"",
|
|
8108
|
+
`Read its mail: cyberlegion mail inbox --owner ${handle}, then cyberlegion mail read --owner ${handle} <id>.`,
|
|
8109
|
+
`Ack what you have handled: cyberlegion mail ack --owner ${handle} <id>.`
|
|
8110
|
+
].join("\n");
|
|
8111
|
+
}
|
|
8112
|
+
/**
|
|
8113
|
+
* Give a standing owner with a home a presence: spawn a unit in the home and bind it, unless a live
|
|
8114
|
+
* presence already exists — the delivery doorbell's step when mail arrives and nobody is standing in.
|
|
8115
|
+
*
|
|
8116
|
+
* Under the presence lock (the one `unit claim` takes) it re-reads the owner and decides there, so
|
|
8117
|
+
* two near-simultaneous deliveries spawn one unit: the second finds the first's presence live and
|
|
8118
|
+
* gets it back as `existing`, to ring like any presence. The lock covers the re-read, the spawn (open
|
|
8119
|
+
* the pane, write the record and brief), the bind, and the trust step, so no other delivery can ring
|
|
8120
|
+
* the new presence while its trust prompt may be showing. Only the first-turn ring runs after it is
|
|
8121
|
+
* released. A delivery that waits past the lock's bound takes the doorbell's lock-timeout warning;
|
|
8122
|
+
* its message is already in the inbox the new presence is about to read.
|
|
8123
|
+
*
|
|
8124
|
+
* The spawn opens its own workspace — never a tab or split of the sender's space, since the sender is
|
|
8125
|
+
* not its parent and may be in no pane — launched from the home's agent definition (resolved from the
|
|
8126
|
+
* home) or its harness. The home's trust prompt is accepted: a person named the folder at
|
|
8127
|
+
* `unit register --standing --home`. A prompt left showing anyway means the unit cannot read its
|
|
8128
|
+
* brief, so it is unbound again (if still the presence, before the lock is released) rather than left
|
|
8129
|
+
* for later deliveries to ring — a ring typed into a trust prompt answers it wrongly.
|
|
8130
|
+
*
|
|
8131
|
+
* Throws when the spawn cannot happen (no multiplexer, a missing home, an unresolvable definition, a
|
|
8132
|
+
* lock timeout). The doorbell turns that into a warning.
|
|
8133
|
+
*/
|
|
8134
|
+
async function spawnPresence(ctx, owner, options = {}) {
|
|
8135
|
+
const home = owner.home;
|
|
8136
|
+
if (!home) throw new Error(`standing owner "${owner.handle}" has no home`);
|
|
8137
|
+
const outcome = await ctx.store.withLockAsync(`presence:${owner.id}`, async () => {
|
|
8138
|
+
const current = loadAgent(ctx.store, owner.id);
|
|
8139
|
+
if (!current) throw new Error(`standing owner "${owner.handle}" is gone`);
|
|
8140
|
+
const live = presenceOf(ctx.store, current);
|
|
8141
|
+
if (live) return { existing: live };
|
|
8142
|
+
const launched = resolveSpawnLaunch({
|
|
8143
|
+
agent: home.agent,
|
|
8144
|
+
harness: home.harness,
|
|
8145
|
+
cwd: home.dir
|
|
8146
|
+
});
|
|
8147
|
+
const res = spawn(ctx, {
|
|
8148
|
+
harness: launched.harness,
|
|
8149
|
+
command: launched.command,
|
|
8150
|
+
briefInstructions: launched.briefInstructions,
|
|
8151
|
+
task: presenceBrief(owner.handle),
|
|
8152
|
+
cwd: home.dir,
|
|
8153
|
+
cwdOwnRepo: true,
|
|
8154
|
+
at: "workspace"
|
|
8155
|
+
});
|
|
8156
|
+
current.presence = res.agent.id;
|
|
8157
|
+
saveAgent(ctx.store, current);
|
|
8158
|
+
const unbind = () => {
|
|
8159
|
+
const latest = loadAgent(ctx.store, owner.id);
|
|
8160
|
+
if (latest?.presence !== res.agent.id) return false;
|
|
8161
|
+
latest.presence = void 0;
|
|
8162
|
+
saveAgent(ctx.store, latest);
|
|
8163
|
+
return true;
|
|
8164
|
+
};
|
|
8165
|
+
let trust;
|
|
8166
|
+
try {
|
|
8167
|
+
trust = await settleSpawnTrust(ctx, res, {
|
|
8168
|
+
...options,
|
|
8169
|
+
trustFolder: true
|
|
8170
|
+
});
|
|
8171
|
+
} catch (err) {
|
|
8172
|
+
unbind();
|
|
8173
|
+
throw err;
|
|
8174
|
+
}
|
|
8175
|
+
const unbound = trust.trustBlocked ? unbind() : false;
|
|
8176
|
+
return {
|
|
8177
|
+
spawned: res,
|
|
8178
|
+
trust,
|
|
8179
|
+
unbound
|
|
8180
|
+
};
|
|
8181
|
+
});
|
|
8182
|
+
if (outcome.existing) return {
|
|
8183
|
+
kind: "existing",
|
|
8184
|
+
presence: outcome.existing
|
|
8185
|
+
};
|
|
8186
|
+
const { spawned: res, trust, unbound } = outcome;
|
|
8187
|
+
const turn = trust.trustBlocked ? trust : await ringSpawnFirstTurn(ctx, res, trust, options);
|
|
8188
|
+
return {
|
|
8189
|
+
kind: "spawned",
|
|
8190
|
+
presence: res.agent,
|
|
8191
|
+
pane: res.pane,
|
|
8192
|
+
unbound,
|
|
8193
|
+
...turn
|
|
8194
|
+
};
|
|
8195
|
+
}
|
|
8196
|
+
//#endregion
|
|
8001
8197
|
//#region src/project.ts
|
|
8002
8198
|
/**
|
|
8003
8199
|
* The canonical git common dir for `dir`, or undefined outside a repository. The common dir is the
|
|
@@ -8480,6 +8676,54 @@ async function startService(ctx, projectRef, name, input) {
|
|
|
8480
8676
|
}
|
|
8481
8677
|
}
|
|
8482
8678
|
//#endregion
|
|
8679
|
+
//#region src/standing-home.ts
|
|
8680
|
+
/**
|
|
8681
|
+
* Decide what a registration does to a standing owner's home: `undefined` keeps whatever the record
|
|
8682
|
+
* already has, `null` drops it, and a `StandingHome` replaces it. Every way a home can be wrong is
|
|
8683
|
+
* refused here, before anything is written — a failure found at delivery time could only be a warning
|
|
8684
|
+
* on someone else's send.
|
|
8685
|
+
*
|
|
8686
|
+
* The checks run in a fixed order so that a combination matching two refusals gets the first:
|
|
8687
|
+
* a home flag outside a named standing registration, then `--home` with `--clear-home`, then a launch
|
|
8688
|
+
* with no `--home`, then the folder, the launch, and the primary checkout.
|
|
8689
|
+
*
|
|
8690
|
+
* `--harness` is a home flag only on a standing registration; a plain `unit register` takes it for
|
|
8691
|
+
* the session itself.
|
|
8692
|
+
*/
|
|
8693
|
+
function resolveHomeFlags(exec, flags) {
|
|
8694
|
+
if ((flags.home !== void 0 || flags.clearHome === true || flags.agent !== void 0) && !(flags.standing && flags.handle)) throw new Error("a home belongs only to a named standing owner — pass --standing --handle <name>");
|
|
8695
|
+
if (!flags.standing) return void 0;
|
|
8696
|
+
if (flags.home !== void 0 && flags.clearHome) throw new Error("--home and --clear-home contradict — pass one");
|
|
8697
|
+
if (flags.home === void 0 && (flags.agent !== void 0 || flags.harness !== void 0)) throw new Error("a launch needs --home: --agent and --harness name how to start a session in it");
|
|
8698
|
+
if (flags.clearHome) return null;
|
|
8699
|
+
if (flags.home === void 0) return void 0;
|
|
8700
|
+
const dir = resolve(flags.home);
|
|
8701
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`the home must already exist: ${flags.home}`);
|
|
8702
|
+
if (flags.agent === void 0 === (flags.harness === void 0)) throw new Error("a home needs exactly one of --agent or --harness");
|
|
8703
|
+
let launch;
|
|
8704
|
+
if (flags.harness !== void 0) {
|
|
8705
|
+
if (!(flags.harness in LAUNCH_MAP)) throw new Error(`unrecognized --harness "${flags.harness}" (${Object.keys(LAUNCH_MAP).join(" | ")})`);
|
|
8706
|
+
launch = { harness: flags.harness };
|
|
8707
|
+
} else {
|
|
8708
|
+
const agent = flags.agent;
|
|
8709
|
+
try {
|
|
8710
|
+
resolveAgentDef({
|
|
8711
|
+
name: agent,
|
|
8712
|
+
cwd: dir
|
|
8713
|
+
});
|
|
8714
|
+
} catch {
|
|
8715
|
+
throw new Error(`the agent definition "${agent}" does not resolve from the home ${dir}`);
|
|
8716
|
+
}
|
|
8717
|
+
launch = { agent };
|
|
8718
|
+
}
|
|
8719
|
+
const primary = primaryRootOf$1(exec, dir);
|
|
8720
|
+
if (primary !== void 0 && realpathSync(primary) === realpathSync(dir)) throw new Error(`the home refuses the primary checkout ${dir} — a spawned unit never works there`);
|
|
8721
|
+
return {
|
|
8722
|
+
dir,
|
|
8723
|
+
...launch
|
|
8724
|
+
};
|
|
8725
|
+
}
|
|
8726
|
+
//#endregion
|
|
8483
8727
|
//#region src/store/errors.ts
|
|
8484
8728
|
/** A record file exists but its content didn't parse as JSON — a torn write (crash mid-`writeFileSync`,
|
|
8485
8729
|
* pre-atomic-write code path) or on-disk tampering. Carries the file path and the original parse
|
|
@@ -8642,6 +8886,16 @@ function withLock(root, name, fn, opts) {
|
|
|
8642
8886
|
handle.release();
|
|
8643
8887
|
}
|
|
8644
8888
|
}
|
|
8889
|
+
/** `withLock` for an async read-decide-write: the lock is held until `fn`'s promise settles, not
|
|
8890
|
+
* merely until it returns one. The wait to acquire is the same bounded, synchronous one. */
|
|
8891
|
+
async function withLockAsync(root, name, fn, opts) {
|
|
8892
|
+
const handle = acquireLock(root, name, opts);
|
|
8893
|
+
try {
|
|
8894
|
+
return await fn();
|
|
8895
|
+
} finally {
|
|
8896
|
+
handle.release();
|
|
8897
|
+
}
|
|
8898
|
+
}
|
|
8645
8899
|
//#endregion
|
|
8646
8900
|
//#region src/store/file-store.ts
|
|
8647
8901
|
/** Parse a record file's content, wrapping a `JSON.parse` failure in a typed, file-named
|
|
@@ -8845,6 +9099,9 @@ var FileStore = class {
|
|
|
8845
9099
|
withLock(name, fn) {
|
|
8846
9100
|
return withLock(this.root, name, fn);
|
|
8847
9101
|
}
|
|
9102
|
+
withLockAsync(name, fn) {
|
|
9103
|
+
return withLockAsync(this.root, name, fn);
|
|
9104
|
+
}
|
|
8848
9105
|
};
|
|
8849
9106
|
//#endregion
|
|
8850
9107
|
//#region src/unit-runtime.ts
|
|
@@ -9232,7 +9489,7 @@ const program = new Command();
|
|
|
9232
9489
|
program.name("cyberlegion").description("Harness-agnostic agent session spawning and messaging over the filesystem").version(VERSION).enablePositionalOptions();
|
|
9233
9490
|
const unit = program.command("unit").description("legion units — register, discover, spawn, and reap");
|
|
9234
9491
|
/** The standing-owner branch of `unit register --standing` (folds the old `identity owner`). */
|
|
9235
|
-
function runStanding(ctx, opts) {
|
|
9492
|
+
function runStanding(ctx, opts, home) {
|
|
9236
9493
|
if (!opts.handle) {
|
|
9237
9494
|
const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
|
|
9238
9495
|
emit(formatOf(opts), {
|
|
@@ -9259,22 +9516,33 @@ function runStanding(ctx, opts) {
|
|
|
9259
9516
|
return;
|
|
9260
9517
|
}
|
|
9261
9518
|
const liveClaim = listAgents(ctx.store).find((a) => a.handle === opts.handle && a.kind !== "standing" && a.status !== "exited");
|
|
9262
|
-
const rec = registerStanding(ctx, {
|
|
9519
|
+
const rec = registerStanding(ctx, {
|
|
9520
|
+
handle: opts.handle,
|
|
9521
|
+
home
|
|
9522
|
+
});
|
|
9263
9523
|
if (liveClaim) console.error(`a live session already claims handle "${opts.handle}"`);
|
|
9264
9524
|
emit(formatOf(opts), {
|
|
9265
9525
|
toon: toonObject({
|
|
9266
9526
|
id: rec.id,
|
|
9267
9527
|
handle: rec.handle,
|
|
9268
9528
|
kind: rec.kind,
|
|
9269
|
-
status: rec.status
|
|
9529
|
+
status: rec.status,
|
|
9530
|
+
home: rec.home?.dir ?? "none",
|
|
9531
|
+
...rec.home ? { launch: rec.home.agent ? `agent ${rec.home.agent}` : `harness ${rec.home.harness}` } : {}
|
|
9270
9532
|
}),
|
|
9271
9533
|
json: rec
|
|
9272
9534
|
});
|
|
9273
9535
|
}
|
|
9274
|
-
withGlobals(unit.command("register")).description("register or refresh this session identity (or --standing: a session-independent owner inbox)").option("--handle <name>", "human handle for this agent").option("--harness <h>", "claude | cursor | codex (else auto-detected)").option("--standing", "mint a standing, session-independent owner inbox (bare, with no --handle: list them)").action((opts) => {
|
|
9536
|
+
withGlobals(unit.command("register")).description("register or refresh this session identity (or --standing: a session-independent owner inbox)").option("--handle <name>", "human handle for this agent").option("--harness <h>", "claude | cursor | codex (else auto-detected)").option("--standing", "mint a standing, session-independent owner inbox (bare, with no --handle: list them)").option("--home <dir>", "with --standing: the existing folder a presence is spawned in when mail arrives and none is live").option("--agent <name>", "with --home: launch that agent definition, resolved from the home").option("--clear-home", "with --standing: drop the owner's home").action((opts) => {
|
|
9275
9537
|
const ctx = ctxOf(opts);
|
|
9538
|
+
let home;
|
|
9539
|
+
try {
|
|
9540
|
+
home = resolveHomeFlags(ctx.exec ?? realExec, opts);
|
|
9541
|
+
} catch (err) {
|
|
9542
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
9543
|
+
}
|
|
9276
9544
|
if (opts.standing) {
|
|
9277
|
-
runStanding(ctx, opts);
|
|
9545
|
+
runStanding(ctx, opts, home);
|
|
9278
9546
|
return;
|
|
9279
9547
|
}
|
|
9280
9548
|
const rec = register(ctx, {
|
|
@@ -9586,7 +9854,7 @@ withGlobals(unit.command("focus")).description("move input focus to a peer's ses
|
|
|
9586
9854
|
}
|
|
9587
9855
|
});
|
|
9588
9856
|
});
|
|
9589
|
-
withGlobals(unit.command("nudge")).description("
|
|
9857
|
+
withGlobals(unit.command("nudge")).description("deliver text as a turn in a peer's session — by default a doorbell telling them to check their mail; --message relays any text (e.g. a decision) instead").argument("<ref>", "unit id, handle, or worktree branch/CR ref").option("--message <text>", "text delivered as a turn in the peer session", DELIVERY_DOORBELL).action(async (ref, opts) => {
|
|
9590
9858
|
const ctx = ctxOf(opts);
|
|
9591
9859
|
touch(ctx);
|
|
9592
9860
|
const { pane, message, resubmits } = await nudgeUnit(ctx, ref, { message: opts.message });
|
|
@@ -9851,7 +10119,8 @@ function defineSend(cmd) {
|
|
|
9851
10119
|
const wake = await wakeRecipient(ctx.store, () => selectSessionAdapter(ctx.env ?? process.env), realExec, {
|
|
9852
10120
|
toId: msg.to,
|
|
9853
10121
|
fromId,
|
|
9854
|
-
noNudge: opts.nudge === false
|
|
10122
|
+
noNudge: opts.nudge === false,
|
|
10123
|
+
spawnHome: (owner) => spawnPresence(ctx, owner)
|
|
9855
10124
|
});
|
|
9856
10125
|
if (wake.warning) console.error(`delivery doorbell not confirmed (message still delivered): ${wake.warning}`);
|
|
9857
10126
|
emit(formatOf(opts), {
|
|
@@ -9859,11 +10128,13 @@ function defineSend(cmd) {
|
|
|
9859
10128
|
sent: msg.id,
|
|
9860
10129
|
to: opts.to,
|
|
9861
10130
|
subject: msg.subject,
|
|
9862
|
-
rung: wake.rung
|
|
10131
|
+
rung: wake.rung,
|
|
10132
|
+
...wake.spawned ? { spawned: wake.spawned } : {}
|
|
9863
10133
|
}),
|
|
9864
10134
|
json: {
|
|
9865
10135
|
...msg,
|
|
9866
|
-
rung: wake.rung
|
|
10136
|
+
rung: wake.rung,
|
|
10137
|
+
...wake.spawned ? { spawned: wake.spawned } : {}
|
|
9867
10138
|
}
|
|
9868
10139
|
});
|
|
9869
10140
|
});
|
package/dist/index.d.mts
CHANGED
|
@@ -83,7 +83,22 @@ interface AgentRecord {
|
|
|
83
83
|
* no presence bound, same as if none were ever claimed — nothing self-heals a stale pointer, it
|
|
84
84
|
* stays inert until re-claimed. Independent of the hub's `mainPane` (the human's read-pane). */
|
|
85
85
|
presence?: string;
|
|
86
|
-
|
|
86
|
+
/** Only meaningful on a `kind: 'standing'` record — where a presence is spawned when mail arrives
|
|
87
|
+
* and none is live (`mail/doorbell`), set by `unit register --standing --home`. Its own field,
|
|
88
|
+
* never `cwd`: `cwd` is wherever the registering process ran and is rewritten on every refresh. */
|
|
89
|
+
home?: StandingHome;
|
|
90
|
+
}
|
|
91
|
+
/** A standing owner's home: an absolute folder, plus exactly one way to launch a session in it — an
|
|
92
|
+
* agent definition resolved from that folder, or a bare harness. */
|
|
93
|
+
type StandingHome = {
|
|
94
|
+
dir: string;
|
|
95
|
+
} & ({
|
|
96
|
+
agent: string;
|
|
97
|
+
harness?: never;
|
|
98
|
+
} | {
|
|
99
|
+
harness: Harness;
|
|
100
|
+
agent?: never;
|
|
101
|
+
});
|
|
87
102
|
/** A registered project: one git repository, shared by its default checkout and every linked
|
|
88
103
|
* worktree of it. Keyed by its git common dir, never by a pane or a display name. */
|
|
89
104
|
interface ProjectRecord {
|
|
@@ -184,6 +199,8 @@ interface Store extends MailboxStore {
|
|
|
184
199
|
* store (e.g. a standing record's presence rebind) should hold the SAME named lock around that
|
|
185
200
|
* whole sequence, not just around the final write. */
|
|
186
201
|
withLock<T>(name: string, fn: () => T): T;
|
|
202
|
+
/** `withLock` held across an async `fn` until its promise settles (`store/lock.ts`). */
|
|
203
|
+
withLockAsync<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
187
204
|
}
|
|
188
205
|
//#endregion
|
|
189
206
|
//#region src/identity.d.ts
|
|
@@ -729,6 +746,11 @@ interface SpawnInput {
|
|
|
729
746
|
repo?: string;
|
|
730
747
|
/** Placement relative to the caller; defaults to 'tab'. */
|
|
731
748
|
at?: MuxPlacement;
|
|
749
|
+
/** With `cwd`: keep the unit out of the primary checkout of the repository **containing `cwd`**,
|
|
750
|
+
* not the caller's, and allow a `cwd` in no repository at all. For a spawn whose caller may run
|
|
751
|
+
* anywhere — the delivery doorbell's spawn into a standing owner's home, sent from a cron job in
|
|
752
|
+
* no repository. */
|
|
753
|
+
cwdOwnRepo?: boolean;
|
|
732
754
|
}
|
|
733
755
|
interface SpawnResult {
|
|
734
756
|
agent: AgentRecord;
|
|
@@ -773,12 +795,14 @@ export declare function spawnAndWake(ctx: IdContext, input: SpawnInput, options?
|
|
|
773
795
|
noWake?: boolean;
|
|
774
796
|
nudgeOpts?: NudgeOptions;
|
|
775
797
|
trustOpts?: TrustOptions;
|
|
776
|
-
}): Promise<SpawnResult &
|
|
798
|
+
}): Promise<SpawnResult & FirstTurn>;
|
|
799
|
+
/** What happened after a spawn landed: whether its first turn was rung, and how its trust prompt went. */
|
|
800
|
+
interface FirstTurn {
|
|
777
801
|
rung: boolean;
|
|
778
802
|
warning?: string;
|
|
779
803
|
trust: TrustOutcome['state'];
|
|
780
804
|
trustBlocked?: string;
|
|
781
|
-
}
|
|
805
|
+
}
|
|
782
806
|
//#endregion
|
|
783
807
|
//#region src/store/file-store.d.ts
|
|
784
808
|
/** The on-disk `Store` implementation — current per-writer sharded `.json` layout (ADR-0020):
|
|
@@ -811,6 +835,7 @@ export declare class FileStore implements Store {
|
|
|
811
835
|
setMainPane(pane: string | null): void;
|
|
812
836
|
getMainPane(): string | undefined;
|
|
813
837
|
withLock<T>(name: string, fn: () => T): T;
|
|
838
|
+
withLockAsync<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
814
839
|
}
|
|
815
840
|
//#endregion
|
|
816
841
|
//#region src/wake/await.d.ts
|
package/dist/index.mjs
CHANGED
|
@@ -303,10 +303,15 @@ function gitWorktree(exec) {
|
|
|
303
303
|
]) ?? void 0
|
|
304
304
|
};
|
|
305
305
|
}
|
|
306
|
-
/**
|
|
307
|
-
*
|
|
308
|
-
|
|
309
|
-
|
|
306
|
+
/** Pick the one live record a handle names. A standing record wins over the rest — an owner report
|
|
307
|
+
* must land in the durable standing inbox, not a dying session's. Otherwise two or more matches are
|
|
308
|
+
* ambiguous (two same-named repos give their service endpoints the same handle), and silently taking
|
|
309
|
+
* the first could deliver to the wrong one, so fail loud naming the ids, as `resolveOwnerMailbox` does. */
|
|
310
|
+
function pickLive(ref, live) {
|
|
311
|
+
const standing = live.find((a) => a.kind === "standing");
|
|
312
|
+
if (standing) return standing;
|
|
313
|
+
if (live.length > 1) throw new Error(`"${ref}" names ${live.length} units — pass an id (${live.map((a) => a.id).join(", ")})`);
|
|
314
|
+
return live[0];
|
|
310
315
|
}
|
|
311
316
|
/** Split a handle's matches into live and exited. An exited unit's pane is gone and its inbox has
|
|
312
317
|
* no reader, so a *name* must never resolve to one — a handle is reusable across units, and the
|
|
@@ -333,7 +338,7 @@ function unaddressable(ref, exited, tried) {
|
|
|
333
338
|
function resolveRecipient(store, to) {
|
|
334
339
|
if (loadAgent(store, to)) return to;
|
|
335
340
|
const { live, exited } = matchHandle(listAgents(store), to);
|
|
336
|
-
const match =
|
|
341
|
+
const match = pickLive(to, live);
|
|
337
342
|
if (!match) throw unaddressable(to, exited, "id and handle");
|
|
338
343
|
return match.id;
|
|
339
344
|
}
|
|
@@ -347,7 +352,7 @@ function resolveAgent(store, ref) {
|
|
|
347
352
|
if (byId) return byId;
|
|
348
353
|
const agents = listAgents(store);
|
|
349
354
|
const { live, exited } = matchHandle(agents, ref);
|
|
350
|
-
const byHandle =
|
|
355
|
+
const byHandle = pickLive(ref, live);
|
|
351
356
|
if (byHandle) return byHandle;
|
|
352
357
|
const byBranchAll = agents.filter((a) => a.worktree?.branch === ref);
|
|
353
358
|
const byBranch = byBranchAll.find((a) => a.status !== "exited");
|
|
@@ -694,6 +699,13 @@ function copiesOn(screen, message) {
|
|
|
694
699
|
* the text, the one in the input box. Two or more new copies mean the harness also posted it above
|
|
695
700
|
* the box, so the turn was taken and nothing is flushed. Text still at the bottom with only one new
|
|
696
701
|
* copy is the swallowed Enter, and is flushed as before.
|
|
702
|
+
*
|
|
703
|
+
* With `requirePosted`, text gone from the box is not enough on its own: text typed before the harness
|
|
704
|
+
* drew its input box, or wiped by a redraw, is gone too, and no turn was taken. The turn then counts
|
|
705
|
+
* as taken only when a new copy of the text is on screen — the harness posted it to its transcript or
|
|
706
|
+
* queue. Text that is neither staged nor posted is lost: after one more settle for a slow redraw, it
|
|
707
|
+
* is typed again. It is opt-in because it needs the posted copy to stay on screen: a busy peer's
|
|
708
|
+
* output can scroll earlier copies away, so a ring to it could read as lost and be typed twice.
|
|
697
709
|
*/
|
|
698
710
|
async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
699
711
|
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
@@ -701,25 +713,36 @@ async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
|
701
713
|
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
702
714
|
if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
|
|
703
715
|
const before = copiesOn(adapter.read(exec, target).text, message);
|
|
704
|
-
const
|
|
716
|
+
const check = () => {
|
|
705
717
|
const screen = adapter.read(exec, target).text;
|
|
706
|
-
|
|
718
|
+
const copies = copiesOn(screen, message);
|
|
719
|
+
if (isStaged(screen, message)) return copies >= before + 2 ? "taken" : "staged";
|
|
720
|
+
return copies > before || !opts.requirePosted ? "taken" : "lost";
|
|
707
721
|
};
|
|
708
722
|
adapter.submit(exec, target, message);
|
|
709
723
|
await sleep(settleMs);
|
|
710
|
-
|
|
724
|
+
let state = check();
|
|
725
|
+
if (state === "taken") return {
|
|
711
726
|
taken: true,
|
|
712
727
|
resubmits: 0
|
|
713
728
|
};
|
|
729
|
+
let lostReads = state === "lost" ? 1 : 0;
|
|
714
730
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
715
|
-
adapter.submit(exec, target);
|
|
731
|
+
if (state === "staged") adapter.submit(exec, target);
|
|
732
|
+
else if (lostReads >= 2) {
|
|
733
|
+
adapter.submit(exec, target, message);
|
|
734
|
+
lostReads = 0;
|
|
735
|
+
}
|
|
716
736
|
await sleep(settleMs);
|
|
717
|
-
|
|
737
|
+
state = check();
|
|
738
|
+
if (state === "taken") return {
|
|
718
739
|
taken: true,
|
|
719
740
|
resubmits: attempt
|
|
720
741
|
};
|
|
742
|
+
lostReads = state === "lost" ? lostReads + 1 : 0;
|
|
721
743
|
}
|
|
722
|
-
|
|
744
|
+
const why = state === "lost" ? "the text vanished without the harness posting it" : `input still staged after ${attempts} re-submit attempts`;
|
|
745
|
+
throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — ${why}`);
|
|
723
746
|
}
|
|
724
747
|
/**
|
|
725
748
|
* The first-turn doorbell `unit spawn` delivers to a freshly-opened paned peer — the instruction
|
|
@@ -778,6 +801,11 @@ const SPAWN_NUDGE_OPTS = {
|
|
|
778
801
|
* ring that never completes within the retry budget is swallowed into a warning. This never throws —
|
|
779
802
|
* it can never fail the spawn.
|
|
780
803
|
*
|
|
804
|
+
* The ring counts only once the harness posts the doorbell (`requirePosted`), not merely once it
|
|
805
|
+
* leaves the input box: a doorbell typed before a booting harness drew its box vanishes the same way,
|
|
806
|
+
* and nothing else will tell the peer its brief exists. A fresh session has no earlier copy to
|
|
807
|
+
* scroll away, so the posted copy is a reliable sign here.
|
|
808
|
+
*
|
|
781
809
|
* The adapter is resolved lazily via `getAdapter` inside the same swallowing try, so even a session
|
|
782
810
|
* whose backend has since gone away (where `selectSessionAdapter` would throw) degrades to a warned
|
|
783
811
|
* no-op rather than a failed spawn.
|
|
@@ -787,7 +815,10 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
787
815
|
try {
|
|
788
816
|
const adapter = getAdapter();
|
|
789
817
|
const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
|
|
790
|
-
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell,
|
|
818
|
+
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, {
|
|
819
|
+
...nudgeOpts,
|
|
820
|
+
requirePosted: true
|
|
821
|
+
}), {
|
|
791
822
|
...guardOpts,
|
|
792
823
|
harness: input.harness,
|
|
793
824
|
ownText: (text) => isRingText(text)
|
|
@@ -1102,6 +1133,16 @@ function primaryRootOfRepo(exec, repo) {
|
|
|
1102
1133
|
throw new Error(`--repo ${repo} is not inside a git repository`);
|
|
1103
1134
|
}
|
|
1104
1135
|
}
|
|
1136
|
+
/** The primary checkout of the repository containing `dir`, or `undefined` when `dir` is in no git
|
|
1137
|
+
* repository — for a folder that may sit outside any repository, where there is no primary checkout
|
|
1138
|
+
* to keep a unit out of. */
|
|
1139
|
+
function primaryRootOf$1(exec, dir) {
|
|
1140
|
+
try {
|
|
1141
|
+
return primaryRootOfRepo(exec, dir);
|
|
1142
|
+
} catch {
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1105
1146
|
/**
|
|
1106
1147
|
* Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
|
|
1107
1148
|
* the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
|
|
@@ -1127,7 +1168,7 @@ function spawn(ctx, input) {
|
|
|
1127
1168
|
if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
|
|
1128
1169
|
if (input.cwd && (input.branch || input.worktreePath || input.repo)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path/--repo");
|
|
1129
1170
|
const id = randomId();
|
|
1130
|
-
const primaryRoot = input.repo ? primaryRootOfRepo(exec, input.repo) : resolvePrimaryRoot$1(exec);
|
|
1171
|
+
const primaryRoot = input.cwd !== void 0 && input.cwdOwnRepo === true ? primaryRootOf$1(exec, input.cwd) : input.repo ? primaryRootOfRepo(exec, input.repo) : resolvePrimaryRoot$1(exec);
|
|
1131
1172
|
const launch = input.command ?? LAUNCH_MAP[harness];
|
|
1132
1173
|
const launchLine = () => composeLaunchLine(ctx, sessionAdapter.name, id, launch);
|
|
1133
1174
|
const from = callerPane(sessionAdapter, normalizedEnv);
|
|
@@ -1137,7 +1178,7 @@ function spawn(ctx, input) {
|
|
|
1137
1178
|
if (input.cwd) {
|
|
1138
1179
|
if (!existsSync(input.cwd)) throw new Error(`--cwd directory must already exist: ${input.cwd}`);
|
|
1139
1180
|
cwd = resolve(input.cwd);
|
|
1140
|
-
assertDistinctFromPrimary$1(cwd, primaryRoot);
|
|
1181
|
+
if (primaryRoot !== void 0) assertDistinctFromPrimary$1(cwd, primaryRoot);
|
|
1141
1182
|
worktree = null;
|
|
1142
1183
|
const at = input.at ?? "tab";
|
|
1143
1184
|
target = sessionAdapter.open(exec, {
|
|
@@ -1148,6 +1189,7 @@ function spawn(ctx, input) {
|
|
|
1148
1189
|
...labelFor(at, input, brief, id)
|
|
1149
1190
|
});
|
|
1150
1191
|
} else {
|
|
1192
|
+
if (primaryRoot === void 0) throw new Error("a worktree spawn needs a primary checkout");
|
|
1151
1193
|
const branch = input.branch ?? `cyberlegion/unit-${id}`;
|
|
1152
1194
|
const at = input.at ?? "workspace";
|
|
1153
1195
|
const worktreePath = input.worktreePath ?? resolveUnitWorktreePath(primaryRoot, id.slice(0, 6));
|
|
@@ -1240,15 +1282,39 @@ function spawn(ctx, input) {
|
|
|
1240
1282
|
*/
|
|
1241
1283
|
async function spawnAndWake(ctx, input, options = {}) {
|
|
1242
1284
|
const res = spawn(ctx, input);
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1285
|
+
const trust = await settleSpawnTrust(ctx, res, options);
|
|
1286
|
+
if (trust.trustBlocked) return {
|
|
1287
|
+
...res,
|
|
1288
|
+
...trust
|
|
1289
|
+
};
|
|
1290
|
+
return {
|
|
1247
1291
|
...res,
|
|
1292
|
+
...await ringSpawnFirstTurn(ctx, res, trust, options)
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Settle a just-spawned unit's folder-trust prompt — the first half of `spawnAndWake` after the spawn
|
|
1297
|
+
* itself. Split from the ring so `spawnPresence` (`presence-spawn.ts`) can hold the presence lock
|
|
1298
|
+
* across this step and release it before the ring. `trustFolder` says a person already vouched for
|
|
1299
|
+
* this folder (a standing owner's registered home), so its prompt is accepted as a worktree spawn's is.
|
|
1300
|
+
*/
|
|
1301
|
+
async function settleSpawnTrust(ctx, res, options) {
|
|
1302
|
+
const trust = await settleTrust(ctx, res, options.trustOpts, options.trustFolder);
|
|
1303
|
+
if (trust.state === "needs-human" || trust.state === "stuck") return {
|
|
1248
1304
|
rung: false,
|
|
1249
1305
|
trust: trust.state,
|
|
1250
1306
|
trustBlocked: trust.message
|
|
1251
1307
|
};
|
|
1308
|
+
return {
|
|
1309
|
+
rung: false,
|
|
1310
|
+
trust: trust.state
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
/** Ring a just-spawned unit's first turn once its trust prompt has settled — the second half of
|
|
1314
|
+
* `spawnAndWake`. */
|
|
1315
|
+
async function ringSpawnFirstTurn(ctx, res, trust, options) {
|
|
1316
|
+
const env = ctx.env ?? process.env;
|
|
1317
|
+
const exec = ctx.exec ?? realExec;
|
|
1252
1318
|
const briefPath = res.agent.brief;
|
|
1253
1319
|
const wake = await wakeSpawn(() => selectSessionAdapter(env, exec), exec, {
|
|
1254
1320
|
target: { id: res.pane },
|
|
@@ -1257,9 +1323,8 @@ async function spawnAndWake(ctx, input, options = {}) {
|
|
|
1257
1323
|
harness: res.agent.harness
|
|
1258
1324
|
}, options.nudgeOpts);
|
|
1259
1325
|
return {
|
|
1260
|
-
...res,
|
|
1261
1326
|
rung: wake.rung,
|
|
1262
|
-
trust: trust.
|
|
1327
|
+
trust: trust.trust,
|
|
1263
1328
|
...wake.warning ? { warning: wake.warning } : {}
|
|
1264
1329
|
};
|
|
1265
1330
|
}
|
|
@@ -1268,7 +1333,7 @@ async function spawnAndWake(ctx, input, options = {}) {
|
|
|
1268
1333
|
* resolved leaves nothing to read, which is no evidence of a prompt: the ring's own containment
|
|
1269
1334
|
* reports that backend.
|
|
1270
1335
|
*/
|
|
1271
|
-
async function settleTrust(ctx, res, opts) {
|
|
1336
|
+
async function settleTrust(ctx, res, opts, trustFolder = false) {
|
|
1272
1337
|
const exec = ctx.exec ?? realExec;
|
|
1273
1338
|
let adapter;
|
|
1274
1339
|
try {
|
|
@@ -1280,7 +1345,7 @@ async function settleTrust(ctx, res, opts) {
|
|
|
1280
1345
|
harness: res.agent.harness,
|
|
1281
1346
|
folder: res.agent.cwd,
|
|
1282
1347
|
pane: res.pane,
|
|
1283
|
-
policy: res.agent.worktree ? "accept" : "ask"
|
|
1348
|
+
policy: res.agent.worktree || trustFolder ? "accept" : "ask"
|
|
1284
1349
|
}, opts);
|
|
1285
1350
|
}
|
|
1286
1351
|
/**
|
|
@@ -2571,6 +2636,16 @@ function withLock(root, name, fn, opts) {
|
|
|
2571
2636
|
handle.release();
|
|
2572
2637
|
}
|
|
2573
2638
|
}
|
|
2639
|
+
/** `withLock` for an async read-decide-write: the lock is held until `fn`'s promise settles, not
|
|
2640
|
+
* merely until it returns one. The wait to acquire is the same bounded, synchronous one. */
|
|
2641
|
+
async function withLockAsync(root, name, fn, opts) {
|
|
2642
|
+
const handle = acquireLock(root, name, opts);
|
|
2643
|
+
try {
|
|
2644
|
+
return await fn();
|
|
2645
|
+
} finally {
|
|
2646
|
+
handle.release();
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2574
2649
|
//#endregion
|
|
2575
2650
|
//#region src/store/file-store.ts
|
|
2576
2651
|
/** Parse a record file's content, wrapping a `JSON.parse` failure in a typed, file-named
|
|
@@ -2774,6 +2849,9 @@ var FileStore = class {
|
|
|
2774
2849
|
withLock(name, fn) {
|
|
2775
2850
|
return withLock(this.root, name, fn);
|
|
2776
2851
|
}
|
|
2852
|
+
withLockAsync(name, fn) {
|
|
2853
|
+
return withLockAsync(this.root, name, fn);
|
|
2854
|
+
}
|
|
2777
2855
|
};
|
|
2778
2856
|
//#endregion
|
|
2779
2857
|
//#region src/wake/await.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cyberlegion",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Harness-agnostic agent session spawning and messaging over the filesystem",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"tsdown": "^0.23.0",
|
|
50
50
|
"tsx": "^4.22.3",
|
|
51
51
|
"typescript": "^7.0.2",
|
|
52
|
-
"vitest": "^
|
|
52
|
+
"vitest": "^5.0.0"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=22"
|
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.4.1",
|
|
5
5
|
"description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "unional"
|
|
@@ -17,7 +17,7 @@ satisfies verdict schema `V`.
|
|
|
17
17
|
> **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
|
|
18
18
|
> this plugin ships. The path is relative to this skill's own directory, not the working directory.
|
|
19
19
|
> If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
|
|
20
|
-
> with the same arguments: `npx -y cyberlegion@1.
|
|
20
|
+
> with the same arguments: `npx -y cyberlegion@1.4.1`.
|
|
21
21
|
|
|
22
22
|
## 1. Resolve the agent definition
|
|
23
23
|
|
|
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
|
|
14
|
-
const FALLBACK = 'npx -y cyberlegion@1.
|
|
14
|
+
const FALLBACK = 'npx -y cyberlegion@1.4.1'
|
|
15
15
|
const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
|
|
16
16
|
|
|
17
17
|
if (existsSync(fileURLToPath(bin))) {
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
The onboarding front door to the Legion — walks a session through getting `cyberlegion` working in
|
|
4
4
|
this repo: probe the environment, register the surfacing hook, and (root session only, only on an
|
|
5
5
|
explicit yes) allow the `cyberlegion` CLI in Claude Code's permissions and bind this pane as the
|
|
6
|
-
durable `legate` owner inbox
|
|
6
|
+
durable `legate` owner inbox, offering that owner a home folder where a session is started when mail
|
|
7
|
+
arrives and nobody holds its presence.
|
|
7
8
|
|
|
8
9
|
User-facing. Triggers on "set up cyberlegion", "onboard the legion", "register the cyberlegion
|
|
9
10
|
surfacing hook", "make this pane my main legion inbox", "get cyberlegion working in this repo". Wraps
|
|
10
|
-
`cyberlegion mux doctor` / `init [--allow-cli]` / `unit register --standing` / `attach` — every mechanic is a
|
|
11
|
+
`cyberlegion mux doctor` / `init [--allow-cli]` / `unit register --standing [--home | --clear-home]` / `attach` — every mechanic is a
|
|
11
12
|
CLI call, no filesystem or hub state touched directly.
|
|
12
13
|
|
|
13
14
|
Distinct from `legate` (spawn/mail/dispatch a peer) and `manage-inbox` (read/ack owner mail once
|
|
14
15
|
bound). See `SKILL.md` for the full flow (probe → consent-gated permission rule + register hook →
|
|
15
|
-
detect root vs spawned → consent-gated bind).
|
|
16
|
+
detect root vs spawned → consent-gated bind, with a home offered at the mint or to an existing owner).
|
|
@@ -8,7 +8,8 @@ description: "Use this skill to set up or onboard cyberlegion in this session or
|
|
|
8
8
|
The onboarding front door to the Legion — a thin, user-invocable wrapper that walks a session through
|
|
9
9
|
getting `cyberlegion` working in this repo: probe the environment, register the surfacing hook, and
|
|
10
10
|
(only in a root session, only on an explicit yes) allow the `cyberlegion` CLI in Claude Code's
|
|
11
|
-
permissions and bind this pane as the durable `legate` owner inbox
|
|
11
|
+
permissions and bind this pane as the durable `legate` owner inbox, offering that owner a **home** to
|
|
12
|
+
wake in when nobody is standing in for it.
|
|
12
13
|
It is a **thin wrapper**: every mechanic is a `cyberlegion` CLI call. The skill holds the *conversation
|
|
13
14
|
and the judgment* — is this a root session? should we ask to bind? what does the environment look
|
|
14
15
|
like? — the CLI holds all the *mechanism*.
|
|
@@ -108,7 +109,18 @@ has it set. Derive this from the probe — never ask the user to declare it.
|
|
|
108
109
|
- **A hook-only request in a root session** ("just register the surfacing hook") — also stop here.
|
|
109
110
|
Registering the hook satisfies the ask; do not proceed to the bind offer unasked.
|
|
110
111
|
- **Root session, broader onboarding intent, no `legate` owner bound yet** — continue to step 4.
|
|
111
|
-
- **Root session where a `legate` owner is already bound** —
|
|
112
|
+
- **Root session where a `legate` owner is already bound** — do not re-ask the bind and do not mint a
|
|
113
|
+
second owner; go to step 6 to offer a home. A bind request ("make this pane my main legion inbox")
|
|
114
|
+
made when the owner already exists lands here too.
|
|
115
|
+
|
|
116
|
+
To tell whether the owner exists, list the standing owners:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
node scripts/cyberlegion.mjs unit register --standing --format json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A record with `"handle": "legate"` is the owner. Its `home` field says where its presence is
|
|
123
|
+
spawned (`dir` plus `harness` or `agent`); no `home` field means it has none.
|
|
112
124
|
|
|
113
125
|
### 4. Ask before binding — never silent
|
|
114
126
|
|
|
@@ -121,21 +133,90 @@ Only a root session with no `legate` owner bound is offered the bind. Ask plainl
|
|
|
121
133
|
- **User agrees explicitly** — proceed to step 5.
|
|
122
134
|
- **Already bound** — never reach this ask (see step 3).
|
|
123
135
|
|
|
124
|
-
### 5. On an explicit yes — mint and bind
|
|
136
|
+
### 5. On an explicit yes — offer a home, then mint and bind
|
|
137
|
+
|
|
138
|
+
**No multiplexer or pane in the probe** → skip the home: mint bare and continue (see *Non-mux parity*
|
|
139
|
+
below). `unit claim` refuses a caller with no multiplexer, and a home is spawned the same way
|
|
140
|
+
`unit spawn` opens a session.
|
|
141
|
+
|
|
142
|
+
Otherwise, before minting, explain the presence and ask for a home. Mail to the `legate` owner
|
|
143
|
+
reaches whoever stands in for it, in this order:
|
|
144
|
+
|
|
145
|
+
1. **A live presence** — a session that ran `unit claim legate`. The claim holds until another
|
|
146
|
+
session claims the owner, until `unit claim legate --clear` unbinds it, or until that session
|
|
147
|
+
exits.
|
|
148
|
+
2. **The home** — when nobody holds the presence, a session is started in the home folder, bound as
|
|
149
|
+
the presence, and woken to read the owner's inbox. A home **outranks this pane**: with a home set,
|
|
150
|
+
a delivery with no live presence starts a session there instead of ringing the pane `attach` binds.
|
|
151
|
+
Say this in the question: setting a home means unclaimed mail no longer rings this pane.
|
|
152
|
+
3. **This pane** (`attach`) — rung only when focused, when there is neither a presence nor a home.
|
|
153
|
+
|
|
154
|
+
Ask plainly, e.g.:
|
|
155
|
+
|
|
156
|
+
> "Should the legate owner have a home — a folder where a session is started to act for it when
|
|
157
|
+
> mail arrives and nobody is live? While you're here, `cyberlegion unit claim legate` makes this
|
|
158
|
+
> session the one that gets the mail, until another session claims it, you run
|
|
159
|
+
> `unit claim legate --clear`, or this one exits; the home is only used when nobody holds it — and
|
|
160
|
+
> once it is set, mail nobody holds starts a session there instead of ringing this pane.
|
|
161
|
+
> A session started there has the folder's trust prompt accepted for it, since you named the
|
|
162
|
+
> folder. Name a folder, or say none."
|
|
163
|
+
|
|
164
|
+
**A folder** → mint with the home and **exactly one** launch:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
node scripts/cyberlegion.mjs unit register --standing --handle legate --home <dir> --harness <harness>
|
|
168
|
+
node scripts/cyberlegion.mjs attach
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`<harness>` is the `harness` the probe reported, or the one the user names instead — one of
|
|
172
|
+
`claude`, `cursor`, or `codex`. When the user
|
|
173
|
+
names an agent definition to launch there, pass `--agent <definition>` and **no** `--harness`. This
|
|
174
|
+
`--agent` is `unit register`'s — an agent definition resolved from the home — not `init --agent`.
|
|
175
|
+
If the probe reported `harness: unknown` and the user named no agent definition, ask for one — never
|
|
176
|
+
run `--home` without a launch, and never pass `--harness unknown`. Pass `<dir>` as an absolute path.
|
|
177
|
+
|
|
178
|
+
**None** → mint bare, as before:
|
|
125
179
|
|
|
126
180
|
```bash
|
|
127
181
|
node scripts/cyberlegion.mjs unit register --standing --handle legate
|
|
128
182
|
node scripts/cyberlegion.mjs attach
|
|
129
183
|
```
|
|
130
184
|
|
|
131
|
-
Run
|
|
132
|
-
`legate` owner inbox first, then bind the current pane as
|
|
185
|
+
Run the mint and `attach` **in this order** and only after the explicit yes: mint the durable,
|
|
186
|
+
session-independent `legate` owner inbox first, then bind the current pane as the owner's main pane.
|
|
187
|
+
|
|
188
|
+
**The CLI refuses a home** (the folder does not exist, it is a repository's primary checkout, the
|
|
189
|
+
agent definition does not resolve from it) — it writes nothing. Tell the user its reason and ask for
|
|
190
|
+
another folder or none; do not pick a folder yourself and do not fall back to a bare mint unasked.
|
|
191
|
+
If it rejects `--home` as an unknown option, the CLI is older than homes: say so, and mint bare only
|
|
192
|
+
if the user agrees. On a decline, mint nothing and skip `attach`; the hook stays.
|
|
133
193
|
|
|
134
194
|
**Non-mux parity.** If the probe reported no multiplexer or pane, `attach` is a no-op —
|
|
135
195
|
that is expected, not a failure. Still run `unit register --standing --handle legate` on yes, and complete
|
|
136
196
|
**without erroring**. The root session surfaces owner mail via the `!spawnedBy` fallback instead of a
|
|
137
197
|
bound pane.
|
|
138
198
|
|
|
199
|
+
### 6. An owner already minted — add or drop a home
|
|
200
|
+
|
|
201
|
+
Reached only from step 3, in a root session with a broader onboarding intent, when `legate` is
|
|
202
|
+
already minted. Never re-ask the bind and never run `attach` here. Re-registering a standing owner
|
|
203
|
+
refreshes the same record — the same id, its bound presence kept — so it is not a second mint.
|
|
204
|
+
|
|
205
|
+
With no multiplexer or pane in the probe, offer nothing here — neither adding nor dropping a home.
|
|
206
|
+
|
|
207
|
+
- **No home** — explain the presence and the trust-prompt acceptance as in step 5 and offer to give the owner one. On a folder, run
|
|
208
|
+
`unit register --standing --handle legate --home <dir>` with exactly one of `--harness` /
|
|
209
|
+
`--agent`, exactly as in step 5; a refusal is handled the same way.
|
|
210
|
+
If the CLI rejects `--home` (or `--clear-home`) as an unknown option, say it predates homes and
|
|
211
|
+
change nothing.
|
|
212
|
+
- **A home** — name its folder and offer to drop it. On a yes:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
node scripts/cyberlegion.mjs unit register --standing --handle legate --clear-home
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- **A decline** — run nothing.
|
|
219
|
+
|
|
139
220
|
## Troubleshooting
|
|
140
221
|
|
|
141
222
|
**A unit's `cyberlegion mail send` was denied by the auto-mode classifier** (*Permission for this
|
|
@@ -149,7 +230,8 @@ the permission ask, or run `cyberlegion init --allow-cli` there.
|
|
|
149
230
|
config format, and never edits Claude Code's settings by hand (the permission rule goes in only
|
|
150
231
|
through `init --allow-cli`, only on an explicit yes in a root session). Its only filesystem read is the plugin's own bundled `${CLAUDE_PLUGIN_ROOT}/.plugin/pins.json`
|
|
151
232
|
version map, to resolve the CLI pin.
|
|
152
|
-
- It never mints or binds an owner identity without an explicit user yes
|
|
233
|
+
- It never mints or binds an owner identity without an explicit user yes, and never sets or drops
|
|
234
|
+
an owner's home except on a folder or a drop the user named.
|
|
153
235
|
- It is distinct from `legate` — sending/spawning/dispatching to a peer is `legate`'s job, not this
|
|
154
236
|
skill's.
|
|
155
237
|
- It is distinct from `manage-inbox` — reading or acking owner mail once bound is `manage-inbox`'s
|
|
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
|
|
14
|
-
const FALLBACK = 'npx -y cyberlegion@1.
|
|
14
|
+
const FALLBACK = 'npx -y cyberlegion@1.4.1'
|
|
15
15
|
const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
|
|
16
16
|
|
|
17
17
|
if (existsSync(fileURLToPath(bin))) {
|
package/skills/legate/SKILL.md
CHANGED
|
@@ -13,7 +13,7 @@ logic, loads no other governance, and writes no state of its own.
|
|
|
13
13
|
> **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
|
|
14
14
|
> this plugin ships. The path is relative to this skill's own directory, not the working directory.
|
|
15
15
|
> If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
|
|
16
|
-
> with the same arguments: `npx -y cyberlegion@1.
|
|
16
|
+
> with the same arguments: `npx -y cyberlegion@1.4.1`.
|
|
17
17
|
|
|
18
18
|
## Doorbell vs mailbox
|
|
19
19
|
|
|
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
|
|
14
|
-
const FALLBACK = 'npx -y cyberlegion@1.
|
|
14
|
+
const FALLBACK = 'npx -y cyberlegion@1.4.1'
|
|
15
15
|
const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
|
|
16
16
|
|
|
17
17
|
if (existsSync(fileURLToPath(bin))) {
|
|
@@ -13,7 +13,7 @@ the one owner mailbox from wherever they are.
|
|
|
13
13
|
> **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
|
|
14
14
|
> this plugin ships. The path is relative to this skill's own directory, not the working directory.
|
|
15
15
|
> If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
|
|
16
|
-
> with the same arguments: `npx -y cyberlegion@1.
|
|
16
|
+
> with the same arguments: `npx -y cyberlegion@1.4.1`.
|
|
17
17
|
|
|
18
18
|
## Resolve the owner handle
|
|
19
19
|
|
|
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
|
|
14
|
-
const FALLBACK = 'npx -y cyberlegion@1.
|
|
14
|
+
const FALLBACK = 'npx -y cyberlegion@1.4.1'
|
|
15
15
|
const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
|
|
16
16
|
|
|
17
17
|
if (existsSync(fileURLToPath(bin))) {
|
|
@@ -15,7 +15,7 @@ agent (`headless-legate`, `sdd-automaton`, the cold judges) to know how *it* rep
|
|
|
15
15
|
> **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
|
|
16
16
|
> this plugin ships. The path is relative to this skill's own directory, not the working directory.
|
|
17
17
|
> If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
|
|
18
|
-
> with the same arguments: `npx -y cyberlegion@1.
|
|
18
|
+
> with the same arguments: `npx -y cyberlegion@1.4.1`.
|
|
19
19
|
|
|
20
20
|
## The one probe: who, if anyone, collects my return?
|
|
21
21
|
|
|
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
|
|
14
|
-
const FALLBACK = 'npx -y cyberlegion@1.
|
|
14
|
+
const FALLBACK = 'npx -y cyberlegion@1.4.1'
|
|
15
15
|
const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
|
|
16
16
|
|
|
17
17
|
if (existsSync(fileURLToPath(bin))) {
|
|
@@ -14,7 +14,7 @@ the unit's **role** — see *Mid-turn messaging is scoped by role*, below.
|
|
|
14
14
|
> **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
|
|
15
15
|
> this plugin ships. The path is relative to this skill's own directory, not the working directory.
|
|
16
16
|
> If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
|
|
17
|
-
> with the same arguments: `npx -y cyberlegion@1.
|
|
17
|
+
> with the same arguments: `npx -y cyberlegion@1.4.1`.
|
|
18
18
|
|
|
19
19
|
## 1. Resolve the agent def
|
|
20
20
|
|
|
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
|
|
14
|
-
const FALLBACK = 'npx -y cyberlegion@1.
|
|
14
|
+
const FALLBACK = 'npx -y cyberlegion@1.4.1'
|
|
15
15
|
const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
|
|
16
16
|
|
|
17
17
|
if (existsSync(fileURLToPath(bin))) {
|