cyberlegion 1.0.0 → 1.2.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
@@ -5527,12 +5527,8 @@ function discoverByAncestry(exec, env) {
5527
5527
  via: "ancestry"
5528
5528
  };
5529
5529
  }
5530
- /** Max flush re-submits after the initial submit, before nudge fails loud. */
5531
- const DEFAULT_ATTEMPTS = 10;
5532
- /** Wait after a submit before reading the pane back, in ms. */
5533
- const DEFAULT_SETTLE_MS = 400;
5534
5530
  /** Length of the message prefix used as the staged-text needle. */
5535
- const NEEDLE_LEN = 40;
5531
+ const NEEDLE_LEN$1 = 40;
5536
5532
  /**
5537
5533
  * Whether `message` is still sitting staged in `visible`'s input box (not yet submitted). A real
5538
5534
  * submit scrolls the message into the transcript, leaving the bottom input box empty, so the
@@ -5542,52 +5538,11 @@ const NEEDLE_LEN = 40;
5542
5538
  */
5543
5539
  function isStaged(visible, message) {
5544
5540
  if (!visible) return true;
5545
- const needle = message.replace(/\s+/g, " ").trim().slice(0, NEEDLE_LEN);
5541
+ const needle = message.replace(/\s+/g, " ").trim().slice(0, NEEDLE_LEN$1);
5546
5542
  if (needle === "") return true;
5547
5543
  return visible.split("\n").filter((l) => l.trim() !== "").slice(-5).join(" ").replace(/\s+/g, " ").trim().includes(needle);
5548
5544
  }
5549
5545
  /**
5550
- * Submit `message` to `target` and verify the peer actually took the turn — a booting harness can
5551
- * swallow the Enter of the initial `submit`, leaving the text staged unsent while nudge would
5552
- * otherwise report false success. Types the message exactly once; a swallowed Enter is recovered by
5553
- * flushing the staged buffer (`adapter.submit` with no text, a bare Enter) — never re-typing the
5554
- * message — up to a bounded number of attempts. Throws if the turn is never taken within the cap.
5555
- *
5556
- * A pane that no longer exists is rejected up front rather than retried: a gone pane and a booting
5557
- * one both read back empty, so without the liveness probe the retry loop reports a dead peer as
5558
- * "never took the turn" — a boot-race shape — and buries the real cause.
5559
- *
5560
- * **Not built on `waitForOutput`, deliberately.** The two look alike and wait on opposite conditions:
5561
- * `waitForOutput` returns when a pattern APPEARS anywhere in the snapshot, while nudge returns when the
5562
- * message DISAPPEARS from the input box at the bottom (`isStaged`) — a negative, position-sensitive
5563
- * condition the wait primitive cannot express, and one that must not be satisfied by the same text
5564
- * sitting up in the transcript, which is exactly where a submitted message ends up. Nor is the loop body
5565
- * the same: nudge does not merely observe between polls, it re-submits, so its "poll" is a corrective
5566
- * action with its own attempt budget rather than a read. What the two DO share is the liveness rule —
5567
- * a gone pane throws instead of being reported as a quiet one — and `pollForOutput` adopts it from here.
5568
- */
5569
- async function nudge(adapter, exec, target, message, opts = {}) {
5570
- const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
5571
- const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
5572
- const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
5573
- if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
5574
- adapter.submit(exec, target, message);
5575
- await sleep(settleMs);
5576
- if (!isStaged(adapter.read(exec, target).text, message)) return {
5577
- taken: true,
5578
- resubmits: 0
5579
- };
5580
- for (let attempt = 1; attempt <= attempts; attempt++) {
5581
- adapter.submit(exec, target);
5582
- await sleep(settleMs);
5583
- if (!isStaged(adapter.read(exec, target).text, message)) return {
5584
- taken: true,
5585
- resubmits: attempt
5586
- };
5587
- }
5588
- throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — input still staged after ${attempts} re-submit attempts`);
5589
- }
5590
- /**
5591
5546
  * This process's own pane, as something `adapter` can address — `MuxOpenOptions.from`'s intended
5592
5547
  * argument for a `pane:*` open, so a split lands on the caller rather than on whichever pane the
5593
5548
  * user is looking at (see `from`'s note for why each backend's default gets that wrong).
@@ -5643,6 +5598,8 @@ function migrateStore(from, to) {
5643
5598
  //#region src/paths.ts
5644
5599
  /** The tracked marker file that makes a hub root initialized (see ensureMarker). */
5645
5600
  const MARKER_FILE = "config.json";
5601
+ /** Where `spawn` stamps that marker inside a worktree it creates, relative to the worktree root. */
5602
+ const WORKTREE_MARKER = `.agents/cyberlegion/${MARKER_FILE}`;
5646
5603
  /** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
5647
5604
  function projectRoot(cwd = process.cwd()) {
5648
5605
  let dir = resolve(cwd);
@@ -6182,6 +6139,7 @@ function reconcile(ctx, opts) {
6182
6139
  const cur = storablePane(ctx.env ?? process.env);
6183
6140
  if (!cur) return [];
6184
6141
  const panes = PANE_ADAPTERS[cur.mux].listPanes(exec);
6142
+ if (panes.length === 0) return [];
6185
6143
  const live = new Set(panes.map((p) => p.id));
6186
6144
  const changed = [];
6187
6145
  for (const rec of listAgents(ctx.store)) {
@@ -6197,18 +6155,34 @@ function reconcile(ctx, opts) {
6197
6155
  if (opts?.adopt) changed.push(...adopt(ctx, panes));
6198
6156
  return changed;
6199
6157
  }
6200
- /** Mark agents whose pane is gone or whose last-seen is stale as exited. Reconcile-culls against the
6201
- * current mux's live set first, then falls through to the per-record paneExists + staleness check
6202
- * (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. */
6203
6178
  function prune(ctx) {
6204
6179
  const exec = ctx.exec ?? realExec;
6205
6180
  const now = ctx.now?.() ?? Date.now();
6206
6181
  const changed = reconcile(ctx);
6182
+ const lists = /* @__PURE__ */ new Map();
6207
6183
  for (const rec of listAgents(ctx.store)) {
6208
6184
  if (!isPrunable(rec)) continue;
6209
- const paneGone = rec.pane ? !PANE_ADAPTERS[rec.pane.mux].paneExists(exec, { id: rec.pane.id }) : false;
6210
- const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
6211
- if (paneGone || stale) {
6185
+ if (rec.pane ? paneGone(exec, rec.pane, lists) : now - new Date(rec.lastSeen).getTime() > STALE_MS) {
6212
6186
  rec.status = "exited";
6213
6187
  saveAgent(ctx.store, rec);
6214
6188
  changed.push(rec);
@@ -6361,6 +6335,59 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
6361
6335
  }
6362
6336
  }
6363
6337
  //#endregion
6338
+ //#region src/console/ring.ts
6339
+ const DEFAULT_ATTEMPTS = 10;
6340
+ const DEFAULT_SETTLE_MS = 400;
6341
+ /** cyber-mux's `isStaged` matches on the same prefix length. */
6342
+ const NEEDLE_LEN = 40;
6343
+ /** How many screen rows carry `message` — the transcript, a queued follow-up, the input box. */
6344
+ function copiesOn(screen, message) {
6345
+ if (!screen) return 0;
6346
+ const needle = message.replace(/\s+/g, " ").trim().slice(0, NEEDLE_LEN);
6347
+ if (needle === "") return 0;
6348
+ return screen.split("\n").filter((row) => row.replace(/\s+/g, " ").includes(needle)).length;
6349
+ }
6350
+ /**
6351
+ * Submit `message` to `target` as a taken turn: cyber-mux's `nudge`, with one more way to see that the
6352
+ * turn was taken.
6353
+ *
6354
+ * `nudge` judges the turn by whether the text still sits at the bottom of the screen, and flushes
6355
+ * with a bare Enter while it does. Some harness screens put a taken turn back in the input box —
6356
+ * cursor-agent with a rejected login posts the ring to its transcript (or queues it as a follow-up)
6357
+ * and restores the same text into the box — so `nudge` flushes, and each flush rings again.
6358
+ *
6359
+ * So the screen is counted before the ring and after each submit. A swallowed Enter adds one copy of
6360
+ * the text, the one in the input box. Two or more new copies mean the harness also posted it above
6361
+ * the box, so the turn was taken and nothing is flushed. Text still at the bottom with only one new
6362
+ * copy is the swallowed Enter, and is flushed as before.
6363
+ */
6364
+ async function ringTurn(adapter, exec, target, message, opts = {}) {
6365
+ const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
6366
+ const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
6367
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
6368
+ 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
+ const before = copiesOn(adapter.read(exec, target).text, message);
6370
+ const taken = () => {
6371
+ const screen = adapter.read(exec, target).text;
6372
+ return !isStaged(screen, message) || copiesOn(screen, message) >= before + 2;
6373
+ };
6374
+ adapter.submit(exec, target, message);
6375
+ await sleep(settleMs);
6376
+ if (taken()) return {
6377
+ taken: true,
6378
+ resubmits: 0
6379
+ };
6380
+ for (let attempt = 1; attempt <= attempts; attempt++) {
6381
+ adapter.submit(exec, target);
6382
+ await sleep(settleMs);
6383
+ if (taken()) return {
6384
+ taken: true,
6385
+ resubmits: attempt
6386
+ };
6387
+ }
6388
+ throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — input still staged after ${attempts} re-submit attempts`);
6389
+ }
6390
+ //#endregion
6364
6391
  //#region src/console/doorbell.ts
