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/index.d.mts
CHANGED
|
@@ -62,6 +62,10 @@ interface AgentRecord {
|
|
|
62
62
|
* mux env prefix not) — what `unit restart` relaunches the unit with. Absent on a record that
|
|
63
63
|
* predates it or that `spawn` did not write; restart then falls back to the harness default. */
|
|
64
64
|
launch?: string;
|
|
65
|
+
/** The harness's own conversation (session) id, as its SessionStart hook last reported it from this
|
|
66
|
+
* unit's pane (`mail hook`) — what `unit restart` resumes. Absent until the hook has fired, and on a
|
|
67
|
+
* harness whose hook never runs; restart then rebriefs a fresh session. */
|
|
68
|
+
conversation?: string;
|
|
65
69
|
spawnedBy?: string;
|
|
66
70
|
/** Absent ⇒ session (backward compat, no migration). 'standing' = a session-independent,
|
|
67
71
|
* prune-exempt owner inbox minted by `unit register --standing`. 'service' = a project service's
|
|
@@ -79,7 +83,22 @@ interface AgentRecord {
|
|
|
79
83
|
* no presence bound, same as if none were ever claimed — nothing self-heals a stale pointer, it
|
|
80
84
|
* stays inert until re-claimed. Independent of the hub's `mainPane` (the human's read-pane). */
|
|
81
85
|
presence?: string;
|
|
82
|
-
|
|
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
|
+
});
|
|
83
102
|
/** A registered project: one git repository, shared by its default checkout and every linked
|
|
84
103
|
* worktree of it. Keyed by its git common dir, never by a pane or a display name. */
|
|
85
104
|
interface ProjectRecord {
|
|
@@ -180,6 +199,8 @@ interface Store extends MailboxStore {
|
|
|
180
199
|
* store (e.g. a standing record's presence rebind) should hold the SAME named lock around that
|
|
181
200
|
* whole sequence, not just around the final write. */
|
|
182
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>;
|
|
183
204
|
}
|
|
184
205
|
//#endregion
|
|
185
206
|
//#region src/identity.d.ts
|
|
@@ -719,8 +740,17 @@ interface SpawnInput {
|
|
|
719
740
|
/** Spawn into this existing directory instead — creates no worktree. Mutually exclusive with
|
|
720
741
|
* `branch`/`worktreePath` (those create a worktree; this reuses one). */
|
|
721
742
|
cwd?: string;
|
|
743
|
+
/** Create the worktree from the repository containing this path rather than the caller's cwd —
|
|
744
|
+
* so a caller spawns for another repository without a `cd` chained in front of the command.
|
|
745
|
+
* Mutually exclusive with `cwd` (that one creates no worktree to place). */
|
|
746
|
+
repo?: string;
|
|
722
747
|
/** Placement relative to the caller; defaults to 'tab'. */
|
|
723
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;
|
|
724
754
|
}
|
|
725
755
|
interface SpawnResult {
|
|
726
756
|
agent: AgentRecord;
|
|
@@ -765,12 +795,14 @@ export declare function spawnAndWake(ctx: IdContext, input: SpawnInput, options?
|
|
|
765
795
|
noWake?: boolean;
|
|
766
796
|
nudgeOpts?: NudgeOptions;
|
|
767
797
|
trustOpts?: TrustOptions;
|
|
768
|
-
}): 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 {
|
|
769
801
|
rung: boolean;
|
|
770
802
|
warning?: string;
|
|
771
803
|
trust: TrustOutcome['state'];
|
|
772
804
|
trustBlocked?: string;
|
|
773
|
-
}
|
|
805
|
+
}
|
|
774
806
|
//#endregion
|
|
775
807
|
//#region src/store/file-store.d.ts
|
|
776
808
|
/** The on-disk `Store` implementation — current per-writer sharded `.json` layout (ADR-0020):
|
|
@@ -803,6 +835,7 @@ export declare class FileStore implements Store {
|
|
|
803
835
|
setMainPane(pane: string | null): void;
|
|
804
836
|
getMainPane(): string | undefined;
|
|
805
837
|
withLock<T>(name: string, fn: () => T): T;
|
|
838
|
+
withLockAsync<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
806
839
|
}
|
|
807
840
|
//#endregion
|
|
808
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");
|
|
@@ -615,6 +620,9 @@ const KILL_LINE = "";
|
|
|
615
620
|
* throws without sending. A multi-row draft cannot be typed back exactly: a scrape cannot tell a
|
|
616
621
|
* wrapped row from a newline, and a newline typed back would submit it.
|
|
617
622
|
*
|
|
623
|
+
* Text `ownText` recognizes is the caller's own, not a draft: it is cleared at once, whatever rows it
|
|
624
|
+
* wraps onto, `send` runs, and nothing is typed back.
|
|
625
|
+
*
|
|
618
626
|
* Text the clear leaves in place was never a draft — an idle placeholder this reader does not know —
|
|
619
627
|
* so `send` runs and nothing is typed back.
|
|
620
628
|
*/
|
|
@@ -631,10 +639,11 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
631
639
|
return { kind: "unknown" };
|
|
632
640
|
}
|
|
633
641
|
};
|
|
642
|
+
const isOwn = (s) => s.kind === "draft" && options.ownText?.(s.text) === true;
|
|
634
643
|
let state = read();
|
|
635
644
|
const start = now();
|
|
636
645
|
let changedAt = start;
|
|
637
|
-
while (state.kind === "draft" && now() - changedAt < idleMs) {
|
|
646
|
+
while (state.kind === "draft" && !isOwn(state) && now() - changedAt < idleMs) {
|
|
638
647
|
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`);
|
|
639
648
|
await sleep(pollMs);
|
|
640
649
|
const next = read();
|
|
@@ -642,12 +651,13 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
642
651
|
state = next;
|
|
643
652
|
}
|
|
644
653
|
if (state.kind !== "draft") return send();
|
|
654
|
+
if (isOwn(state)) {
|
|
655
|
+
await clearLine(adapter, exec, target, sleep);
|
|
656
|
+
return send();
|
|
657
|
+
}
|
|
645
658
|
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`);
|
|
646
659
|
const draft = state.text;
|
|
647
|
-
adapter
|
|
648
|
-
await sleep(KEY_SETTLE_MS);
|
|
649
|
-
adapter.sendText(exec, target, KILL_LINE);
|
|
650
|
-
await sleep(KEY_SETTLE_MS);
|
|
660
|
+
await clearLine(adapter, exec, target, sleep);
|
|
651
661
|
const after = read();
|
|
652
662
|
if (after.kind === "draft" && after.text === draft) return send();
|
|
653
663
|
try {
|
|
@@ -657,6 +667,12 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
|
|
|
657
667
|
adapter.sendText(exec, target, draft);
|
|
658
668
|
}
|
|
659
669
|
}
|
|
670
|
+
async function clearLine(adapter, exec, target, sleep) {
|
|
671
|
+
adapter.sendText(exec, target, END_OF_LINE);
|
|
672
|
+
await sleep(KEY_SETTLE_MS);
|
|
673
|
+
adapter.sendText(exec, target, KILL_LINE);
|
|
674
|
+
await sleep(KEY_SETTLE_MS);
|
|
675
|
+
}
|
|
660
676
|
//#endregion
|
|
661
677
|
//#region src/console/ring.ts
|
|
662
678
|
const DEFAULT_ATTEMPTS = 10;
|
|
@@ -683,6 +699,13 @@ function copiesOn(screen, message) {
|
|
|
683
699
|
* the text, the one in the input box. Two or more new copies mean the harness also posted it above
|
|
684
700
|
* the box, so the turn was taken and nothing is flushed. Text still at the bottom with only one new
|
|
685
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.
|
|
686
709
|
*/
|
|
687
710
|
async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
688
711
|
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
@@ -690,28 +713,37 @@ async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
|
690
713
|
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
691
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.`);
|
|
692
715
|
const before = copiesOn(adapter.read(exec, target).text, message);
|
|
693
|
-
const
|
|
716
|
+
const check = () => {
|
|
694
717
|
const screen = adapter.read(exec, target).text;
|
|
695
|
-
|
|
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";
|
|
696
721
|
};
|
|
697
722
|
adapter.submit(exec, target, message);
|
|
698
723
|
await sleep(settleMs);
|
|
699
|
-
|
|
724
|
+
let state = check();
|
|
725
|
+
if (state === "taken") return {
|
|
700
726
|
taken: true,
|
|
701
727
|
resubmits: 0
|
|
702
728
|
};
|
|
729
|
+
let lostReads = state === "lost" ? 1 : 0;
|
|
703
730
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
704
|
-
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
|
+
}
|
|
705
736
|
await sleep(settleMs);
|
|
706
|
-
|
|
737
|
+
state = check();
|
|
738
|
+
if (state === "taken") return {
|
|
707
739
|
taken: true,
|
|
708
740
|
resubmits: attempt
|
|
709
741
|
};
|
|
742
|
+
lostReads = state === "lost" ? lostReads + 1 : 0;
|
|
710
743
|
}
|
|
711
|
-
|
|
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}`);
|
|
712
746
|
}
|
|
713
|
-
//#endregion
|
|
714
|
-
//#region src/console/doorbell.ts
|
|
715
747
|
/**
|
|
716
748
|
* The first-turn doorbell `unit spawn` delivers to a freshly-opened paned peer — the instruction
|
|
717
749
|
* itself, not a notification that context is already populated. It names the brief's **file path**
|
|
@@ -725,6 +757,28 @@ async function ringTurn(adapter, exec, target, message, opts = {}) {
|
|
|
725
757
|
function spawnDoorbell(briefPath) {
|
|
726
758
|
return `Read your brief at ${briefPath}, then begin work.`;
|
|
727
759
|
}
|
|
760
|
+
const SPAWN_DOORBELL = /^Readyourbriefat.+,thenbeginwork\.$/;
|
|
761
|
+
const RESUME_DOORBELL = /^Yoursessionwasrestartedwithitsconversationresumed—continueyourwork\.Ifyouhavenoearlierconversation,readyourbriefat.+,thenbeginwork\.$/;
|
|
762
|
+
/**
|
|
763
|
+
* Whether `text`, read out of a peer's input box, is a ring cyberlegion typed — the delivery doorbell,
|
|
764
|
+
* a spawn or resume doorbell, or `message`, the one about to be rung — rather than a human's draft. A harness
|
|
765
|
+
* can take a ring and put its text back in the box (cursor-agent with a rejected login). Whitespace is
|
|
766
|
+
* ignored, since the box wraps the text onto rows wherever it likes.
|
|
767
|
+
*/
|
|
768
|
+
function isRingText(text, message) {
|
|
769
|
+
const squeeze = (s) => s.replace(/\s+/g, "");
|
|
770
|
+
const box = squeeze(text);
|
|
771
|
+
if (box === "") return false;
|
|
772
|
+
return box === squeeze("You have unread mail — check your inbox.") || SPAWN_DOORBELL.test(box) || RESUME_DOORBELL.test(box) || message !== void 0 && box === squeeze(message);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* The first-turn instruction for a restarted session that resumed its conversation. The resume can
|
|
776
|
+
* fall back to a fresh session (the harness no longer has the conversation), and the ring cannot tell
|
|
777
|
+
* which one took the turn, so it names the brief for a session that finds no earlier conversation.
|
|
778
|
+
*/
|
|
779
|
+
function resumeDoorbell(briefPath) {
|
|
780
|
+
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.`;
|
|
781
|
+
}
|
|
728
782
|
/**
|
|
729
783
|
* A freshly-launched harness cold-boots slower than an already-running peer, so the spawn first-turn
|
|
730
784
|
* ring gets a wider retry budget than a plain `mail send` doorbell (nudge's own 10 × 400ms): flush the
|
|
@@ -747,6 +801,11 @@ const SPAWN_NUDGE_OPTS = {
|
|
|
747
801
|
* ring that never completes within the retry budget is swallowed into a warning. This never throws —
|
|
748
802
|
* it can never fail the spawn.
|
|
749
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
|
+
*
|
|
750
809
|
* The adapter is resolved lazily via `getAdapter` inside the same swallowing try, so even a session
|
|
751
810
|
* whose backend has since gone away (where `selectSessionAdapter` would throw) degrades to a warned
|
|
752
811
|
* no-op rather than a failed spawn.
|
|
@@ -755,9 +814,14 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
|
|
|
755
814
|
if (input.noWake) return { rung: false };
|
|
756
815
|
try {
|
|
757
816
|
const adapter = getAdapter();
|
|
758
|
-
|
|
817
|
+
const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
|
|
818
|
+
await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, {
|
|
819
|
+
...nudgeOpts,
|
|
820
|
+
requirePosted: true
|
|
821
|
+
}), {
|
|
759
822
|
...guardOpts,
|
|
760
|
-
harness: input.harness
|
|
823
|
+
harness: input.harness,
|
|
824
|
+
ownText: (text) => isRingText(text)
|
|
761
825
|
});
|
|
762
826
|
return {
|
|
763
827
|
rung: true,
|
|
@@ -1055,6 +1119,30 @@ const LAUNCH_MAP = {
|
|
|
1055
1119
|
cursor: "cursor-agent",
|
|
1056
1120
|
codex: "codex"
|
|
1057
1121
|
};
|
|
1122
|
+
/** The primary checkout of the repository containing `repo`, asked of git from inside it (`git -C`)
|
|
1123
|
+
* rather than from the process cwd. */
|
|
1124
|
+
function primaryRootOfRepo(exec, repo) {
|
|
1125
|
+
const dir = resolve(repo);
|
|
1126
|
+
try {
|
|
1127
|
+
return resolvePrimaryRoot$1((cmd, args) => exec(cmd, cmd === "git" ? [
|
|
1128
|
+
"-C",
|
|
1129
|
+
dir,
|
|
1130
|
+
...args
|
|
1131
|
+
] : args));
|
|
1132
|
+
} catch {
|
|
1133
|
+
throw new Error(`--repo ${repo} is not inside a git repository`);
|
|
1134
|
+
}
|
|
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
|
+
}
|
|
1058
1146
|
/**
|
|
1059
1147
|
* Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
|
|
1060
1148
|
* the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
|
|
@@ -1078,9 +1166,9 @@ function spawn(ctx, input) {
|
|
|
1078
1166
|
if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
|
|
1079
1167
|
const brief = resolveBrief(input);
|
|
1080
1168
|
if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
|
|
1081
|
-
if (input.cwd && (input.branch || input.worktreePath)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path");
|
|
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");
|
|
1082
1170
|
const id = randomId();
|
|
1083
|
-
const primaryRoot = 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);
|
|
1084
1172
|
const launch = input.command ?? LAUNCH_MAP[harness];
|
|
1085
1173
|
const launchLine = () => composeLaunchLine(ctx, sessionAdapter.name, id, launch);
|
|
1086
1174
|
const from = callerPane(sessionAdapter, normalizedEnv);
|
|
@@ -1090,7 +1178,7 @@ function spawn(ctx, input) {
|
|
|
1090
1178
|
if (input.cwd) {
|
|
1091
1179
|
if (!existsSync(input.cwd)) throw new Error(`--cwd directory must already exist: ${input.cwd}`);
|
|
1092
1180
|
cwd = resolve(input.cwd);
|
|
1093
|
-
assertDistinctFromPrimary$1(cwd, primaryRoot);
|
|
1181
|
+
if (primaryRoot !== void 0) assertDistinctFromPrimary$1(cwd, primaryRoot);
|
|
1094
1182
|
worktree = null;
|
|
1095
1183
|
const at = input.at ?? "tab";
|
|
1096
1184
|
target = sessionAdapter.open(exec, {
|
|
@@ -1101,6 +1189,7 @@ function spawn(ctx, input) {
|
|
|
1101
1189
|
...labelFor(at, input, brief, id)
|
|
1102
1190
|
});
|
|
1103
1191
|
} else {
|
|
1192
|
+
if (primaryRoot === void 0) throw new Error("a worktree spawn needs a primary checkout");
|
|
1104
1193
|
const branch = input.branch ?? `cyberlegion/unit-${id}`;
|
|
1105
1194
|
const at = input.at ?? "workspace";
|
|
1106
1195
|
const worktreePath = input.worktreePath ?? resolveUnitWorktreePath(primaryRoot, id.slice(0, 6));
|
|
@@ -1193,15 +1282,39 @@ function spawn(ctx, input) {
|
|
|
1193
1282
|
*/
|
|
1194
1283
|
async function spawnAndWake(ctx, input, options = {}) {
|
|
1195
1284
|
const res = spawn(ctx, input);
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1285
|
+
const trust = await settleSpawnTrust(ctx, res, options);
|
|
1286
|
+
if (trust.trustBlocked) return {
|
|
1287
|
+
...res,
|
|
1288
|
+
...trust
|
|
1289
|
+
};
|
|
1290
|
+
return {
|
|
1200
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 {
|
|
1201
1304
|
rung: false,
|
|
1202
1305
|
trust: trust.state,
|
|
1203
1306
|
trustBlocked: trust.message
|
|
1204
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;
|
|
1205
1318
|
const briefPath = res.agent.brief;
|
|
1206
1319
|
const wake = await wakeSpawn(() => selectSessionAdapter(env, exec), exec, {
|
|
1207
1320
|
target: { id: res.pane },
|
|
@@ -1210,9 +1323,8 @@ async function spawnAndWake(ctx, input, options = {}) {
|
|
|
1210
1323
|
harness: res.agent.harness
|
|
1211
1324
|
}, options.nudgeOpts);
|
|
1212
1325
|
return {
|
|
1213
|
-
...res,
|
|
1214
1326
|
rung: wake.rung,
|
|
1215
|
-
trust: trust.
|
|
1327
|
+
trust: trust.trust,
|
|
1216
1328
|
...wake.warning ? { warning: wake.warning } : {}
|
|
1217
1329
|
};
|
|
1218
1330
|
}
|
|
@@ -1221,7 +1333,7 @@ async function spawnAndWake(ctx, input, options = {}) {
|
|
|
1221
1333
|
* resolved leaves nothing to read, which is no evidence of a prompt: the ring's own containment
|
|
1222
1334
|
* reports that backend.
|
|
1223
1335
|
*/
|
|
1224
|
-
async function settleTrust(ctx, res, opts) {
|
|
1336
|
+
async function settleTrust(ctx, res, opts, trustFolder = false) {
|
|
1225
1337
|
const exec = ctx.exec ?? realExec;
|
|
1226
1338
|
let adapter;
|
|
1227
1339
|
try {
|
|
@@ -1233,7 +1345,7 @@ async function settleTrust(ctx, res, opts) {
|
|
|
1233
1345
|
harness: res.agent.harness,
|
|
1234
1346
|
folder: res.agent.cwd,
|
|
1235
1347
|
pane: res.pane,
|
|
1236
|
-
policy: res.agent.worktree ? "accept" : "ask"
|
|
1348
|
+
policy: res.agent.worktree || trustFolder ? "accept" : "ask"
|
|
1237
1349
|
}, opts);
|
|
1238
1350
|
}
|
|
1239
1351
|
/**
|
|
@@ -1260,10 +1372,25 @@ function labelFor(at, input, brief, id) {
|
|
|
1260
1372
|
* supplied `ctx.self`), the multiplexer env prefix, then the harness launch command. Writes the shim
|
|
1261
1373
|
* as a side effect, so call it only past every refusal, right before the session opens. Shared by
|
|
1262
1374
|
* `spawn` and `unit restart` so a restarted unit boots exactly as a spawned one does.
|
|
1375
|
+
*
|
|
1376
|
+
* `bindSelf` has the new pane run `unit rebind <id>` through the shim before the harness starts, so
|
|
1377
|
+
* the session binds itself to the unit even when the caller dies before its own bind write. It needs
|
|
1378
|
+
* the shim, so a context with no recorded invocation gets no bind step.
|
|
1379
|
+
*/
|
|
1380
|
+
function composeLaunchLine(ctx, muxName, id, launch, options = {}) {
|
|
1381
|
+
const shim = ctx.self ? writeSelfShim(paths.dataDir(ctx.store.root, id), ctx.self) : void 0;
|
|
1382
|
+
const bind = shim && options.bindSelf ? `${shellQuote$1(shim)} unit rebind ${shellQuote$1(id)} --space ${shellQuote$1(ctx.store.root)} >/dev/null 2>&1; ` : "";
|
|
1383
|
+
const prefix = `${shim ? shimEnvPrefix(shim) : ""}${muxEnvPrefix(muxName)}`;
|
|
1384
|
+
return `${bind}${prefix}${launch}${options.fallback ? ` || ${prefix}${options.fallback}` : ""}`;
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Put the shim's directory first on PATH, so a brief's bare `cyberlegion` runs it, and name the shim
|
|
1388
|
+
* itself in `$CYBERLEGION_CLI`. The plugin's hook runs outside that PATH lookup (and a PATH lookup
|
|
1389
|
+
* cannot tell this shim from a stale global install), so it reads the variable instead: set only
|
|
1390
|
+
* here, it marks a spawned session and names the CLI that spawned it.
|
|
1263
1391
|
*/
|
|
1264
|
-
function
|
|
1265
|
-
|
|
1266
|
-
return `${shimDir ? `PATH=${shellQuote$1(shimDir)}:"$PATH" ` : ""}${muxEnvPrefix(muxName)}${launch}`;
|
|
1392
|
+
function shimEnvPrefix(shim) {
|
|
1393
|
+
return `PATH=${shellQuote$1(dirname(shim))}:"$PATH" CYBERLEGION_CLI=${shellQuote$1(shim)} `;
|
|
1267
1394
|
}
|
|
1268
1395
|
/**
|
|
1269
1396
|
* The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
|
|
@@ -1279,7 +1406,7 @@ function muxEnvPrefix(muxName) {
|
|
|
1279
1406
|
}
|
|
1280
1407
|
/**
|
|
1281
1408
|
* Write `<dataDir>/bin/cyberlegion`, a POSIX shim that execs `self` with the caller's arguments,
|
|
1282
|
-
* and return its
|
|
1409
|
+
* and return its path. A spawned session gets that directory first on its PATH, so the
|
|
1283
1410
|
* `cyberlegion` a brief tells it to run is the install that spawned it: nothing is resolved from
|
|
1284
1411
|
* the registry or the session's own PATH at report time.
|
|
1285
1412
|
*/
|
|
@@ -1289,7 +1416,7 @@ function writeSelfShim(dataDir, self) {
|
|
|
1289
1416
|
const shim = join(dir, "cyberlegion");
|
|
1290
1417
|
writeFileSync(shim, `#!/bin/sh\nexec ${self.map(shellQuote$1).join(" ")} "$@"\n`);
|
|
1291
1418
|
chmodSync(shim, 493);
|
|
1292
|
-
return
|
|
1419
|
+
return shim;
|
|
1293
1420
|
}
|
|
1294
1421
|
/** Single-quote `s` for a POSIX shell, so any path survives word splitting and expansion. */
|
|
1295
1422
|
function shellQuote$1(s) {
|
|
@@ -2509,6 +2636,16 @@ function withLock(root, name, fn, opts) {
|
|
|
2509
2636
|
handle.release();
|
|
2510
2637
|
}
|
|
2511
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
|
+
}
|
|
2512
2649
|
//#endregion
|
|
2513
2650
|
//#region src/store/file-store.ts
|
|
2514
2651
|
/** Parse a record file's content, wrapping a `JSON.parse` failure in a typed, file-named
|
|
@@ -2712,6 +2849,9 @@ var FileStore = class {
|
|
|
2712
2849
|
withLock(name, fn) {
|
|
2713
2850
|
return withLock(this.root, name, fn);
|
|
2714
2851
|
}
|
|
2852
|
+
withLockAsync(name, fn) {
|
|
2853
|
+
return withLockAsync(this.root, name, fn);
|
|
2854
|
+
}
|
|
2715
2855
|
};
|
|
2716
2856
|
//#endregion
|
|
2717
2857
|
//#region src/wake/await.ts
|
package/hooks/hooks.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"hooks": [
|
|
6
6
|
{
|
|
7
7
|
"type": "command",
|
|
8
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/cyberlegion.mjs\" mail hook --event SessionStart"
|
|
8
|
+
"command": "if [ -x \"$CYBERLEGION_CLI\" ]; then \"$CYBERLEGION_CLI\" mail hook --event SessionStart; else node \"${CLAUDE_PLUGIN_ROOT}/bin/cyberlegion.mjs\" mail hook --event SessionStart; fi"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cyberlegion",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
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.0",
|
|
5
5
|
"description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "unional"
|
package/readme.md
CHANGED
|
@@ -161,6 +161,11 @@ launcher cannot be resolved, a skill names one fallback pinned to the version th
|
|
|
161
161
|
`npx -y cyberlegion@<version>`. `pnpm version` rewrites those pins and `.plugin/pins.json` at every
|
|
162
162
|
release, and the test suite fails on a stale one.
|
|
163
163
|
|
|
164
|
+
Claude Code puts the plugin's `bin/` on the Bash tool's PATH, and `bin/cyberlegion` forwards to
|
|
165
|
+
`bin/cyberlegion.mjs`, so a bare `cyberlegion …` runs the shipped CLI in any Claude Code session with
|
|
166
|
+
no global install. claude.ai and Cowork, including plugins distributed through claude.ai organization
|
|
167
|
+
settings, refuse a plugin with a top-level `bin/`, so the plugin does not install there.
|
|
168
|
+
|
|
164
169
|
## License
|
|
165
170
|
|
|
166
171
|
MIT
|
|
@@ -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.0`.
|
|
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.0'
|
|
15
15
|
const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
|
|
16
16
|
|
|
17
17
|
if (existsSync(fileURLToPath(bin))) {
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
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
|
-
explicit yes) bind this pane as the
|
|
5
|
+
explicit yes) allow the `cyberlegion` CLI in Claude Code's permissions and bind this pane as the
|
|
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.
|
|
6
8
|
|
|
7
9
|
User-facing. Triggers on "set up cyberlegion", "onboard the legion", "register the cyberlegion
|
|
8
10
|
surfacing hook", "make this pane my main legion inbox", "get cyberlegion working in this repo". Wraps
|
|
9
|
-
`cyberlegion mux doctor` / `init` / `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
|
|
10
12
|
CLI call, no filesystem or hub state touched directly.
|
|
11
13
|
|
|
12
14
|
Distinct from `legate` (spawn/mail/dispatch a peer) and `manage-inbox` (read/ack owner mail once
|
|
13
|
-
bound). See `SKILL.md` for the full flow (probe →
|
|
14
|
-
consent-gated bind).
|
|
15
|
+
bound). See `SKILL.md` for the full flow (probe → consent-gated permission rule + register hook →
|
|
16
|
+
detect root vs spawned → consent-gated bind, with a home offered at the mint or to an existing owner).
|