cyberlegion 1.2.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberlegion",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
5
5
  "author": {
6
6
  "name": "unional"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberlegion",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
5
5
  "author": {
6
6
  "name": "unional"
package/.plugin/pins.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "cyberlegion": "1.2.0"
2
+ "cyberlegion": "1.3.0"
3
3
  }
@@ -0,0 +1,5 @@
1
+ #!/bin/sh
2
+ # Claude Code puts a plugin's bin/ on the Bash tool's PATH, where the file name is the command name.
3
+ # This extensionless twin of cyberlegion.mjs makes a bare `cyberlegion` resolve to the CLI this
4
+ # plugin ships, with no global install.
5
+ exec node "$(dirname -- "$0")/cyberlegion.mjs" "$@"
package/dist/cli.mjs CHANGED
@@ -6292,6 +6292,9 @@ const KILL_LINE = "";
6292
6292
  * throws without sending. A multi-row draft cannot be typed back exactly: a scrape cannot tell a
6293
6293
  * wrapped row from a newline, and a newline typed back would submit it.
6294
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
+ *
6295
6298
  * Text the clear leaves in place was never a draft — an idle placeholder this reader does not know —
6296
6299
  * so `send` runs and nothing is typed back.
6297
6300
  */
@@ -6308,10 +6311,11 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
6308
6311
  return { kind: "unknown" };
6309
6312
  }
6310
6313
  };
6314
+ const isOwn = (s) => s.kind === "draft" && options.ownText?.(s.text) === true;
6311
6315
  let state = read();
6312
6316
  const start = now();
6313
6317
  let changedAt = start;
6314
- while (state.kind === "draft" && now() - changedAt < idleMs) {
6318
+ while (state.kind === "draft" && !isOwn(state) && now() - changedAt < idleMs) {
6315
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`);
6316
6320
  await sleep(pollMs);
6317
6321
  const next = read();
@@ -6319,12 +6323,13 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
6319
6323
  state = next;
6320
6324
  }
6321
6325
  if (state.kind !== "draft") return send();
6326
+ if (isOwn(state)) {
6327
+ await clearLine(adapter, exec, target, sleep);
6328
+ return send();
6329
+ }
6322
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`);
6323
6331
  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);
6332
+ await clearLine(adapter, exec, target, sleep);
6328
6333
  const after = read();
6329
6334
  if (after.kind === "draft" && after.text === draft) return send();
6330
6335
  try {
@@ -6334,6 +6339,12 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
6334
6339
  adapter.sendText(exec, target, draft);
6335
6340
  }
6336
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
+ }
6337
6348
  //#endregion
6338
6349
  //#region src/console/ring.ts
6339
6350
  const DEFAULT_ATTEMPTS = 10;
@@ -6404,6 +6415,28 @@ const DELIVERY_DOORBELL = "You have unread mail — check your inbox.";
6404
6415
  function spawnDoorbell(briefPath) {
6405
6416
  return `Read your brief at ${briefPath}, then begin work.`;
6406
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
+ }
6407
6440
  /**
6408
6441
  * A freshly-launched harness cold-boots slower than an already-running peer, so the spawn first-turn
6409
6442
  * ring gets a wider retry budget than a plain `mail send` doorbell (nudge's own 10 × 400ms): flush the
@@ -6472,7 +6505,8 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
6472
6505
  const target = { id: pane };
6473
6506
  await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
6474
6507
  ...guardOpts,
6475
- harness
6508
+ harness,
6509
+ ownText: (text) => isRingText(text)
6476
6510
  });
6477
6511
  return {
6478
6512
  rung: true,
@@ -6505,9 +6539,11 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
6505
6539
  if (input.noWake) return { rung: false };
6506
6540
  try {
6507
6541
  const adapter = getAdapter();
6508
- 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), {
6509
6544
  ...guardOpts,
6510
- harness: input.harness
6545
+ harness: input.harness,
6546
+ ownText: (text) => isRingText(text)
6511
6547
  });
6512
6548
  return {
6513
6549
  rung: true,
@@ -6836,6 +6872,20 @@ function resetCommandFor(harness) {
6836
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`);
6837
6873
  throw new Error(`"${harness}" is not in the reset map (${Object.keys(RESET_MAP).join(" | ")}) — unit clear refuses to guess a command`);
6838
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
+ }
6839
6889
  /**
6840
6890
  * Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
6841
6891
  * the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
@@ -6859,9 +6909,9 @@ function spawn(ctx, input) {
6859
6909
  if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
6860
6910
  const brief = resolveBrief(input);
6861
6911
  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");
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");
6863
6913
  const id = randomId();
6864
- const primaryRoot = resolvePrimaryRoot(exec);
6914
+ const primaryRoot = input.repo ? primaryRootOfRepo(exec, input.repo) : resolvePrimaryRoot(exec);
6865
6915
  const launch = input.command ?? LAUNCH_MAP[harness];
6866
6916
  const launchLine = () => composeLaunchLine(ctx, sessionAdapter.name, id, launch);
6867
6917
  const from = callerPane(sessionAdapter, normalizedEnv);
@@ -7041,10 +7091,43 @@ function labelFor(at, input, brief, id) {
7041
7091
  * supplied `ctx.self`), the multiplexer env prefix, then the harness launch command. Writes the shim
7042
7092
  * as a side effect, so call it only past every refusal, right before the session opens. Shared by
7043
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.
7098
+ */
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.
7044
7109
  */
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}`;
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)} `;
7048
7131
  }