6365
6392
  /** The doorbell text delivered to a woken recipient; also the standalone `unit nudge` default. */
6366
6393
  const DELIVERY_DOORBELL = "You have unread mail — check your inbox.";
@@ -6443,7 +6470,7 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
6443
6470
  try {
6444
6471
  const adapter = getAdapter();
6445
6472
  const target = { id: pane };
6446
- await withDraftGuard(adapter, exec, target, () => nudge(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
6473
+ await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
6447
6474
  ...guardOpts,
6448
6475
  harness
6449
6476
  });
@@ -6478,7 +6505,7 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
6478
6505
  if (input.noWake) return { rung: false };
6479
6506
  try {
6480
6507
  const adapter = getAdapter();
6481
- await withDraftGuard(adapter, exec, input.target, () => nudge(adapter, exec, input.target, spawnDoorbell(input.briefPath), nudgeOpts), {
6508
+ await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, spawnDoorbell(input.briefPath), nudgeOpts), {
6482
6509
  ...guardOpts,
6483
6510
  harness: input.harness
6484
6511
  });
@@ -6495,6 +6522,117 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
6495
6522
  }
6496
6523
  }
6497
6524
  //#endregion
6525
+ //#region src/console/trust.ts
6526
+ /**
6527
+ * Each harness's folder-trust prompt, as it draws it, and the keys that accept it. Read from Claude
6528
+ * Code 2.1.286, Codex 0.153.4 (and its newer "Trust this folder?" wording), and cursor-agent
6529
+ * 2026.09.26, each opened in a real tmux pane on a folder it did not trust.
6530
+ *
6531
+ * Claude Code lists "No, exit" first and focuses it, so a bare Enter quits the harness. Its accept is
6532
+ * therefore Down, a check that the screen shows "Yes, I trust this folder" selected, and only then
6533
+ * Enter. Codex highlights its first option on `1` and confirms on Enter. cursor-agent takes `a`
6534
+ * directly.
6535
+ */
6536
+ const TRUST_PROMPTS = {
6537
+ claude: {
6538
+ markers: [["Quick safety check", "Yes, I trust this folder"]],
6539
+ accept: [{
6540
+ keys: ["Down"],
6541
+ until: /❯\s*Yes, I trust this folder/
6542
+ }, { keys: ["Enter"] }]
6543
+ },
6544
+ codex: {
6545
+ markers: [["Do you trust the contents of this directory?", "Yes, continue"], ["Trust this folder?", "Trust and continue"]],
6546
+ accept: [{
6547
+ text: "1",
6548
+ until: /›\s*1\.\s*(?:Yes, continue|Trust and continue)/
6549
+ }, { keys: ["Enter"] }]
6550
+ },
6551
+ cursor: {
6552
+ markers: [["Workspace Trust Required", "Trust this workspace"]],
6553
+ accept: [{ text: "a" }]
6554
+ }
6555
+ };
6556
+ const collapse = (s) => s.replace(/\s+/g, " ");
6557
+ /** Whether `screen` shows `harness`'s folder-trust prompt. */
6558
+ function showsTrustPrompt(harness, screen) {
6559
+ const flat = collapse(screen);
6560
+ return TRUST_PROMPTS[harness].markers.some((set) => set.every((phrase) => flat.includes(collapse(phrase))));
6561
+ }
6562
+ /**
6563
+ * Watch a freshly-opened pane until its harness either shows its folder-trust prompt or settles on a
6564
+ * screen without one, and answer the prompt by `input.policy`.
6565
+ *
6566
+ * "Settled" means the screen has changed from its first read (the shell, still showing the launch
6567
+ * line while the harness loads) and then read the same `stableReads` times in a row. A harness that
6568
+ * never draws within the timeout, or a screen that stays blank for `blankReads` reads, is
6569
+ * `unsettled`: no evidence of a prompt, so the caller rings as it would have before. Only the screen is read; no harness config file is consulted, so what counts is
6570
+ * what the harness itself decided to show.
6571
+ */
6572
+ async function answerTrustPrompt(adapter, exec, target, input, opts = {}) {
6573
+ const pollMs = opts.pollMs ?? 500;
6574
+ const timeoutMs = opts.timeoutMs ?? 2e4;
6575
+ const stableReads = opts.stableReads ?? 3;
6576
+ const blankReads = opts.blankReads ?? 6;
6577
+ const keyDelayMs = opts.keyDelayMs ?? 1e3;
6578
+ const attempts = opts.attempts ?? 5;
6579
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
6580
+ const read = () => {
6581
+ try {
6582
+ return adapter.read(exec, target).text ?? "";
6583
+ } catch {
6584
+ return "";
6585
+ }
6586
+ };
6587
+ const shown = (screen) => showsTrustPrompt(input.harness, screen);
6588
+ const first = read();
6589
+ let seen = shown(first);
6590
+ let previous = first;
6591
+ let changed = false;
6592
+ let same = 1;
6593
+ for (let waited = 0; !seen && waited < timeoutMs; waited += pollMs) {
6594
+ await sleep(pollMs);
6595
+ const screen = read();
6596
+ if (shown(screen)) {
6597
+ seen = true;
6598
+ break;
6599
+ }
6600
+ if (screen !== first) changed = true;
6601
+ same = screen === previous ? same + 1 : 1;
6602
+ previous = screen;
6603
+ if (changed && same >= stableReads) return { state: "none" };
6604
+ if (!changed && first.trim() === "" && same >= blankReads) return { state: "unsettled" };
6605
+ }
6606
+ if (!seen) return { state: "unsettled" };
6607
+ const where = `${input.harness} in pane ${input.pane} is asking whether to trust ${input.folder}`;
6608
+ if (input.policy === "ask") return {
6609
+ state: "needs-human",
6610
+ message: `${where}. unit spawn does not trust a folder it did not create, so a person must answer that prompt. The unit is registered and its brief written; once the prompt is answered, nudge the unit to its brief.`
6611
+ };
6612
+ await sleep(keyDelayMs);
6613
+ for (const move of TRUST_PROMPTS[input.harness].accept) {
6614
+ let done = move.until ? move.until.test(read()) : false;
6615
+ for (let attempt = 0; !done && attempt < attempts; attempt++) {
6616
+ if (move.keys) adapter.sendKeys(exec, target, move.keys);
6617
+ if (move.text) adapter.sendText(exec, target, move.text);
6618
+ await sleep(pollMs);
6619
+ done = move.until ? move.until.test(read()) : true;
6620
+ }
6621
+ if (!done) return stuck(where);
6622
+ }
6623
+ for (let attempt = 0; attempt < attempts; attempt++) {
6624
+ if (!shown(read())) return { state: "accepted" };
6625
+ await sleep(pollMs);
6626
+ }
6627
+ return stuck(where);
6628
+ }
6629
+ function stuck(where) {
6630
+ return {
6631
+ state: "stuck",
6632
+ message: `${where}, and the prompt is still showing after spawn sent the keys that accept it. A person must answer it.`
6633
+ };
6634
+ }
6635
+ //#endregion
6498
6636
  //#region src/mux-select.ts
