cyberlegion 1.2.0 → 1.4.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/bin/cyberlegion +5 -0
- package/dist/cli.mjs +560 -97
- package/dist/index.d.mts +36 -3
- package/dist/index.mjs +177 -37
- package/hooks/hooks.json +1 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/readme.md +5 -0
- package/skills/dispatch-governance/SKILL.md +1 -1
- package/skills/dispatch-governance/scripts/cyberlegion.mjs +1 -1
- package/skills/init-cyberlegion/README.md +6 -4
- package/skills/init-cyberlegion/SKILL.md +141 -15
- 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/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");
|
|
@@ -6292,6 +6303,9 @@ const KILL_LINE = "";
|
|
|
6292
6303
|
* throws without sending. A multi-row draft cannot be typed back exactly: a scrape cannot tell a
|
|
6293
6304
|
* wrapped row from a newline, and a newline typed back would submit it.
|
|
6294
6305
|
*
|
|
6306
|
+
* Text `ownText` recognizes is the caller's own, not a draft: it is cleared at once, whatever rows it
|
|
6307
|
+
* wraps onto, `send` runs, and nothing is typed back.
|
|
6308
|
+
*
|
|
6295
6309
|
* Text the clear leaves in place was never a draft — an idle placeholder this reader does not know —
|
|
6296
6310
|
* so `send` runs and nothing is typed back.
|
|
6297
6311
|
*/
|
|
@@ -6308,10 +6322,11 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
6308
6322
|
return { kind: "unknown" };
|
|
6309
6323
|
}
|
|
6310
6324
|
};
|
|
6325
|
+
const isOwn = (s) => s.kind === "draft" && options.ownText?.(s.text) === true;
|
|
6311
6326
|
let state = read();
|
|
6312
6327
|
const start = now();
|
|
6313
6328
|
let changedAt = start;
|
|
6314
|
-
while (state.kind === "draft" && now() - changedAt < idleMs) {
|
|
6329
|
+
while (state.kind === "draft" && !isOwn(state) && now() - changedAt < idleMs) {
|
|
6315
6330
|
if (now() - start + pollMs > maxWaitMs) throw new Error(`pane ${target.id} holds a draft the user is still editing — gave up after ${maxWaitMs / 1e3}s without typing`);
|
|
6316
6331
|
await sleep(pollMs);
|
|
6317
6332
|
const next = read();
|
|
@@ -6319,12 +6334,13 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
6319
6334
|
state = next;
|
|
6320
6335
|
}
|
|
6321
6336
|
if (state.kind !== "draft") return send();
|
|
6337
|
+
if (isOwn(state)) {
|
|
6338
|
+
await clearLine(adapter, exec, target, sleep);
|
|
6339
|
+
return send();
|
|
6340
|
+
}
|
|
6322
6341
|
if (state.rows > 1) throw new Error(`pane ${target.id} holds an idle draft spanning ${state.rows} rows, which cannot be typed back exactly — left it untouched`);
|
|
6323
6342
|
const draft = state.text;
|
|
6324
|
-
adapter
|
|
6325
|
-
await sleep(KEY_SETTLE_MS);
|
|
6326
|
-
adapter.sendText(exec, target, KILL_LINE);
|
|
6327
|
-
await sleep(KEY_SETTLE_MS);
|
|
6343
|
+
await clearLine(adapter, exec, target, sleep);
|
|
6328
6344
|
const after = read();
|
|
6329
6345
|
if (after.kind === "draft" && after.text === draft) return send();
|
|
6330
6346
|
try {
|
|
@@ -6334,6 +6350,12 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
6334
6350
|
adapter.sendText(exec, target, draft);
|
|
6335
6351
|
}
|
|
6336
6352
|
}
|
|
6353
|
+
async function clearLine(adapter, exec, target, sleep) {
|
|
6354
|
+
adapter.sendText(exec, target, END_OF_LINE);
|
|
6355
|
+
await sleep(KEY_SETTLE_MS);
|
|
6356
|
+
adapter.sendText(exec, target, KILL_LINE);
|
|
6357
|
+
await sleep(KEY_SETTLE_MS);
|
|
6358
|
+
}
|
|
6337
6359
|
//#endregion
|
|
6338
6360
|
//#region src/console/ring.ts
|
|
6339
6361
|
const DEFAULT_ATTEMPTS = 10;
|
|
@@ -6360,6 +6382,13 @@ function copiesOn(screen, message) {
|
|
|
6360
6382
|
* the text, the one in the input box. Two or more new copies mean the harness also posted it above
|
|
6361
6383
|
* the box, so the turn was taken and nothing is flushed. Text still at the bottom with only one new
|
|
6362
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.
|
|
6363
6392
|
*/
|
|
6364
6393
|
async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
6365
6394
|
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
@@ -6367,25 +6396,36 @@ async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
|
6367
6396
|
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6368
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.`);
|
|
6369
6398
|
const before = copiesOn(adapter.read(exec, target).text, message);
|
|
6370
|
-
const
|
|
6399
|
+
const check = () => {
|
|
6371
6400
|
const screen = adapter.read(exec, target).text;
|
|
6372
|
-
|
|
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";
|
|
6373
6404
|
};
|
|
6374
6405
|
adapter.submit(exec, target, message);
|
|
6375
6406
|
await sleep(settleMs);
|
|
6376
|
-
|
|
6407
|
+
let state = check();
|
|
6408
|
+
if (state === "taken") return {
|
|
6377
6409
|
taken: true,
|
|
6378
6410
|
resubmits: 0
|
|
6379
6411
|
};
|
|
6412
|
+
let lostReads = state === "lost" ? 1 : 0;
|
|
6380
6413
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
6381
|
-
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
|
+
}
|
|
6382
6419
|
await sleep(settleMs);
|
|
6383
|
-
|
|
6420
|
+
state = check();
|
|
6421
|
+
if (state === "taken") return {
|
|
6384
6422
|
taken: true,
|
|
6385
6423
|
resubmits: attempt
|
|
6386
6424
|
};
|
|
6425
|
+
lostReads = state === "lost" ? lostReads + 1 : 0;
|
|
6387
6426
|
}
|
|
6388
|
-
|
|
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}`);
|
|
6389
6429
|
}
|
|
6390
6430
|
//#endregion
|
|
6391
6431
|
//#region src/console/doorbell.ts
|
|
@@ -6404,6 +6444,28 @@ const DELIVERY_DOORBELL = "You have unread mail — check your inbox.";
|
|
|
6404
6444
|
function spawnDoorbell(briefPath) {
|
|
6405
6445
|
return `Read your brief at ${briefPath}, then begin work.`;
|
|
6406
6446
|
}
|
|
6447
|
+
const SPAWN_DOORBELL = /^Readyourbriefat.+,thenbeginwork\.$/;
|
|
6448
|
+
const RESUME_DOORBELL = /^Yoursessionwasrestartedwithitsconversationresumed—continueyourwork\.Ifyouhavenoearlierconversation,readyourbriefat.+,thenbeginwork\.$/;
|
|
6449
|
+
/**
|
|
6450
|
+
* Whether `text`, read out of a peer's input box, is a ring cyberlegion typed — the delivery doorbell,
|
|
6451
|
+
* a spawn or resume doorbell, or `message`, the one about to be rung — rather than a human's draft. A harness
|
|
6452
|
+
* can take a ring and put its text back in the box (cursor-agent with a rejected login). Whitespace is
|
|
6453
|
+
* ignored, since the box wraps the text onto rows wherever it likes.
|
|
6454
|
+
*/
|
|
6455
|
+
function isRingText(text, message) {
|
|
6456
|
+
const squeeze = (s) => s.replace(/\s+/g, "");
|
|
6457
|
+
const box = squeeze(text);
|
|
6458
|
+
if (box === "") return false;
|
|
6459
|
+
return box === squeeze("You have unread mail — check your inbox.") || SPAWN_DOORBELL.test(box) || RESUME_DOORBELL.test(box) || message !== void 0 && box === squeeze(message);
|
|
6460
|
+
}
|
|
6461
|
+
/**
|
|
6462
|
+
* The first-turn instruction for a restarted session that resumed its conversation. The resume can
|
|
6463
|
+
* fall back to a fresh session (the harness no longer has the conversation), and the ring cannot tell
|
|
6464
|
+
* which one took the turn, so it names the brief for a session that finds no earlier conversation.
|
|
6465
|
+
*/
|
|
6466
|
+
function resumeDoorbell(briefPath) {
|
|
6467
|
+
return `Your session was restarted with its conversation resumed — continue your work. If you have no earlier conversation, read your brief at ${briefPath}, then begin work.`;
|
|
6468
|
+
}
|
|
6407
6469
|
/**
|
|
6408
6470
|
* A freshly-launched harness cold-boots slower than an already-running peer, so the spawn first-turn
|
|
6409
6471
|
* ring gets a wider retry budget than a plain `mail send` doorbell (nudge's own 10 × 400ms): flush the
|
|
@@ -6415,6 +6477,11 @@ const SPAWN_NUDGE_OPTS = {
|
|
|
6415
6477
|
attempts: 20,
|
|
6416
6478
|
settleMs: 400
|
|
6417
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
|
+
}
|
|
6418
6485
|
/** Resolve an agent's live session pane: its recorded pane, else a pane pointer keyed by its id. */
|
|
6419
6486
|
function paneOf(store, id) {
|
|
6420
6487
|
return loadAgent(store, id)?.pane?.id ?? store.findPaneByAgentId(id);
|
|
@@ -6440,8 +6507,24 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
|
|
|
6440
6507
|
let pane;
|
|
6441
6508
|
let harness;
|
|
6442
6509
|
let focusGated = false;
|
|
6510
|
+
let spawnWarning;
|
|
6443
6511
|
if (recipient.kind === "standing") {
|
|
6444
|
-
|
|
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
|
+
}
|
|
6445
6528
|
if (presenceUnit) {
|
|
6446
6529
|
pane = paneOf(store, presenceUnit.id);
|
|
6447
6530
|
harness = presenceUnit.harness;
|
|
@@ -6453,37 +6536,46 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
|
|
|
6453
6536
|
pane = paneOf(store, recipient.id);
|
|
6454
6537
|
harness = recipient.harness;
|
|
6455
6538
|
}
|
|
6456
|
-
|
|
6457
|
-
if (
|
|
6458
|
-
|
|
6459
|
-
|
|
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
|
+
}
|
|
6460
6560
|
try {
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
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
|
+
};
|
|
6464
6578
|
}
|
|
6465
|
-
if (focused === false) return {
|
|
6466
|
-
rung: false,
|
|
6467
|
-
pane
|
|
6468
|
-
};
|
|
6469
|
-
}
|
|
6470
|
-
try {
|
|
6471
|
-
const adapter = getAdapter();
|
|
6472
|
-
const target = { id: pane };
|
|
6473
|
-
await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
|
|
6474
|
-
...guardOpts,
|
|
6475
|
-
harness
|
|
6476
|
-
});
|
|
6477
|
-
return {
|
|
6478
|
-
rung: true,
|
|
6479
|
-
pane
|
|
6480
|
-
};
|
|
6481
|
-
} catch (err) {
|
|
6482
|
-
return {
|
|
6483
|
-
rung: false,
|
|
6484
|
-
pane,
|
|
6485
|
-
warning: err instanceof Error ? err.message : String(err)
|
|
6486
|
-
};
|
|
6487
6579
|
}
|
|
6488
6580
|
}
|
|
6489
6581
|
/**
|
|
@@ -6497,6 +6589,11 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
|
|
|
6497
6589
|
* ring that never completes within the retry budget is swallowed into a warning. This never throws —
|
|
6498
6590
|
* it can never fail the spawn.
|
|
6499
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
|
+
*
|
|
6500
6597
|
* The adapter is resolved lazily via `getAdapter` inside the same swallowing try, so even a session
|
|
6501
6598
|
* whose backend has since gone away (where `selectSessionAdapter` would throw) degrades to a warned
|
|
6502
6599
|
* no-op rather than a failed spawn.
|
|
@@ -6505,9 +6602,14 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
6505
6602
|
if (input.noWake) return { rung: false };
|
|
6506
6603
|
try {
|
|
6507
6604
|
const adapter = getAdapter();
|
|
6508
|
-
|
|
6605
|
+
const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
|
|
6606
|
+
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, {
|
|
6607
|
+
...nudgeOpts,
|
|
6608
|
+
requirePosted: true
|
|
6609
|
+
}), {
|
|
6509
6610
|
...guardOpts,
|
|
6510
|
-
harness: input.harness
|
|
6611
|
+
harness: input.harness,
|
|
6612
|
+
ownText: (text) => isRingText(text)
|
|
6511
6613
|
});
|
|
6512
6614
|
return {
|
|
6513
6615
|
rung: true,
|
|
@@ -6836,6 +6938,30 @@ function resetCommandFor(harness) {
|
|
|
6836
6938
|
if (FALSE_FRIEND_HARNESSES.has(harness)) throw new Error(`"${harness}" has no honest fresh-context command — its own "/clear" clears only the terminal screen, not the model context, so unit clear refuses to send a false-friend reset that would leave stale context behind`);
|
|
6837
6939
|
throw new Error(`"${harness}" is not in the reset map (${Object.keys(RESET_MAP).join(" | ")}) — unit clear refuses to guess a command`);
|
|
6838
6940
|
}
|
|
6941
|
+
/** The primary checkout of the repository containing `repo`, asked of git from inside it (`git -C`)
|
|
6942
|
+
* rather than from the process cwd. */
|
|
6943
|
+
function primaryRootOfRepo(exec, repo) {
|
|
6944
|
+
const dir = resolve(repo);
|
|
6945
|
+
try {
|
|
6946
|
+
return resolvePrimaryRoot((cmd, args) => exec(cmd, cmd === "git" ? [
|
|
6947
|
+
"-C",
|
|
6948
|
+
dir,
|
|
6949
|
+
...args
|
|
6950
|
+
] : args));
|
|
6951
|
+
} catch {
|
|
6952
|
+
throw new Error(`--repo ${repo} is not inside a git repository`);
|
|
6953
|
+
}
|
|
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
|
+
}
|
|
6839
6965
|
/**
|
|
6840
6966
|
* Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
|
|
6841
6967
|
* the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
|
|
@@ -6859,9 +6985,9 @@ function spawn(ctx, input) {
|
|
|
6859
6985
|
if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
|
|
6860
6986
|
const brief = resolveBrief(input);
|
|
6861
6987
|
if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
|
|
6862
|
-
if (input.cwd && (input.branch || input.worktreePath)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path");
|
|
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");
|
|
6863
6989
|
const id = randomId();
|
|
6864
|
-
const primaryRoot = 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);
|
|
6865
6991
|
const launch = input.command ?? LAUNCH_MAP[harness];
|
|
6866
6992
|
const launchLine = () => composeLaunchLine(ctx, sessionAdapter.name, id, launch);
|
|
6867
6993
|
const from = callerPane(sessionAdapter, normalizedEnv);
|
|
@@ -6871,7 +6997,7 @@ function spawn(ctx, input) {
|
|
|
6871
6997
|
if (input.cwd) {
|
|
6872
6998
|
if (!existsSync(input.cwd)) throw new Error(`--cwd directory must already exist: ${input.cwd}`);
|
|
6873
6999
|
cwd = resolve(input.cwd);
|
|
6874
|
-
assertDistinctFromPrimary(cwd, primaryRoot);
|
|
7000
|
+
if (primaryRoot !== void 0) assertDistinctFromPrimary(cwd, primaryRoot);
|
|
6875
7001
|
worktree = null;
|
|
6876
7002
|
const at = input.at ?? "tab";
|
|
6877
7003
|
target = sessionAdapter.open(exec, {
|
|
@@ -6882,6 +7008,7 @@ function spawn(ctx, input) {
|
|
|
6882
7008
|
...labelFor(at, input, brief, id)
|
|
6883
7009
|
});
|
|
6884
7010
|
} else {
|
|
7011
|
+
if (primaryRoot === void 0) throw new Error("a worktree spawn needs a primary checkout");
|
|
6885
7012
|
const branch = input.branch ?? `cyberlegion/unit-${id}`;
|
|
6886
7013
|
const at = input.at ?? "workspace";
|
|
6887
7014
|
const worktreePath = input.worktreePath ?? resolveUnitWorktreePath(primaryRoot, id.slice(0, 6));
|
|
@@ -6974,15 +7101,39 @@ function spawn(ctx, input) {
|
|
|
6974
7101
|
*/
|
|
6975
7102
|
async function spawnAndWake(ctx, input, options = {}) {
|
|
6976
7103
|
const res = spawn(ctx, input);
|
|
6977
|
-
const
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
7104
|
+
const trust = await settleSpawnTrust(ctx, res, options);
|
|
7105
|
+
if (trust.trustBlocked) return {
|
|
7106
|
+
...res,
|
|
7107
|
+
...trust
|
|
7108
|
+
};
|
|
7109
|
+
return {
|
|
6981
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 {
|
|
6982
7123
|
rung: false,
|
|
6983
7124
|
trust: trust.state,
|
|
6984
7125
|
trustBlocked: trust.message
|
|
6985
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;
|
|
6986
7137
|
const briefPath = res.agent.brief;
|
|
6987
7138
|
const wake = await wakeSpawn(() => selectSessionAdapter(env, exec), exec, {
|
|
6988
7139
|
target: { id: res.pane },
|
|
@@ -6991,9 +7142,8 @@ async function spawnAndWake(ctx, input, options = {}) {
|
|
|
6991
7142
|
harness: res.agent.harness
|
|
6992
7143
|
}, options.nudgeOpts);
|
|
6993
7144
|
return {
|
|
6994
|
-
...res,
|
|
6995
7145
|
rung: wake.rung,
|
|
6996
|
-
trust: trust.
|
|
7146
|
+
trust: trust.trust,
|
|
6997
7147
|
...wake.warning ? { warning: wake.warning } : {}
|
|
6998
7148
|
};
|
|
6999
7149
|
}
|
|
@@ -7002,7 +7152,7 @@ async function spawnAndWake(ctx, input, options = {}) {
|
|
|
7002
7152
|
* resolved leaves nothing to read, which is no evidence of a prompt: the ring's own containment
|
|
7003
7153
|
* reports that backend.
|
|
7004
7154
|
*/
|
|
7005
|
-
async function settleTrust(ctx, res, opts) {
|
|
7155
|
+
async function settleTrust(ctx, res, opts, trustFolder = false) {
|
|
7006
7156
|
const exec = ctx.exec ?? realExec;
|
|
7007
7157
|
let adapter;
|
|
7008
7158
|
try {
|
|
@@ -7014,7 +7164,7 @@ async function settleTrust(ctx, res, opts) {
|
|
|
7014
7164
|
harness: res.agent.harness,
|
|
7015
7165
|
folder: res.agent.cwd,
|
|
7016
7166
|
pane: res.pane,
|
|
7017
|
-
policy: res.agent.worktree ? "accept" : "ask"
|
|
7167
|
+
policy: res.agent.worktree || trustFolder ? "accept" : "ask"
|
|
7018
7168
|
}, opts);
|
|
7019
7169
|
}
|
|
7020
7170
|
/**
|
|
@@ -7041,10 +7191,43 @@ function labelFor(at, input, brief, id) {
|
|
|
7041
7191
|
* supplied `ctx.self`), the multiplexer env prefix, then the harness launch command. Writes the shim
|
|
7042
7192
|
* as a side effect, so call it only past every refusal, right before the session opens. Shared by
|
|
7043
7193
|
* `spawn` and `unit restart` so a restarted unit boots exactly as a spawned one does.
|
|
7194
|
+
*
|
|
7195
|
+
* `bindSelf` has the new pane run `unit rebind <id>` through the shim before the harness starts, so
|
|
7196
|
+
* the session binds itself to the unit even when the caller dies before its own bind write. It needs
|
|
7197
|
+
* the shim, so a context with no recorded invocation gets no bind step.
|
|
7198
|
+
*/
|
|
7199
|
+
function composeLaunchLine(ctx, muxName, id, launch, options = {}) {
|
|
7200
|
+
const shim = ctx.self ? writeSelfShim(paths.dataDir(ctx.store.root, id), ctx.self) : void 0;
|
|
7201
|
+
const bind = shim && options.bindSelf ? `${shellQuote$1(shim)} unit rebind ${shellQuote$1(id)} --space ${shellQuote$1(ctx.store.root)} >/dev/null 2>&1; ` : "";
|
|
7202
|
+
const prefix = `${shim ? shimEnvPrefix(shim) : ""}${muxEnvPrefix(muxName)}`;
|
|
7203
|
+
return `${bind}${prefix}${launch}${options.fallback ? ` || ${prefix}${options.fallback}` : ""}`;
|
|
7204
|
+
}
|
|
7205
|
+
/**
|
|
7206
|
+
* How each harness resumes a conversation by id, appended to the unit's own launch command. Cursor is
|
|
7207
|
+
* absent: its hook's id is not confirmed to be the chat id `cursor-agent --resume` takes, so a cursor
|
|
7208
|
+
* unit restarts fresh and is rebriefed.
|
|
7209
|
+
*/
|
|
7210
|
+
const RESUME_MAP = {
|
|
7211
|
+
claude: (id) => `--resume ${shellQuote$1(id)}`,
|
|
7212
|
+
codex: (id) => `resume ${shellQuote$1(id)}`
|
|
7213
|
+
};
|
|
7214
|
+
/**
|
|
7215
|
+
* The launch that resumes `conversation`, or undefined when this harness has no resume here or the
|
|
7216
|
+
* launch is not the harness's own command — a wrapper script may not take the harness's flags.
|
|
7217
|
+
*/
|
|
7218
|
+
function resumeLaunch(harness, launch, conversation) {
|
|
7219
|
+
const resume = RESUME_MAP[harness];
|
|
7220
|
+
if (!resume || launch.split(/\s+/)[0] !== LAUNCH_MAP[harness]) return void 0;
|
|
7221
|
+
return `${launch} ${resume(conversation)}`;
|
|
7222
|
+
}
|
|
7223
|
+
/**
|
|
7224
|
+
* Put the shim's directory first on PATH, so a brief's bare `cyberlegion` runs it, and name the shim
|
|
7225
|
+
* itself in `$CYBERLEGION_CLI`. The plugin's hook runs outside that PATH lookup (and a PATH lookup
|
|
7226
|
+
* cannot tell this shim from a stale global install), so it reads the variable instead: set only
|
|
7227
|
+
* here, it marks a spawned session and names the CLI that spawned it.
|
|
7044
7228
|
*/
|
|
7045
|
-
function
|
|
7046
|
-
|
|
7047
|
-
return `${shimDir ? `PATH=${shellQuote$1(shimDir)}:"$PATH" ` : ""}${muxEnvPrefix(muxName)}${launch}`;
|
|
7229
|
+
function shimEnvPrefix(shim) {
|
|
7230
|
+
return `PATH=${shellQuote$1(dirname(shim))}:"$PATH" CYBERLEGION_CLI=${shellQuote$1(shim)} `;
|
|
7048
7231
|
}
|
|
7049
7232
|
/**
|
|
7050
7233
|
* The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
|
|
@@ -7073,7 +7256,7 @@ function selfInvocation() {
|
|
|
7073
7256
|
}
|
|
7074
7257
|
/**
|
|
7075
7258
|
* Write `<dataDir>/bin/cyberlegion`, a POSIX shim that execs `self` with the caller's arguments,
|
|
7076
|
-
* and return its
|
|
7259
|
+
* and return its path. A spawned session gets that directory first on its PATH, so the
|
|
7077
7260
|
* `cyberlegion` a brief tells it to run is the install that spawned it: nothing is resolved from
|
|
7078
7261
|
* the registry or the session's own PATH at report time.
|
|
7079
7262
|
*/
|
|
@@ -7083,7 +7266,7 @@ function writeSelfShim(dataDir, self) {
|
|
|
7083
7266
|
const shim = join(dir, "cyberlegion");
|
|
7084
7267
|
writeFileSync(shim, `#!/bin/sh\nexec ${self.map(shellQuote$1).join(" ")} "$@"\n`);
|
|
7085
7268
|
chmodSync(shim, 493);
|
|
7086
|
-
return
|
|
7269
|
+
return shim;
|
|
7087
7270
|
}
|
|
7088
7271
|
/** Single-quote `s` for a POSIX shell, so any path survives word splitting and expansion. */
|
|
7089
7272
|
function shellQuote$1(s) {
|
|
@@ -7135,7 +7318,8 @@ async function nudgeUnit(ctx, ref, options = {}) {
|
|
|
7135
7318
|
const adapter = selectSessionAdapter(ctx.env ?? process.env, exec);
|
|
7136
7319
|
const result = await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, message, options.nudgeOpts), {
|
|
7137
7320
|
...options.guardOpts,
|
|
7138
|
-
harness: agent.harness
|
|
7321
|
+
harness: agent.harness,
|
|
7322
|
+
ownText: (text) => isRingText(text, message)
|
|
7139
7323
|
});
|
|
7140
7324
|
return {
|
|
7141
7325
|
agent,
|
|
@@ -7174,7 +7358,8 @@ async function clearUnit(ctx, ref, options = {}) {
|
|
|
7174
7358
|
const adapter = selectSessionAdapter(env, exec);
|
|
7175
7359
|
await withDraftGuard(adapter, exec, target, async () => adapter.submit(exec, target, command), {
|
|
7176
7360
|
...options.guardOpts,
|
|
7177
|
-
harness: agent.harness
|
|
7361
|
+
harness: agent.harness,
|
|
7362
|
+
ownText: (text) => isRingText(text)
|
|
7178
7363
|
});
|
|
7179
7364
|
return {
|
|
7180
7365
|
agent,
|
|
@@ -7469,6 +7654,7 @@ function spawnCommandInput(opts, listCursorModels) {
|
|
|
7469
7654
|
branch: opts.branch,
|
|
7470
7655
|
worktreePath: opts.worktreePath,
|
|
7471
7656
|
cwd: opts.cwd,
|
|
7657
|
+
repo: opts.repo,
|
|
7472
7658
|
at: opts.at
|
|
7473
7659
|
},
|
|
7474
7660
|
noWake: opts.wake === false,
|
|
@@ -7860,6 +8046,154 @@ function fail(msg) {
|
|
|
7860
8046
|
process.exit(1);
|
|
7861
8047
|
}
|
|
7862
8048
|
//#endregion
|
|
8049
|
+
//#region src/permission.ts
|
|
8050
|
+
const CLI_RULE = "Bash(cyberlegion *)";
|
|
8051
|
+
const COVERING = /^Bash(?:\((?:\*|cyberlegion(?: \*|:\*|\*))\))?$/;
|
|
8052
|
+
/** Claude Code's user settings file: `$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`. */
|
|
8053
|
+
function claudeUserSettingsFile(env = process.env) {
|
|
8054
|
+
return join(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json");
|
|
8055
|
+
}
|
|
8056
|
+
function readSettings(file) {
|
|
8057
|
+
if (!existsSync(file)) return {
|
|
8058
|
+
settings: {},
|
|
8059
|
+
allow: []
|
|
8060
|
+
};
|
|
8061
|
+
let settings;
|
|
8062
|
+
try {
|
|
8063
|
+
settings = JSON.parse(readFileSync(file, "utf8"));
|
|
8064
|
+
} catch {
|
|
8065
|
+
throw new Error(`cannot parse Claude Code settings ${file} — fix it by hand, then re-run`);
|
|
8066
|
+
}
|
|
8067
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) throw new Error(`Claude Code settings ${file} is not a JSON object — fix it by hand, then re-run`);
|
|
8068
|
+
const { permissions } = settings;
|
|
8069
|
+
const allow = permissions?.allow ?? [];
|
|
8070
|
+
if (permissions !== void 0 && (typeof permissions !== "object" || permissions === null) || !Array.isArray(allow)) throw new Error(`Claude Code settings ${file} has a malformed permissions.allow — fix it by hand, then re-run`);
|
|
8071
|
+
return {
|
|
8072
|
+
settings,
|
|
8073
|
+
allow
|
|
8074
|
+
};
|
|
8075
|
+
}
|
|
8076
|
+
const covers = (allow) => allow.some((rule) => typeof rule === "string" && COVERING.test(rule));
|
|
8077
|
+
/** Whether the settings file's allow list already lets `cyberlegion` subcommands run. */
|
|
8078
|
+
function permissionRuleState(file) {
|
|
8079
|
+
try {
|
|
8080
|
+
return covers(readSettings(file).allow) ? "present" : "missing";
|
|
8081
|
+
} catch {
|
|
8082
|
+
return "unreadable";
|
|
8083
|
+
}
|
|
8084
|
+
}
|
|
8085
|
+
/**
|
|
8086
|
+
* Add the CLI rule to the settings file's allow list, merged in after every existing entry. A
|
|
8087
|
+
* covering rule already there is a no-op; a file that cannot be read as settings is never rewritten.
|
|
8088
|
+
*/
|
|
8089
|
+
function addPermissionRule(file) {
|
|
8090
|
+
const { settings, allow } = readSettings(file);
|
|
8091
|
+
if (covers(allow)) return "present";
|
|
8092
|
+
settings.permissions = {
|
|
8093
|
+
...settings.permissions ?? {},
|
|
8094
|
+
allow: [...allow, CLI_RULE]
|
|
8095
|
+
};
|
|
8096
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
8097
|
+
writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
8098
|
+
return "added";
|
|
8099
|
+
}
|
|
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
|
|
7863
8197
|
//#region src/project.ts
|
|
7864
8198
|
/**
|
|
7865
8199
|
* The canonical git common dir for `dir`, or undefined outside a repository. The common dir is the
|
|
@@ -8342,6 +8676,54 @@ async function startService(ctx, projectRef, name, input) {
|
|
|
8342
8676
|
}
|
|
8343
8677
|
}
|
|
8344
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
|
|
8345
8727
|
//#region src/store/errors.ts
|
|
8346
8728
|
/** A record file exists but its content didn't parse as JSON — a torn write (crash mid-`writeFileSync`,
|
|
8347
8729
|
* pre-atomic-write code path) or on-disk tampering. Carries the file path and the original parse
|
|
@@ -8504,6 +8886,16 @@ function withLock(root, name, fn, opts) {
|
|
|
8504
8886
|
handle.release();
|
|
8505
8887
|
}
|
|
8506
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
|
+
}
|
|
8507
8899
|
//#endregion
|
|
8508
8900
|
//#region src/store/file-store.ts
|
|
8509
8901
|
/** Parse a record file's content, wrapping a `JSON.parse` failure in a typed, file-named
|
|
@@ -8707,6 +9099,9 @@ var FileStore = class {
|
|
|
8707
9099
|
withLock(name, fn) {
|
|
8708
9100
|
return withLock(this.root, name, fn);
|
|
8709
9101
|
}
|
|
9102
|
+
withLockAsync(name, fn) {
|
|
9103
|
+
return withLockAsync(this.root, name, fn);
|
|
9104
|
+
}
|
|
8710
9105
|
};
|
|
8711
9106
|
//#endregion
|
|
8712
9107
|
//#region src/unit-runtime.ts
|
|
@@ -8794,8 +9189,11 @@ function stopUnit(ctx, ref) {
|
|
|
8794
9189
|
/**
|
|
8795
9190
|
* Give a unit a fresh runtime and keep the unit: stop a still-running session (the same verified stop
|
|
8796
9191
|
* as `stopUnit`), open a new session at the unit's cwd with the launch it was spawned with, bind the
|
|
8797
|
-
* record to the new pane, and ring it
|
|
8798
|
-
*
|
|
9192
|
+
* record to the new pane, and ring it. When the record carries the harness's conversation id and the
|
|
9193
|
+
* harness can resume by id, the session resumes that conversation and is rung to continue, with the
|
|
9194
|
+
* plain launch as a shell fallback should the harness reject the id; otherwise (or with `fresh`) it
|
|
9195
|
+
* starts empty and is rung to read its brief — a rebrief. Every refusal runs before anything is torn
|
|
9196
|
+
* down.
|
|
8799
9197
|
*
|
|
8800
9198
|
* The unit is recorded `stopped` before the open, so a restart interrupted between the two leaves an
|
|
8801
9199
|
* ordinary stopped unit and a rerun recovers it; nothing needs a separate repair path.
|
|
@@ -8817,6 +9215,7 @@ async function restartUnit(ctx, ref, options = {}) {
|
|
|
8817
9215
|
const env = ctx.env ?? process.env;
|
|
8818
9216
|
const exec = ctx.exec ?? realExec;
|
|
8819
9217
|
const launch = stopped.launch ?? LAUNCH_MAP[harness];
|
|
9218
|
+
const resume = stopped.conversation && !options.fresh ? resumeLaunch(harness, launch, stopped.conversation) : void 0;
|
|
8820
9219
|
const at = stopped.worktree ? "workspace" : "tab";
|
|
8821
9220
|
let target;
|
|
8822
9221
|
let adapter;
|
|
@@ -8829,7 +9228,10 @@ async function restartUnit(ctx, ref, options = {}) {
|
|
|
8829
9228
|
}) } : {};
|
|
8830
9229
|
target = adapter.open(exec, {
|
|
8831
9230
|
cwd: stopped.cwd,
|
|
8832
|
-
launch: composeLaunchLine(ctx, adapter.name, rec.id,
|
|
9231
|
+
launch: resume ? composeLaunchLine(ctx, adapter.name, rec.id, resume, {
|
|
9232
|
+
bindSelf: true,
|
|
9233
|
+
fallback: launch
|
|
9234
|
+
}) : composeLaunchLine(ctx, adapter.name, rec.id, launch, { bindSelf: true }),
|
|
8833
9235
|
at,
|
|
8834
9236
|
from: callerPane(adapter, normalizeMuxEnv(env)),
|
|
8835
9237
|
...label
|
|
@@ -8853,13 +9255,15 @@ async function restartUnit(ctx, ref, options = {}) {
|
|
|
8853
9255
|
const wake = await wakeSpawn(() => adapter, exec, {
|
|
8854
9256
|
target,
|
|
8855
9257
|
briefPath: briefPath ?? "",
|
|
8856
|
-
noWake: options.noWake || !briefPath
|
|
9258
|
+
noWake: options.noWake || !briefPath,
|
|
9259
|
+
resumed: !!resume
|
|
8857
9260
|
}, options.nudgeOpts);
|
|
8858
9261
|
return {
|
|
8859
9262
|
agent: bound,
|
|
8860
9263
|
...previousPane ? { previousPane } : {},
|
|
8861
9264
|
pane: target.id,
|
|
8862
9265
|
launch,
|
|
9266
|
+
resumed: !!resume,
|
|
8863
9267
|
rung: wake.rung,
|
|
8864
9268
|
...wake.warning ? { warning: wake.warning } : {}
|
|
8865
9269
|
};
|
|
@@ -8957,6 +9361,29 @@ function showUnit(ctx, ref) {
|
|
|
8957
9361
|
controls: controlsFor(ctx, rec, liveness)
|
|
8958
9362
|
};
|
|
8959
9363
|
}
|
|
9364
|
+
/**
|
|
9365
|
+
* Record the harness's conversation id on the calling unit, from the JSON its SessionStart hook hands
|
|
9366
|
+
* `mail hook` on stdin (`session_id`, which Claude Code and Codex both send). The hook fires on every
|
|
9367
|
+
* session start in the pane — a fresh start, a resume, a `/clear` — so the record follows the
|
|
9368
|
+
* conversation the pane is in now. Best-effort and silent: an unparseable input, no id, or a caller
|
|
9369
|
+
* that is not a unit records nothing and never fails the harness hook.
|
|
9370
|
+
*/
|
|
9371
|
+
function recordConversation(ctx, hookInput) {
|
|
9372
|
+
let id;
|
|
9373
|
+
try {
|
|
9374
|
+
id = JSON.parse(hookInput)?.session_id;
|
|
9375
|
+
} catch {
|
|
9376
|
+
return;
|
|
9377
|
+
}
|
|
9378
|
+
if (typeof id !== "string" || id === "") return;
|
|
9379
|
+
const meId = resolveSelfId(ctx);
|
|
9380
|
+
const rec = meId ? loadAgent(ctx.store, meId) : void 0;
|
|
9381
|
+
if (!rec || rec.kind === "standing" || rec.kind === "service" || rec.conversation === id) return;
|
|
9382
|
+
saveAgent(ctx.store, {
|
|
9383
|
+
...rec,
|
|
9384
|
+
conversation: id
|
|
9385
|
+
});
|
|
9386
|
+
}
|
|
8960
9387
|
//#endregion
|
|
8961
9388
|
//#region src/wake/await.ts
|
|
8962
9389
|
/** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
|
|
@@ -9062,7 +9489,7 @@ const program = new Command();
|
|
|
9062
9489
|
program.name("cyberlegion").description("Harness-agnostic agent session spawning and messaging over the filesystem").version(VERSION).enablePositionalOptions();
|
|
9063
9490
|
const unit = program.command("unit").description("legion units — register, discover, spawn, and reap");
|
|
9064
9491
|
/** The standing-owner branch of `unit register --standing` (folds the old `identity owner`). */
|
|
9065
|
-
function runStanding(ctx, opts) {
|
|
9492
|
+
function runStanding(ctx, opts, home) {
|
|
9066
9493
|
if (!opts.handle) {
|
|
9067
9494
|
const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
|
|
9068
9495
|
emit(formatOf(opts), {
|
|
@@ -9089,22 +9516,33 @@ function runStanding(ctx, opts) {
|
|
|
9089
9516
|
return;
|
|
9090
9517
|
}
|
|
9091
9518
|
const liveClaim = listAgents(ctx.store).find((a) => a.handle === opts.handle && a.kind !== "standing" && a.status !== "exited");
|
|
9092
|
-
const rec = registerStanding(ctx, {
|
|
9519
|
+
const rec = registerStanding(ctx, {
|
|
9520
|
+
handle: opts.handle,
|
|
9521
|
+
home
|
|
9522
|
+
});
|
|
9093
9523
|
if (liveClaim) console.error(`a live session already claims handle "${opts.handle}"`);
|
|
9094
9524
|
emit(formatOf(opts), {
|
|
9095
9525
|
toon: toonObject({
|
|
9096
9526
|
id: rec.id,
|
|
9097
9527
|
handle: rec.handle,
|
|
9098
9528
|
kind: rec.kind,
|
|
9099
|
-
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}` } : {}
|
|
9100
9532
|
}),
|
|
9101
9533
|
json: rec
|
|
9102
9534
|
});
|
|
9103
9535
|
}
|
|
9104
|
-
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) => {
|
|
9105
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
|
+
}
|
|
9106
9544
|
if (opts.standing) {
|
|
9107
|
-
runStanding(ctx, opts);
|
|
9545
|
+
runStanding(ctx, opts, home);
|
|
9108
9546
|
return;
|
|
9109
9547
|
}
|
|
9110
9548
|
const rec = register(ctx, {
|
|
@@ -9249,7 +9687,7 @@ function warnEffortNotApplied(spawnInput) {
|
|
|
9249
9687
|
}
|
|
9250
9688
|
/** The launch options `unit spawn` and `service start` share. */
|
|
9251
9689
|
function withSpawnOptions(cmd) {
|
|
9252
|
-
return withGlobals(cmd).option("--harness <h>", "claude | cursor | codex (required unless --agent/--agent-file resolves one)").option("--agent <name>", "resolve an agent def (.agents/agents/<name>.md) for harness/model/effort/instructions").option("--agent-file <path>", "read an exact agent def file instead of resolving by name").option("--model <name>", "model for this launch only (flag > agent def > harness default)").option("--effort <level>", "effort for this launch only (flag > agent def > harness default)").option("--task <text>", "brief text, or - for stdin").option("--brief-file <path>", "read the brief from a file").option("--handle <name>", "handle for the new peer").option("--branch <name>", "branch for the new worktree (default cyberlegion/unit-<id>)").option("--worktree-path <path>", "where to check out the new worktree").option("--cwd <path>", "spawn the session in an existing directory; create no worktree (mutually exclusive with --branch/--worktree-path)").addOption(new Option("--at <placement>", "where to open the new session (default: new-worktree → workspace, --cwd → tab)").choices([
|
|
9690
|
+
return withGlobals(cmd).option("--harness <h>", "claude | cursor | codex (required unless --agent/--agent-file resolves one)").option("--agent <name>", "resolve an agent def (.agents/agents/<name>.md) for harness/model/effort/instructions").option("--agent-file <path>", "read an exact agent def file instead of resolving by name").option("--model <name>", "model for this launch only (flag > agent def > harness default)").option("--effort <level>", "effort for this launch only (flag > agent def > harness default)").option("--task <text>", "brief text, or - for stdin").option("--brief-file <path>", "read the brief from a file").option("--handle <name>", "handle for the new peer").option("--branch <name>", "branch for the new worktree (default cyberlegion/unit-<id>)").option("--worktree-path <path>", "where to check out the new worktree").option("-C, --repo <path>", "create the worktree from the git repository containing <path>, not the current directory's").option("--cwd <path>", "spawn the session in an existing directory; create no worktree (mutually exclusive with --branch/--worktree-path/--repo)").addOption(new Option("--at <placement>", "where to open the new session (default: new-worktree → workspace, --cwd → tab)").choices([
|
|
9253
9691
|
"pane:right",
|
|
9254
9692
|
"pane:down",
|
|
9255
9693
|
"tab",
|
|
@@ -9349,15 +9787,19 @@ withGlobals(unit.command("stop")).description("end a unit's session and keep the
|
|
|
9349
9787
|
});
|
|
9350
9788
|
if (!res.alreadyStopped) nextStep(`cyberlegion unit restart ${res.agent.handle}`);
|
|
9351
9789
|
});
|
|
9352
|
-
withGlobals(unit.command("restart")).description("give a unit a
|
|
9790
|
+
withGlobals(unit.command("restart")).description("give a unit a new session and keep the unit — stops a running session, relaunches it resuming the harness's recorded conversation where it can, and otherwise rebriefs it").argument("<ref>", "unit id, handle, or worktree branch/CR ref").option("--no-wake", "do not ring the new session to read its brief (the caller briefs it by mail)").option("--fresh", "start an empty session and rebrief it, even when the unit's conversation could be resumed").action(async (ref, opts) => {
|
|
9353
9791
|
const ctx = ctxOf(opts);
|
|
9354
9792
|
touch(ctx);
|
|
9355
|
-
const res = await restartUnit(ctx, ref, {
|
|
9793
|
+
const res = await restartUnit(ctx, ref, {
|
|
9794
|
+
noWake: opts.wake === false,
|
|
9795
|
+
fresh: opts.fresh === true
|
|
9796
|
+
});
|
|
9356
9797
|
emit(formatOf(opts), {
|
|
9357
9798
|
toon: toonObject({
|
|
9358
9799
|
restarted: res.agent.id,
|
|
9359
9800
|
previous: res.previousPane ?? "-",
|
|
9360
9801
|
pane: res.pane,
|
|
9802
|
+
resumed: res.resumed,
|
|
9361
9803
|
rung: res.rung
|
|
9362
9804
|
}),
|
|
9363
9805
|
json: res
|
|
@@ -9677,7 +10119,8 @@ function defineSend(cmd) {
|
|
|
9677
10119
|
const wake = await wakeRecipient(ctx.store, () => selectSessionAdapter(ctx.env ?? process.env), realExec, {
|
|
9678
10120
|
toId: msg.to,
|
|
9679
10121
|
fromId,
|
|
9680
|
-
noNudge: opts.nudge === false
|
|
10122
|
+
noNudge: opts.nudge === false,
|
|
10123
|
+
spawnHome: (owner) => spawnPresence(ctx, owner)
|
|
9681
10124
|
});
|
|
9682
10125
|
if (wake.warning) console.error(`delivery doorbell not confirmed (message still delivered): ${wake.warning}`);
|
|
9683
10126
|
emit(formatOf(opts), {
|
|
@@ -9685,11 +10128,13 @@ function defineSend(cmd) {
|
|
|
9685
10128
|
sent: msg.id,
|
|
9686
10129
|
to: opts.to,
|
|
9687
10130
|
subject: msg.subject,
|
|
9688
|
-
rung: wake.rung
|
|
10131
|
+
rung: wake.rung,
|
|
10132
|
+
...wake.spawned ? { spawned: wake.spawned } : {}
|
|
9689
10133
|
}),
|
|
9690
10134
|
json: {
|
|
9691
10135
|
...msg,
|
|
9692
|
-
rung: wake.rung
|
|
10136
|
+
rung: wake.rung,
|
|
10137
|
+
...wake.spawned ? { spawned: wake.spawned } : {}
|
|
9693
10138
|
}
|
|
9694
10139
|
});
|
|
9695
10140
|
});
|
|
@@ -9841,6 +10286,9 @@ withGlobals(mail.command("watch")).description("stream new matching mail as it a
|
|
|
9841
10286
|
withGlobals(mail.command("hook")).description("emit the harness hook injection payload (raw JSON on stdout, not TOON)").option("--event <event>", "SessionStart", "SessionStart").action((opts) => {
|
|
9842
10287
|
const ctx = ctxOf(opts);
|
|
9843
10288
|
touch(ctx);
|
|
10289
|
+
if (!process.stdin.isTTY) try {
|
|
10290
|
+
recordConversation(ctx, readFileSync(0, "utf8"));
|
|
10291
|
+
} catch {}
|
|
9844
10292
|
const payload = injectInbox(ctx, opts.event);
|
|
9845
10293
|
if (payload) console.log(JSON.stringify(payload));
|
|
9846
10294
|
});
|
|
@@ -9935,30 +10383,33 @@ withGlobals(agent.command("path")).description("print the resolved def file path
|
|
|
9935
10383
|
json: { path: def.path }
|
|
9936
10384
|
});
|
|
9937
10385
|
});
|
|
10386
|
+
const ALLOW_CLI_HINT = `cyberlegion init --allow-cli to add ${CLI_RULE} to Claude Code's permissions.allow, so units can mail their results back`;
|
|
9938
10387
|
const mux = program.command("mux").description("the unit-agnostic pane layer — multiplexer detection and diagnostics");
|
|
9939
10388
|
withGlobals(mux.command("doctor")).description("probe harness, multiplexer (ancestry-discovered), hub root, and self-id").action((opts) => {
|
|
9940
10389
|
const ctx = ctxOf(opts);
|
|
9941
10390
|
const harness = detectHarness(void 0, ctx) ?? "unknown";
|
|
9942
10391
|
const probe = probeMultiplexer(ctx.exec ?? realExec, normalizeMuxEnv(ctx.env ?? process.env));
|
|
9943
10392
|
const selfId = resolveSelfId(ctx) ?? "-";
|
|
10393
|
+
const permissionRule = harness === "claude" ? permissionRuleState(claudeUserSettingsFile(ctx.env ?? process.env)) : "n/a";
|
|
10394
|
+
const report = {
|
|
10395
|
+
harness,
|
|
10396
|
+
mux: probe.mux,
|
|
10397
|
+
pane: probe.pane,
|
|
10398
|
+
via: probe.via,
|
|
10399
|
+
hubRoot: ctx.store.root,
|
|
10400
|
+
selfId
|
|
10401
|
+
};
|
|
9944
10402
|
emit(formatOf(opts), {
|
|
9945
10403
|
toon: toonObject({
|
|
9946
|
-
|
|
9947
|
-
|
|
9948
|
-
pane: probe.pane,
|
|
9949
|
-
via: probe.via,
|
|
9950
|
-
hubRoot: ctx.store.root,
|
|
9951
|
-
selfId
|
|
10404
|
+
...report,
|
|
10405
|
+
permissionRule
|
|
9952
10406
|
}),
|
|
9953
10407
|
json: {
|
|
9954
|
-
|
|
9955
|
-
|
|
9956
|
-
pane: probe.pane,
|
|
9957
|
-
via: probe.via,
|
|
9958
|
-
hubRoot: ctx.store.root,
|
|
9959
|
-
selfId
|
|
10408
|
+
...report,
|
|
10409
|
+
permissionRule
|
|
9960
10410
|
}
|
|
9961
10411
|
});
|
|
10412
|
+
if (permissionRule === "missing") nextStep(ALLOW_CLI_HINT);
|
|
9962
10413
|
if (probe.mux !== "none") nextStep(`export CYBER_MUX=${probe.mux}${probe.pane ? ` CYBER_MUX_PANE=${probe.pane}` : ""} — pin the fast-path, skip ancestry discovery on later calls`);
|
|
9963
10414
|
});
|
|
9964
10415
|
withGlobals(mux.command("mode")).description("report the detected session-backend mode").action((opts) => {
|
|
@@ -10009,7 +10460,7 @@ withGlobals(program.command("admin").description("hub-state maintenance").comman
|
|
|
10009
10460
|
json: res
|
|
10010
10461
|
});
|
|
10011
10462
|
});
|
|
10012
|
-
withGlobals(program.command("init")).description("resolve this session harness, set up the Legion surfacing hook, and advise owner binding").option("--agent <h>", "claude | cursor | codex (else auto-detected)").option("--dir <path>", "project dir to write config into", process.cwd()).option("--pin <version>", "version the npx fallback of the cursor hook fetches (e.g. the bundled plugin version)").action((opts) => {
|
|
10463
|
+
withGlobals(program.command("init")).description("resolve this session harness, set up the Legion surfacing hook, and advise owner binding").option("--agent <h>", "claude | cursor | codex (else auto-detected)").option("--dir <path>", "project dir to write config into", process.cwd()).option("--pin <version>", "version the npx fallback of the cursor hook fetches (e.g. the bundled plugin version)").option("--allow-cli", `add ${CLI_RULE} to Claude Code's user permissions.allow (claude only)`).action((opts) => {
|
|
10013
10464
|
const ctx = ctxOf(opts);
|
|
10014
10465
|
let harness;
|
|
10015
10466
|
try {
|
|
@@ -10019,6 +10470,16 @@ withGlobals(program.command("init")).description("resolve this session harness,
|
|
|
10019
10470
|
} catch (err) {
|
|
10020
10471
|
fail(err instanceof Error ? err.message : String(err));
|
|
10021
10472
|
}
|
|
10473
|
+
if (opts.allowCli && harness !== "claude") fail(`--allow-cli applies to claude only, not ${harness}`);
|
|
10474
|
+
let permissionRule;
|
|
10475
|
+
if (harness === "claude") {
|
|
10476
|
+
const settingsFile = claudeUserSettingsFile(ctx.env ?? process.env);
|
|
10477
|
+
try {
|
|
10478
|
+
permissionRule = opts.allowCli ? addPermissionRule(settingsFile) : permissionRuleState(settingsFile);
|
|
10479
|
+
} catch (err) {
|
|
10480
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
10481
|
+
}
|
|
10482
|
+
}
|
|
10022
10483
|
let results;
|
|
10023
10484
|
try {
|
|
10024
10485
|
results = install(harness, opts.dir, opts.pin);
|
|
@@ -10039,12 +10500,14 @@ withGlobals(program.command("init")).description("resolve this session harness,
|
|
|
10039
10500
|
key: "file",
|
|
10040
10501
|
get: (r) => r.file
|
|
10041
10502
|
}
|
|
10042
|
-
], `harness ${harness}, ${results.length} hooks`),
|
|
10503
|
+
], `harness ${harness}, ${results.length} hooks${permissionRule ? `, permission rule ${permissionRule}` : ""}`),
|
|
10043
10504
|
json: {
|
|
10044
10505
|
harness,
|
|
10045
|
-
hooks: results
|
|
10506
|
+
hooks: results,
|
|
10507
|
+
...permissionRule && { permissionRule }
|
|
10046
10508
|
}
|
|
10047
10509
|
});
|
|
10510
|
+
if (permissionRule === "missing") nextStep(ALLOW_CLI_HINT);
|
|
10048
10511
|
if (!listAgents(ctx.store).some((a) => a.kind === "standing")) {
|
|
10049
10512
|
nextStep("cyberlegion unit register --standing --handle <name> to mint the durable owner inbox");
|
|
10050
10513
|
nextStep("cyberlegion attach to bind this pane as the owner live presence");
|