7049
7132
  /**
7050
7133
  * The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
@@ -7073,7 +7156,7 @@ function selfInvocation() {
7073
7156
  }
7074
7157
  /**
7075
7158
  * 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
7159
+ * and return its path. A spawned session gets that directory first on its PATH, so the
7077
7160
  * `cyberlegion` a brief tells it to run is the install that spawned it: nothing is resolved from
7078
7161
  * the registry or the session's own PATH at report time.
7079
7162
  */
@@ -7083,7 +7166,7 @@ function writeSelfShim(dataDir, self) {
7083
7166
  const shim = join(dir, "cyberlegion");
7084
7167
  writeFileSync(shim, `#!/bin/sh\nexec ${self.map(shellQuote$1).join(" ")} "$@"\n`);
7085
7168
  chmodSync(shim, 493);
7086
- return dir;
7169
+ return shim;
7087
7170
  }
7088
7171
  /** Single-quote `s` for a POSIX shell, so any path survives word splitting and expansion. */
7089
7172
  function shellQuote$1(s) {
@@ -7135,7 +7218,8 @@ async function nudgeUnit(ctx, ref, options = {}) {
7135
7218
  const adapter = selectSessionAdapter(ctx.env ?? process.env, exec);
7136
7219
  const result = await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, message, options.nudgeOpts), {
7137
7220
  ...options.guardOpts,
7138
- harness: agent.harness
7221
+ harness: agent.harness,
7222
+ ownText: (text) => isRingText(text, message)
7139
7223
  });
7140
7224
  return {
7141
7225
  agent,
@@ -7174,7 +7258,8 @@ async function clearUnit(ctx, ref, options = {}) {
7174
7258
  const adapter = selectSessionAdapter(env, exec);
7175
7259
  await withDraftGuard(adapter, exec, target, async () => adapter.submit(exec, target, command), {
7176
7260
  ...options.guardOpts,
7177
- harness: agent.harness
7261
+ harness: agent.harness,
7262
+ ownText: (text) => isRingText(text)
7178
7263
  });
7179
7264
  return {
7180
7265
  agent,
@@ -7469,6 +7554,7 @@ function spawnCommandInput(opts, listCursorModels) {
7469
7554
  branch: opts.branch,
7470
7555
  worktreePath: opts.worktreePath,
7471
7556
  cwd: opts.cwd,
7557
+ repo: opts.repo,
7472
7558
  at: opts.at
7473
7559
  },
7474
7560
  noWake: opts.wake === false,
@@ -7860,6 +7946,58 @@ function fail(msg) {
7860
7946
  process.exit(1);
7861
7947
  }
7862
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
7863
8001
  //#region src/project.ts
7864
8002
  /**
7865
8003
  * The canonical git common dir for `dir`, or undefined outside a repository. The common dir is the
@@ -8794,8 +8932,11 @@ function stopUnit(ctx, ref) {
8794
8932
  /**
8795
8933
  * Give a unit a fresh runtime and keep the unit: stop a still-running session (the same verified stop
8796
8934
  * 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.
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.
8799
8940
  *
8800
8941
  * The unit is recorded `stopped` before the open, so a restart interrupted between the two leaves an
8801
8942
  * ordinary stopped unit and a rerun recovers it; nothing needs a separate repair path.
@@ -8817,6 +8958,7 @@ async function restartUnit(ctx, ref, options = {}) {
8817
8958
  const env = ctx.env ?? process.env;
8818
8959
  const exec = ctx.exec ?? realExec;
8819
8960
  const launch = stopped.launch ?? LAUNCH_MAP[harness];
8961
+ const resume = stopped.conversation && !options.fresh ? resumeLaunch(harness, launch, stopped.conversation) : void 0;
8820
8962
  const at = stopped.worktree ? "workspace" : "tab";
8821
8963
  let target;
8822
8964
  let adapter;
@@ -8829,7 +8971,10 @@ async function restartUnit(ctx, ref, options = {}) {
8829
8971
  }) } : {};
8830
8972
  target = adapter.open(exec, {
8831
8973
  cwd: stopped.cwd,
8832
- 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 }),
8833
8978
  at,
8834
8979
  from: callerPane(adapter, normalizeMuxEnv(env)),
8835
8980
  ...label
@@ -8853,13 +8998,15 @@ async function restartUnit(ctx, ref, options = {}) {
8853
8998
  const wake = await wakeSpawn(() => adapter, exec, {
8854
8999
  target,
8855
9000
  briefPath: briefPath ?? "",
8856
- noWake: options.noWake || !briefPath
9001
+ noWake: options.noWake || !briefPath,
9002
+ resumed: !!resume
8857
9003
  }, options.nudgeOpts);
8858
9004
  return {
8859
9005
  agent: bound,
8860
9006
  ...previousPane ? { previousPane } : {},
8861
9007
  pane: target.id,
8862
9008
  launch,
9009
+ resumed: !!resume,
8863
9010
  rung: wake.rung,
8864
9011
  ...wake.warning ? { warning: wake.warning } : {}
8865
9012
  };
@@ -8957,6 +9104,29 @@ function showUnit(ctx, ref) {
8957
9104
  controls: controlsFor(ctx, rec, liveness)
8958
9105
  };
8959
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
+ }
8960
9130
  //#endregion
8961
9131
  //#region src/wake/await.ts
8962
9132
  /** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
@@ -9249,7 +9419,7 @@ function warnEffortNotApplied(spawnInput) {
9249
9419
  }
9250
9420
  /** The launch options `unit spawn` and `service start` share. */
9251
9421
  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([
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([
9253
9423
  "pane:right",
9254
9424
  "pane:down",
9255
9425
  "tab",
@@ -9349,15 +9519,19 @@ withGlobals(unit.command("stop")).description("end a unit's session and keep the
9349
9519
  });
9350
9520
  if (!res.alreadyStopped) nextStep(`cyberlegion unit restart ${res.agent.handle}`);
9351
9521
  });
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) => {
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) => {
9353
9523
  const ctx = ctxOf(opts);
9354
9524
  touch(ctx);
9355
- 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
+ });
9356
9529
  emit(formatOf(opts), {
9357
9530
  toon: toonObject({
9358
9531
  restarted: res.agent.id,
9359
9532
  previous: res.previousPane ?? "-",
9360
9533
  pane: res.pane,
9534
+ resumed: res.resumed,
9361
9535
  rung: res.rung
9362
9536
  }),
9363
9537
  json: res
@@ -9841,6 +10015,9 @@ withGlobals(mail.command("watch")).description("stream new matching mail as it a
9841
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) => {
9842
10016
  const ctx = ctxOf(opts);
9843
10017
  touch(ctx);
10018
+ if (!process.stdin.isTTY) try {
10019
+ recordConversation(ctx, readFileSync(0, "utf8"));
10020
+ } catch {}
9844
10021
  const payload = injectInbox(ctx, opts.event);
9845
10022
  if (payload) console.log(JSON.stringify(payload));
9846
10023
  });
@@ -9935,30 +10112,33 @@ withGlobals(agent.command("path")).description("print the resolved def file path
9935
10112
  json: { path: def.path }
9936
10113
  });
9937
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`;
9938
10116
  const mux = program.command("mux").description("the unit-agnostic pane layer — multiplexer detection and diagnostics");
9939
10117
  withGlobals(mux.command("doctor")).description("probe harness, multiplexer (ancestry-discovered), hub root, and self-id").action((opts) => {
9940
10118
  const ctx = ctxOf(opts);
9941
10119
  const harness = detectHarness(void 0, ctx) ?? "unknown";
9942
10120
  const probe = probeMultiplexer(ctx.exec ?? realExec, normalizeMuxEnv(ctx.env ?? process.env));
9943
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
+ };
9944
10131
  emit(formatOf(opts), {
9945
10132
  toon: toonObject({
9946
- harness,
9947
- mux: probe.mux,
9948
- pane: probe.pane,
9949
- via: probe.via,
9950
- hubRoot: ctx.store.root,
9951
- selfId
10133
+ ...report,
10134
+ permissionRule
9952
10135
  }),
9953
10136
  json: {
9954
- harness,
9955
- mux: probe.mux,
9956
- pane: probe.pane,
9957
- via: probe.via,
9958
- hubRoot: ctx.store.root,
9959
- selfId
10137
+ ...report,
10138
+ permissionRule
9960
10139
  }
9961
10140
  });
10141
+ if (permissionRule === "missing") nextStep(ALLOW_CLI_HINT);
9962
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`);
9963
10143
  });