6499
6637
  /**
6500
6638
  * Backend selection via cyber-mux's two-mode mux probe, normalized through the transitional
@@ -6759,7 +6897,10 @@ function spawn(ctx, input) {
6759
6897
  assertDistinctFromPrimary(opened.worktree.root, primaryRoot);
6760
6898
  ensureMarker(join(opened.worktree.root, ".agents", "cyberlegion"));
6761
6899
  cwd = opened.worktree.root;
6762
- worktree = opened.worktree;
6900
+ worktree = {
6901
+ ...opened.worktree,
6902
+ primaryRoot
6903
+ };
6763
6904
  target = opened.target;
6764
6905
  } else {
6765
6906
  const added = gitWorktreeAdapter.add(exec, {
@@ -6770,7 +6911,10 @@ function spawn(ctx, input) {
6770
6911
  assertDistinctFromPrimary(added.root, primaryRoot);
6771
6912
  ensureMarker(join(added.root, ".agents", "cyberlegion"));
6772
6913
  cwd = added.root;
6773
- worktree = added;
6914
+ worktree = {
6915
+ ...added,
6916
+ primaryRoot
6917
+ };
6774
6918
  target = sessionAdapter.open(exec, {
6775
6919
  cwd,
6776
6920
  launch: launchLine(),
@@ -6820,11 +6964,25 @@ function spawn(ctx, input) {
6820
6964
  * The ring is best-effort on top of the guaranteed spawn effect (worktree + session + registry
6821
6965
  * record): `--no-wake` rings nothing, and a ring that never completes is reported as a warning on
6822
6966
  * the result rather than thrown, so it can never fail a spawn that already landed.
6967
+ *
6968
+ * Before the ring, the harness's folder-trust prompt is answered (`answerTrustPrompt`), with or
6969
+ * without `--no-wake`: a unit stuck at that prompt never reads its brief, and the doorbell typed into
6970
+ * it would answer it wrongly (Claude Code's focused option is "No, exit"). A worktree spawn made the
6971
+ * folder from the caller's own repository, so its prompt is accepted. A `--cwd` folder was not made
6972
+ * here, so its prompt is left for a person. A prompt left showing either way rings nothing and is
6973
+ * reported as `trustBlocked`, a failure the caller must surface, not a best-effort warning.
6823
6974
  */
