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