9964
10144
  withGlobals(mux.command("mode")).description("report the detected session-backend mode").action((opts) => {
@@ -10009,7 +10189,7 @@ withGlobals(program.command("admin").description("hub-state maintenance").comman
10009
10189
  json: res
10010
10190
  });
10011
10191
  });
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) => {
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) => {
10013
10193
  const ctx = ctxOf(opts);
10014
10194
  let harness;
10015
10195
  try {
@@ -10019,6 +10199,16 @@ withGlobals(program.command("init")).description("resolve this session harness,
10019
10199
  } catch (err) {
10020
10200
  fail(err instanceof Error ? err.message : String(err));
10021
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
+ }
10022
10212
  let results;
10023
10213
  try {
10024
10214
  results = install(harness, opts.dir, opts.pin);
@@ -10039,12 +10229,14 @@ withGlobals(program.command("init")).description("resolve this session harness,
10039
10229
  key: "file",
10040
10230
  get: (r) => r.file
10041
10231
  }
10042
- ], `harness ${harness}, ${results.length} hooks`),
10232
+ ], `harness ${harness}, ${results.length} hooks${permissionRule ? `, permission rule ${permissionRule}` : ""}`),
10043
10233
  json: {
10044
10234
  harness,
10045
- hooks: results
10235
+ hooks: results,
10236
+ ...permissionRule && { permissionRule }
10046
10237
  }
10047
10238
  });
10239
+ if (permissionRule === "missing") nextStep(ALLOW_CLI_HINT);
10048
10240
  if (!listAgents(ctx.store).some((a) => a.kind === "standing")) {
10049
10241
  nextStep("cyberlegion unit register --standing --handle <name> to mint the durable owner inbox");
10050
10242
  nextStep("cyberlegion attach to bind this pane as the owner live presence");
package/dist/index.d.mts CHANGED
@@ -62,6 +62,10 @@ interface AgentRecord {
62
62
  * mux env prefix not) — what `unit restart` relaunches the unit with. Absent on a record that
63
63
  * predates it or that `spawn` did not write; restart then falls back to the harness default. */
64
64
  launch?: string;
65
+ /** The harness's own conversation (session) id, as its SessionStart hook last reported it from this
66
+ * unit's pane (`mail hook`) — what `unit restart` resumes. Absent until the hook has fired, and on a
67
+ * harness whose hook never runs; restart then rebriefs a fresh session. */
68
+ conversation?: string;
65
69
  spawnedBy?: string;
66
70
  /** Absent ⇒ session (backward compat, no migration). 'standing' = a session-independent,
67
71
  * prune-exempt owner inbox minted by `unit register --standing`. 'service' = a project service's
@@ -719,6 +723,10 @@ interface SpawnInput {
719
723
  /** Spawn into this existing directory instead — creates no worktree. Mutually exclusive with
720
724
  * `branch`/`worktreePath` (those create a worktree; this reuses one). */
721
725
  cwd?: string;
726
+ /** Create the worktree from the repository containing this path rather than the caller's cwd —
727
+ * so a caller spawns for another repository without a `cd` chained in front of the command.
728
+ * Mutually exclusive with `cwd` (that one creates no worktree to place). */
729
+ repo?: string;
722
730
  /** Placement relative to the caller; defaults to 'tab'. */
723
731
  at?: MuxPlacement;
724
732
  }
