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/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
- /** Prefer a standing record over a plain session record when both match a handle — an owner
307
- * report must land in the durable standing inbox, not a dying session's. */
308
- function preferStanding(matches) {
309
- return matches.find((a) => a.kind === "standing") ?? matches[0];
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 = preferStanding(live);
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 = preferStanding(live);
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.sendText(exec, target, END_OF_LINE);
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 taken = () => {
716
+ const check = () => {
694
717
  const screen = adapter.read(exec, target).text;
695
- return !isStaged(screen, message) || copiesOn(screen, message) >= before + 2;
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
- if (taken()) return {
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
- if (taken()) return {
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
- throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — input still staged after ${attempts} re-submit attempts`);
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
- await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, spawnDoorbell(input.briefPath), nudgeOpts), {
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 env = ctx.env ?? process.env;
1197
- const exec = ctx.exec ?? realExec;
1198
- const trust = await settleTrust(ctx, res, options.trustOpts);
1199
- if (trust.state === "needs-human" || trust.state === "stuck") return {
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.state,
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 composeLaunchLine(ctx, muxName, id, launch) {
1265
- const shimDir = ctx.self ? writeSelfShim(paths.dataDir(ctx.store.root, id), ctx.self) : void 0;
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 directory. A spawned session gets that directory first on its PATH, so the
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 dir;
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.2.0",
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": "^4.1.7"
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.2.0",
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.2.0`.
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.2.0'
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 durable `legate` owner inbox.
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 → register hook → detect root vs spawned →
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).