cyberlegion 1.1.0 → 1.3.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
@@ -6139,6 +6139,7 @@ function reconcile(ctx, opts) {
6139
6139
  const cur = storablePane(ctx.env ?? process.env);
6140
6140
  if (!cur) return [];
6141
6141
  const panes = PANE_ADAPTERS[cur.mux].listPanes(exec);
6142
+ if (panes.length === 0) return [];
6142
6143
  const live = new Set(panes.map((p) => p.id));
6143
6144
  const changed = [];
6144
6145
  for (const rec of listAgents(ctx.store)) {
@@ -6154,18 +6155,34 @@ function reconcile(ctx, opts) {
6154
6155
  if (opts?.adopt) changed.push(...adopt(ctx, panes));
6155
6156
  return changed;
6156
6157
  }
6157
- /** Mark agents whose pane is gone or whose last-seen is stale as exited. Reconcile-culls against the
6158
- * current mux's live set first, then falls through to the per-record paneExists + staleness check
6159
- * (covers the other mux and sessions outside any multiplexer pane). */
6158
+ /**
6159
+ * Whether a record's pane is positively gone: its existence probe fails AND its mux answers with a
6160
+ * pane list that does not hold it. `paneExists` alone collapses "unreachable" into "gone", so a mux
6161
+ * that answers no list at all (server down, a failed query) leaves the pane undecided — never gone.
6162
+ * The list is read at most once per mux per call.
6163
+ */
6164
+ function paneGone(exec, pane, lists) {
6165
+ const adapter = PANE_ADAPTERS[pane.mux];
6166
+ if (adapter.paneExists(exec, { id: pane.id })) return false;
6167
+ let panes = lists.get(pane.mux);
6168
+ if (!panes) {
6169
+ panes = adapter.listPanes(exec);
6170
+ lists.set(pane.mux, panes);
6171
+ }
6172
+ return panes.length > 0 && !panes.some((p) => p.id === pane.id);
6173
+ }
6174
+ /** Mark agents whose session is gone as exited. Reconcile-culls against the current mux's live set
6175
+ * first, then judges each remaining record (covers the other mux and sessions outside any
6176
+ * multiplexer pane): a pane-bound record by its pane alone — a live or unprobeable pane keeps it
6177
+ * whatever its last-seen — and a pane-less record by the staleness timer, its only signal. */
6160
6178
  function prune(ctx) {
6161
6179
  const exec = ctx.exec ?? realExec;
6162
6180
  const now = ctx.now?.() ?? Date.now();
6163
6181
  const changed = reconcile(ctx);
6182
+ const lists = /* @__PURE__ */ new Map();
6164
6183
  for (const rec of listAgents(ctx.store)) {
6165
6184
  if (!isPrunable(rec)) continue;
6166
- const paneGone = rec.pane ? !PANE_ADAPTERS[rec.pane.mux].paneExists(exec, { id: rec.pane.id }) : false;
6167
- const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
6168
- if (paneGone || stale) {
6185
+ if (rec.pane ? paneGone(exec, rec.pane, lists) : now - new Date(rec.lastSeen).getTime() > STALE_MS) {
6169
6186
  rec.status = "exited";
6170
6187
  saveAgent(ctx.store, rec);
6171
6188
  changed.push(rec);
@@ -6275,6 +6292,9 @@ const KILL_LINE = "";
6275
6292
  * throws without sending. A multi-row draft cannot be typed back exactly: a scrape cannot tell a
6276
6293
  * wrapped row from a newline, and a newline typed back would submit it.
6277
6294
  *
6295
+ * Text `ownText` recognizes is the caller's own, not a draft: it is cleared at once, whatever rows it
6296
+ * wraps onto, `send` runs, and nothing is typed back.
6297
+ *
6278
6298
  * Text the clear leaves in place was never a draft — an idle placeholder this reader does not know —
6279
6299
  * so `send` runs and nothing is typed back.
6280
6300
  */
@@ -6291,10 +6311,11 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
6291
6311
  return { kind: "unknown" };
6292
6312
  }
6293
6313
  };
6314
+ const isOwn = (s) => s.kind === "draft" && options.ownText?.(s.text) === true;
6294
6315
  let state = read();
6295
6316
  const start = now();
6296
6317
  let changedAt = start;
6297
- while (state.kind === "draft" && now() - changedAt < idleMs) {
6318
+ while (state.kind === "draft" && !isOwn(state) && now() - changedAt < idleMs) {
6298
6319
  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`);
6299
6320
  await sleep(pollMs);
6300
6321
  const next = read();
@@ -6302,12 +6323,13 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
6302
6323
  state = next;
6303
6324
  }
6304
6325
  if (state.kind !== "draft") return send();
6326
+ if (isOwn(state)) {
6327
+ await clearLine(adapter, exec, target, sleep);
6328
+ return send();
6329
+ }
6305
6330
  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`);
6306
6331
  const draft = state.text;
6307
- adapter.sendText(exec, target, END_OF_LINE);
6308
- await sleep(KEY_SETTLE_MS);
6309
- adapter.sendText(exec, target, KILL_LINE);
6310
- await sleep(KEY_SETTLE_MS);
6332
+ await clearLine(adapter, exec, target, sleep);
6311
6333
  const after = read();
6312
6334
  if (after.kind === "draft" && after.text === draft) return send();
6313
6335
  try {
@@ -6317,6 +6339,12 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
6317
6339
  adapter.sendText(exec, target, draft);
6318
6340
  }
6319
6341
  }
6342
+ async function clearLine(adapter, exec, target, sleep) {
6343
+ adapter.sendText(exec, target, END_OF_LINE);
6344
+ await sleep(KEY_SETTLE_MS);
6345
+ adapter.sendText(exec, target, KILL_LINE);
6346
+ await sleep(KEY_SETTLE_MS);
6347
+ }
6320
6348
  //#endregion
6321
6349
  //#region src/console/ring.ts
6322
6350
  const DEFAULT_ATTEMPTS = 10;
@@ -6387,6 +6415,28 @@ const DELIVERY_DOORBELL = "You have unread mail — check your inbox.";
6387
6415
  function spawnDoorbell(briefPath) {
6388
6416
  return `Read your brief at ${briefPath}, then begin work.`;
6389
6417
  }
6418
+ const SPAWN_DOORBELL = /^Readyourbriefat.+,thenbeginwork\.$/;
6419
+ const RESUME_DOORBELL = /^Yoursessionwasrestartedwithitsconversationresumed—continueyourwork\.Ifyouhavenoearlierconversation,readyourbriefat.+,thenbeginwork\.$/;
6420
+ /**
6421
+ * Whether `text`, read out of a peer's input box, is a ring cyberlegion typed — the delivery doorbell,
6422
+ * a spawn or resume doorbell, or `message`, the one about to be rung — rather than a human's draft. A harness
6423
+ * can take a ring and put its text back in the box (cursor-agent with a rejected login). Whitespace is
6424
+ * ignored, since the box wraps the text onto rows wherever it likes.
6425
+ */
6426
+ function isRingText(text, message) {
6427
+ const squeeze = (s) => s.replace(/\s+/g, "");
6428
+ const box = squeeze(text);
6429
+ if (box === "") return false;
6430
+ return box === squeeze("You have unread mail — check your inbox.") || SPAWN_DOORBELL.test(box) || RESUME_DOORBELL.test(box) || message !== void 0 && box === squeeze(message);
6431
+ }
6432
+ /**
6433
+ * The first-turn instruction for a restarted session that resumed its conversation. The resume can
6434
+ * fall back to a fresh session (the harness no longer has the conversation), and the ring cannot tell
6435
+ * which one took the turn, so it names the brief for a session that finds no earlier conversation.
6436
+ */
6437
+ function resumeDoorbell(briefPath) {
6438
+ 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.`;
6439
+ }
6390
6440
  /**
6391
6441
  * A freshly-launched harness cold-boots slower than an already-running peer, so the spawn first-turn
6392
6442
  * ring gets a wider retry budget than a plain `mail send` doorbell (nudge's own 10 × 400ms): flush the
@@ -6455,7 +6505,8 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
6455
6505
  const target = { id: pane };
6456
6506
  await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
6457
6507
  ...guardOpts,
6458
- harness
6508
+ harness,
6509
+ ownText: (text) => isRingText(text)
6459
6510
  });
6460
6511
  return {
6461
6512
  rung: true,
@@ -6488,9 +6539,11 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
6488
6539
  if (input.noWake) return { rung: false };
6489
6540
  try {
6490
6541
  const adapter = getAdapter();
6491
- await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, spawnDoorbell(input.briefPath), nudgeOpts), {
6542
+ const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
6543
+ await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, nudgeOpts), {
6492
6544
  ...guardOpts,
6493
- harness: input.harness
6545
+ harness: input.harness,
6546
+ ownText: (text) => isRingText(text)
6494
6547
  });
6495
6548
  return {
6496
6549
  rung: true,
@@ -6819,6 +6872,20 @@ function resetCommandFor(harness) {
6819
6872
  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`);
6820
6873
  throw new Error(`"${harness}" is not in the reset map (${Object.keys(RESET_MAP).join(" | ")}) — unit clear refuses to guess a command`);
6821
6874
  }
6875
+ /** The primary checkout of the repository containing `repo`, asked of git from inside it (`git -C`)
6876
+ * rather than from the process cwd. */
6877
+ function primaryRootOfRepo(exec, repo) {
6878
+ const dir = resolve(repo);
6879
+ try {
6880
+ return resolvePrimaryRoot((cmd, args) => exec(cmd, cmd === "git" ? [
6881
+ "-C",
6882
+ dir,
6883
+ ...args
6884
+ ] : args));
6885
+ } catch {
6886
+ throw new Error(`--repo ${repo} is not inside a git repository`);
6887
+ }
6888
+ }
6822
6889
  /**
6823
6890
  * Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
6824
6891
  * the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
@@ -6842,9 +6909,9 @@ function spawn(ctx, input) {
6842
6909
  if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
6843
6910
  const brief = resolveBrief(input);
6844
6911
  if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
6845
- if (input.cwd && (input.branch || input.worktreePath)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path");
6912
+ if (input.cwd && (input.branch || input.worktreePath || input.repo)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path/--repo");
6846
6913
  const id = randomId();
6847
- const primaryRoot = resolvePrimaryRoot(exec);
6914
+ const primaryRoot = input.repo ? primaryRootOfRepo(exec, input.repo) : resolvePrimaryRoot(exec);
6848
6915
  const launch = input.command ?? LAUNCH_MAP[harness];
6849
6916
  const launchLine = () => composeLaunchLine(ctx, sessionAdapter.name, id, launch);
6850
6917
  const from = callerPane(sessionAdapter, normalizedEnv);
@@ -6880,7 +6947,10 @@ function spawn(ctx, input) {
6880
6947
  assertDistinctFromPrimary(opened.worktree.root, primaryRoot);
6881
6948
  ensureMarker(join(opened.worktree.root, ".agents", "cyberlegion"));
6882
6949
  cwd = opened.worktree.root;
6883
- worktree = opened.worktree;
6950
+ worktree = {
6951
+ ...opened.worktree,
6952
+ primaryRoot
6953
+ };
6884
6954
  target = opened.target;
6885
6955
  } else {
6886
6956
  const added = gitWorktreeAdapter.add(exec, {
@@ -6891,7 +6961,10 @@ function spawn(ctx, input) {
6891
6961
  assertDistinctFromPrimary(added.root, primaryRoot);
6892
6962
  ensureMarker(join(added.root, ".agents", "cyberlegion"));
6893
6963
  cwd = added.root;
6894
- worktree = added;
6964
+ worktree = {
6965
+ ...added,
6966
+ primaryRoot
6967
+ };
6895
6968
  target = sessionAdapter.open(exec, {
6896
6969
  cwd,
6897
6970
  launch: launchLine(),
@@ -7018,10 +7091,43 @@ function labelFor(at, input, brief, id) {
7018
7091
  * supplied `ctx.self`), the multiplexer env prefix, then the harness launch command. Writes the shim
7019
7092
  * as a side effect, so call it only past every refusal, right before the session opens. Shared by
7020
7093
  * `spawn` and `unit restart` so a restarted unit boots exactly as a spawned one does.
7094
+ *
7095
+ * `bindSelf` has the new pane run `unit rebind <id>` through the shim before the harness starts, so
7096
+ * the session binds itself to the unit even when the caller dies before its own bind write. It needs
7097
+ * the shim, so a context with no recorded invocation gets no bind step.
7021
7098
  */
7022
- function composeLaunchLine(ctx, muxName, id, launch) {
7023
- const shimDir = ctx.self ? writeSelfShim(paths.dataDir(ctx.store.root, id), ctx.self) : void 0;
7024
- return `${shimDir ? `PATH=${shellQuote$1(shimDir)}:"$PATH" ` : ""}${muxEnvPrefix(muxName)}${launch}`;
7099
+ function composeLaunchLine(ctx, muxName, id, launch, options = {}) {
7100
+ const shim = ctx.self ? writeSelfShim(paths.dataDir(ctx.store.root, id), ctx.self) : void 0;
7101
+ const bind = shim && options.bindSelf ? `${shellQuote$1(shim)} unit rebind ${shellQuote$1(id)} --space ${shellQuote$1(ctx.store.root)} >/dev/null 2>&1; ` : "";
7102
+ const prefix = `${shim ? shimEnvPrefix(shim) : ""}${muxEnvPrefix(muxName)}`;
7103
+ return `${bind}${prefix}${launch}${options.fallback ? ` || ${prefix}${options.fallback}` : ""}`;
7104
+ }
7105
+ /**
7106
+ * How each harness resumes a conversation by id, appended to the unit's own launch command. Cursor is
7107
+ * absent: its hook's id is not confirmed to be the chat id `cursor-agent --resume` takes, so a cursor
7108
+ * unit restarts fresh and is rebriefed.
7109
+ */
7110
+ const RESUME_MAP = {
7111
+ claude: (id) => `--resume ${shellQuote$1(id)}`,
7112
+ codex: (id) => `resume ${shellQuote$1(id)}`
7113
+ };
7114
+ /**
7115
+ * The launch that resumes `conversation`, or undefined when this harness has no resume here or the
7116
+ * launch is not the harness's own command — a wrapper script may not take the harness's flags.
7117
+ */
7118
+ function resumeLaunch(harness, launch, conversation) {
7119
+ const resume = RESUME_MAP[harness];
7120
+ if (!resume || launch.split(/\s+/)[0] !== LAUNCH_MAP[harness]) return void 0;
7121
+ return `${launch} ${resume(conversation)}`;
7122
+ }
7123
+ /**
7124
+ * Put the shim's directory first on PATH, so a brief's bare `cyberlegion` runs it, and name the shim
7125
+ * itself in `$CYBERLEGION_CLI`. The plugin's hook runs outside that PATH lookup (and a PATH lookup
7126
+ * cannot tell this shim from a stale global install), so it reads the variable instead: set only
7127
+ * here, it marks a spawned session and names the CLI that spawned it.
7128
+ */
7129
+ function shimEnvPrefix(shim) {
7130
+ return `PATH=${shellQuote$1(dirname(shim))}:"$PATH" CYBERLEGION_CLI=${shellQuote$1(shim)} `;
7025
7131
  }
7026
7132
  /**
7027
7133
  * The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
@@ -7050,7 +7156,7 @@ function selfInvocation() {
7050
7156
  }
7051
7157
  /**
7052
7158
  * Write `<dataDir>/bin/cyberlegion`, a POSIX shim that execs `self` with the caller's arguments,
7053
- * and return its directory. A spawned session gets that directory first on its PATH, so the
7159
+ * and return its path. A spawned session gets that directory first on its PATH, so the
7054
7160
  * `cyberlegion` a brief tells it to run is the install that spawned it: nothing is resolved from
7055
7161
  * the registry or the session's own PATH at report time.
7056
7162
  */
@@ -7060,7 +7166,7 @@ function writeSelfShim(dataDir, self) {
7060
7166
  const shim = join(dir, "cyberlegion");
7061
7167
  writeFileSync(shim, `#!/bin/sh\nexec ${self.map(shellQuote$1).join(" ")} "$@"\n`);
7062
7168
  chmodSync(shim, 493);
7063
- return dir;
7169
+ return shim;
7064
7170
  }
7065
7171
  /** Single-quote `s` for a POSIX shell, so any path survives word splitting and expansion. */
7066
7172
  function shellQuote$1(s) {
@@ -7112,7 +7218,8 @@ async function nudgeUnit(ctx, ref, options = {}) {
7112
7218
  const adapter = selectSessionAdapter(ctx.env ?? process.env, exec);
7113
7219
  const result = await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, message, options.nudgeOpts), {
7114
7220
  ...options.guardOpts,
7115
- harness: agent.harness
7221
+ harness: agent.harness,
7222
+ ownText: (text) => isRingText(text, message)
7116
7223
  });
7117
7224
  return {
7118
7225
  agent,
@@ -7151,7 +7258,8 @@ async function clearUnit(ctx, ref, options = {}) {
7151
7258
  const adapter = selectSessionAdapter(env, exec);
7152
7259
  await withDraftGuard(adapter, exec, target, async () => adapter.submit(exec, target, command), {
7153
7260
  ...options.guardOpts,
7154
- harness: agent.harness
7261
+ harness: agent.harness,
7262
+ ownText: (text) => isRingText(text)
7155
7263
  });
7156
7264
  return {
7157
7265
  agent,
@@ -7446,6 +7554,7 @@ function spawnCommandInput(opts, listCursorModels) {
7446
7554
  branch: opts.branch,
7447
7555
  worktreePath: opts.worktreePath,
7448
7556
  cwd: opts.cwd,
7557
+ repo: opts.repo,
7449
7558
  at: opts.at
7450
7559
  },
7451
7560
  noWake: opts.wake === false,
@@ -7584,12 +7693,18 @@ function decommission(ctx, input) {
7584
7693
  const exec = ctx.exec ?? realExec;
7585
7694
  const env = ctx.env ?? process.env;
7586
7695
  const worktreeRoot = rec.worktree?.root;
7587
- const primaryRoot = worktreeRoot ? resolvePrimaryRoot(exec) : void 0;
7588
- if (worktreeRoot && resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error(`refusing to decommission "${input.id}" — its worktree is the primary checkout; neither --force nor --keep-worktree overrides this`);
7589
7696
  const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
7697
+ const primaryRoot = worktreeExists ? primaryRootOf(exec, worktreeRoot) : rec.worktree?.primaryRoot;
7698
+ if (worktreeRoot && primaryRoot && resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error(`refusing to decommission "${input.id}" — its worktree is the primary checkout; neither --force nor --keep-worktree overrides this`);
7590
7699
  const removesWorktree = worktreeExists && !input.keepWorktree;
7591
7700
  if (removesWorktree && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
7592
7701
  const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
7702
+ if (worktreeRoot && !worktreeExists && primaryRoot && existsSync(primaryRoot)) exec("git", [
7703
+ "-C",
7704
+ primaryRoot,
7705
+ "worktree",
7706
+ "prune"
7707
+ ]);
7593
7708
  if (removesWorktree) try {
7594
7709
  gitWorktreeAdapter.remove(exec, worktreeRoot, { primaryRoot });
7595
7710
  } catch (err) {
@@ -7610,6 +7725,22 @@ function decommission(ctx, input) {
7610
7725
  };
7611
7726
  }
7612
7727
  /**
7728
+ * The primary checkout of the repository `worktreeRoot` belongs to — `--git-common-dir` asked of the
7729
+ * worktree itself, so the answer does not depend on where the caller runs. Throws for a directory git
7730
+ * does not know: there is no repository to remove it from.
7731
+ */
7732
+ function primaryRootOf(exec, worktreeRoot) {
7733
+ const commonDir = exec("git", [
7734
+ "-C",
7735
+ worktreeRoot,
7736
+ "rev-parse",
7737
+ "--path-format=absolute",
7738
+ "--git-common-dir"
7739
+ ]);
7740
+ if (!commonDir) throw new Error(`cannot resolve the repository of worktree ${worktreeRoot} — git does not know it`);
7741
+ return dirname(commonDir);
7742
+ }
7743
+ /**
7613
7744
  * Whether the worktree holds work a removal would discard. The marker `spawn` stamps into every
7614
7745
  * worktree it creates is cyberlegion's own file, not the unit's work: in a project that does not
7615
7746
  * track it, it shows as untracked in every spawned worktree, and counting it would make a plain close
@@ -7644,10 +7775,7 @@ const VENDORS = {
7644
7775
  claude: {
7645
7776
  file: ".claude/settings.json",
7646
7777
  shape: "claude",
7647
- events: {
7648
- SessionStart: "SessionStart",
7649
- PostToolUse: "PostToolUse"
7650
- },
7778
+ events: { SessionStart: "SessionStart" },
7651
7779
  pluginHook: true
7652
7780
  },
7653
7781
  cursor: {
@@ -7659,13 +7787,11 @@ const VENDORS = {
7659
7787
  codex: {
7660
7788
  file: ".codex/hooks.json",
7661
7789
  shape: "cursor",
7662
- events: {
7663
- SessionStart: "SessionStart",
7664
- PostToolUse: "PostToolUse"
7665
- },
7790
+ events: { SessionStart: "SessionStart" },
7666
7791
  pluginHook: true
7667
7792
  }
7668
7793
  };
7794
+ const RETIRED_EVENTS = ["PostToolUse"];
7669
7795
  function readJson(file) {
7670
7796
  if (!existsSync(file)) return {};
7671
7797
  try {
@@ -7709,6 +7835,17 @@ function install(harness, projectDir = process.cwd(), pin) {
7709
7835
  status
7710
7836
  });
7711
7837
  }
7838
+ if (spec.pluginHook) for (const event of RETIRED_EVENTS) {
7839
+ if (!(spec.shape === "claude" ? removeClaude(settings, event, event) : removeCursor(settings, event, event))) continue;
7840
+ changed = true;
7841
+ results.push({
7842
+ harness,
7843
+ event,
7844
+ vendorEvent: event,
7845
+ file,
7846
+ status: "removed project hook"
7847
+ });
7848
+ }
7712
7849
  if (changed) writeJson$1(file, settings);
7713
7850
  return results;
7714
7851
  }
@@ -7723,12 +7860,10 @@ function upsertClaude(settings, event, command) {
7723
7860
  return "already present";
7724
7861
  }
7725
7862
  }
7726
- const group = { hooks: [{
7863
+ groups.push({ hooks: [{
7727
7864
  type: "command",
7728
7865
  command
7729
- }] };
7730
- if (event === "PostToolUse") group.matcher = "Write|Edit";
7731
- groups.push(group);
7866
+ }] });
7732
7867
  return "registered";
7733
7868
  }
7734
7869
  function upsertCursor(settings, event, command) {
@@ -7811,6 +7946,58 @@ function fail(msg) {
7811
7946
  process.exit(1);
7812
7947
  }
7813
7948
  //#endregion
7949
+ //#region src/permission.ts
7950
+ const CLI_RULE = "Bash(cyberlegion *)";
7951
+ const COVERING = /^Bash(?:\((?:\*|cyberlegion(?: \*|:\*|\*))\))?$/;
7952
+ /** Claude Code's user settings file: `$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`. */
7953
+ function claudeUserSettingsFile(env = process.env) {
7954
+ return join(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json");
7955
+ }
7956
+ function readSettings(file) {
7957
+ if (!existsSync(file)) return {
7958
+ settings: {},
7959
+ allow: []
7960
+ };
7961
+ let settings;
7962
+ try {
7963
+ settings = JSON.parse(readFileSync(file, "utf8"));
7964
+ } catch {
7965
+ throw new Error(`cannot parse Claude Code settings ${file} — fix it by hand, then re-run`);
7966
+ }
7967
+ 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`);
7968
+ const { permissions } = settings;
7969
+ const allow = permissions?.allow ?? [];
7970
+ 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`);
7971
+ return {
7972
+ settings,
7973
+ allow
7974
+ };
7975
+ }
7976
+ const covers = (allow) => allow.some((rule) => typeof rule === "string" && COVERING.test(rule));
7977
+ /** Whether the settings file's allow list already lets `cyberlegion` subcommands run. */
7978
+ function permissionRuleState(file) {
7979
+ try {
7980
+ return covers(readSettings(file).allow) ? "present" : "missing";
7981
+ } catch {
7982
+ return "unreadable";
7983
+ }
7984
+ }
7985
+ /**
7986
+ * Add the CLI rule to the settings file's allow list, merged in after every existing entry. A
7987
+ * covering rule already there is a no-op; a file that cannot be read as settings is never rewritten.
7988
+ */
7989
+ function addPermissionRule(file) {
7990
+ const { settings, allow } = readSettings(file);
7991
+ if (covers(allow)) return "present";
7992
+ settings.permissions = {
7993
+ ...settings.permissions ?? {},
7994
+ allow: [...allow, CLI_RULE]
7995
+ };
7996
+ mkdirSync(dirname(file), { recursive: true });
7997
+ writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
7998
+ return "added";
7999
+ }
8000
+ //#endregion
7814
8001
  //#region src/project.ts
7815
8002
  /**
7816
8003
  * The canonical git common dir for `dir`, or undefined outside a repository. The common dir is the
@@ -7932,7 +8119,7 @@ function byPath(ctx, dir) {
7932
8119
  }
7933
8120
  //#endregion
7934
8121
  //#region src/runtime/inject-inbox.ts
7935
- const EVENTS = ["SessionStart", "PostToolUse"];
8122
+ const EVENTS = ["SessionStart"];
7936
8123
  /**
7937
8124
  * Resolve the calling agent, gather its unread mail (and a standing owner's, when this session is
7938
8125
  * the hub's main pane), and return the SessionStart-style injection payload — or null when there is
@@ -7980,9 +8167,6 @@ function injectInbox(ctx, event) {
7980
8167
  }
7981
8168
  }
7982
8169
  } catch {}
7983
- if (rec && !rec.spawnedBy) try {
7984
- if (cur ? !ctx.store.getMainPane() : !listAgents(ctx.store).some((a) => a.kind === "standing")) parts.push("## Legion setup\n\nThis pane has no owner inbox bound yet — run `cyberlegion init` to register the surfacing hook and bind this pane as the owner live presence.");
7985
- } catch {}
7986
8170
  if (parts.length === 0) return null;
7987
8171
  return { hookSpecificOutput: {
7988
8172
  hookEventName: event,
@@ -8748,8 +8932,11 @@ function stopUnit(ctx, ref) {
8748
8932
  /**
8749
8933
  * Give a unit a fresh runtime and keep the unit: stop a still-running session (the same verified stop
8750
8934
  * as `stopUnit`), open a new session at the unit's cwd with the launch it was spawned with, bind the
8751
- * record to the new pane, and ring it to read its brief — a rebrief, since a new session starts with
8752
- * an empty context. Every refusal runs before anything is torn down.
8935
+ * record to the new pane, and ring it. When the record carries the harness's conversation id and the
8936
+ * harness can resume by id, the session resumes that conversation and is rung to continue, with the
8937
+ * plain launch as a shell fallback should the harness reject the id; otherwise (or with `fresh`) it
8938
+ * starts empty and is rung to read its brief — a rebrief. Every refusal runs before anything is torn
8939
+ * down.
8753
8940
  *
8754
8941
  * The unit is recorded `stopped` before the open, so a restart interrupted between the two leaves an
8755
8942
  * ordinary stopped unit and a rerun recovers it; nothing needs a separate repair path.
@@ -8771,6 +8958,7 @@ async function restartUnit(ctx, ref, options = {}) {
8771
8958
  const env = ctx.env ?? process.env;
8772
8959
  const exec = ctx.exec ?? realExec;
8773
8960
  const launch = stopped.launch ?? LAUNCH_MAP[harness];
8961
+ const resume = stopped.conversation && !options.fresh ? resumeLaunch(harness, launch, stopped.conversation) : void 0;
8774
8962
  const at = stopped.worktree ? "workspace" : "tab";
8775
8963
  let target;
8776
8964
  let adapter;
@@ -8783,7 +8971,10 @@ async function restartUnit(ctx, ref, options = {}) {
8783
8971
  }) } : {};
8784
8972
  target = adapter.open(exec, {
8785
8973
  cwd: stopped.cwd,
8786
- launch: composeLaunchLine(ctx, adapter.name, rec.id, launch),
8974
+ launch: resume ? composeLaunchLine(ctx, adapter.name, rec.id, resume, {
8975
+ bindSelf: true,
8976
+ fallback: launch
8977
+ }) : composeLaunchLine(ctx, adapter.name, rec.id, launch, { bindSelf: true }),
8787
8978
  at,
8788
8979
  from: callerPane(adapter, normalizeMuxEnv(env)),
8789
8980
  ...label
@@ -8807,13 +8998,15 @@ async function restartUnit(ctx, ref, options = {}) {
8807
8998
  const wake = await wakeSpawn(() => adapter, exec, {
8808
8999
  target,
8809
9000
  briefPath: briefPath ?? "",
8810
- noWake: options.noWake || !briefPath
9001
+ noWake: options.noWake || !briefPath,
9002
+ resumed: !!resume
8811
9003
  }, options.nudgeOpts);
8812
9004
  return {
8813
9005
  agent: bound,
8814
9006
  ...previousPane ? { previousPane } : {},
8815
9007
  pane: target.id,
8816
9008
  launch,
9009
+ resumed: !!resume,
8817
9010
  rung: wake.rung,
8818
9011
  ...wake.warning ? { warning: wake.warning } : {}
8819
9012
  };
@@ -8911,6 +9104,29 @@ function showUnit(ctx, ref) {
8911
9104
  controls: controlsFor(ctx, rec, liveness)
8912
9105
  };
8913
9106
  }
9107
+ /**
9108
+ * Record the harness's conversation id on the calling unit, from the JSON its SessionStart hook hands
9109
+ * `mail hook` on stdin (`session_id`, which Claude Code and Codex both send). The hook fires on every
9110
+ * session start in the pane — a fresh start, a resume, a `/clear` — so the record follows the
9111
+ * conversation the pane is in now. Best-effort and silent: an unparseable input, no id, or a caller
9112
+ * that is not a unit records nothing and never fails the harness hook.
9113
+ */
9114
+ function recordConversation(ctx, hookInput) {
9115
+ let id;
9116
+ try {
9117
+ id = JSON.parse(hookInput)?.session_id;
9118
+ } catch {
9119
+ return;
9120
+ }
9121
+ if (typeof id !== "string" || id === "") return;
9122
+ const meId = resolveSelfId(ctx);
9123
+ const rec = meId ? loadAgent(ctx.store, meId) : void 0;
9124
+ if (!rec || rec.kind === "standing" || rec.kind === "service" || rec.conversation === id) return;
9125
+ saveAgent(ctx.store, {
9126
+ ...rec,
9127
+ conversation: id
9128
+ });
9129
+ }
8914
9130
  //#endregion
8915
9131
  //#region src/wake/await.ts
8916
9132
  /** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
@@ -9203,7 +9419,7 @@ function warnEffortNotApplied(spawnInput) {
9203
9419
  }
9204
9420
  /** The launch options `unit spawn` and `service start` share. */
9205
9421
  function withSpawnOptions(cmd) {
9206
- 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([
9422
+ 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([
9207
9423
  "pane:right",
9208
9424
  "pane:down",
9209
9425
  "tab",
@@ -9303,15 +9519,19 @@ withGlobals(unit.command("stop")).description("end a unit's session and keep the
9303
9519
  });
9304
9520
  if (!res.alreadyStopped) nextStep(`cyberlegion unit restart ${res.agent.handle}`);
9305
9521
  });
9306
- 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) => {
9522
+ 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) => {
9307
9523
  const ctx = ctxOf(opts);
9308
9524
  touch(ctx);
9309
- const res = await restartUnit(ctx, ref, { noWake: opts.wake === false });
9525
+ const res = await restartUnit(ctx, ref, {
9526
+ noWake: opts.wake === false,
9527
+ fresh: opts.fresh === true
9528
+ });
9310
9529
  emit(formatOf(opts), {
9311
9530
  toon: toonObject({
9312
9531
  restarted: res.agent.id,
9313
9532
  previous: res.previousPane ?? "-",
9314
9533
  pane: res.pane,
9534
+ resumed: res.resumed,
9315
9535
  rung: res.rung
9316
9536
  }),
9317
9537
  json: res
@@ -9792,9 +10012,12 @@ withGlobals(mail.command("watch")).description("stream new matching mail as it a
9792
10012
  });
9793
10013
  });
9794
10014
  });
9795
- withGlobals(mail.command("hook")).description("emit the harness hook injection payload (raw JSON on stdout, not TOON)").option("--event <event>", "SessionStart | PostToolUse", "SessionStart").action((opts) => {
10015
+ withGlobals(mail.command("hook")).description("emit the harness hook injection payload (raw JSON on stdout, not TOON)").option("--event <event>", "SessionStart", "SessionStart").action((opts) => {
9796
10016
  const ctx = ctxOf(opts);
9797
10017
  touch(ctx);
10018
+ if (!process.stdin.isTTY) try {
10019
+ recordConversation(ctx, readFileSync(0, "utf8"));
10020
+ } catch {}
9798
10021
  const payload = injectInbox(ctx, opts.event);
9799
10022
  if (payload) console.log(JSON.stringify(payload));
9800
10023
  });
@@ -9889,30 +10112,33 @@ withGlobals(agent.command("path")).description("print the resolved def file path
9889
10112
  json: { path: def.path }
9890
10113
  });
9891
10114
  });
10115
+ 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`;
9892
10116
  const mux = program.command("mux").description("the unit-agnostic pane layer — multiplexer detection and diagnostics");
9893
10117
  withGlobals(mux.command("doctor")).description("probe harness, multiplexer (ancestry-discovered), hub root, and self-id").action((opts) => {
9894
10118
  const ctx = ctxOf(opts);
9895
10119
  const harness = detectHarness(void 0, ctx) ?? "unknown";
9896
10120
  const probe = probeMultiplexer(ctx.exec ?? realExec, normalizeMuxEnv(ctx.env ?? process.env));
9897
10121
  const selfId = resolveSelfId(ctx) ?? "-";
10122
+ const permissionRule = harness === "claude" ? permissionRuleState(claudeUserSettingsFile(ctx.env ?? process.env)) : "n/a";
10123
+ const report = {
10124
+ harness,
10125
+ mux: probe.mux,
10126
+ pane: probe.pane,
10127
+ via: probe.via,
10128
+ hubRoot: ctx.store.root,
10129
+ selfId
10130
+ };
9898
10131
  emit(formatOf(opts), {
9899
10132
  toon: toonObject({
9900
- harness,
9901
- mux: probe.mux,
9902
- pane: probe.pane,
9903
- via: probe.via,
9904
- hubRoot: ctx.store.root,
9905
- selfId
10133
+ ...report,
10134
+ permissionRule
9906
10135
  }),
9907
10136
  json: {
9908
- harness,
9909
- mux: probe.mux,
9910
- pane: probe.pane,
9911
- via: probe.via,
9912
- hubRoot: ctx.store.root,
9913
- selfId
10137
+ ...report,
10138
+ permissionRule
9914
10139
  }
9915
10140
  });
10141
+ if (permissionRule === "missing") nextStep(ALLOW_CLI_HINT);
9916
10142
  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`);
9917
10143
  });
9918
10144
  withGlobals(mux.command("mode")).description("report the detected session-backend mode").action((opts) => {
@@ -9963,7 +10189,7 @@ withGlobals(program.command("admin").description("hub-state maintenance").comman
9963
10189
  json: res
9964
10190
  });
9965
10191
  });
9966
- 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) => {
10192
+ 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) => {
9967
10193
  const ctx = ctxOf(opts);
9968
10194
  let harness;
9969
10195
  try {
@@ -9973,6 +10199,16 @@ withGlobals(program.command("init")).description("resolve this session harness,
9973
10199
  } catch (err) {
9974
10200
  fail(err instanceof Error ? err.message : String(err));
9975
10201
  }
10202
+ if (opts.allowCli && harness !== "claude") fail(`--allow-cli applies to claude only, not ${harness}`);
10203
+ let permissionRule;
10204
+ if (harness === "claude") {
10205
+ const settingsFile = claudeUserSettingsFile(ctx.env ?? process.env);
10206
+ try {
10207
+ permissionRule = opts.allowCli ? addPermissionRule(settingsFile) : permissionRuleState(settingsFile);
10208
+ } catch (err) {
10209
+ fail(err instanceof Error ? err.message : String(err));
10210
+ }
10211
+ }
9976
10212
  let results;
9977
10213
  try {
9978
10214
  results = install(harness, opts.dir, opts.pin);
@@ -9993,12 +10229,14 @@ withGlobals(program.command("init")).description("resolve this session harness,
9993
10229
  key: "file",
9994
10230
  get: (r) => r.file
9995
10231
  }
9996
- ], `harness ${harness}, ${results.length} hooks`),
10232
+ ], `harness ${harness}, ${results.length} hooks${permissionRule ? `, permission rule ${permissionRule}` : ""}`),
9997
10233
  json: {
9998
10234
  harness,
9999
- hooks: results
10235
+ hooks: results,
10236
+ ...permissionRule && { permissionRule }
10000
10237
  }
10001
10238
  });
10239
+ if (permissionRule === "missing") nextStep(ALLOW_CLI_HINT);
10002
10240
  if (!listAgents(ctx.store).some((a) => a.kind === "standing")) {
10003
10241
  nextStep("cyberlegion unit register --standing --handle <name> to mint the durable owner inbox");
10004
10242
  nextStep("cyberlegion attach to bind this pane as the owner live presence");