package/dist/index.mjs CHANGED
@@ -615,6 +615,9 @@ const KILL_LINE = "";
615
615
  * throws without sending. A multi-row draft cannot be typed back exactly: a scrape cannot tell a
616
616
  * wrapped row from a newline, and a newline typed back would submit it.
617
617
  *
618
+ * Text `ownText` recognizes is the caller's own, not a draft: it is cleared at once, whatever rows it
619
+ * wraps onto, `send` runs, and nothing is typed back.
620
+ *
618
621
  * Text the clear leaves in place was never a draft — an idle placeholder this reader does not know —
619
622
  * so `send` runs and nothing is typed back.
620
623
  */
@@ -631,10 +634,11 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
631
634
  return { kind: "unknown" };
632
635
  }
633
636
  };
637
+ const isOwn = (s) => s.kind === "draft" && options.ownText?.(s.text) === true;
634
638
  let state = read();
635
639
  const start = now();
636
640
  let changedAt = start;
637
- while (state.kind === "draft" && now() - changedAt < idleMs) {
641
+ while (state.kind === "draft" && !isOwn(state) && now() - changedAt < idleMs) {
638
642
  if (now() - start + pollMs > maxWaitMs) throw new Error(`pane ${target.id} holds a draft the user is still editing — gave up after ${maxWaitMs / 1e3}s without typing`);
639
643
  await sleep(pollMs);
640
644
  const next = read();
@@ -642,12 +646,13 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
642
646
  state = next;
643
647
  }
644
648
  if (state.kind !== "draft") return send();
649
+ if (isOwn(state)) {
650
+ await clearLine(adapter, exec, target, sleep);
651
+ return send();
652
+ }
645
653
  if (state.rows > 1) throw new Error(`pane ${target.id} holds an idle draft spanning ${state.rows} rows, which cannot be typed back exactly — left it untouched`);
646
654
  const draft = state.text;
647
- adapter.sendText(exec, target, END_OF_LINE);
648
- await sleep(KEY_SETTLE_MS);
649
- adapter.sendText(exec, target, KILL_LINE);
650
- await sleep(KEY_SETTLE_MS);
655
+ await clearLine(adapter, exec, target, sleep);
651
656
  const after = read();
652
657
  if (after.kind === "draft" && after.text === draft) return send();
653
658
  try {
@@ -657,6 +662,12 @@ async function withDraftGuard(adapter, exec, target, send, options = {}) {
657
662
  adapter.sendText(exec, target, draft);
658
663
  }
659
664
  }
665
+ async function clearLine(adapter, exec, target, sleep) {
666
+ adapter.sendText(exec, target, END_OF_LINE);
667
+ await sleep(KEY_SETTLE_MS);
668
+ adapter.sendText(exec, target, KILL_LINE);
669
+ await sleep(KEY_SETTLE_MS);
670
+ }
660
671
  //#endregion
661
672
  //#region src/console/ring.ts
662
673
  const DEFAULT_ATTEMPTS = 10;
@@ -710,8 +721,6 @@ async function ringTurn(adapter, exec, target, message, opts = {}) {
710
721
  }
711
722
  throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — input still staged after ${attempts} re-submit attempts`);
712
723
  }
713
- //#endregion
714
- //#region src/console/doorbell.ts
715
724
  /**
716
725
  * The first-turn doorbell `unit spawn` delivers to a freshly-opened paned peer — the instruction
717
726
  * itself, not a notification that context is already populated. It names the brief's **file path**
@@ -725,6 +734,28 @@ async function ringTurn(adapter, exec, target, message, opts = {}) {
725
734
  function spawnDoorbell(briefPath) {
726
735
  return `Read your brief at ${briefPath}, then begin work.`;
727
736
  }
737
+ const SPAWN_DOORBELL = /^Readyourbriefat.+,thenbeginwork\.$/;
738
+ const RESUME_DOORBELL = /^Yoursessionwasrestartedwithitsconversationresumed—continueyourwork\.Ifyouhavenoearlierconversation,readyourbriefat.+,thenbeginwork\.$/;
739
+ /**
740
+ * Whether `text`, read out of a peer's input box, is a ring cyberlegion typed — the delivery doorbell,
741
+ * a spawn or resume doorbell, or `message`, the one about to be rung — rather than a human's draft. A harness
742
+ * can take a ring and put its text back in the box (cursor-agent with a rejected login). Whitespace is
743
+ * ignored, since the box wraps the text onto rows wherever it likes.
744
+ */
745
+ function isRingText(text, message) {
746
+ const squeeze = (s) => s.replace(/\s+/g, "");
747
+ const box = squeeze(text);
748
+ if (box === "") return false;
749
+ return box === squeeze("You have unread mail — check your inbox.") || SPAWN_DOORBELL.test(box) || RESUME_DOORBELL.test(box) || message !== void 0 && box === squeeze(message);
750
+ }
751
+ /**
752
+ * The first-turn instruction for a restarted session that resumed its conversation. The resume can
753
+ * fall back to a fresh session (the harness no longer has the conversation), and the ring cannot tell
754
+ * which one took the turn, so it names the brief for a session that finds no earlier conversation.
755
+ */
756
+ function resumeDoorbell(briefPath) {
757
+ 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.`;
758
+ }
728
759
  /**
729
760
  * A freshly-launched harness cold-boots slower than an already-running peer, so the spawn first-turn
730
761
  * ring gets a wider retry budget than a plain `mail send` doorbell (nudge's own 10 × 400ms): flush the
@@ -755,9 +786,11 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
755
786
  if (input.noWake) return { rung: false };
756
787
  try {
757
788
  const adapter = getAdapter();
758
- await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, spawnDoorbell(input.briefPath), nudgeOpts), {
789
+ const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
790
+ await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, nudgeOpts), {
759
791
  ...guardOpts,
760
- harness: input.harness
792
+ harness: input.harness,
793
+ ownText: (text) => isRingText(text)
761
794
  });
762
795
  return {
763
796
  rung: true,
@@ -1055,6 +1088,20 @@ const LAUNCH_MAP = {
1055
1088
  cursor: "cursor-agent",
1056
1089
  codex: "codex"
1057
1090
  };
1091
+ /** The primary checkout of the repository containing `repo`, asked of git from inside it (`git -C`)
1092
+ * rather than from the process cwd. */
1093
+ function primaryRootOfRepo(exec, repo) {
1094
+ const dir = resolve(repo);
1095
+ try {
1096
+ return resolvePrimaryRoot$1((cmd, args) => exec(cmd, cmd === "git" ? [
1097
+ "-C",
1098
+ dir,
1099
+ ...args
1100
+ ] : args));
1101
+ } catch {
1102
+ throw new Error(`--repo ${repo} is not inside a git repository`);
1103
+ }
1104
+ }
1058
1105
  /**
1059
1106
  * Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
1060
1107
  * the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
@@ -1078,9 +1125,9 @@ function spawn(ctx, input) {
1078
1125
  if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
1079
1126
  const brief = resolveBrief(input);
1080
1127
  if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
1081
- if (input.cwd && (input.branch || input.worktreePath)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path");
1128
+ if (input.cwd && (input.branch || input.worktreePath || input.repo)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path/--repo");
1082
1129
  const id = randomId();
1083
- const primaryRoot = resolvePrimaryRoot$1(exec);
1130
+ const primaryRoot = input.repo ? primaryRootOfRepo(exec, input.repo) : resolvePrimaryRoot$1(exec);
1084
1131
  const launch = input.command ?? LAUNCH_MAP[harness];
1085
1132
  const launchLine = () => composeLaunchLine(ctx, sessionAdapter.name, id, launch);
1086
1133
  const from = callerPane(sessionAdapter, normalizedEnv);
@@ -1260,10 +1307,25 @@ function labelFor(at, input, brief, id) {
1260
1307
  * supplied `ctx.self`), the multiplexer env prefix, then the harness launch command. Writes the shim
1261
1308
  * as a side effect, so call it only past every refusal, right before the session opens. Shared by
1262
1309
  * `spawn` and `unit restart` so a restarted unit boots exactly as a spawned one does.
1310
+ *
1311
+ * `bindSelf` has the new pane run `unit rebind <id>` through the shim before the harness starts, so
1312
+ * the session binds itself to the unit even when the caller dies before its own bind write. It needs
1313
+ * the shim, so a context with no recorded invocation gets no bind step.
1314
+ */
1315
+ function composeLaunchLine(ctx, muxName, id, launch, options = {}) {
1316
+ const shim = ctx.self ? writeSelfShim(paths.dataDir(ctx.store.root, id), ctx.self) : void 0;
1317
+ const bind = shim && options.bindSelf ? `${shellQuote$1(shim)} unit rebind ${shellQuote$1(id)} --space ${shellQuote$1(ctx.store.root)} >/dev/null 2>&1; ` : "";
1318
+ const prefix = `${shim ? shimEnvPrefix(shim) : ""}${muxEnvPrefix(muxName)}`;
1319
+ return `${bind}${prefix}${launch}${options.fallback ? ` || ${prefix}${options.fallback}` : ""}`;
1320
+ }
1321
+ /**
1322
+ * Put the shim's directory first on PATH, so a brief's bare `cyberlegion` runs it, and name the shim
1323
+ * itself in `$CYBERLEGION_CLI`. The plugin's hook runs outside that PATH lookup (and a PATH lookup
1324
+ * cannot tell this shim from a stale global install), so it reads the variable instead: set only
1325
+ * here, it marks a spawned session and names the CLI that spawned it.
1263
1326
  */