6824
6975
  async function spawnAndWake(ctx, input, options = {}) {
6825
6976
  const res = spawn(ctx, input);
6826
6977
  const env = ctx.env ?? process.env;
6827
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 {
6981
+ ...res,
6982
+ rung: false,
6983
+ trust: trust.state,
6984
+ trustBlocked: trust.message
6985
+ };
6828
6986
  const briefPath = res.agent.brief;
6829
6987
  const wake = await wakeSpawn(() => selectSessionAdapter(env, exec), exec, {
6830
6988
  target: { id: res.pane },
@@ -6835,10 +6993,31 @@ async function spawnAndWake(ctx, input, options = {}) {
6835
6993
  return {
6836
6994
  ...res,
6837
6995
  rung: wake.rung,
6996
+ trust: trust.state,
6838
6997
  ...wake.warning ? { warning: wake.warning } : {}
6839
6998
  };
6840
6999
  }
6841
7000
  /**
7001
+ * Answer the new pane's folder-trust prompt, if its harness shows one. A backend that cannot be
7002
+ * resolved leaves nothing to read, which is no evidence of a prompt: the ring's own containment
7003
+ * reports that backend.
7004
+ */
7005
+ async function settleTrust(ctx, res, opts) {
7006
+ const exec = ctx.exec ?? realExec;
7007
+ let adapter;
7008
+ try {
7009
+ adapter = selectSessionAdapter(ctx.env ?? process.env, exec);
7010
+ } catch {
7011
+ return { state: "unsettled" };
7012
+ }
7013
+ return answerTrustPrompt(adapter, exec, { id: res.pane }, {
7014
+ harness: res.agent.harness,
7015
+ folder: res.agent.cwd,
7016
+ pane: res.pane,
7017
+ policy: res.agent.worktree ? "accept" : "ask"
7018
+ }, opts);
7019
+ }
7020
+ /**
6842
7021
  * The label a `workspace` placement opens under — the short human-scannable name that makes a unit's
6843
7022
  * own visible space findable by eye (`console/workspace-label.ts` owns the naming rule). Only the
6844
7023
  * `workspace` placement carries one: a pane or tab lives inside a space the caller is already
@@ -6954,7 +7133,7 @@ async function nudgeUnit(ctx, ref, options = {}) {
6954
7133
  const exec = ctx.exec ?? realExec;
6955
7134
  const message = options.message || "You have unread mail — check your inbox.";
6956
7135
  const adapter = selectSessionAdapter(ctx.env ?? process.env, exec);
6957
- const result = await withDraftGuard(adapter, exec, target, () => nudge(adapter, exec, target, message, options.nudgeOpts), {
7136
+ const result = await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, message, options.nudgeOpts), {
6958
7137
  ...options.guardOpts,
6959
7138
  harness: agent.harness
6960
7139
  });
@@ -7145,25 +7324,38 @@ const DEFAULT_HARNESS = "claude";
7145
7324
  function shellQuote(value) {
7146
7325
  return `'${value.replace(/'/g, `'\\''`)}'`;
7147
7326
  }
7148
- /** Cursor carries effort as a bracket parameter on the model (`<model>[effort=<level>]`): merge it into
7149
- * any bracket list the model already has, replacing an `effort=` already there. */
7150
- function withCursorEffort(model, effort) {
7151
- const open = model.lastIndexOf("[");
7152
- if (open === -1 || !model.endsWith("]")) return `${model}[effort=${effort}]`;
7153
- const params = model.slice(open + 1, -1).split(",").filter((p) => p !== "" && !p.startsWith("effort="));
7154
- return `${model.slice(0, open)}[${[...params, `effort=${effort}`].join(",")}]`;
7155
- }
7156
- /** The model + effort arguments for one harness. No two harnesses spell effort alike: claude has
7157
- * `--effort`, codex only a config override, cursor only a parameter on a named model. Cursor refuses
7158
- * an effort on its default (`auto[effort=high]` is not a model it accepts), so a cursor effort with
7159
- * no model is left off and `realizeLaunch` reports it as not applied. */
7327
+ /** Cursor's model + effort, as the model id to launch and whether the effort rode along. Cursor has
7328
+ * no effort flag, and refuses the `<model>[effort=<level>]` bracket form its `--help` documents
7329
+ * (#72); `cursor-agent models` lists effort baked into flat ids (`claude-opus-5-high`) instead. So
7330
+ * the effort travels as the listed `<model>-<level>` id, or the model itself when it already is
7331
+ * one; with no such id listed, the model launches without the effort. Cursor also refuses an
7332
+ * effort on its default (`auto[effort=high]`), so with no model there is nothing to launch it on. */
7333
+ function cursorModel(model, effort, listCursorModels = () => []) {
7334
+ if (!model || !effort) return {
7335
+ model,
7336
+ effortApplied: !effort
7337
+ };
7338
+ const listed = listCursorModels();
7339
+ const suffix = `-${effort}`;
7340
+ if (model.endsWith(suffix) && listed.includes(model)) return {
7341
+ model,
7342
+ effortApplied: true
7343
+ };
7344
+ const flat = `${model}${suffix}`;
7345
+ if (listed.includes(flat)) return {
7346
+ model: flat,
7347
+ effortApplied: true
7348
+ };
7349
+ return {
7350
+ model,
7351
+ effortApplied: false
7352
+ };
7353
+ }
7354
+ /** The model + effort arguments for claude or codex, which spell effort differently: claude has
7355
+ * `--effort`, codex only a config override. Cursor's effort rides on its model (`cursorModel`). */
7160
7356
  function modelAndEffortArgs(harness, model, effort) {
7161
- if (harness === "cursor") {
7162
- if (!model) return [];
7163
- return ["--model", shellQuote(effort ? withCursorEffort(model, effort) : model)];
7164
- }
7165
7357
  const args = model ? ["--model", shellQuote(model)] : [];
7166
- if (!effort) return args;
7358
+ if (!effort || harness === "cursor") return args;
7167
7359
  if (harness === "codex") return [
7168
7360
  ...args,
7169
7361
  "-c",
@@ -7189,18 +7381,20 @@ function instructionArgs(harness, instructions) {
7189
7381
  }
7190
7382
  /** Build the harness launch invocation for a def — explicit `model`/`effort`/`harness` win over the
7191
7383
  * def's own tags, which win over the harness default. The def's effort and instructions each go
7192
- * through the harness's own control (see `modelAndEffortArgs`, `instructionArgs`). */
7384
+ * through the harness's own control (see `cursorModel`, `modelAndEffortArgs`, `instructionArgs`). */
7193
7385
  function realizeLaunch(def, opts = {}) {
7194
7386
  const harness = opts.harness ?? def.harness ?? DEFAULT_HARNESS;
7195
- const model = opts.model ?? def.model;
7196
7387
  const effort = opts.effort ?? def.effort;
7388
+ const asked = opts.model ?? def.model;
7389
+ const cursor = harness === "cursor" ? cursorModel(asked, effort, opts.listCursorModels) : void 0;
7390
+ const model = cursor ? cursor.model : asked;
7197
7391
  const parts = [
7198
7392
  LAUNCH_MAP[harness],
7199
7393
  ...modelAndEffortArgs(harness, model, effort),
7200
7394
  ...instructionArgs(harness, def.instructions)
7201
7395
  ];
7202
7396
  const briefInstructions = harness === "cursor" && def.instructions ? def.instructions : void 0;
7203
- const notApplied = harness === "cursor" && effort && !model ? effort : void 0;
7397
+ const notApplied = cursor && !cursor.effortApplied ? effort : void 0;
7204
7398
  return {
7205
7399
  harness,
7206
7400
  command: parts.join(" "),
@@ -7224,7 +7418,8 @@ function resolveSpawnLaunch(input) {
7224
7418
  const overrides = {
7225
7419
  harness: input.harness,
7226
7420
  model: input.model,
7227
- effort: input.effort
7421
+ effort: input.effort,
7422
+ listCursorModels: input.listCursorModels
7228
7423
  };
7229
7424
  if (!input.agent && !input.agentFile) {
7230
7425
  if (!input.harness || !input.model && !input.effort) return { harness: input.harness };
@@ -7248,15 +7443,19 @@ function resolveSpawnLaunch(input) {
7248
7443
  * `--agent`/`--agent-file` def into the harness and composed launch command, with an explicit
7249
7444
  * `--harness`/`--model`/`--effort` overriding the def's own.
7250
7445
  *
7446
+ * `listCursorModels` is the cursor model listing a cursor effort is carried through (see `realizeLaunch`);
7447
+ * the CLI passes the live `cursor-agent models` probe.
7448
+ *
7251
7449
  * Throws when no harness can be resolved, so the CLI's own `fail()` still renders it.
7252
7450
  */
7253
- function spawnCommandInput(opts) {
7451
+ function spawnCommandInput(opts, listCursorModels) {
7254
7452
  const { harness, command, model, effort, effortNotApplied, briefInstructions } = resolveSpawnLaunch({
7255
7453
  agent: opts.agent,
7256
7454
  agentFile: opts.agentFile,
7257
7455
  harness: opts.harness,
7258
7456
  model: opts.model,
7259
- effort: opts.effort
7457
+ effort: opts.effort,
7458
+ listCursorModels
7260
7459
  });
7261
7460
  if (!harness) throw new Error("unit spawn needs --harness, or --agent/--agent-file resolving one");
7262
7461
  return {
@@ -7285,6 +7484,30 @@ function readCommandOutput(format, result) {
7285
7484
  return format === "json" ? JSON.stringify(result, null, 2) : result.output;
7286
7485
  }
7287
7486
  //#endregion
7487
+ //#region src/cursor-models.ts
7488
+ /** The model ids in `cursor-agent models` output: the first token of each line, ANSI colour and list
7489
+ * bullets stripped. Headings and tips come along too; callers only test a specific id for membership. */
7490
+ function parseCursorModels(output) {
7491
+ return stripVTControlCharacters(output).split("\n").map((line) => line.trim().replace(/^[-*•]\s+/, "").split(/\s+/)[0] ?? "").filter((id) => id !== "");
7492
+ }
7493
+ /** The model ids this account's `cursor-agent` lists, or none when the probe fails (not installed,
7494
+ * signed out, timed out) — the caller then launches without the effort and warns. */
7495
+ function listCursorModels() {
7496
+ try {
7497
+ return parseCursorModels(execFileSync("cursor-agent", ["models"], {
7498
+ encoding: "utf8",
7499
+ stdio: [
7500
+ "ignore",
7501
+ "pipe",
7502
+ "ignore"
7503
+ ],
7504
+ timeout: 15e3
7505
+ }));
7506
+ } catch {
7507
+ return [];
7508
+ }
7509
+ }
7510
+ //#endregion
7288
7511
  //#region src/message.ts
7289
7512
  /** Write one message into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
7290
7513
  function send(ctx, input) {
@@ -7384,12 +7607,18 @@ function decommission(ctx, input) {
7384
7607
  const exec = ctx.exec ?? realExec;
7385
7608
  const env = ctx.env ?? process.env;
7386
7609
  const worktreeRoot = rec.worktree?.root;
7387
- const primaryRoot = worktreeRoot ? resolvePrimaryRoot(exec) : void 0;
7388
- 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`);
7389
7610
  const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
7611
+ const primaryRoot = worktreeExists ? primaryRootOf(exec, worktreeRoot) : rec.worktree?.primaryRoot;
7612
+ 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`);
7390
7613
  const removesWorktree = worktreeExists && !input.keepWorktree;
7391
7614
  if (removesWorktree && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
7392
7615
  const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
7616
+ if (worktreeRoot && !worktreeExists && primaryRoot && existsSync(primaryRoot)) exec("git", [
7617
+ "-C",
7618
+ primaryRoot,
7619
+ "worktree",
7620
+ "prune"
7621
+ ]);
7393
7622
  if (removesWorktree) try {
7394
7623
  gitWorktreeAdapter.remove(exec, worktreeRoot, { primaryRoot });
7395
7624
  } catch (err) {
@@ -7409,13 +7638,38 @@ function decommission(ctx, input) {
7409
7638
  pane
7410
7639
  };
7411
7640
  }
7641
+ /**
7642
+ * The primary checkout of the repository `worktreeRoot` belongs to — `--git-common-dir` asked of the
7643
+ * worktree itself, so the answer does not depend on where the caller runs. Throws for a directory git
7644
+ * does not know: there is no repository to remove it from.
7645
+ */
7646
+ function primaryRootOf(exec, worktreeRoot) {
7647
+ const commonDir = exec("git", [
7648
+ "-C",
7649
+ worktreeRoot,
7650
+ "rev-parse",
7651
+ "--path-format=absolute",
7652
+ "--git-common-dir"
7653
+ ]);
7654
+ if (!commonDir) throw new Error(`cannot resolve the repository of worktree ${worktreeRoot} — git does not know it`);
7655
+ return dirname(commonDir);
7656
+ }
7657
+ /**
7658
+ * Whether the worktree holds work a removal would discard. The marker `spawn` stamps into every
7659
+ * worktree it creates is cyberlegion's own file, not the unit's work: in a project that does not
7660
+ * track it, it shows as untracked in every spawned worktree, and counting it would make a plain close
7661
+ * of a finished unit always demand `--force`. Only that exact untracked path is set aside — listed
7662
+ * per file, so anything else the unit left under `.agents/` still counts.
7663
+ */
7412
7664
  function isDirty(exec, worktreeRoot) {
7413
- return !!exec("git", [
7665
+ const status = exec("git", [
7414
7666
  "-C",
7415
7667
  worktreeRoot,
7416
7668
  "status",
7417
- "--porcelain"
7669
+ "--porcelain",
7670
+ "--untracked-files=all"
7418
7671
  ]);
7672
+ return !!status && status.split("\n").some((line) => line && line !== `?? ${WORKTREE_MARKER}`);
7419
7673
  }
7420
7674
  //#endregion
7421
7675
  //#region src/install.ts
@@ -7423,33 +7677,35 @@ const PIN_TOKEN = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
7423
7677
  function validatePin(pin) {
7424
7678
  if (!PIN_TOKEN.test(pin)) throw new Error(`invalid --pin "${pin}" — expected a version or dist-tag token like 0.2.0 or latest (no spaces, ranges, or shell metacharacters)`);
7425
7679
  }
7426
- const hookCommand = (event, pin) => pin ? `npx cyberlegion@${pin} mail hook --event ${event}` : `npx cyberlegion mail hook --event ${event}`;
7680
+ const hookCommand = (event, pin) => `if command -v cyberlegion >/dev/null 2>&1; then cyberlegion mail hook --event ${event}; else npx -y cyberlegion${pin ? `@${pin}` : ""} mail hook --event ${event}; fi`;
7681
+ const HOOK_GENERATIONS = [/^(?:npx (?:-y )?cyberlegion(?:@\S+)?|cyberlegion) mail hook --event (\w+)$/, /^if command -v cyberlegion >\/dev\/null 2>&1; then cyberlegion mail hook --event (\w+); else npx -y cyberlegion(?:@\S+)? mail hook --event \1; fi$/];
7427
7682
  function hookTarget(command) {
7428
- return command.match(/^(?:npx cyberlegion(?:@[^\s]+)?|cyberlegion) (mail hook --event \S+)$/)?.[1];
7683
+ for (const generation of HOOK_GENERATIONS) {
7684
+ const event = command.match(generation)?.[1];
7685
+ if (event) return event;
7686
+ }
7429
7687
  }
7430
7688
  const VENDORS = {
7431
7689
  claude: {
7432
7690
  file: ".claude/settings.json",
7433
7691
  shape: "claude",
7434
- events: {
7435
- SessionStart: "SessionStart",
7436
- PostToolUse: "PostToolUse"
7437
- }
7692
+ events: { SessionStart: "SessionStart" },
7693
+ pluginHook: true
7438
7694
  },
7439
7695
  cursor: {
7440
7696
  file: ".cursor/hooks.json",
7441
7697
  shape: "cursor",
7442
- events: { SessionStart: "sessionStart" }
7698
+ events: { SessionStart: "sessionStart" },
7699
+ pluginHook: false
7443
7700
  },
7444
7701
  codex: {
7445
7702
  file: ".codex/hooks.json",
7446
7703
  shape: "cursor",
7447
- events: {
7448
- SessionStart: "SessionStart",
7449
- PostToolUse: "PostToolUse"
7450
- }
7704
+ events: { SessionStart: "SessionStart" },
7705
+ pluginHook: true
7451
7706
  }
7452
7707
  };
7708
+ const RETIRED_EVENTS = ["PostToolUse"];
7453
7709
  function readJson(file) {
7454
7710
  if (!existsSync(file)) return {};
7455
7711
  try {
@@ -7462,7 +7718,10 @@ function writeJson$1(file, data) {
7462
7718
  mkdirSync(dirname(file), { recursive: true });
7463
7719
  writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
7464
7720
  }
7465
- /** Register the surfacing hook into one harness's config, idempotently. */
7721
+ /**
7722
+ * Make sure the surfacing hook fires for one harness, idempotently: leave it to the plugin where the
7723
+ * plugin ships it (removing an earlier project copy), otherwise register the PATH-first project hook.
7724
+ */
7466
7725
  function install(harness, projectDir = process.cwd(), pin) {
7467
7726
  const spec = VENDORS[harness];
7468
7727
  if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
@@ -7470,9 +7729,18 @@ function install(harness, projectDir = process.cwd(), pin) {
7470
7729
  const file = join(projectDir, spec.file);
7471
7730
  const settings = readJson(file);
7472
7731
  const results = [];
7732
+ let changed = false;
7473
7733
  for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
7474
- const command = hookCommand(canonical, pin);
7475
- const status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
7734
+ let status;
7735
+ if (spec.pluginHook) {
7736
+ const removed = spec.shape === "claude" ? removeClaude(settings, vendorEvent, canonical) : removeCursor(settings, vendorEvent, canonical);
7737
+ status = removed ? "removed project hook" : "provided by plugin";
7738
+ changed ||= removed;
7739
+ } else {
7740
+ const command = hookCommand(canonical, pin);
7741
+ status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
7742
+ changed = true;
7743
+ }
7476
7744
  results.push({
7477
7745
  harness,
7478
7746
  event: canonical,
@@ -7481,7 +7749,18 @@ function install(harness, projectDir = process.cwd(), pin) {
7481
7749
  status
7482
7750
  });
7483
7751
  }
7484
- writeJson$1(file, settings);
7752
+ if (spec.pluginHook) for (const event of RETIRED_EVENTS) {
7753
+ if (!(spec.shape === "claude" ? removeClaude(settings, event, event) : removeCursor(settings, event, event))) continue;
7754
+ changed = true;
7755
+ results.push({
7756
+ harness,
7757
+ event,
7758
+ vendorEvent: event,
7759
+ file,
7760
+ status: "removed project hook"
7761
+ });
7762
+ }
7763
+ if (changed) writeJson$1(file, settings);
7485
7764
  return results;
7486
7765
  }
7487
7766
  function upsertClaude(settings, event, command) {
@@ -7495,12 +7774,10 @@ function upsertClaude(settings, event, command) {
7495
7774
  return "already present";
7496
7775
  }
7497
7776
  }
7498
- const group = { hooks: [{
7777
+ groups.push({ hooks: [{
7499
7778
  type: "command",
7500
7779
  command
7501
- }] };
7502
- if (event === "PostToolUse") group.matcher = "Write|Edit";
7503
- groups.push(group);
7780
+ }] });
7504
7781
  return "registered";
7505
7782
  }
7506
7783
  function upsertCursor(settings, event, command) {
@@ -7518,6 +7795,36 @@ function upsertCursor(settings, event, command) {
7518
7795
  list.push({ command });
7519
7796
  return "registered";
7520
7797
  }
7798
+ const isOurs = (command, event) => command !== void 0 && hookTarget(command) === event;
7799
+ function removeClaude(settings, event, canonical) {
7800
+ const hooks = settings.hooks;
7801
+ const groups = hooks?.[event];
7802
+ if (!hooks || !groups) return false;
7803
+ let removed = false;
7804
+ const kept = [];
7805
+ for (const g of groups) {
7806
+ const entries = (g.hooks ?? []).filter((h) => !isOurs(h.command, canonical));
7807
+ if (entries.length !== (g.hooks ?? []).length) removed = true;
7808
+ if (entries.length > 0) kept.push({
7809
+ ...g,
7810
+ hooks: entries
7811
+ });
7812
+ }
7813
+ if (!removed) return false;
7814
+ if (kept.length > 0) hooks[event] = kept;
7815
+ else delete hooks[event];
7816
+ return true;
7817
+ }
7818
+ function removeCursor(settings, event, canonical) {
7819
+ const hooks = settings.hooks;
7820
+ const list = hooks?.[event];
7821
+ if (!hooks || !list) return false;
7822
+ const kept = list.filter((h) => !isOurs(h.command, canonical));
7823
+ if (kept.length === list.length) return false;
7824
+ if (kept.length > 0) hooks[event] = kept;
7825
+ else delete hooks[event];
7826
+ return true;
7827
+ }
7521
7828
  //#endregion
7522
7829
  //#region src/output.ts
7523
7830
  function stringifyCell(v) {
@@ -7674,7 +7981,7 @@ function byPath(ctx, dir) {
7674
7981
  }
7675
7982
  //#endregion
7676
7983
  //#region src/runtime/inject-inbox.ts
7677
- const EVENTS = ["SessionStart", "PostToolUse"];
7984
+ const EVENTS = ["SessionStart"];
7678
7985
  /**
7679
7986
  * Resolve the calling agent, gather its unread mail (and a standing owner's, when this session is
7680
7987
  * the hub's main pane), and return the SessionStart-style injection payload — or null when there is
@@ -7722,9 +8029,6 @@ function injectInbox(ctx, event) {
7722
8029
  }
7723
8030
  }
7724
8031
  } catch {}
7725
- if (rec && !rec.spawnedBy) try {
7726
- 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.");
7727
- } catch {}
7728
8032
  if (parts.length === 0) return null;
7729
8033
  return { hookSpecificOutput: {
7730
8034
  hookEventName: event,
@@ -8932,10 +9236,15 @@ function reportedEffort(launched) {
8932
9236
  if (launched.effortNotApplied) return `${launched.effortNotApplied} (not applied)`;
8933
9237
  return launched.effort ?? HARNESS_DEFAULT;
8934
9238
  }
8935
- /** Warn on stderr when the launch dropped an effort it could not carry (cursor with no model). */
9239
+ /** Warn on stderr when the launch dropped an effort it could not carry: cursor with no model, or with
9240
+ * no `<model>-<level>` id in `cursor-agent models`. */
8936
9241
  function warnEffortNotApplied(spawnInput) {
8937
- const level = spawnInput.launched.effortNotApplied;
9242
+ const { effortNotApplied: level, model } = spawnInput.launched;
8938
9243
  if (!level) return;
9244
+ if (model) {
9245
+ console.error(`${spawnInput.input.harness} carries effort only in a model id that cursor-agent models lists, and it lists no "${model}-${level}"; effort "${level}" not applied — launching ${model} at its default effort (run cursor-agent models to pick an id)`);
9246
+ return;
9247
+ }
8939
9248
  console.error(`${spawnInput.input.harness} carries effort only as a parameter on a named model and none was set; effort "${level}" not applied — launching at the harness default model and effort (pass --model to apply it)`);
8940
9249
  }
8941
9250
  /** The launch options `unit spawn` and `service start` share. */
@@ -8947,19 +9256,34 @@ function withSpawnOptions(cmd) {
8947
9256
  "workspace"
8948
9257
  ])).option("--no-wake", "suppress the first-turn doorbell (spawn idle; the caller drives the first turn itself)");
8949
9258
  }
9259
+ /**
9260
+ * A spawn left at its harness's folder-trust prompt is a failure the caller must see, not a
9261
+ * best-effort warning: the unit cannot read its brief until a person answers the prompt. The spawn
9262
+ * still landed, so the report names the step that finishes it, and the exit code is non-zero.
9263
+ */
9264
+ function reportTrustBlocked(res) {
9265
+ if (!res.trustBlocked) return;
9266
+ console.error(res.trustBlocked);
9267
+ if (res.agent.brief) {
9268
+ const doorbell = spawnDoorbell(res.agent.brief).replaceAll("'", "'\\''");
9269
+ console.error(`then: cyberlegion unit nudge ${res.agent.handle} --message '${doorbell}'`);
9270
+ }
9271
+ process.exitCode = 1;
9272
+ }
8950
9273
  function defineSpawn(cmd) {
8951
9274
  return withSpawnOptions(cmd).description("launch a new peer session in its own git worktree (tmux or herdr)").action(async (opts) => {
8952
9275
  const ctx = ctxOf(opts);
8953
9276
  touch(ctx);
8954
9277
  let spawnInput;
8955
9278
  try {
8956
- spawnInput = spawnCommandInput(opts);
9279
+ spawnInput = spawnCommandInput(opts, listCursorModels);
8957
9280
  } catch (err) {
8958
9281
  fail(err instanceof Error ? err.message : String(err));
8959
9282
  }
8960
9283
  warnEffortNotApplied(spawnInput);
8961
9284
  const res = await spawnAndWake(ctx, spawnInput.input, { noWake: spawnInput.noWake });
8962
9285
  if (res.warning) console.error(`first-turn doorbell not confirmed (peer still spawned; nudge it manually): ${res.warning}`);
9286
+ reportTrustBlocked(res);
8963
9287
  emit(formatOf(opts), {
8964
9288
  toon: toonObject({
8965
9289
  spawned: res.agent.id,
@@ -8969,7 +9293,8 @@ function defineSpawn(cmd) {
8969
9293
  effort: reportedEffort(spawnInput.launched),
8970
9294
  worktree: res.agent.worktree?.root,
8971
9295
  pane: res.pane,
8972
- rung: res.rung
9296
+ rung: res.rung,
9297
+ trust: res.trust
8973
9298
  }),
8974
9299
  json: {
8975
9300
  agent: res.agent,
@@ -8977,7 +9302,8 @@ function defineSpawn(cmd) {
8977
9302
  launch: res.launch,
8978
9303
  model: spawnInput.launched.model ?? HARNESS_DEFAULT,
8979
9304
  effort: reportedEffort(spawnInput.launched),
8980
- rung: res.rung
9305
+ rung: res.rung,
9306
+ trust: res.trust
8981
9307
  }
8982
9308
  });
8983
9309
  nextStep(`cyberlegion unit read ${res.agent.id}`);
@@ -9303,7 +9629,7 @@ withSpawnOptions(service.command("start")).description("resolve the healthy owne
9303
9629
  touch(ctx);
9304
9630
  let spawnInput;
9305
9631
  try {
9306
- spawnInput = spawnCommandInput(opts);
9632
+ spawnInput = spawnCommandInput(opts, listCursorModels);
9307
9633
  } catch (err) {
9308
9634
  fail(err instanceof Error ? err.message : String(err));
9309
9635
  }
@@ -9317,6 +9643,7 @@ withSpawnOptions(service.command("start")).description("resolve the healthy owne
9317
9643
  launch: async () => {
9318
9644
  const spawned = await spawnAndWake(ctx, spawnInput.input, { noWake: spawnInput.noWake });
9319
9645
  warning = spawned.warning;
9646
+ reportTrustBlocked(spawned);
9320
9647
  return {
9321
9648
  unit: spawned.agent.id,
9322
9649
  pane: spawned.pane
@@ -9511,7 +9838,7 @@ withGlobals(mail.command("watch")).description("stream new matching mail as it a
9511
9838
  });
9512
9839
  });
9513
9840
  });
9514
- 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) => {
9841
+ withGlobals(mail.command("hook")).description("emit the harness hook injection payload (raw JSON on stdout, not TOON)").option("--event <event>", "SessionStart", "SessionStart").action((opts) => {
9515
9842
  const ctx = ctxOf(opts);
9516
9843
  touch(ctx);
9517
9844
  const payload = injectInbox(ctx, opts.event);
@@ -9682,7 +10009,7 @@ withGlobals(program.command("admin").description("hub-state maintenance").comman
9682
10009
  json: res
9683
10010
  });
9684
10011
  });
9685
- withGlobals(program.command("init")).description("resolve this session harness, register 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 to pin the registered npx hook command to (e.g. the bundled plugin version)").action((opts) => {
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) => {
9686
10013
  const ctx = ctxOf(opts);
9687
10014
  let harness;
9688
10015
  try {