cyberlegion 1.3.0 → 1.4.1

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