1264
- function composeLaunchLine(ctx, muxName, id, launch) {
1265
- const shimDir = ctx.self ? writeSelfShim(paths.dataDir(ctx.store.root, id), ctx.self) : void 0;
1266
- return `${shimDir ? `PATH=${shellQuote$1(shimDir)}:"$PATH" ` : ""}${muxEnvPrefix(muxName)}${launch}`;
1327
+ function shimEnvPrefix(shim) {
1328
+ return `PATH=${shellQuote$1(dirname(shim))}:"$PATH" CYBERLEGION_CLI=${shellQuote$1(shim)} `;
1267
1329
  }
1268
1330
  /**
1269
1331
  * The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
@@ -1279,7 +1341,7 @@ function muxEnvPrefix(muxName) {
1279
1341
  }
1280
1342
  /**
1281
1343
  * Write `<dataDir>/bin/cyberlegion`, a POSIX shim that execs `self` with the caller's arguments,
1282
- * and return its directory. A spawned session gets that directory first on its PATH, so the
1344
+ * and return its path. A spawned session gets that directory first on its PATH, so the
1283
1345
  * `cyberlegion` a brief tells it to run is the install that spawned it: nothing is resolved from
1284
1346
  * the registry or the session's own PATH at report time.
1285
1347
  */
@@ -1289,7 +1351,7 @@ function writeSelfShim(dataDir, self) {
1289
1351
  const shim = join(dir, "cyberlegion");
1290
1352
  writeFileSync(shim, `#!/bin/sh\nexec ${self.map(shellQuote$1).join(" ")} "$@"\n`);
1291
1353
  chmodSync(shim, 493);
1292
- return dir;
1354
+ return shim;
1293
1355
  }
1294
1356
  /** Single-quote `s` for a POSIX shell, so any path survives word splitting and expansion. */
1295
1357
  function shellQuote$1(s) {
package/hooks/hooks.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/cyberlegion.mjs\" mail hook --event SessionStart"
8
+ "command": "if [ -x \"$CYBERLEGION_CLI\" ]; then \"$CYBERLEGION_CLI\" mail hook --event SessionStart; else node \"${CLAUDE_PLUGIN_ROOT}/bin/cyberlegion.mjs\" mail hook --event SessionStart; fi"
9
9
  }
10
10
  ]
