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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.plugin/pins.json +1 -1
- package/bin/cyberlegion +5 -0
- package/dist/cli.mjs +231 -39
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +78 -16
- package/hooks/hooks.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/readme.md +5 -0
- package/skills/dispatch-governance/SKILL.md +1 -1
- package/skills/dispatch-governance/scripts/cyberlegion.mjs +1 -1
- package/skills/init-cyberlegion/README.md +5 -4
- package/skills/init-cyberlegion/SKILL.md +54 -10
- package/skills/init-cyberlegion/scripts/cyberlegion.mjs +1 -1
- package/skills/legate/SKILL.md +1 -1
- package/skills/legate/scripts/cyberlegion.mjs +1 -1
- package/skills/manage-inbox/SKILL.md +1 -1
- package/skills/manage-inbox/scripts/cyberlegion.mjs +1 -1
- package/skills/relay-governance/SKILL.md +1 -1
- package/skills/relay-governance/scripts/cyberlegion.mjs +1 -1
- package/skills/subagent-backend-governance/SKILL.md +1 -1
- package/skills/subagent-backend-governance/scripts/cyberlegion.mjs +1 -1
package/.plugin/pins.json
CHANGED
package/bin/cyberlegion
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
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
|
|
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
|
|
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
|
|
8798
|
-
*
|
|
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,
|
|
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
|
|
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, {
|
|
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
|
-
|
|
9947
|
-
|
|
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
|
-
|
|
9955
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1265
|
-
|
|
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
|
|
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
|
|
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
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 →
|
|
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,
|
|
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)
|
|
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 `
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
106
|
-
config format
|
|
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.
|
|
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))) {
|
package/skills/legate/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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))) {
|