11
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberlegion",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Harness-agnostic agent session spawning and messaging over the filesystem",
5
5
  "keywords": [
6
6
  "agent",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "cyberlegion",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
6
6
  "author": {
7
7
  "name": "unional"
package/readme.md CHANGED
@@ -161,6 +161,11 @@ launcher cannot be resolved, a skill names one fallback pinned to the version th
161
161
  `npx -y cyberlegion@<version>`. `pnpm version` rewrites those pins and `.plugin/pins.json` at every
162
162
  release, and the test suite fails on a stale one.
163
163
 
164
+ Claude Code puts the plugin's `bin/` on the Bash tool's PATH, and `bin/cyberlegion` forwards to
165
+ `bin/cyberlegion.mjs`, so a bare `cyberlegion …` runs the shipped CLI in any Claude Code session with
166
+ no global install. claude.ai and Cowork, including plugins distributed through claude.ai organization
167
+ settings, refuse a plugin with a top-level `bin/`, so the plugin does not install there.
168
+
164
169
  ## License
165
170
 
166
171
  MIT
@@ -17,7 +17,7 @@ satisfies verdict schema `V`.
17
17
  > **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
18
18
  > this plugin ships. The path is relative to this skill's own directory, not the working directory.
19
19
  > If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
20
- > with the same arguments: `npx -y cyberlegion@1.2.0`.
20
+ > with the same arguments: `npx -y cyberlegion@1.3.0`.
21
21
 
22
22
  ## 1. Resolve the agent definition
23
23
 
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { fileURLToPath } from 'node:url'
13
13
 
14
- const FALLBACK = 'npx -y cyberlegion@1.2.0'
14
+ const FALLBACK = 'npx -y cyberlegion@1.3.0'
15
15
  const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
16
16
 
17
17
  if (existsSync(fileURLToPath(bin))) {
@@ -2,13 +2,14 @@
2
2
 
3
3
  The onboarding front door to the Legion — walks a session through getting `cyberlegion` working in
4
4
  this repo: probe the environment, register the surfacing hook, and (root session only, only on an
5
- explicit yes) bind this pane as the durable `legate` owner inbox.
5
+ explicit yes) allow the `cyberlegion` CLI in Claude Code's permissions and bind this pane as the
6
+ durable `legate` owner inbox.
6
7
 
7
8
  User-facing. Triggers on "set up cyberlegion", "onboard the legion", "register the cyberlegion
8
9
  surfacing hook", "make this pane my main legion inbox", "get cyberlegion working in this repo". Wraps
9
- `cyberlegion mux doctor` / `init` / `unit register --standing` / `attach` — every mechanic is a
10
+ `cyberlegion mux doctor` / `init [--allow-cli]` / `unit register --standing` / `attach` — every mechanic is a
10
11
  CLI call, no filesystem or hub state touched directly.
11
12
 
12
13
  Distinct from `legate` (spawn/mail/dispatch a peer) and `manage-inbox` (read/ack owner mail once
13
- bound). See `SKILL.md` for the full flow (probe → register hook → detect root vs spawned →
14
- consent-gated bind).
14
+ bound). See `SKILL.md` for the full flow (probe → consent-gated permission rule + register hook →
15
+ detect root vs spawned → consent-gated bind).
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  name: init-cyberlegion
3
- description: "Use this skill to set up or onboard cyberlegion in this session or repo — probe the environment, register the mail-surfacing hook, and (in a root session, on your yes) bind this pane as the durable legate owner inbox. Triggers: 'set up cyberlegion', 'onboard the legion', 'register the cyberlegion surfacing hook', 'make this pane my main legion inbox', 'get cyberlegion working in this repo'. Not for spawning/messaging/dispatching a peer (that is legate), reading or acking owner mail (that is manage-inbox), or unrelated init like a git repo, npm package, or commit discipline."
3
+ description: "Use this skill to set up or onboard cyberlegion in this session or repo — probe the environment, register the mail-surfacing hook, (in a root session, on your yes) allow the cyberlegion CLI in Claude Code's permissions, and bind this pane as the durable legate owner inbox. Triggers: 'set up cyberlegion', 'onboard the legion', 'register the cyberlegion surfacing hook', 'make this pane my main legion inbox', 'get cyberlegion working in this repo'. Not for spawning/messaging/dispatching a peer (that is legate), reading or acking owner mail (that is manage-inbox), or unrelated init like a git repo, npm package, or commit discipline."
4
4
  ---
5
5
 
6
6
  # init-cyberlegion
7
7
 
8
8
  The onboarding front door to the Legion — a thin, user-invocable wrapper that walks a session through
9
9
  getting `cyberlegion` working in this repo: probe the environment, register the surfacing hook, and
10
- (only in a root session, only on an explicit yes) bind this pane as the durable `legate` owner inbox.
10
+ (only in a root session, only on an explicit yes) allow the `cyberlegion` CLI in Claude Code's
11
+ permissions and bind this pane as the durable `legate` owner inbox.
11
12
  It is a **thin wrapper**: every mechanic is a `cyberlegion` CLI call. The skill holds the *conversation
12
13
  and the judgment* — is this a root session? should we ask to bind? what does the environment look
13
14
  like? — the CLI holds all the *mechanism*.
@@ -37,21 +38,54 @@ node scripts/cyberlegion.mjs mux doctor
37
38
  ```
38
39
 
39
40
  Run this **before** touching the hook or any identity. It reports `harness`, `mux`, `pane`,
40
- `hubRoot`, and `selfId` — read it to learn the environment (is there a multiplexer? a pane?) and to
41
- detect root vs spawned (see step 3). Narrate a short, grounded summary of what it found — do not
42
- invent facts the probe did not report.
41
+ `hubRoot`, `selfId`, and `permissionRule` — read it to learn the environment (is there a
42
+ multiplexer? a pane? does Claude Code allow the CLI?) and to detect root vs spawned (see step 3).
43
+ Narrate a short, grounded summary of what it found, including the permission rule (e.g. "permission
44
+ rule: missing") — do not invent facts the probe did not report.
43
45
 
44
- ### 2. Register the surfacing hook
46
+ `permissionRule` is whether Claude Code's user settings carry a `permissions.allow` rule covering
47
+ `cyberlegion`: `present`, `missing`, `unreadable`, or `n/a` (not Claude Code). Without it, Claude
48
+ Code's auto-mode classifier can deny a unit's `cyberlegion mail send` as *External System Writes*,
49
+ so a unit that finished its work cannot report back. Step 2 handles it.
50
+
51
+ ### 2. Register the surfacing hook (and, on a yes, allow the CLI)
45
52
 
46
53
  ```bash
47
54
  node scripts/cyberlegion.mjs init --pin <version>
48
55
  ```
49
56
 
57
+ **The permission rule — ask first, never silent.** When the probe reported `permissionRule: missing`
58
+ in a **root** session with a broader onboarding intent (see step 3 for how root is derived), ask
59
+ before running `init`, e.g.:
60
+
61
+ > "Claude Code has no permission rule for `cyberlegion`, so its auto-mode classifier can block a
62
+ > unit's `cyberlegion mail send` and the unit's report never reaches you. Add
63
+ > `Bash(cyberlegion *)` to `permissions.allow` in your Claude Code user settings
64
+ > (`~/.claude/settings.json`)? It is merged into your existing list."
65
+
66
+ On an explicit yes, add `--allow-cli` to the `init` call — the CLI merges the rule into the existing
67
+ array, never replaces it, and refuses to rewrite a settings file it cannot parse:
68
+
69
+ ```bash
70
+ node scripts/cyberlegion.mjs init --pin <version> --allow-cli
71
+ ```
72
+
73
+ On a decline, run `init` without `--allow-cli` and say the rule stays missing. Never edit the
74
+ settings file by hand. Do not ask, and do not pass `--allow-cli`, when:
75
+
76
+ - the rule is `present` or `n/a` — nothing to do;
77
+ - the rule is `unreadable` — tell the user the settings file could not be parsed and must be fixed
78
+ by hand; `--allow-cli` would refuse it;
79
+ - this is a **spawned** unit — it never changes the user's global settings; report the rule as
80
+ missing in its result so the human can run this skill from a root session;
81
+ - the request is hook-only — report the rule's state and stop (see step 3).
82
+
50
83
  Pass `--pin <version>` with the version resolved above so a project hook's npx fallback fetches the
51
84
  shipped version; **omit `--pin`** when the map yielded no version.
52
85
 
53
86
  Auto-detect is the default — no `--agent` flag. Pass `--agent <name>` **only** when `mux doctor` could
54
- not auto-detect the harness, or the user named one explicitly (it composes with `--pin`):
87
+ not auto-detect the harness, or the user named one explicitly (it composes with `--pin` and
88
+ `--allow-cli`):
55
89
 
56
90
  ```bash
57
91
  node scripts/cyberlegion.mjs init --pin <version> --agent <name>
@@ -60,7 +94,9 @@ node scripts/cyberlegion.mjs init --pin <version> --agent <name>
60
94
  This step is **idempotent**: if the hook is already registered, `init` reports `already present` —
61
95
  that is a clean no-op, never a duplicate registration and never an error. On Claude Code and Codex the
62
96
  plugin ships the hook itself, so `init` reports `provided by plugin` (or `removed project hook` when it
63
- cleared one an earlier `init` wrote); both mean the hook is set up.
97
+ cleared one an earlier `init` wrote); both mean the hook is set up. On Claude Code its summary also
98
+ reports `permission rule <state>` — `added` after `--allow-cli`, `present` when a covering rule was
99
+ already there.
64
100
 
65
101
  ### 3. Detect root vs spawned — derived, never asked
66
102
 
@@ -100,10 +136,18 @@ that is expected, not a failure. Still run `unit register --standing --handle le
100
136
  **without erroring**. The root session surfaces owner mail via the `!spawnedBy` fallback instead of a
101
137
  bound pane.
102
138
 
139
+ ## Troubleshooting
140
+
141
+ **A unit's `cyberlegion mail send` was denied by the auto-mode classifier** (*Permission for this
142
+ action was denied by the Claude Code auto mode classifier. Reason: [External System Writes]*) — the
143
+ `Bash(cyberlegion *)` permission rule is missing. Run this skill from a root session and say yes to
144
+ the permission ask, or run `cyberlegion init --allow-cli` there.
145
+
103
146
  ## Boundaries
104
147
 
105
- - Every mechanic here is a `cyberlegion` CLI call — this skill writes no hub state and invents no
106
- config format. Its only filesystem read is the plugin's own bundled `${CLAUDE_PLUGIN_ROOT}/.plugin/pins.json`
148
+ - Every mechanic here is a `cyberlegion` CLI call — this skill writes no hub state, invents no
149
+ config format, and never edits Claude Code's settings by hand (the permission rule goes in only
150
+ through `init --allow-cli`, only on an explicit yes in a root session). Its only filesystem read is the plugin's own bundled `${CLAUDE_PLUGIN_ROOT}/.plugin/pins.json`
107
151
  version map, to resolve the CLI pin.
108
152
  - It never mints or binds an owner identity without an explicit user yes.
109
153
  - It is distinct from `legate` — sending/spawning/dispatching to a peer is `legate`'s job, not this
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { fileURLToPath } from 'node:url'
13
13
 
14
- const FALLBACK = 'npx -y cyberlegion@1.2.0'
14
+ const FALLBACK = 'npx -y cyberlegion@1.3.0'
15
15
  const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
16
16
 
17
17
  if (existsSync(fileURLToPath(bin))) {
@@ -13,7 +13,7 @@ logic, loads no other governance, and writes no state of its own.
13
13
  > **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
14
14
  > this plugin ships. The path is relative to this skill's own directory, not the working directory.
15
15
  > If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
16
- > with the same arguments: `npx -y cyberlegion@1.2.0`.
16
+ > with the same arguments: `npx -y cyberlegion@1.3.0`.
17
17
 
18
18
  ## Doorbell vs mailbox
19
19
 
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { fileURLToPath } from 'node:url'
13
13
 
14
- const FALLBACK = 'npx -y cyberlegion@1.2.0'
14
+ const FALLBACK = 'npx -y cyberlegion@1.3.0'
15
15
  const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
16
16
 
17
17
  if (existsSync(fileURLToPath(bin))) {
@@ -13,7 +13,7 @@ the one owner mailbox from wherever they are.
13
13
  > **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
14
14
  > this plugin ships. The path is relative to this skill's own directory, not the working directory.
15
15
  > If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
16
- > with the same arguments: `npx -y cyberlegion@1.2.0`.
16
+ > with the same arguments: `npx -y cyberlegion@1.3.0`.
17
17
 
18
18
  ## Resolve the owner handle
19
19
 
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { fileURLToPath } from 'node:url'
13
13
 
14
- const FALLBACK = 'npx -y cyberlegion@1.2.0'
14
+ const FALLBACK = 'npx -y cyberlegion@1.3.0'
15
15
  const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
16
16
 
17
17
  if (existsSync(fileURLToPath(bin))) {
@@ -15,7 +15,7 @@ agent (`headless-legate`, `sdd-automaton`, the cold judges) to know how *it* rep
15
15
  > **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
16
16
  > this plugin ships. The path is relative to this skill's own directory, not the working directory.
17
17
  > If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
18
- > with the same arguments: `npx -y cyberlegion@1.2.0`.
18
+ > with the same arguments: `npx -y cyberlegion@1.3.0`.
19
19
 
20
20
  ## The one probe: who, if anyone, collects my return?
21
21
 
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { fileURLToPath } from 'node:url'
13
13
 
14
- const FALLBACK = 'npx -y cyberlegion@1.2.0'
14
+ const FALLBACK = 'npx -y cyberlegion@1.3.0'
15
15
  const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
16
16
 
17
17
  if (existsSync(fileURLToPath(bin))) {
@@ -14,7 +14,7 @@ the unit's **role** — see *Mid-turn messaging is scoped by role*, below.
14
14
  > **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
15
15
  > this plugin ships. The path is relative to this skill's own directory, not the working directory.
16
16
  > If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
17
- > with the same arguments: `npx -y cyberlegion@1.2.0`.
17
+ > with the same arguments: `npx -y cyberlegion@1.3.0`.
18
18
 
19
19
  ## 1. Resolve the agent def
20
20
 
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { fileURLToPath } from 'node:url'
13
13
 
14
- const FALLBACK = 'npx -y cyberlegion@1.2.0'
14
+ const FALLBACK = 'npx -y cyberlegion@1.3.0'
15
15
  const bin = new URL('../../../bin/cyberlegion.mjs', import.meta.url)
16
16
 
17
17
  if (existsSync(fileURLToPath(bin))) {