cyberlegion 0.3.2 → 0.5.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.
Files changed (33) hide show
  1. package/.claude-plugin/plugin.json +20 -0
  2. package/.codex-plugin/plugin.json +20 -0
  3. package/.plugin/pins.json +3 -0
  4. package/agents/headless-legate.md +59 -0
  5. package/bin/cyberlegion.mjs +18 -1
  6. package/dist/cli.mjs +7823 -72
  7. package/dist/index.d.mts +262 -11
  8. package/dist/index.mjs +2298 -4
  9. package/package.json +10 -3
  10. package/plugin.json +24 -0
  11. package/readme.md +63 -0
  12. package/skills/dispatch-governance/README.md +29 -0
  13. package/skills/dispatch-governance/SKILL.md +143 -0
  14. package/skills/dispatch-governance/scripts/cyberlegion.mjs +31 -0
  15. package/skills/init-cyberlegion/README.md +14 -0
  16. package/skills/init-cyberlegion/SKILL.md +112 -0
  17. package/skills/init-cyberlegion/scripts/cyberlegion.mjs +31 -0
  18. package/skills/legate/README.md +22 -0
  19. package/skills/legate/SKILL.md +62 -0
  20. package/skills/legate/scripts/cyberlegion.mjs +31 -0
  21. package/skills/manage-inbox/README.md +12 -0
  22. package/skills/manage-inbox/SKILL.md +90 -0
  23. package/skills/manage-inbox/scripts/cyberlegion.mjs +31 -0
  24. package/skills/relay-governance/README.md +36 -0
  25. package/skills/relay-governance/SKILL.md +204 -0
  26. package/skills/relay-governance/scripts/cyberlegion.mjs +31 -0
  27. package/skills/session-adapter-governance/README.md +17 -0
  28. package/skills/session-adapter-governance/SKILL.md +74 -0
  29. package/skills/subagent-backend-governance/README.md +35 -0
  30. package/skills/subagent-backend-governance/SKILL.md +99 -0
  31. package/skills/subagent-backend-governance/scripts/cyberlegion.mjs +31 -0
  32. package/dist/cli.d.mts +0 -12
  33. package/dist/watch-D33fQcUI.mjs +0 -2044
package/dist/index.mjs CHANGED
@@ -1,4 +1,2298 @@
1
- import { $ as resolveProjectLocalRoot, A as bumpLastSeen, D as selectSessionAdapter, E as spawnAndWake, F as loadAgent, I as prune, J as listAgentDefs, K as touch, L as realExec, N as detectHarness, P as listAgents, Q as projectRoot, T as spawn, U as resolveRecipient, V as resolveAgent, W as resolveSelfId, X as ensureMarker, Y as resolveAgentDef, Z as paths, _ as install, a as emit, c as toonList, d as deleteMessage, et as resolveRoot, f as inbox, g as send, h as resolveBody, i as injectInbox, l as toonObject, n as awaitReply, o as fail, p as peek, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, y as realizeLaunch, z as register } from "./watch-D33fQcUI.mjs";
2
- import { probeMultiplexer } from "cyber-mux";
3
- import { assertDistinctFromPrimary, gitWorktreeAdapter, resolvePrimaryRoot } from "cyber-mux/worktree";
4
- export { FileStore, ack, assertDistinctFromPrimary, awaitReply, bumpLastSeen, decommission, deleteMessage, detectHarness, emit, ensureMarker, fail, gitWorktreeAdapter, inbox, injectInbox, install, listAgentDefs, listAgents, loadAgent, nextStep, paths, peek, probeMultiplexer, projectRoot, prune, realExec, realizeLaunch, register, resolveAgent, resolveAgentDef, resolveBody, resolvePrimaryRoot, resolveProjectLocalRoot, resolveRecipient, resolveRoot, resolveSelfId, selectSessionAdapter, send, spawn, spawnAndWake, toonList, toonObject, touch, watchMail };
1
+ import { callerPane, currentPane, herdrMuxAdapter, nodeExec, nudge, probeMultiplexer, probeMultiplexer as probeMultiplexer$1, tmuxMuxAdapter } from "cyber-mux";
2
+ import { assertDistinctFromPrimary, assertDistinctFromPrimary as assertDistinctFromPrimary$1, gitWorktreeAdapter, gitWorktreeAdapter as gitWorktreeAdapter$1, resolvePrimaryRoot, resolvePrimaryRoot as resolvePrimaryRoot$1 } from "cyber-mux/worktree";
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { execFileSync } from "node:child_process";
7
+ import { homedir } from "node:os";
8
+ //#region src/mux-env.ts
9
+ /**
10
+ * Transitional env-normalization seam (mux.feature: "a pane carrying only the legacy fast-path vars
11
+ * is still honored"). cyber-mux's own fast-path reads `$CYBER_MUX`/`$CYBER_MUX_PANE` — its
12
+ * `currentPane` hardcodes those names (no `envPrefix` override), and `probeMultiplexer`'s `envPrefix`
13
+ * option renames the WHOLE pair together, so neither can be steered to fall back onto a
14
+ * differently-named legacy pair. Every call into cyber-mux that reads the fast-path
15
+ * (`probeMultiplexer`, `currentPane`, `callerPane`, `resolveMuxAdapter`) must be handed this seam's
16
+ * output instead of the raw env.
17
+ *
18
+ * When `$CYBER_MUX`/`$CYBER_MUX_PANE` are both absent and either legacy `$CYBERLEGION_MUX`/
19
+ * `$CYBERLEGION_MUX_PANE` var is present, copies the legacy pair onto the current names. Never
20
+ * overwrites an already-set current var — the current pair always wins outright the moment either
21
+ * half of it is set, exactly matching the frozen precedence chain (mux.feature: "the current
22
+ * fast-path vars win over the legacy pair when both are set").
23
+ *
24
+ * Transitional — deleted once no pre-migration pane (one that only ever exported the legacy pair) is
25
+ * still alive.
26
+ */
27
+ function normalizeMuxEnv(env) {
28
+ const hasCurrent = env.CYBER_MUX !== void 0 || env.CYBER_MUX_PANE !== void 0;
29
+ const hasLegacy = env.CYBERLEGION_MUX !== void 0 || env.CYBERLEGION_MUX_PANE !== void 0;
30
+ if (hasCurrent || !hasLegacy) return env;
31
+ return {
32
+ ...env,
33
+ CYBER_MUX: env.CYBERLEGION_MUX,
34
+ CYBER_MUX_PANE: env.CYBERLEGION_MUX_PANE
35
+ };
36
+ }
37
+ //#endregion
38
+ //#region src/paths.ts
39
+ /** The tracked marker file that makes a hub root initialized (see ensureMarker). */
40
+ const MARKER_FILE = "config.json";
41
+ /** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
42
+ function projectRoot(cwd = process.cwd()) {
43
+ let dir = resolve(cwd);
44
+ for (;;) {
45
+ if (existsSync(join(dir, ".git"))) return dir;
46
+ const parent = dirname(dir);
47
+ if (parent === dir) return resolve(cwd);
48
+ dir = parent;
49
+ }
50
+ }
51
+ function makeDefaultExec(cwd) {
52
+ return (cmd, args) => {
53
+ try {
54
+ return execFileSync(cmd, args, {
55
+ encoding: "utf8",
56
+ stdio: [
57
+ "ignore",
58
+ "pipe",
59
+ "ignore"
60
+ ],
61
+ cwd
62
+ }).trim();
63
+ } catch {
64
+ return null;
65
+ }
66
+ };
67
+ }
68
+ /**
69
+ * Resolve the cyberlegion hub root. Precedence: explicit --root/--space, then `$CYBERLEGION_ROOT`,
70
+ * then the GLOBAL hub `~/.agents/cyberlegion` (addressable across every project and worktree
71
+ * boundary — identity/mail/registry state lives here by default; `--space` isolates it), falling
72
+ * back to a project-local `.agents/cyberlegion` only when no home directory is resolvable.
73
+ */
74
+ function resolveRoot(opts = {}) {
75
+ const env = opts.env ?? process.env;
76
+ const explicit = opts.root ?? opts.space ?? env.CYBERLEGION_ROOT;
77
+ if (explicit) return resolve(explicit);
78
+ const home = homedir();
79
+ if (home) return join(home, ".agents", "cyberlegion");
80
+ return join(projectRoot(opts.cwd), ".agents", "cyberlegion");
81
+ }
82
+ /**
83
+ * Resolve the primary checkout's project-local cyberlegion dir — the tracked marker for a
84
+ * legion-enabled project (never the global hub, and never where spawned unit worktrees themselves
85
+ * are checked out — see `resolveUnitWorktreePath`), keyed off `git worktree`'s common-dir so it
86
+ * resolves the same from the primary checkout or from any of its linked worktrees.
87
+ */
88
+ function resolveProjectLocalRoot(opts = {}) {
89
+ const exec = opts.exec ?? makeDefaultExec(opts.cwd);
90
+ let primary;
91
+ try {
92
+ primary = resolvePrimaryRoot$1(exec);
93
+ } catch {
94
+ primary = void 0;
95
+ }
96
+ return join(primary ?? projectRoot(opts.cwd), ".agents", "cyberlegion");
97
+ }
98
+ /**
99
+ * Create the tracked `config.json` marker at `root` (a hub root) if it does not already exist.
100
+ * Callers pass the root under which the marker should be created/ensured; this mkdir's it as
101
+ * needed. Idempotent — never overwrites an existing marker.
102
+ */
103
+ function ensureMarker(root) {
104
+ mkdirSync(root, { recursive: true });
105
+ const marker = join(root, MARKER_FILE);
106
+ if (!existsSync(marker)) writeFileSync(marker, `${JSON.stringify({ version: 1 }, null, 2)}\n`);
107
+ }
108
+ /**
109
+ * Thrown when an agent/message id destined to become a filename PATH SEGMENT fails
110
+ * `assertSafeId` — see that function's doc for why this rejects rather than encodes.
111
+ */
112
+ var InvalidIdError = class extends Error {
113
+ kind;
114
+ value;
115
+ constructor(kind, value) {
116
+ super(`invalid ${kind} ${JSON.stringify(value)} — must not be empty, ".", "..", or contain a path separator`);
117
+ this.kind = kind;
118
+ this.value = value;
119
+ this.name = "InvalidIdError";
120
+ }
121
+ };
122
+ const UNSAFE_ID = /[\\/\0]/;
123
+ /**
124
+ * Filename-safety guard for any id that becomes a bare PATH SEGMENT under the hub root — agent ids
125
+ * (`agentFile`/`inboxDir`/`dataDir`/`briefFile`) and message ids (`messageFile`). Both are
126
+ * effectively user/peer-controlled: an agent id round-trips a caller-supplied `--handle`-adjacent
127
+ * value in some flows, and nothing upstream of the store validates it before it reaches a `join()`.
128
+ *
129
+ * REJECT, don't encode: unlike `sanitizePane` (which deliberately encodes, because a tmux/herdr pane
130
+ * locator is an opaque, internally-produced token that only needs to survive as a *lookup key*, not
131
+ * round-trip as an identity), an agent/message id is a PRIMARY KEY — `getAgent(id)` must return
132
+ * exactly what `putAgent` stored under that same `id`. Silently encoding two different malformed ids
133
+ * onto the same sanitized filename would let one caller's write silently clobber or read another's
134
+ * record — a worse failure than a loud, immediate refusal. So: empty, `.`, `..`, and any embedded
135
+ * path separator (POSIX `/`, Windows `\`) or NUL byte are rejected outright, which also rejects an
136
+ * absolute path outright (an absolute path always contains a separator).
137
+ */
138
+ function assertSafeId(id, kind) {
139
+ if (!id || id === "." || id === ".." || UNSAFE_ID.test(id)) throw new InvalidIdError(kind, id);
140
+ return id;
141
+ }
142
+ const paths = {
143
+ agentsDir: (root) => join(root, "agents"),
144
+ agentFile: (root, id) => join(root, "agents", `${assertSafeId(id, "agent id")}.json`),
145
+ panesDir: (root) => join(root, "panes"),
146
+ paneFile: (root, pane) => join(root, "panes", `${sanitizePane(pane)}.id`),
147
+ inboxDir: (root, id) => join(root, "inbox", assertSafeId(id, "agent id")),
148
+ inboxReadDir: (root, id) => join(root, "inbox", assertSafeId(id, "agent id"), "read"),
149
+ dataDir: (root, id) => join(root, "data", assertSafeId(id, "agent id")),
150
+ briefFile: (root, id) => join(root, "data", assertSafeId(id, "agent id"), "brief.md"),
151
+ mainPaneFile: (root) => join(root, "main-pane.id"),
152
+ projectsDir: (root) => join(root, "projects"),
153
+ serviceLeaseFile: (root, project, service) => join(root, "services", assertSafeId(project, "project id"), `${assertSafeId(service, "service name")}.json`),
154
+ projectFile: (root, id) => join(root, "projects", `${assertSafeId(id, "project id")}.json`),
155
+ /** A message's file path within `toId`'s unread/read inbox dir, keyed by its own collision-free
156
+ * id — validated the same as an agent id (it's the same class of risk: a peer- or CLI-controlled
157
+ * string becoming a filename). */
158
+ messageFile: (root, toId, msgId) => join(paths.inboxDir(root, toId), `${assertSafeId(msgId, "message id")}.json`),
159
+ messageReadFile: (root, toId, msgId) => join(paths.inboxReadDir(root, toId), `${assertSafeId(msgId, "message id")}.json`)
160
+ };
161
+ /**
162
+ * Where a spawned unit's own git worktree is checked out by default — a sibling of the primary
163
+ * checkout (`<parent>/<repo>.worktrees/legion-<id>`), never nested inside the primary's own working
164
+ * tree. A linked worktree living inside the primary's tree is untracked-but-present: it pollutes
165
+ * `git status` in the primary checkout, confuses tooling that walks the tree recursively (test
166
+ * runners, watchers, `find`/`rm -rf`), and risks a tree-wide op in the primary crossing into the
167
+ * nested worktree's own checkout. `<repo>.worktrees/` matches the sibling convention already in use
168
+ * for other tools' worktrees in this environment (herdr's `worktree-<word>-<word>-<hash>`, cursor's
169
+ * `<proj><count>`); the `legion-` prefix self-identifies this tool's own units the same way, without
170
+ * needing a subfolder.
171
+ */
172
+ function resolveUnitWorktreePath(primaryRoot, id) {
173
+ return join(dirname(primaryRoot), `${basename(primaryRoot)}.worktrees`, `legion-${id}`);
174
+ }
175
+ /** tmux pane ids look like "%3"; make them filesystem-safe. */
176
+ function sanitizePane(pane) {
177
+ return pane.replace(/[^A-Za-z0-9_-]/g, "_");
178
+ }
179
+ //#endregion
180
+ //#region src/identity.ts
181
+ const realExec = nodeExec;
182
+ /** `currentPane`, narrowed to a backend the registry can actually store a locator under. A caller
183
+ * inside a pane-carrying-but-unstorable multiplexer (wezterm/zellij) is simply unpaned here — it can
184
+ * still resolve an identity via the `$CYBERLEGION_AGENT_ID` env fallback, it just cannot bind a pane
185
+ * locator to it. */
186
+ function storablePane(env) {
187
+ const cur = currentPane(normalizeMuxEnv(env));
188
+ return cur && (cur.mux === "tmux" || cur.mux === "herdr") ? {
189
+ mux: cur.mux,
190
+ pane: cur.pane
191
+ } : void 0;
192
+ }
193
+ const nowIso = (ctx) => new Date(ctx.now?.() ?? Date.now()).toISOString();
194
+ function randomId() {
195
+ return randomBytes(8).toString("hex");
196
+ }
197
+ function loadAgent(store, id) {
198
+ return store.getAgent(id);
199
+ }
200
+ function saveAgent(store, rec) {
201
+ store.putAgent(rec);
202
+ }
203
+ function listAgents(store) {
204
+ return store.listAgents();
205
+ }
206
+ /** Detect the harness: explicit wins, then env probes, then the tmux pane command. */
207
+ function detectHarness(explicit, ctx) {
208
+ if (explicit) {
209
+ if (explicit === "claude" || explicit === "cursor" || explicit === "codex") return explicit;
210
+ throw new Error(`unknown --harness "${explicit}" (expected claude | cursor | codex)`);
211
+ }
212
+ const env = ctx.env ?? process.env;
213
+ if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return "claude";
214
+ if (Object.keys(env).some((k) => k.startsWith("CURSOR"))) return "cursor";
215
+ if (Object.keys(env).some((k) => k.startsWith("CODEX"))) return "codex";
216
+ const pane = env.TMUX_PANE;
217
+ if (pane) {
218
+ const cmd = (ctx.exec ?? realExec)("tmux", [
219
+ "display-message",
220
+ "-p",
221
+ "-t",
222
+ pane,
223
+ "#{pane_current_command}"
224
+ ]);
225
+ if (cmd?.includes("cursor")) return "cursor";
226
+ if (cmd?.includes("codex")) return "codex";
227
+ if (cmd?.includes("claude")) return "claude";
228
+ }
229
+ }
230
+ /**
231
+ * Recover the calling agent's own id, mux-agnostically. Inside any multiplexer pane (tmux or herdr)
232
+ * the pane index is authoritative — a pane with no pane entry is simply unregistered and must NOT
233
+ * adopt `$CYBERLEGION_AGENT_ID` (that env fallback applies ONLY when the session is in no
234
+ * multiplexer pane at all). There is no shared bare "self" file — self-id is always pane-keyed or
235
+ * explicit via the env var.
236
+ */
237
+ function resolveSelfId(ctx) {
238
+ const env = ctx.env ?? process.env;
239
+ const cur = currentPane(normalizeMuxEnv(env));
240
+ if (cur) return ctx.store.resolvePaneId(cur.pane);
241
+ return env.CYBERLEGION_AGENT_ID || void 0;
242
+ }
243
+ /** Register (or idempotently refresh) this session's identity. */
244
+ function register(ctx, input) {
245
+ ctx.store.ensureMarker();
246
+ const env = ctx.env ?? process.env;
247
+ const harness = detectHarness(input.harness, ctx);
248
+ if (!harness) throw new Error("could not detect harness — pass --harness claude|cursor|codex");
249
+ const existingId = resolveSelfId(ctx);
250
+ const existing = existingId ? loadAgent(ctx.store, existingId) : void 0;
251
+ const id = existing?.id ?? existingId ?? randomId();
252
+ const ts = nowIso(ctx);
253
+ const cur = storablePane(env);
254
+ const exec = ctx.exec ?? realExec;
255
+ const rec = {
256
+ id,
257
+ handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
258
+ harness,
259
+ cwd: process.cwd(),
260
+ worktree: existing?.worktree ?? gitWorktree(exec),
261
+ pane: cur ? paneLocator(cur, exec, env) : null,
262
+ status: "active",
263
+ createdAt: existing?.createdAt ?? ts,
264
+ lastSeen: ts,
265
+ ...existing?.brief ? { brief: existing.brief } : {},
266
+ ...existing?.spawnedBy ? { spawnedBy: existing.spawnedBy } : {}
267
+ };
268
+ saveAgent(ctx.store, rec);
269
+ if (cur) ctx.store.putPaneIndex(cur.pane, id);
270
+ return rec;
271
+ }
272
+ /** Build the record's pane locator. `window`/`session` are tmux-only (herdr's pane id is
273
+ * self-contained and its CLI needs nothing more to address a pane). */
274
+ function paneLocator(cur, exec, env) {
275
+ if (cur.mux === "tmux") return {
276
+ mux: "tmux",
277
+ id: cur.pane,
278
+ window: exec("tmux", [
279
+ "display-message",
280
+ "-p",
281
+ "-t",
282
+ cur.pane,
283
+ "#{window_id}"
284
+ ]) ?? void 0,
285
+ session: env.TMUX?.split(",")[0]
286
+ };
287
+ return {
288
+ mux: cur.mux,
289
+ id: cur.pane
290
+ };
291
+ }
292
+ function gitWorktree(exec) {
293
+ const root = exec("git", ["rev-parse", "--show-toplevel"]);
294
+ if (!root) return null;
295
+ return {
296
+ root,
297
+ branch: exec("git", [
298
+ "rev-parse",
299
+ "--abbrev-ref",
300
+ "HEAD"
301
+ ]) ?? void 0
302
+ };
303
+ }
304
+ /** Prefer a standing record over a plain session record when both match a handle — an owner
305
+ * report must land in the durable standing inbox, not a dying session's. */
306
+ function preferStanding(matches) {
307
+ return matches.find((a) => a.kind === "standing") ?? matches[0];
308
+ }
309
+ /** Split a handle's matches into live and exited. An exited unit's pane is gone and its inbox has
310
+ * no reader, so a *name* must never resolve to one — a handle is reusable across units, and the
311
+ * dead holders of it outnumber the live one over time. Standing records never exit. An explicit id
312
+ * still resolves either way: naming a unit outright is a deliberate choice, unlike reaching for a
313
+ * handle and silently landing on a corpse. */
314
+ function matchHandle(agents, handle) {
315
+ const matched = agents.filter((a) => a.handle === handle);
316
+ return {
317
+ live: matched.filter((a) => a.status !== "exited"),
318
+ exited: matched.filter((a) => a.status === "exited")
319
+ };
320
+ }
321
+ /** Fail loudly when a handle names only the dead — never fall through to a corpse. */
322
+ function unaddressable(ref, exited, tried) {
323
+ if (exited.length > 0) {
324
+ const dead = exited.map((a) => `${a.id.slice(0, 6)}${a.pane ? ` (${a.pane.id})` : ""}`).join(", ");
325
+ return /* @__PURE__ */ new Error(`"${ref}" matches only exited unit(s) — ${dead} — which have no reader. Address a live unit ('cyberlegion unit who'), or run 'cyberlegion unit register --standing --handle ${ref}' for a durable inbox.`);
326
+ }
327
+ return /* @__PURE__ */ new Error(`no agent addressable as "${ref}" (tried ${tried})`);
328
+ }
329
+ /** Resolve a recipient argument (id or handle) to an agent id. A handle resolves to live units
330
+ * only — mail addressed to an exited unit lands in an inbox nobody reads. */
331
+ function resolveRecipient(store, to) {
332
+ if (loadAgent(store, to)) return to;
333
+ const { live, exited } = matchHandle(listAgents(store), to);
334
+ const match = preferStanding(live);
335
+ if (!match) throw unaddressable(to, exited, "id and handle");
336
+ return match.id;
337
+ }
338
+ /**
339
+ * Resolve a unit reference by id, handle, or its worktree branch (the unit↔CR join key: an
340
+ * `AgentRecord.worktree.branch` equals the SDD `<cr-ref>` it maps to, when spawned for one). Used
341
+ * by verbs that address "the unit working on CR X" as well as "the unit named X".
342
+ */
343
+ function resolveAgent(store, ref) {
344
+ const byId = loadAgent(store, ref);
345
+ if (byId) return byId;
346
+ const agents = listAgents(store);
347
+ const { live, exited } = matchHandle(agents, ref);
348
+ const byHandle = preferStanding(live);
349
+ if (byHandle) return byHandle;
350
+ const byBranchAll = agents.filter((a) => a.worktree?.branch === ref);
351
+ const byBranch = byBranchAll.find((a) => a.status !== "exited");
352
+ if (byBranch) return byBranch;
353
+ throw unaddressable(ref, [...exited, ...byBranchAll.filter((a) => a.status === "exited")].filter((a, i, all) => all.findIndex((b) => b.id === a.id) === i), "id, handle, and worktree branch/CR");
354
+ }
355
+ function bumpLastSeen(ctx, id) {
356
+ const rec = loadAgent(ctx.store, id);
357
+ if (!rec) return;
358
+ rec.lastSeen = nowIso(ctx);
359
+ saveAgent(ctx.store, rec);
360
+ }
361
+ /** Refresh this session's own last-seen if it is registered — best-effort, never throws. */
362
+ function touch(ctx) {
363
+ const id = resolveSelfId(ctx);
364
+ if (id) bumpLastSeen(ctx, id);
365
+ }
366
+ const STALE_MS = 9e5;
367
+ /** Standing and service records have no session of their own, so nothing about a pane or a timer
368
+ * can declare them dead. */
369
+ function isSessionIndependent(rec) {
370
+ return rec.kind === "standing" || rec.kind === "service";
371
+ }
372
+ /**
373
+ * Whether a unit's session is still there, as far as its backend can tell. "Cannot rule out alive"
374
+ * must never become grounds to replace an owner — the same fail-closed policy `store/lock.ts` takes
375
+ * on an ambiguous holder — so a session reads as gone only on positive evidence: its multiplexer
376
+ * answered with a pane list and the pane is not in it. A record with no pane cannot be probed, and a
377
+ * backend the caller cannot reach (a different server, no client, a failed query) answers with
378
+ * nothing; both read as live. `paneExists` is not used here because it collapses "unreachable" into
379
+ * "gone". An exited record is never live.
380
+ */
381
+ function sessionLive(ctx, rec) {
382
+ if (rec.status === "exited") return false;
383
+ if (!rec.pane) return true;
384
+ const panes = PANE_ADAPTERS[rec.pane.mux].listPanes(ctx.exec ?? realExec);
385
+ if (panes.length === 0) return true;
386
+ const id = rec.pane.id;
387
+ return panes.some((p) => p.id === id);
388
+ }
389
+ /** The per-mux session adapters `prune` consults for pane liveness — each answers with its own
390
+ * backend primitive so a herdr pane is never probed with a tmux query, and vice versa. */
391
+ const PANE_ADAPTERS = {
392
+ tmux: tmuxMuxAdapter,
393
+ herdr: herdrMuxAdapter
394
+ };
395
+ /** Map a backend-reported agent string to a known harness — substring-matched like the tmux
396
+ * pane-command probe in `detectHarness`; anything else is unclassifiable. */
397
+ function harnessFromAgent(agent) {
398
+ if (!agent) return void 0;
399
+ if (agent.includes("cursor")) return "cursor";
400
+ if (agent.includes("codex")) return "codex";
401
+ if (agent.includes("claude")) return "claude";
402
+ }
403
+ /**
404
+ * Adopt half of reconcile-against-mux: mint a record for each live pane with a detectable harness
405
+ * and no matching record — bind pane→id, handle from the pane's reported cwd basename (sanitized;
406
+ * `id.slice(0, 6)` when the backend reports no cwd), status active, lastSeen now. A pane is bound —
407
+ * and never adopted — when its pane index resolves to an existing record or any record (any status,
408
+ * exited included) bears it; resurrecting an exited record is the in-pane session's own `register`
409
+ * via the pane pointer, never reconcile's. tmux panes carry no harness signal, so only herdr panes
410
+ * are adoptable today.
411
+ */
412
+ function adopt(ctx, panes) {
413
+ const agents = listAgents(ctx.store);
414
+ const adopted = [];
415
+ for (const pane of panes) {
416
+ if (pane.mux !== "tmux" && pane.mux !== "herdr") continue;
417
+ const harness = harnessFromAgent(pane.harness);
418
+ if (!harness) continue;
419
+ const boundId = ctx.store.resolvePaneId(pane.id);
420
+ if (boundId && loadAgent(ctx.store, boundId)) continue;
421
+ if (agents.some((a) => a.pane?.mux === pane.mux && a.pane.id === pane.id)) continue;
422
+ const id = randomId();
423
+ const ts = nowIso(ctx);
424
+ const rec = {
425
+ id,
426
+ handle: pane.cwd ? sanitizePane(basename(pane.cwd)) : id.slice(0, 6),
427
+ harness,
428
+ cwd: pane.cwd ?? "",
429
+ pane: {
430
+ mux: pane.mux,
431
+ id: pane.id
432
+ },
433
+ status: "active",
434
+ createdAt: ts,
435
+ lastSeen: ts
436
+ };
437
+ saveAgent(ctx.store, rec);
438
+ ctx.store.putPaneIndex(pane.id, id);
439
+ adopted.push(rec);
440
+ }
441
+ return adopted;
442
+ }
443
+ /**
444
+ * Cull dead records against the current mux's live pane set (the mux the caller is actually inside,
445
+ * per `currentPane`) — mux-scoped: it never declares the *other* mux's records dead, since it can't
446
+ * enumerate them. Standing records are exempt; a `pane: null` record can't be pane-culled by
447
+ * enumeration (left to `prune`'s staleness timer). Outside any multiplexer pane there is nothing to
448
+ * enumerate, so it culls nothing. With `adopt` set (the `who --reconcile` path — `prune` stays
449
+ * cull-only), the same live set also feeds adoption of unbound harness-bearing panes.
450
+ */
451
+ function reconcile(ctx, opts) {
452
+ const exec = ctx.exec ?? realExec;
453
+ const cur = storablePane(ctx.env ?? process.env);
454
+ if (!cur) return [];
455
+ const panes = PANE_ADAPTERS[cur.mux].listPanes(exec);
456
+ const live = new Set(panes.map((p) => p.id));
457
+ const changed = [];
458
+ for (const rec of listAgents(ctx.store)) {
459
+ if (isSessionIndependent(rec)) continue;
460
+ if (rec.status === "exited") continue;
461
+ if (!rec.pane) continue;
462
+ if (rec.pane.mux !== cur.mux) continue;
463
+ if (!live.has(rec.pane.id)) {
464
+ rec.status = "exited";
465
+ saveAgent(ctx.store, rec);
466
+ changed.push(rec);
467
+ }
468
+ }
469
+ if (opts?.adopt) changed.push(...adopt(ctx, panes));
470
+ return changed;
471
+ }
472
+ /** Mark agents whose pane is gone or whose last-seen is stale as exited. Reconcile-culls against the
473
+ * current mux's live set first, then falls through to the per-record paneExists + staleness check
474
+ * (covers the other mux and sessions outside any multiplexer pane). */
475
+ function prune(ctx) {
476
+ const exec = ctx.exec ?? realExec;
477
+ const now = ctx.now?.() ?? Date.now();
478
+ const changed = reconcile(ctx);
479
+ for (const rec of listAgents(ctx.store)) {
480
+ if (isSessionIndependent(rec)) continue;
481
+ if (rec.status === "exited") continue;
482
+ const paneGone = rec.pane ? !PANE_ADAPTERS[rec.pane.mux].paneExists(exec, { id: rec.pane.id }) : false;
483
+ const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
484
+ if (paneGone || stale) {
485
+ rec.status = "exited";
486
+ saveAgent(ctx.store, rec);
487
+ changed.push(rec);
488
+ }
489
+ }
490
+ return changed;
491
+ }
492
+ //#endregion
493
+ //#region src/console/doorbell.ts
494
+ /**
495
+ * The first-turn doorbell `unit spawn` delivers to a freshly-opened paned peer — the instruction
496
+ * itself, not a notification that context is already populated. It names the brief's **file path**
497
+ * and tells the peer to read it and begin, so pickup does not depend on a SessionStart hook firing
498
+ * in the child (superseding ADR-0027, which split payload-delivery from turn-delivery).
499
+ *
500
+ * The path is named, never the brief's body: the brief is still written to its file and still never
501
+ * typed into the pane, so a long brief costs one line here however large it is, and a re-submit on
502
+ * the boot race re-types this instruction rather than the payload.
503
+ */
504
+ function spawnDoorbell(briefPath) {
505
+ return `Read your brief at ${briefPath}, then begin work.`;
506
+ }
507
+ /**
508
+ * A freshly-launched harness cold-boots slower than an already-running peer, so the spawn first-turn
509
+ * ring gets a wider retry budget than a plain `mail send` doorbell (nudge's own 10 × 400ms): flush the
510
+ * staged buffer for up to ~8s (20 × 400ms) before giving up. The common case still returns after one
511
+ * settle cycle once the peer takes the turn — the budget only bounds a slow or stuck boot. Still
512
+ * bounded — a harness that never reaches its prompt resolves to a best-effort warning, never a hang.
513
+ */
514
+ const SPAWN_NUDGE_OPTS = {
515
+ attempts: 20,
516
+ settleMs: 400
517
+ };
518
+ /**
519
+ * Best-effort deliver a freshly-spawned paned peer's first turn so it acts on its brief with no
520
+ * human nudge. A paned agent boots to an idle prompt — the brief sits unread on disk and the model
521
+ * takes no turn on its own, unlike a subagent (where the caller's Task call IS the turn). So `unit
522
+ * spawn` rings the instruction (`spawnDoorbell`, naming the brief's file path) over the
523
+ * boot-race-aware `nudge` submit-verify path (a taken turn, never typing the brief itself), the same
524
+ * best-effort ring `wakeRecipient` gives `mail send`: the spawn (worktree, session, registry record)
525
+ * is the guaranteed effect and the ring is opportunistic on top, so `--no-wake` rings nothing and a
526
+ * ring that never completes within the retry budget is swallowed into a warning. This never throws —
527
+ * it can never fail the spawn.
528
+ *
529
+ * The adapter is resolved lazily via `getAdapter` inside the same swallowing try, so even a session
530
+ * whose backend has since gone away (where `selectSessionAdapter` would throw) degrades to a warned
531
+ * no-op rather than a failed spawn.
532
+ */
533
+ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS) {
534
+ if (input.noWake) return { rung: false };
535
+ try {
536
+ await nudge(getAdapter(), exec, input.target, spawnDoorbell(input.briefPath), nudgeOpts);
537
+ return {
538
+ rung: true,
539
+ pane: input.target.id
540
+ };
541
+ } catch (err) {
542
+ return {
543
+ rung: false,
544
+ pane: input.target.id,
545
+ warning: err instanceof Error ? err.message : String(err)
546
+ };
547
+ }
548
+ }
549
+ //#endregion
550
+ //#region src/mux-select.ts
551
+ /**
552
+ * Backend selection via cyber-mux's two-mode mux probe, normalized through the transitional
553
+ * `$CYBERLEGION_MUX*` → `$CYBER_MUX*` env seam (`mux-env.ts`) — tmux/herdr map to their existing
554
+ * cyber-mux adapters.
555
+ *
556
+ * cyber-mux detects MORE backends than `unit/registry`'s `AgentRecord.pane` can carry a locator
557
+ * under (`'tmux' | 'herdr'` only). A DETECTED wezterm/zellij is refused HERE, before anything opens,
558
+ * naming the backend it found (mux.feature: "a detected backend a unit record cannot carry is
559
+ * refused before opening anything") — driving it would open a real pane no record could name,
560
+ * stranding a live session `prune` can never reap and no caller can nudge. Anything else (`none`,
561
+ * `screen`) falls through to the plain "no backend" refusal, unchanged from before the migration.
562
+ */
563
+ function selectSessionAdapter(env, exec = realExec) {
564
+ const probe = probeMultiplexer$1(exec, normalizeMuxEnv(env));
565
+ if (probe.mux === "tmux") return tmuxMuxAdapter;
566
+ if (probe.mux === "herdr") return herdrMuxAdapter;
567
+ if (probe.mux === "wezterm" || probe.mux === "zellij") throw new Error(`spawn detected ${probe.mux}, a backend unit/registry cannot store a pane locator under (only tmux and herdr) — refusing before opening anything`);
568
+ throw new Error("spawn requires a session backend — run inside tmux ($TMUX) or herdr ($HERDR_ENV=1)");
569
+ }
570
+ //#endregion
571
+ //#region src/workspace-label.ts
572
+ /** Leading actions that tear down or revert — the `A2-` class, checked first. */
573
+ const TEARDOWN_ACTIONS = /* @__PURE__ */ new Set([
574
+ "clean",
575
+ "cleanup",
576
+ "decommission",
577
+ "delete",
578
+ "deprecate",
579
+ "disable",
580
+ "drop",
581
+ "prune",
582
+ "purge",
583
+ "remove",
584
+ "retire",
585
+ "revert",
586
+ "revoke",
587
+ "rollback",
588
+ "teardown",
589
+ "undo",
590
+ "uninstall",
591
+ "unregister"
592
+ ]);
593
+ /** Leading actions that only look and report — the `9S-` class, checked after teardown. */
594
+ const RECON_ACTIONS = /* @__PURE__ */ new Set([
595
+ "analyze",
596
+ "assess",
597
+ "audit",
598
+ "check",
599
+ "compare",
600
+ "debug",
601
+ "diagnose",
602
+ "evaluate",
603
+ "examine",
604
+ "explain",
605
+ "explore",
606
+ "find",
607
+ "identify",
608
+ "inspect",
609
+ "investigate",
610
+ "locate",
611
+ "measure",
612
+ "profile",
613
+ "read",
614
+ "reproduce",
615
+ "research",
616
+ "review",
617
+ "scan",
618
+ "survey",
619
+ "trace",
620
+ "triage",
621
+ "verify"
622
+ ]);
623
+ /**
624
+ * Leading actions that build or change — the `2B-` class. `2B-` is also the code for a brief whose
625
+ * lead matches nothing at all, but the two cases differ in the SUBJECT: a recognized action is
626
+ * dropped from it (the code already says "build"), an unrecognized leading word is kept (it is the
627
+ * subject's own first noun, and dropping it would strip real signal).
628
+ */
629
+ const BUILD_ACTIONS = /* @__PURE__ */ new Set([
630
+ "add",
631
+ "backfill",
632
+ "build",
633
+ "create",
634
+ "document",
635
+ "enable",
636
+ "extend",
637
+ "fix",
638
+ "generalize",
639
+ "harden",
640
+ "implement",
641
+ "introduce",
642
+ "make",
643
+ "migrate",
644
+ "move",
645
+ "port",
646
+ "refactor",
647
+ "rename",
648
+ "replace",
649
+ "rework",
650
+ "ship",
651
+ "split",
652
+ "support",
653
+ "swap",
654
+ "update",
655
+ "wire",
656
+ "write"
657
+ ]);
658
+ /** Dropped when it leads the subject — an article carries no identifying signal. */
659
+ const LEADING_ARTICLES = /* @__PURE__ */ new Set([
660
+ "a",
661
+ "an",
662
+ "the"
663
+ ]);
664
+ /**
665
+ * Resolve the label a `workspace` placement opens under. Total width never exceeds `LABEL_CAP`, and
666
+ * the result always carries a code — a brief that yields no usable subject falls back to the unit's
667
+ * own short id rather than to a bare code.
668
+ */
669
+ function deriveWorkspaceLabel(input) {
670
+ const briefWords = tokenize(firstNonEmptyLine(input.brief));
671
+ const lead = briefWords[0];
672
+ let code = "2B-";
673
+ let leadIsAction = false;
674
+ if (lead && TEARDOWN_ACTIONS.has(lead)) {
675
+ code = "A2-";
676
+ leadIsAction = true;
677
+ } else if (lead && RECON_ACTIONS.has(lead)) {
678
+ code = "9S-";
679
+ leadIsAction = true;
680
+ } else if (lead && BUILD_ACTIONS.has(lead)) leadIsAction = true;
681
+ let subjectWords;
682
+ if (input.handle) subjectWords = tokenize(input.handle);
683
+ else {
684
+ subjectWords = leadIsAction ? briefWords.slice(1) : briefWords.slice();
685
+ if (subjectWords[0] && LEADING_ARTICLES.has(subjectWords[0])) subjectWords.shift();
686
+ }
687
+ const subject = fitWords(subjectWords, 27) || input.id.slice(0, 6);
688
+ return `${code}${subject}`;
689
+ }
690
+ /** The brief's first line with any content — a leading blank line is not the subject. */
691
+ function firstNonEmptyLine(brief) {
692
+ for (const line of brief.split("\n")) if (line.trim() !== "") return line;
693
+ return "";
694
+ }
695
+ /** Lowercase alphanumeric runs; every other character is a separator, never part of a word. */
696
+ function tokenize(text) {
697
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w !== "");
698
+ }
699
+ /**
700
+ * Join whole words with `-` while they fit `budget`, so the label ends on a complete word rather
701
+ * than mid-word. A single first word wider than the whole budget is hard-truncated — that is the
702
+ * only case where a label can end mid-word, and the alternative is no subject at all.
703
+ */
704
+ function fitWords(words, budget) {
705
+ if (words.length === 0) return "";
706
+ let out = words[0].slice(0, budget);
707
+ for (const word of words.slice(1)) {
708
+ const next = `${out}-${word}`;
709
+ if (next.length > budget) break;
710
+ out = next;
711
+ }
712
+ return out;
713
+ }
714
+ //#endregion
715
+ //#region src/session.ts
716
+ /** How each harness's own CLI is launched in the new pane. */
717
+ const LAUNCH_MAP = {
718
+ claude: "claude",
719
+ cursor: "cursor-agent",
720
+ codex: "codex"
721
+ };
722
+ /**
723
+ * Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
724
+ * the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
725
+ * its cwd set to that worktree, pre-register the peer, and drop its brief as a file — never typed
726
+ * into its prompt. Nothing loads that file for the peer: it is the spawn wake that tells the peer to
727
+ * go read it, naming its path (`spawnAndWake` below, and ADR-0032). `spawn` alone therefore leaves a
728
+ * peer sitting idle with its brief unread, which is why callers want `spawnAndWake`.
729
+ *
730
+ * Spawned unit worktrees live sibling to the primary checkout (`<parent>/<repo>.worktrees/legion-<id6>`),
731
+ * never nested inside it, even though the registry/mailbox itself lives in the global hub — the hub
732
+ * addresses units across project and worktree boundaries, but a unit's checkout is necessarily
733
+ * scoped to this one project's git remote.
734
+ */
735
+ function spawn(ctx, input) {
736
+ ctx.store.ensureMarker();
737
+ const env = ctx.env ?? process.env;
738
+ const exec = ctx.exec ?? realExec;
739
+ const sessionAdapter = selectSessionAdapter(env, exec);
740
+ const normalizedEnv = normalizeMuxEnv(env);
741
+ const harness = input.harness;
742
+ if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
743
+ const brief = resolveBrief(input);
744
+ if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
745
+ if (input.cwd && (input.branch || input.worktreePath)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path");
746
+ const id = randomId();
747
+ const primaryRoot = resolvePrimaryRoot$1(exec);
748
+ const launch = input.command ?? LAUNCH_MAP[harness];
749
+ const launchLine = () => {
750
+ const shimDir = ctx.self ? writeSelfShim(paths.dataDir(ctx.store.root, id), ctx.self) : void 0;
751
+ return `${shimDir ? `PATH=${shellQuote$1(shimDir)}:"$PATH" ` : ""}${muxEnvPrefix(sessionAdapter.name)}${launch}`;
752
+ };
753
+ const from = callerPane(sessionAdapter, normalizedEnv);
754
+ let cwd;
755
+ let worktree;
756
+ let target;
757
+ if (input.cwd) {
758
+ if (!existsSync(input.cwd)) throw new Error(`--cwd directory must already exist: ${input.cwd}`);
759
+ cwd = resolve(input.cwd);
760
+ assertDistinctFromPrimary$1(cwd, primaryRoot);
761
+ worktree = null;
762
+ const at = input.at ?? "tab";
763
+ target = sessionAdapter.open(exec, {
764
+ cwd,
765
+ launch: launchLine(),
766
+ at,
767
+ from,
768
+ ...labelFor(at, input, brief, id)
769
+ });
770
+ } else {
771
+ const branch = input.branch ?? `cyberlegion/unit-${id}`;
772
+ const at = input.at ?? "workspace";
773
+ const worktreePath = input.worktreePath ?? resolveUnitWorktreePath(primaryRoot, id.slice(0, 6));
774
+ assertDistinctFromPrimary$1(resolve(worktreePath), primaryRoot);
775
+ if (at === "workspace" && sessionAdapter.worktree) {
776
+ const opened = sessionAdapter.worktree.createInWorkspace(exec, {
777
+ primaryRoot,
778
+ branch,
779
+ path: worktreePath,
780
+ launch: launchLine(),
781
+ ...labelFor(at, input, brief, id)
782
+ });
783
+ assertDistinctFromPrimary$1(opened.worktree.root, primaryRoot);
784
+ ensureMarker(join(opened.worktree.root, ".agents", "cyberlegion"));
785
+ cwd = opened.worktree.root;
786
+ worktree = opened.worktree;
787
+ target = opened.target;
788
+ } else {
789
+ const added = gitWorktreeAdapter$1.add(exec, {
790
+ primaryRoot,
791
+ path: worktreePath,
792
+ branch
793
+ });
794
+ assertDistinctFromPrimary$1(added.root, primaryRoot);
795
+ ensureMarker(join(added.root, ".agents", "cyberlegion"));
796
+ cwd = added.root;
797
+ worktree = added;
798
+ target = sessionAdapter.open(exec, {
799
+ cwd,
800
+ launch: launchLine(),
801
+ at,
802
+ from,
803
+ ...labelFor(at, input, brief, id)
804
+ });
805
+ }
806
+ }
807
+ const ts = new Date(ctx.now?.() ?? Date.now()).toISOString();
808
+ const muxName = sessionAdapter.name;
809
+ const rec = {
810
+ id,
811
+ handle: input.handle ?? id.slice(0, 6),
812
+ harness,
813
+ cwd,
814
+ worktree,
815
+ pane: muxName === "tmux" || muxName === "herdr" ? {
816
+ mux: muxName,
817
+ id: target.id
818
+ } : null,
819
+ status: "active",
820
+ createdAt: ts,
821
+ lastSeen: ts,
822
+ brief: paths.briefFile(ctx.store.root, id),
823
+ ...resolveSelfId(ctx) ? { spawnedBy: resolveSelfId(ctx) } : {}
824
+ };
825
+ saveAgent(ctx.store, rec);
826
+ ctx.store.putPaneIndex(target.id, id);
827
+ ctx.store.writeBrief(id, composeBrief(brief, input.briefInstructions));
828
+ return {
829
+ agent: rec,
830
+ pane: target.id,
831
+ launch
832
+ };
833
+ }
834
+ /**
835
+ * `unit spawn` end to end: open the peer, then deliver its first turn.
836
+ *
837
+ * The two acts live together here rather than at the CLI call site so the **brief path the doorbell
838
+ * names is derived from the record `spawn` just wrote**, not handed in by a caller. That is the
839
+ * point of the seam: a caller cannot ring with the wrong path because it supplies no path — an
840
+ * empty string, a stale path, or the brief's whole body are all unrepresentable here, rather than
841
+ * merely untested.
842
+ *
843
+ * The ring is best-effort on top of the guaranteed spawn effect (worktree + session + registry
844
+ * record): `--no-wake` rings nothing, and a ring that never completes is reported as a warning on
845
+ * the result rather than thrown, so it can never fail a spawn that already landed.
846
+ */
847
+ async function spawnAndWake(ctx, input, options = {}) {
848
+ const res = spawn(ctx, input);
849
+ const env = ctx.env ?? process.env;
850
+ const exec = ctx.exec ?? realExec;
851
+ const briefPath = res.agent.brief;
852
+ const wake = await wakeSpawn(() => selectSessionAdapter(env, exec), exec, {
853
+ target: { id: res.pane },
854
+ briefPath: briefPath ?? "",
855
+ noWake: options.noWake || !briefPath
856
+ }, options.nudgeOpts);
857
+ return {
858
+ ...res,
859
+ rung: wake.rung,
860
+ ...wake.warning ? { warning: wake.warning } : {}
861
+ };
862
+ }
863
+ /**
864
+ * The label a `workspace` placement opens under — the short human-scannable name that makes a unit's
865
+ * own visible space findable by eye (`console/workspace-label.ts` owns the naming rule). Only the
866
+ * `workspace` placement carries one: a pane or tab lives inside a space the caller is already
867
+ * looking at, so it has nothing of its own to name. Spread into the adapter options, so a
868
+ * non-workspace placement passes no `label` key at all rather than an explicit `undefined`.
869
+ *
870
+ * The gate is load-bearing, not defensive: cyber-mux names whatever tier `at` opens — a workspace,
871
+ * a tab, or a pane — so passing a label on a `tab` placement would rename the caller's own tab.
872
+ * Exported so that gate is tested directly rather than only through a backend's arguments.
873
+ */
874
+ function labelFor(at, input, brief, id) {
875
+ if (at !== "workspace") return {};
876
+ return { label: deriveWorkspaceLabel({
877
+ brief,
878
+ handle: input.handle,
879
+ id
880
+ }) };
881
+ }
882
+ /**
883
+ * The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
884
+ * multiplexer fast-path and never has to run its own ancestry discovery (`$CYBER_MUX` /
885
+ * `$CYBER_MUX_PANE`). `VAR=val cmd` scopes the vars to that one process (and its children)
886
+ * without needing `export`. tmux natively sets `$TMUX_PANE` for a pane's own processes, so the
887
+ * pane var is expanded by the child's own shell rather than baked in here.
888
+ */
889
+ function muxEnvPrefix(muxName) {
890
+ if (muxName === "tmux") return "CYBER_MUX=tmux CYBER_MUX_PANE=$TMUX_PANE ";
891
+ if (muxName === "herdr") return "CYBER_MUX=herdr ";
892
+ return "";
893
+ }
894
+ /**
895
+ * Write `<dataDir>/bin/cyberlegion`, a POSIX shim that execs `self` with the caller's arguments,
896
+ * and return its directory. A spawned session gets that directory first on its PATH, so the
897
+ * `cyberlegion` a brief tells it to run is the install that spawned it: nothing is resolved from
898
+ * the registry or the session's own PATH at report time.
899
+ */
900
+ function writeSelfShim(dataDir, self) {
901
+ const dir = join(dataDir, "bin");
902
+ mkdirSync(dir, { recursive: true });
903
+ const shim = join(dir, "cyberlegion");
904
+ writeFileSync(shim, `#!/bin/sh\nexec ${self.map(shellQuote$1).join(" ")} "$@"\n`);
905
+ chmodSync(shim, 493);
906
+ return dir;
907
+ }
908
+ /** Single-quote `s` for a POSIX shell, so any path survives word splitting and expansion. */
909
+ function shellQuote$1(s) {
910
+ return `'${s.replaceAll("'", "'\\''")}'`;
911
+ }
912
+ /** The brief file's text: the task alone, or — when the harness took no instructions on its command
913
+ * line — the def's instructions under their own heading ahead of it, so the peer can see they reach
914
+ * it as a user turn rather than a system prompt. */
915
+ function composeBrief(brief, instructions) {
916
+ if (!instructions) return brief;
917
+ return `## Agent instructions\n\n${instructions}\n\n## Brief\n\n${brief}`;
918
+ }
919
+ /** Resolve a spawn brief from --brief-file, --task -, or --task <text>; null if no source given. */
920
+ function resolveBrief(input, readStdin = () => readFileSync(0, "utf8")) {
921
+ if (input.briefFile) return readFileSync(input.briefFile, "utf8");
922
+ if (input.task === "-") return readStdin();
923
+ if (input.task != null && input.task !== "") return input.task;
924
+ return null;
925
+ }
926
+ //#endregion
927
+ //#region src/agentdef/resolve.ts
928
+ function parseAgentDefFile(text) {
929
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/.exec(text);
930
+ if (!m) return {
931
+ fm: {},
932
+ instructions: text.trim()
933
+ };
934
+ const [, block, rest] = m;
935
+ const fm = {};
936
+ const lines = block.split("\n");
937
+ let i = 0;
938
+ while (i < lines.length) {
939
+ const line = lines[i].replace(/\r$/, "");
940
+ if (line.trim() === "" || line.trim().startsWith("#")) {
941
+ i++;
942
+ continue;
943
+ }
944
+ if (line.length - line.trimStart().length > 0) {
945
+ i++;
946
+ continue;
947
+ }
948
+ const idx = line.indexOf(":");
949
+ if (idx === -1) {
950
+ i++;
951
+ continue;
952
+ }
953
+ const key = line.slice(0, idx).trim();
954
+ let value = line.slice(idx + 1).trim();
955
+ i++;
956
+ if (value === ">" || value === ">-" || value === "|" || value === "|-") {
957
+ const parts = [];
958
+ while (i < lines.length) {
959
+ const l = lines[i].replace(/\r$/, "");
960
+ if (l.trim() === "") {
961
+ i++;
962
+ continue;
963
+ }
964
+ if (l.length - l.trimStart().length === 0) break;
965
+ parts.push(l.trim());
966
+ i++;
967
+ }
968
+ value = parts.join(value.startsWith("|") ? "\n" : " ");
969
+ }
970
+ fm[key] = unquote(value);
971
+ }
972
+ return {
973
+ fm,
974
+ instructions: rest.trim()
975
+ };
976
+ }
977
+ function unquote(v) {
978
+ if (v.length >= 2 && (v.startsWith("\"") && v.endsWith("\"") || v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1);
979
+ return v;
980
+ }
981
+ function toBool(v) {
982
+ if (v === void 0) return void 0;
983
+ if (v === "true") return true;
984
+ if (v === "false") return false;
985
+ }
986
+ /** The known harnesses are exactly those with a launch binary — one home for the set. */
987
+ const HARNESSES = Object.keys(LAUNCH_MAP);
988
+ /** A `harness` tag outside the known set has no launch binary, so a typo fails here, loudly, rather
989
+ * than surfacing later as an unlaunchable command. */
990
+ function toHarness(value, path) {
991
+ if (value === void 0 || HARNESSES.includes(value)) return value;
992
+ throw new Error(`agent def "${path}" has unknown harness "${value}" — expected one of ${HARNESSES.join(", ")}`);
993
+ }
994
+ function toAgentDef(path, text, fallbackName) {
995
+ const { fm, instructions } = parseAgentDefFile(text);
996
+ return {
997
+ name: fm.name ?? fallbackName,
998
+ description: fm.description,
999
+ model: fm.model,
1000
+ effort: fm.effort,
1001
+ harness: toHarness(fm.harness, path),
1002
+ warm: toBool(fm.warm),
1003
+ interactive: toBool(fm.interactive),
1004
+ instructions,
1005
+ path
1006
+ };
1007
+ }
1008
+ /** The project's own `.agents/agents/` dir — the convention `agent list`/name-search scans. */
1009
+ function projectAgentsDir(cwd) {
1010
+ return join(projectRoot(cwd), ".agents", "agents");
1011
+ }
1012
+ /** Resolve one agent definition, by explicit file or by name search. Throws a clear error when
1013
+ * neither locates a def — never returns a partial/empty result. */
1014
+ function resolveAgentDef(input) {
1015
+ if (input.file) {
1016
+ const path = resolve(input.file);
1017
+ if (!existsSync(path)) throw new Error(`agent def file "${input.file}" does not exist`);
1018
+ return toAgentDef(path, readFileSync(path, "utf8"), basename(path).replace(/\.md$/, ""));
1019
+ }
1020
+ if (!input.name) throw new Error("resolveAgentDef needs a --agent <name> or --agent-file <path>");
1021
+ const roots = [...input.searchRoots ?? [], projectAgentsDir(input.cwd)];
1022
+ for (const root of roots) {
1023
+ const candidate = join(root, `${input.name}.md`);
1024
+ if (existsSync(candidate)) return toAgentDef(candidate, readFileSync(candidate, "utf8"), input.name);
1025
+ }
1026
+ throw new Error(`no agent definition named "${input.name}" found under .agents/agents/`);
1027
+ }
1028
+ /** List every resolvable agent def under `.agents/agents/` (+ any extra search roots). Definitive
1029
+ * empty array when the dir is absent or empty — never throws for "none found" here (unlike a
1030
+ * named resolve, listing is inherently zero-or-more). */
1031
+ function listAgentDefs(input = {}) {
1032
+ const roots = [...input.searchRoots ?? [], projectAgentsDir(input.cwd)];
1033
+ const seen = /* @__PURE__ */ new Set();
1034
+ const defs = [];
1035
+ for (const root of roots) {
1036
+ if (!existsSync(root)) continue;
1037
+ for (const file of readdirSync(root)) {
1038
+ if (!file.endsWith(".md")) continue;
1039
+ const path = join(root, file);
1040
+ if (seen.has(path)) continue;
1041
+ seen.add(path);
1042
+ defs.push(toAgentDef(path, readFileSync(path, "utf8"), file.replace(/\.md$/, "")));
1043
+ }
1044
+ }
1045
+ return defs;
1046
+ }
1047
+ //#endregion
1048
+ //#region src/agentdef/realize.ts
1049
+ const DEFAULT_HARNESS = "claude";
1050
+ /** POSIX single-quote a value for safe inclusion in a shell command line. */
1051
+ function shellQuote(value) {
1052
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1053
+ }
1054
+ /** Cursor carries effort as a bracket parameter on the model (`<model>[effort=<level>]`): merge it into
1055
+ * any bracket list the model already has, replacing an `effort=` already there. */
1056
+ function withCursorEffort(model, effort) {
1057
+ const open = model.lastIndexOf("[");
1058
+ if (open === -1 || !model.endsWith("]")) return `${model}[effort=${effort}]`;
1059
+ const params = model.slice(open + 1, -1).split(",").filter((p) => p !== "" && !p.startsWith("effort="));
1060
+ return `${model.slice(0, open)}[${[...params, `effort=${effort}`].join(",")}]`;
1061
+ }
1062
+ /** The model + effort arguments for one harness. No two harnesses spell effort alike: claude has
1063
+ * `--effort`, codex only a config override, cursor only a parameter on the model — so a cursor
1064
+ * effort with no model has nowhere to go and throws rather than launching at the default effort. */
1065
+ function modelAndEffortArgs(harness, model, effort) {
1066
+ if (harness === "cursor") {
1067
+ if (effort && !model) throw new Error(`cursor carries effort only as a parameter on the model; set a model to launch with effort "${effort}"`);
1068
+ if (!model) return [];
1069
+ return ["--model", shellQuote(effort ? withCursorEffort(model, effort) : model)];
1070
+ }
1071
+ const args = model ? ["--model", shellQuote(model)] : [];
1072
+ if (!effort) return args;
1073
+ if (harness === "codex") return [
1074
+ ...args,
1075
+ "-c",
1076
+ shellQuote(`model_reasoning_effort="${effort}"`)
1077
+ ];
1078
+ return [
1079
+ ...args,
1080
+ "--effort",
1081
+ shellQuote(effort)
1082
+ ];
1083
+ }
1084
+ /** A TOML basic string: JSON's escapes are all legal TOML escapes, and TOML also forbids a raw DEL. */
1085
+ function tomlBasicString(value) {
1086
+ return JSON.stringify(value).replace(/\x7f/g, "\\u007f");
1087
+ }
1088
+ /** The instruction arguments for one harness. Only claude has an append-to-system-prompt flag;
1089
+ * codex takes a `developer_instructions` config override, added on top of its own base
1090
+ * instructions. Cursor has no channel at all — its instructions go to the brief instead. */
1091
+ function instructionArgs(harness, instructions) {
1092
+ if (!instructions || harness === "cursor") return [];
1093
+ if (harness === "claude") return ["--append-system-prompt", shellQuote(instructions)];
1094
+ return ["-c", shellQuote(`developer_instructions=${tomlBasicString(instructions)}`)];
1095
+ }
1096
+ /** Build the harness launch invocation for a def — explicit `model`/`effort`/`harness` win over the
1097
+ * def's own tags, which win over the harness default. The def's effort and instructions each go
1098
+ * through the harness's own control (see `modelAndEffortArgs`, `instructionArgs`). */
1099
+ function realizeLaunch(def, opts = {}) {
1100
+ const harness = opts.harness ?? def.harness ?? DEFAULT_HARNESS;
1101
+ const model = opts.model ?? def.model;
1102
+ const effort = opts.effort ?? def.effort;
1103
+ const parts = [
1104
+ LAUNCH_MAP[harness],
1105
+ ...modelAndEffortArgs(harness, model, effort),
1106
+ ...instructionArgs(harness, def.instructions)
1107
+ ];
1108
+ const briefInstructions = harness === "cursor" && def.instructions ? def.instructions : void 0;
1109
+ return {
1110
+ harness,
1111
+ command: parts.join(" "),
1112
+ model,
1113
+ effort,
1114
+ ...briefInstructions ? { briefInstructions } : {}
1115
+ };
1116
+ }
1117
+ //#endregion
1118
+ //#region src/decommission.ts
1119
+ /**
1120
+ * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses
1121
+ * the primary checkout (absolute — neither `--force` nor `--keep-worktree` overrides it) and a
1122
+ * dirty worktree unless `--force`. Teardown always precedes reap: an already-gone worktree or pane
1123
+ * is tolerated, but a genuine worktree-removal failure aborts and leaves the record intact so the
1124
+ * operation is retryable.
1125
+ *
1126
+ * `keepWorktree` reaps everything else — pane, record, pane pointer, brief — and leaves the
1127
+ * checkout on disk, reporting it as `retainedWorktree`.
1128
+ */
1129
+ function decommission(ctx, input) {
1130
+ const rec = loadAgent(ctx.store, input.id);
1131
+ if (!rec) throw new Error(`no unit registered as agents/${input.id}.json — nothing to decommission`);
1132
+ if (rec.kind === "service") throw new Error(`refusing to decommission "${input.id}" — it is a service endpoint, not a unit`);
1133
+ const exec = ctx.exec ?? realExec;
1134
+ const env = ctx.env ?? process.env;
1135
+ const worktreeRoot = rec.worktree?.root;
1136
+ const primaryRoot = worktreeRoot ? resolvePrimaryRoot$1(exec) : void 0;
1137
+ if (worktreeRoot && resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error(`refusing to decommission "${input.id}" — its worktree is the primary checkout; neither --force nor --keep-worktree overrides this`);
1138
+ const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
1139
+ const removesWorktree = worktreeExists && !input.keepWorktree;
1140
+ if (removesWorktree && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
1141
+ const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
1142
+ if (removesWorktree) try {
1143
+ gitWorktreeAdapter$1.remove(exec, worktreeRoot, { primaryRoot });
1144
+ } catch (err) {
1145
+ throw new Error(`decommission "${input.id}" aborted — worktree removal failed, record left intact for retry: ${err instanceof Error ? err.message : String(err)}`);
1146
+ }
1147
+ if (pane) try {
1148
+ selectSessionAdapter(env, exec).teardown(exec, { id: pane });
1149
+ } catch {}
1150
+ ctx.store.removeAgent(input.id);
1151
+ if (pane) ctx.store.removePaneIndex(pane);
1152
+ ctx.store.removeAgentData(input.id);
1153
+ return {
1154
+ agent: rec,
1155
+ worktreeRoot,
1156
+ retainedWorktree: worktreeExists && input.keepWorktree ? worktreeRoot : void 0,
1157
+ pane
1158
+ };
1159
+ }
1160
+ function isDirty(exec, worktreeRoot) {
1161
+ return !!exec("git", [
1162
+ "-C",
1163
+ worktreeRoot,
1164
+ "status",
1165
+ "--porcelain"
1166
+ ]);
1167
+ }
1168
+ //#endregion
1169
+ //#region src/install.ts
1170
+ const PIN_TOKEN = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
1171
+ function validatePin(pin) {
1172
+ if (!PIN_TOKEN.test(pin)) throw new Error(`invalid --pin "${pin}" — expected a version or dist-tag token like 0.2.0 or latest (no spaces, ranges, or shell metacharacters)`);
1173
+ }
1174
+ const hookCommand = (event, pin) => pin ? `npx cyberlegion@${pin} mail hook --event ${event}` : `npx cyberlegion mail hook --event ${event}`;
1175
+ function hookTarget(command) {
1176
+ return command.match(/^(?:npx cyberlegion(?:@[^\s]+)?|cyberlegion) (mail hook --event \S+)$/)?.[1];
1177
+ }
1178
+ const VENDORS = {
1179
+ claude: {
1180
+ file: ".claude/settings.json",
1181
+ shape: "claude",
1182
+ events: {
1183
+ SessionStart: "SessionStart",
1184
+ PostToolUse: "PostToolUse"
1185
+ }
1186
+ },
1187
+ cursor: {
1188
+ file: ".cursor/hooks.json",
1189
+ shape: "cursor",
1190
+ events: { SessionStart: "sessionStart" }
1191
+ },
1192
+ codex: {
1193
+ file: ".codex/hooks.json",
1194
+ shape: "cursor",
1195
+ events: {
1196
+ SessionStart: "SessionStart",
1197
+ PostToolUse: "PostToolUse"
1198
+ }
1199
+ }
1200
+ };
1201
+ function readJson(file) {
1202
+ if (!existsSync(file)) return {};
1203
+ try {
1204
+ return JSON.parse(readFileSync(file, "utf8"));
1205
+ } catch {
1206
+ return {};
1207
+ }
1208
+ }
1209
+ function writeJson$1(file, data) {
1210
+ mkdirSync(dirname(file), { recursive: true });
1211
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
1212
+ }
1213
+ /** Register the surfacing hook into one harness's config, idempotently. */
1214
+ function install(harness, projectDir = process.cwd(), pin) {
1215
+ const spec = VENDORS[harness];
1216
+ if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
1217
+ if (pin !== void 0) validatePin(pin);
1218
+ const file = join(projectDir, spec.file);
1219
+ const settings = readJson(file);
1220
+ const results = [];
1221
+ for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
1222
+ const command = hookCommand(canonical, pin);
1223
+ const status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
1224
+ results.push({
1225
+ harness,
1226
+ event: canonical,
1227
+ vendorEvent,
1228
+ file,
1229
+ status
1230
+ });
1231
+ }
1232
+ writeJson$1(file, settings);
1233
+ return results;
1234
+ }
1235
+ function upsertClaude(settings, event, command) {
1236
+ const hooks = settings.hooks ??= {};
1237
+ const groups = hooks[event] ??= [];
1238
+ const target = hookTarget(command);
1239
+ for (const g of groups) for (const h of g.hooks ?? []) {
1240
+ if (h.command === command) return "already present";
1241
+ if (target && hookTarget(h.command) === target) {
1242
+ h.command = command;
1243
+ return "already present";
1244
+ }
1245
+ }
1246
+ const group = { hooks: [{
1247
+ type: "command",
1248
+ command
1249
+ }] };
1250
+ if (event === "PostToolUse") group.matcher = "Write|Edit";
1251
+ groups.push(group);
1252
+ return "registered";
1253
+ }
1254
+ function upsertCursor(settings, event, command) {
1255
+ if (settings.version == null) settings.version = 1;
1256
+ const hooks = settings.hooks ??= {};
1257
+ const list = hooks[event] ??= [];
1258
+ const target = hookTarget(command);
1259
+ for (const h of list) {
1260
+ if (h.command === command) return "already present";
1261
+ if (target && hookTarget(h.command) === target) {
1262
+ h.command = command;
1263
+ return "already present";
1264
+ }
1265
+ }
1266
+ list.push({ command });
1267
+ return "registered";
1268
+ }
1269
+ //#endregion
1270
+ //#region src/message.ts
1271
+ /** Write one message into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
1272
+ function send(ctx, input) {
1273
+ const toId = resolveRecipient(ctx.store, input.to);
1274
+ const from = loadAgent(ctx.store, input.fromId);
1275
+ const ts = ctx.now?.() ?? Date.now();
1276
+ const msg = {
1277
+ id: `${ts}-${randomBytes(3).toString("hex")}`,
1278
+ from: input.fromId,
1279
+ fromHandle: from?.handle ?? input.fromId,
1280
+ to: toId,
1281
+ ...input.subject ? { subject: input.subject } : {},
1282
+ body: input.body,
1283
+ ...input.thread ? { thread: input.thread } : {},
1284
+ ...input.replyTo ? { replyTo: input.replyTo } : {},
1285
+ ts,
1286
+ sentAt: new Date(ts).toISOString()
1287
+ };
1288
+ ctx.store.putMessage(toId, msg);
1289
+ return msg;
1290
+ }
1291
+ /** Resolve a message body from the --body flag, a --body-file path, or stdin (--body-file -). */
1292
+ function resolveBody(body, bodyFile, readStdin = () => readFileSync(0, "utf8")) {
1293
+ if (body != null) return body;
1294
+ if (bodyFile) return bodyFile === "-" ? readStdin() : readFileSync(bodyFile, "utf8");
1295
+ throw new Error("provide --body <text> or --body-file <path|->");
1296
+ }
1297
+ /** List the caller's mail, chronological (lexical id sort == time order). */
1298
+ function inbox(ctx, q) {
1299
+ const snap = ctx.store.listInbox(q.meId);
1300
+ const unread = snap.unread.map((m) => ({
1301
+ ...m,
1302
+ read: false
1303
+ }));
1304
+ const acked = q.unread ? [] : snap.read.map((m) => ({
1305
+ ...m,
1306
+ read: true
1307
+ }));
1308
+ let items = [...unread, ...acked];
1309
+ if (q.from) items = items.filter((m) => m.from === q.from || m.fromHandle === q.from);
1310
+ if (q.thread) items = items.filter((m) => m.thread === q.thread);
1311
+ return items.sort((a, b) => a.id.localeCompare(b.id));
1312
+ }
1313
+ /** Peek at a message (unread or already-acked) without changing its state. */
1314
+ function peek(ctx, meId, msgId) {
1315
+ const snap = ctx.store.listInbox(meId);
1316
+ return [...snap.unread, ...snap.read].find((m) => m.id === msgId);
1317
+ }
1318
+ /** Acknowledge a message by moving it out of the unread set. Errors if not currently unread. */
1319
+ function ack(ctx, meId, msgId) {
1320
+ return ctx.store.ackMessage(meId, msgId);
1321
+ }
1322
+ /** Permanently remove a message (unread or already-acked) from the caller's inbox. */
1323
+ function deleteMessage(ctx, meId, msgId) {
1324
+ ctx.store.removeMessage(meId, msgId);
1325
+ }
1326
+ //#endregion
1327
+ //#region src/output.ts
1328
+ function stringifyCell(v) {
1329
+ if (v == null) return "";
1330
+ const s = String(v);
1331
+ return /[,\n"]/.test(s) ? `"${s.replace(/"/g, "\"\"")}"` : s;
1332
+ }
1333
+ /** One TOON-encoded object: `key: value` lines, blank/undefined fields dropped. */
1334
+ function toonObject(fields) {
1335
+ return Object.entries(fields).filter(([, v]) => v != null).map(([k, v]) => `${k}: ${v}`).join("\n");
1336
+ }
1337
+ /** One TOON-encoded list: a `name[N]{field,...}:` header plus one row per item, then an aggregate
1338
+ * summary line. Definitive on empty — still emits `name[0]{...}:` plus the summary line. */
1339
+ function toonList(name, items, fields, summary) {
1340
+ return [
1341
+ `${name}[${items.length}]{${fields.map((f) => f.key).join(",")}}:`,
1342
+ ...items.map((item) => fields.map((f) => stringifyCell(f.get(item))).join(",")).map((r) => ` ${r}`),
1343
+ summary
1344
+ ].join("\n");
1345
+ }
1346
+ /** Print a command's result to stdout in the requested format. */
1347
+ function emit(format, payload) {
1348
+ if (format === "json") console.log(JSON.stringify(payload.json, null, 2));
1349
+ else console.log(payload.toon);
1350
+ }
1351
+ /** A stderr-only next-step suggestion — never part of the machine-readable stdout result. */
1352
+ function nextStep(msg) {
1353
+ console.error(`→ ${msg}`);
1354
+ }
1355
+ /** A structured, fail-loud error: stderr + exit 1. Never prompts, never partially writes. */
1356
+ function fail(msg) {
1357
+ console.error(JSON.stringify({ error: msg }));
1358
+ process.exit(1);
1359
+ }
1360
+ //#endregion
1361
+ //#region src/project.ts
1362
+ /**
1363
+ * The canonical git common dir for `dir`, or undefined outside a repository. The common dir is the
1364
+ * one path every checkout of a repository shares — the default checkout and each linked worktree
1365
+ * all report the same `.git` — so it is what makes "same project from two worktrees" one reference.
1366
+ * Realpath'd so a symlinked path to the same repository cannot mint a second id.
1367
+ */
1368
+ function commonDirOf(exec, dir) {
1369
+ const out = exec("git", [
1370
+ "-C",
1371
+ dir,
1372
+ "rev-parse",
1373
+ "--path-format=absolute",
1374
+ "--git-common-dir"
1375
+ ]);
1376
+ if (!out) return void 0;
1377
+ return realOrResolved(out);
1378
+ }
1379
+ /**
1380
+ * Derive a project's stable reference from its canonical common dir. Deterministic rather than
1381
+ * minted, so two worktrees registering the same project at the same instant converge on one id with
1382
+ * no lock, and two unrelated repositories that merely share a directory name never collide.
1383
+ */
1384
+ function projectIdOf(commonDir) {
1385
+ return `prj-${createHash("sha256").update(commonDir).digest("hex").slice(0, 16)}`;
1386
+ }
1387
+ function realOrResolved(path) {
1388
+ try {
1389
+ return realpathSync(path);
1390
+ } catch {
1391
+ return resolve(path);
1392
+ }
1393
+ }
1394
+ /**
1395
+ * The default checkout's root, and whether that answer is authoritative. Asked from the default
1396
+ * checkout itself (its git dir IS the common dir), `--show-toplevel` is exact wherever the git dir
1397
+ * lives. Asked from a linked worktree, git has no pointer back to the default checkout when its git
1398
+ * dir was separated (`git init --separate-git-dir`) — even `git worktree list` then reports the git
1399
+ * dir — so the common dir's parent is only a guess.
1400
+ */
1401
+ function defaultCheckoutOf(exec, dir, commonDir) {
1402
+ const gitDir = exec("git", [
1403
+ "-C",
1404
+ dir,
1405
+ "rev-parse",
1406
+ "--path-format=absolute",
1407
+ "--git-dir"
1408
+ ]);
1409
+ const top = exec("git", [
1410
+ "-C",
1411
+ dir,
1412
+ "rev-parse",
1413
+ "--show-toplevel"
1414
+ ]);
1415
+ if (gitDir && top && realOrResolved(gitDir) === commonDir) return {
1416
+ root: realOrResolved(top),
1417
+ exact: true
1418
+ };
1419
+ return {
1420
+ root: dirname(commonDir),
1421
+ exact: false
1422
+ };
1423
+ }
1424
+ /**
1425
+ * Register (or idempotently refresh) the project containing `dir` — any checkout of it, default or
1426
+ * linked. Keeps the first `registeredAt`, so re-registering from a worktree is a no-op in effect.
1427
+ */
1428
+ function registerProject(ctx, input = {}) {
1429
+ const exec = ctx.exec ?? realExec;
1430
+ const dir = resolve(input.dir ?? process.cwd());
1431
+ const commonDir = commonDirOf(exec, dir);
1432
+ if (!commonDir) throw new Error(`cannot register a project at "${dir}" — not inside a git repository`);
1433
+ ctx.store.ensureMarker();
1434
+ const id = projectIdOf(commonDir);
1435
+ const existing = ctx.store.getProject(id);
1436
+ const checkout = defaultCheckoutOf(exec, dir, commonDir);
1437
+ const root = checkout.exact || !existing ? checkout.root : existing.root;
1438
+ const rec = {
1439
+ id,
1440
+ name: basename(root),
1441
+ root,
1442
+ commonDir,
1443
+ registeredAt: existing?.registeredAt ?? new Date(ctx.now?.() ?? Date.now()).toISOString()
1444
+ };
1445
+ ctx.store.putProject(rec);
1446
+ return rec;
1447
+ }
1448
+ function listProjects(store) {
1449
+ return store.listProjects();
1450
+ }
1451
+ function looksLikePath(ref) {
1452
+ return isAbsolute(ref) || ref.startsWith(".") || ref.includes(sep) || ref.includes("/");
1453
+ }
1454
+ function isDirectory(path) {
1455
+ return existsSync(path) && statSync(path).isDirectory();
1456
+ }
1457
+ /**
1458
+ * Resolve a project from anywhere — by id, by a path inside any of its checkouts, or by name when
1459
+ * exactly one registered project carries it. A path registers its project on first use: git has
1460
+ * already confirmed it is a repository, and registering is idempotent, so there is nothing a typo
1461
+ * could mint. A name never registers — nothing can be found by a name no checkout has reported —
1462
+ * and an ambiguous name names its candidates rather than picking one.
1463
+ */
1464
+ function resolveProject(ctx, ref) {
1465
+ const byId = ctx.store.getProject(ref);
1466
+ if (byId) return byId;
1467
+ const asPath = resolve(ref);
1468
+ if (looksLikePath(ref) && isDirectory(asPath)) return byPath(ctx, asPath);
1469
+ const named = ctx.store.listProjects().filter((p) => p.name === ref);
1470
+ if (named.length === 1) return named[0];
1471
+ if (named.length > 1) throw new Error(`project name "${ref}" is ambiguous — ${named.map((p) => `${p.id} (${p.root})`).join(", ")}; pass an id or a path`);
1472
+ if (isDirectory(asPath)) return byPath(ctx, asPath);
1473
+ throw new Error(`no registered project "${ref}" (tried id, path, and name) — pass a path inside the repository to register it`);
1474
+ }
1475
+ function byPath(ctx, dir) {
1476
+ const commonDir = commonDirOf(ctx.exec ?? realExec, dir);
1477
+ if (!commonDir) throw new Error(`"${dir}" is not inside a git repository`);
1478
+ return ctx.store.getProject(projectIdOf(commonDir)) ?? registerProject(ctx, { dir });
1479
+ }
1480
+ //#endregion
1481
+ //#region src/runtime/inject-inbox.ts
1482
+ const EVENTS = ["SessionStart", "PostToolUse"];
1483
+ /**
1484
+ * Resolve the calling agent, gather its unread mail (and a standing owner's, when this session is
1485
+ * the hub's main pane), and return the SessionStart-style injection payload — or null when there is
1486
+ * nothing to inject (an unregistered caller or an empty inbox never fails the harness hook).
1487
+ *
1488
+ * This never injects a brief. Brief delivery is the spawn wake's job: the first-turn doorbell
1489
+ * carries the instruction and names the brief's file path, so pickup does not depend on this hook
1490
+ * firing in the child (`unit/lifecycle`, superseding ADR-0027).
1491
+ */
1492
+ function injectInbox(ctx, event) {
1493
+ if (!EVENTS.includes(event)) throw new Error(`unsupported --event "${event}" (expected ${EVENTS.join(" | ")})`);
1494
+ let meId = resolveSelfId(ctx);
1495
+ if (!meId) {
1496
+ if (currentPane(normalizeMuxEnv(ctx.env ?? process.env))) try {
1497
+ register(ctx, {});
1498
+ meId = resolveSelfId(ctx);
1499
+ } catch {
1500
+ return null;
1501
+ }
1502
+ if (!meId) return null;
1503
+ }
1504
+ const parts = [];
1505
+ const rec = loadAgent(ctx.store, meId);
1506
+ const unread = inbox({ store: ctx.store }, {
1507
+ meId,
1508
+ unread: true
1509
+ });
1510
+ if (unread.length > 0) {
1511
+ const lines = unread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
1512
+ parts.push(`## Unread mail (${unread.length})\n\n${lines.join("\n")}`);
1513
+ }
1514
+ const cur = currentPane(normalizeMuxEnv(ctx.env ?? process.env));
1515
+ if (rec && !rec.spawnedBy) try {
1516
+ const bound = ctx.store.getMainPane();
1517
+ if (!bound || cur?.pane === bound) {
1518
+ const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
1519
+ for (const owner of standing) {
1520
+ const ownerUnread = inbox({ store: ctx.store }, {
1521
+ meId: owner.id,
1522
+ unread: true
1523
+ });
1524
+ if (ownerUnread.length === 0) continue;
1525
+ const lines = ownerUnread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
1526
+ parts.push(`## Owner mail — ${owner.handle} (${ownerUnread.length})\n\n${lines.join("\n")}`);
1527
+ }
1528
+ }
1529
+ } catch {}
1530
+ if (rec && !rec.spawnedBy) try {
1531
+ if (cur ? !ctx.store.getMainPane() : !listAgents(ctx.store).some((a) => a.kind === "standing")) parts.push("## Legion setup\n\nThis pane has no owner inbox bound yet — run `cyberlegion init` to register the surfacing hook and bind this pane as the owner live presence.");
1532
+ } catch {}
1533
+ if (parts.length === 0) return null;
1534
+ return { hookSpecificOutput: {
1535
+ hookEventName: event,
1536
+ additionalContext: parts.join("\n\n")
1537
+ } };
1538
+ }
1539
+ //#endregion
1540
+ //#region src/service.ts
1541
+ /** A refused ownership transition. `stale` means the caller's generation, token, or holder claim is
1542
+ * not the current one — the caller is not (or no longer) the authority it claims to be. */
1543
+ var ServiceOwnershipError = class extends Error {
1544
+ code;
1545
+ constructor(code, message) {
1546
+ super(message);
1547
+ this.code = code;
1548
+ this.name = "ServiceOwnershipError";
1549
+ }
1550
+ };
1551
+ /** How long a reservation holds before another caller may take over a start that never bound. */
1552
+ const DEFAULT_RESERVATION_TTL_MS = 3e5;
1553
+ const SERVICE_NAME = /^[a-z0-9][a-z0-9_-]{0,62}$/;
1554
+ function assertServiceName(name) {
1555
+ if (!SERVICE_NAME.test(name)) throw new Error(`invalid service name "${name}" — use lowercase letters, digits, "-" or "_" (max 63)`);
1556
+ }
1557
+ function serviceEndpointId(projectId, name) {
1558
+ return `svc-${projectId}-${name}`;
1559
+ }
1560
+ const nowMs = (ctx) => ctx.now?.() ?? Date.now();
1561
+ const iso = (ms) => new Date(ms).toISOString();
1562
+ function lockName(projectId, name) {
1563
+ return `service-${projectId}-${name}`;
1564
+ }
1565
+ function isLive(ctx, unit) {
1566
+ if (unit.status === "exited") return false;
1567
+ return ctx.isLive ? ctx.isLive(unit) : sessionLive(ctx, unit);
1568
+ }
1569
+ function ensureEndpoint(ctx, project, name) {
1570
+ const id = serviceEndpointId(project.id, name);
1571
+ const existing = loadAgent(ctx.store, id);
1572
+ if (existing) return existing;
1573
+ ctx.store.ensureMarker();
1574
+ const ts = iso(nowMs(ctx));
1575
+ const rec = {
1576
+ id,
1577
+ handle: `${name}@${project.name}`,
1578
+ kind: "service",
1579
+ service: {
1580
+ project: project.id,
1581
+ name
1582
+ },
1583
+ cwd: project.root,
1584
+ pane: null,
1585
+ status: "active",
1586
+ createdAt: ts,
1587
+ lastSeen: ts
1588
+ };
1589
+ saveAgent(ctx.store, rec);
1590
+ return rec;
1591
+ }
1592
+ function view(ctx, project, endpoint, lease) {
1593
+ const base = {
1594
+ project,
1595
+ endpoint,
1596
+ lease
1597
+ };
1598
+ if (lease.state === "vacant") return {
1599
+ ...base,
1600
+ health: "vacant",
1601
+ control: "none"
1602
+ };
1603
+ if (lease.state === "reserved") {
1604
+ const expired = nowMs(ctx) > Date.parse(lease.reservation?.expiresAt ?? "");
1605
+ return {
1606
+ ...base,
1607
+ health: expired ? "expired" : "starting",
1608
+ control: "none",
1609
+ ...expired ? { note: "the reservation expired without a bound owner; the next acquire takes over" } : {}
1610
+ };
1611
+ }
1612
+ const owner = lease.holder ? loadAgent(ctx.store, lease.holder) : void 0;
1613
+ if (!owner) return {
1614
+ ...base,
1615
+ health: "unhealthy",
1616
+ control: "none",
1617
+ note: "the owner has no unit record"
1618
+ };
1619
+ const control = owner.pane || ctx.store.findPaneByAgentId(owner.id) ? "pane" : "none";
1620
+ const controlNote = control === "none" ? "the owner resolves, but its session control is not recoverable from here (no multiplexer pane — e.g. a native subagent only its parent can drive)" : void 0;
1621
+ if (!isLive(ctx, owner)) return {
1622
+ ...base,
1623
+ owner,
1624
+ health: "unhealthy",
1625
+ control,
1626
+ note: "the owner session is gone"
1627
+ };
1628
+ return {
1629
+ ...base,
1630
+ owner,
1631
+ health: "healthy",
1632
+ control,
1633
+ ...controlNote ? { note: controlNote } : {}
1634
+ };
1635
+ }
1636
+ function vacantLease(ctx, projectId, name) {
1637
+ return {
1638
+ project: projectId,
1639
+ service: name,
1640
+ endpoint: serviceEndpointId(projectId, name),
1641
+ generation: 0,
1642
+ state: "vacant",
1643
+ updatedAt: iso(nowMs(ctx))
1644
+ };
1645
+ }
1646
+ /** Load the project, service endpoint, and lease — creating the endpoint and a vacant lease when
1647
+ * `create` is set, else throwing for a service that was never started. */
1648
+ function load(ctx, projectRef, name, create) {
1649
+ assertServiceName(name);
1650
+ const project = resolveProject(ctx, projectRef);
1651
+ const lease = ctx.store.getServiceLease(project.id, name);
1652
+ const endpoint = loadAgent(ctx.store, serviceEndpointId(project.id, name));
1653
+ if (lease && endpoint) return {
1654
+ project,
1655
+ endpoint,
1656
+ lease
1657
+ };
1658
+ if (!create) throw new Error(`no service "${name}" in project ${project.name} (${project.id})`);
1659
+ return {
1660
+ project,
1661
+ endpoint: ensureEndpoint(ctx, project, name),
1662
+ lease: lease ?? vacantLease(ctx, project.id, name)
1663
+ };
1664
+ }
1665
+ /** Run `fn` on the service's current state under its lock — every ownership transition goes here. */
1666
+ function transition(ctx, projectRef, name, create, fn) {
1667
+ assertServiceName(name);
1668
+ const project = resolveProject(ctx, projectRef);
1669
+ return ctx.store.withLock(lockName(project.id, name), () => fn(load(ctx, project.id, name, create)));
1670
+ }
1671
+ function write(ctx, lease) {
1672
+ const next = {
1673
+ ...lease,
1674
+ updatedAt: iso(nowMs(ctx))
1675
+ };
1676
+ ctx.store.putServiceLease(next);
1677
+ return next;
1678
+ }
1679
+ function stale(message) {
1680
+ return new ServiceOwnershipError("stale", message);
1681
+ }
1682
+ /** Read a service's ownership without changing anything. Throws for a service never started. */
1683
+ function resolveService(ctx, projectRef, name) {
1684
+ const { project, endpoint, lease } = load(ctx, projectRef, name, false);
1685
+ return view(ctx, project, endpoint, lease);
1686
+ }
1687
+ /**
1688
+ * Contact-or-start: resolve the service's healthy owner, or reserve the right to start one. Exactly
1689
+ * one of any number of concurrent callers gets `reserved`; the rest see `starting` (a start is in
1690
+ * progress) or `resolved`. A vacant service, an unhealthy owner, and an expired reservation are all
1691
+ * reservable, each under a new generation that fences out whatever held the old one.
1692
+ */
1693
+ function acquireService(ctx, projectRef, name, input = {}) {
1694
+ return transition(ctx, projectRef, name, true, ({ project, endpoint, lease }) => {
1695
+ const current = view(ctx, project, endpoint, lease);
1696
+ if (input.force) {
1697
+ if (input.force.generation !== lease.generation) throw stale(`cannot force: generation ${input.force.generation} is not the current ${lease.generation}`);
1698
+ } else if (current.health === "healthy") return {
1699
+ outcome: "resolved",
1700
+ lease,
1701
+ view: current
1702
+ };
1703
+ else if (current.health === "starting") return {
1704
+ outcome: "starting",
1705
+ lease,
1706
+ view: current
1707
+ };
1708
+ const now = nowMs(ctx);
1709
+ const token = randomBytes(8).toString("hex");
1710
+ const next = write(ctx, {
1711
+ project: project.id,
1712
+ service: name,
1713
+ endpoint: endpoint.id,
1714
+ generation: lease.generation + 1,
1715
+ state: "reserved",
1716
+ reservation: {
1717
+ token,
1718
+ ...input.by ? { by: input.by } : {},
1719
+ at: iso(now),
1720
+ expiresAt: iso(now + (input.ttlMs ?? 3e5)),
1721
+ ...input.force ? { forced: true } : {}
1722
+ },
1723
+ updatedAt: iso(now)
1724
+ });
1725
+ return {
1726
+ outcome: "reserved",
1727
+ lease: next,
1728
+ view: view(ctx, project, endpoint, next),
1729
+ token
1730
+ };
1731
+ });
1732
+ }
1733
+ function liveUnit(ctx, id) {
1734
+ const unit = loadAgent(ctx.store, id);
1735
+ if (!unit) throw new ServiceOwnershipError("unknown-unit", `no unit "${id}"`);
1736
+ if (!isLive(ctx, unit)) throw new ServiceOwnershipError("unit-not-live", `unit "${id}" has no live session`);
1737
+ return unit;
1738
+ }
1739
+ /**
1740
+ * Complete a reservation: make `unit` the owner at the reserved generation. Refused when the
1741
+ * reservation is no longer the current one (it expired and someone else re-reserved, or it was
1742
+ * released) — the starter then holds a runtime that is not the authority and must stop it. A
1743
+ * reservation that expired with nobody taking over is still bindable: nothing replaced it.
1744
+ */
1745
+ function bindService(ctx, projectRef, name, input) {
1746
+ return transition(ctx, projectRef, name, false, ({ project, endpoint, lease }) => {
1747
+ if (lease.state !== "reserved" || lease.generation !== input.generation || lease.reservation?.token !== input.token) throw stale(`reservation for generation ${input.generation} is no longer current (now ${lease.state} at ${lease.generation})`);
1748
+ liveUnit(ctx, input.unit);
1749
+ return view(ctx, project, endpoint, write(ctx, {
1750
+ project: lease.project,
1751
+ service: lease.service,
1752
+ endpoint: lease.endpoint,
1753
+ generation: lease.generation,
1754
+ state: "active",
1755
+ holder: input.unit,
1756
+ updatedAt: lease.updatedAt
1757
+ }));
1758
+ });
1759
+ }
1760
+ /**
1761
+ * Give the service up: abandon a reservation (a failed start — pass its `token`) or step down as
1762
+ * owner (pass the holder `unit`). Either must match the current generation. The generation is kept;
1763
+ * the next acquire bumps it.
1764
+ */
1765
+ function releaseService(ctx, projectRef, name, input) {
1766
+ return transition(ctx, projectRef, name, false, ({ project, endpoint, lease }) => {
1767
+ if (!(lease.generation === input.generation && (lease.state === "reserved" && input.token !== void 0 && lease.reservation?.token === input.token || lease.state === "active" && input.unit !== void 0 && lease.holder === input.unit))) throw stale(`nothing to release at generation ${input.generation} (now ${lease.state} at ${lease.generation})`);
1768
+ return view(ctx, project, endpoint, write(ctx, {
1769
+ project: lease.project,
1770
+ service: lease.service,
1771
+ endpoint: lease.endpoint,
1772
+ generation: lease.generation,
1773
+ state: "vacant",
1774
+ updatedAt: lease.updatedAt
1775
+ }));
1776
+ });
1777
+ }
1778
+ /**
1779
+ * Transfer ownership from the current holder to another live unit, under a new generation. Only the
1780
+ * holder at the current generation can hand off, and afterwards it is stale.
1781
+ */
1782
+ function handoffService(ctx, projectRef, name, input) {
1783
+ return transition(ctx, projectRef, name, false, ({ project, endpoint, lease }) => {
1784
+ if (lease.state !== "active" || lease.generation !== input.generation || lease.holder !== input.from) throw stale(`"${input.from}" is not the owner at generation ${input.generation}`);
1785
+ liveUnit(ctx, input.to);
1786
+ return view(ctx, project, endpoint, write(ctx, {
1787
+ ...lease,
1788
+ generation: lease.generation + 1,
1789
+ holder: input.to
1790
+ }));
1791
+ });
1792
+ }
1793
+ /**
1794
+ * The fencing check: succeed only when `unit` owns the service at exactly `generation`. A runtime
1795
+ * calls this before acting as the service's authority; a stale runtime — replaced, handed off, or
1796
+ * recovered past — is refused.
1797
+ */
1798
+ function verifyOwnership(ctx, projectRef, name, input) {
1799
+ const { project, endpoint, lease } = load(ctx, projectRef, name, false);
1800
+ if (lease.state !== "active" || lease.holder !== input.unit || lease.generation !== input.generation) throw stale(`"${input.unit}" at generation ${input.generation} is not the owner (now ${lease.state}${lease.holder ? ` by ${lease.holder}` : ""} at ${lease.generation})`);
1801
+ return view(ctx, project, endpoint, lease);
1802
+ }
1803
+ /**
1804
+ * Run `fn` as the verified owner, holding the service lock so no transition can interleave between
1805
+ * the check and the act. For short critical sections only — the lock is bounded-wait for everyone
1806
+ * else — and never reentrant: `fn` must not call another service transition on the same service.
1807
+ */
1808
+ function withOwnership(ctx, projectRef, name, input, fn) {
1809
+ assertServiceName(name);
1810
+ const project = resolveProject(ctx, projectRef);
1811
+ return ctx.store.withLock(lockName(project.id, name), () => fn(verifyOwnership(ctx, project.id, name, input)));
1812
+ }
1813
+ /**
1814
+ * Resolve-or-start end to end: acquire, and only when this caller wins the reservation, launch a
1815
+ * runtime and bind it. A launch that throws releases the reservation (best-effort — an expiry covers
1816
+ * a release that itself fails) so the start is retryable at once. A launch that finishes after its
1817
+ * reservation was superseded is refused at bind: the launched unit is not the owner, and the error
1818
+ * names it so the caller can stop it.
1819
+ */
1820
+ async function startService(ctx, projectRef, name, input) {
1821
+ const acquired = acquireService(ctx, projectRef, name, input);
1822
+ if (acquired.outcome !== "reserved") return {
1823
+ outcome: acquired.outcome,
1824
+ view: acquired.view
1825
+ };
1826
+ const { generation, endpoint } = acquired.lease;
1827
+ let launched;
1828
+ try {
1829
+ launched = await input.launch({
1830
+ generation,
1831
+ endpoint
1832
+ });
1833
+ } catch (err) {
1834
+ try {
1835
+ releaseService(ctx, projectRef, name, {
1836
+ generation,
1837
+ token: acquired.token
1838
+ });
1839
+ } catch {}
1840
+ throw err;
1841
+ }
1842
+ try {
1843
+ return {
1844
+ outcome: "started",
1845
+ view: bindService(ctx, projectRef, name, {
1846
+ generation,
1847
+ token: acquired.token,
1848
+ unit: launched.unit
1849
+ }),
1850
+ launched
1851
+ };
1852
+ } catch (err) {
1853
+ if (err instanceof ServiceOwnershipError) throw new ServiceOwnershipError(err.code, `launched unit "${launched.unit}" could not become the owner — ${err.message}; stop it`);
1854
+ throw err;
1855
+ }
1856
+ }
1857
+ //#endregion
1858
+ //#region src/store/errors.ts
1859
+ /** A record file exists but its content didn't parse as JSON — a torn write (crash mid-`writeFileSync`,
1860
+ * pre-atomic-write code path) or on-disk tampering. Carries the file path and the original parse
1861
+ * error so a caller can report exactly what's broken and where, rather than "Unexpected token" with
1862
+ * no location. */
1863
+ var CorruptRecordError = class extends Error {
1864
+ file;
1865
+ constructor(file, cause) {
1866
+ super(`corrupt record at "${file}": ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
1867
+ this.file = file;
1868
+ this.name = "CorruptRecordError";
1869
+ }
1870
+ };
1871
+ //#endregion
1872
+ //#region src/store/process-liveness.ts
1873
+ /**
1874
+ * Probe whether `pid` is alive using `process.kill(pid, 0)` — the POSIX "does this process exist"
1875
+ * idiom; it sends no signal, it only tests deliverability. `ESRCH` (no such process) is the only
1876
+ * errno that means "definitely dead". Every other errno — `EPERM` above all, common under sandboxes
1877
+ * and containers where a genuinely live process can be unsignalable by this one, or any process
1878
+ * owned by a different uid — reads as `'unknown'`, never `'dead'`: collapsing `EPERM` into "dead" is
1879
+ * exactly the defect this three-state return exists to make structurally impossible to reintroduce
1880
+ * at a new call site. Same-pid is trivially `'alive'` (a process checking its own prior identity,
1881
+ * e.g. after a crash-and-restart that reused nothing).
1882
+ */
1883
+ function probeProcess(pid) {
1884
+ if (pid === process.pid) return "alive";
1885
+ try {
1886
+ process.kill(pid, 0);
1887
+ return "alive";
1888
+ } catch (err) {
1889
+ return err.code === "ESRCH" ? "dead" : "unknown";
1890
+ }
1891
+ }
1892
+ //#endregion
1893
+ //#region src/store/lock.ts
1894
+ var LockTimeoutError = class extends Error {
1895
+ name;
1896
+ constructor(name) {
1897
+ super(`timed out waiting for lock "${name}"`);
1898
+ this.name = name;
1899
+ this.name = "LockTimeoutError";
1900
+ }
1901
+ };
1902
+ function locksDir(root) {
1903
+ return join(root, "locks");
1904
+ }
1905
+ function lockDirFor(root, name) {
1906
+ return join(locksDir(root), `${name}.lock`);
1907
+ }
1908
+ /** Read a lock dir's recorded holder. Absent/corrupt/mid-acquire (holder.json not written yet) all
1909
+ * read as "unknown" — never grounds to steal. An ambiguous read must never look like a green light. */
1910
+ function readHolder(dir) {
1911
+ try {
1912
+ return JSON.parse(readFileSync(join(dir, "holder.json"), "utf8"));
1913
+ } catch {
1914
+ return;
1915
+ }
1916
+ }
1917
+ /** Locking's policy on `probeProcess`'s `'unknown'` state: treat it exactly like `'alive'` — i.e.
1918
+ * NOT `'dead'`. The one invariant that must never break here is stealing a lock a live holder still
1919
+ * holds, and `'unknown'` means "cannot rule out alive", so it can only ever be safe to fold it into
1920
+ * the "do not steal" side. This is a local policy decision, not `probeProcess`'s — a different call
1921
+ * site (a staleness reaper, say) folding `'unknown'` into "not provably alive" instead would be
1922
+ * reading the SAME three states toward the opposite default, which is exactly the bug class this
1923
+ * three-state type exists to force each call site to decide explicitly (see process-liveness.ts). */
1924
+ function heldByLiveOrUnknownPid(pid) {
1925
+ return probeProcess(pid) !== "dead";
1926
+ }
1927
+ function sleepSync(ms) {
1928
+ if (ms <= 0) return;
1929
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1930
+ }
1931
+ /**
1932
+ * Attempt to reclaim an abandoned lock dir without ever discarding a LIVE one out from under its
1933
+ * holder — the exact defect agmsg/firstmate's lock suites exist to catch (evidence.md items 3-4).
1934
+ *
1935
+ * `mkdirSync` on the lock path itself already arbitrates ordinary contention (EEXIST = someone else
1936
+ * got there first); the harder race is a STEAL: two processes independently deciding the same lock
1937
+ * looks dead. `renameSync` gives the same exclusivity `mkdirSync` gives for a fresh path, but for an
1938
+ * EXISTING one — of any number of processes racing to rename the SAME source path away, exactly one
1939
+ * succeeds and the rest get ENOENT. So the rename-away is the sole arbiter of "who gets to attempt
1940
+ * the steal", not the earlier staleness read.
1941
+ *
1942
+ * That still leaves one race: the content we read as stale might not be the content we actually
1943
+ * capture, if the true holder released and a NEW, live holder re-acquired at this same path between
1944
+ * our staleness read and our rename. So after winning the rename, re-check the pid we ACTUALLY
1945
+ * captured (not the one from the earlier read) — if it's alive, this was a live lock we grabbed by
1946
+ * accident; put it back immediately (best-effort — if a third party has since re-mkdir'd the path,
1947
+ * the invariant we're protecting already holds, so just drop our capture) and refuse to finalize.
1948
+ */
1949
+ function tryReclaimStale(dir) {
1950
+ const grave = `${dir}.stale-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1951
+ try {
1952
+ renameSync(dir, grave);
1953
+ } catch (err) {
1954
+ if (err.code === "ENOENT") return false;
1955
+ throw err;
1956
+ }
1957
+ const captured = readHolder(grave);
1958
+ if (captured && heldByLiveOrUnknownPid(captured.pid)) {
1959
+ try {
1960
+ renameSync(grave, dir);
1961
+ } catch {
1962
+ rmSync(grave, {
1963
+ recursive: true,
1964
+ force: true
1965
+ });
1966
+ }
1967
+ return false;
1968
+ }
1969
+ rmSync(grave, {
1970
+ recursive: true,
1971
+ force: true
1972
+ });
1973
+ return true;
1974
+ }
1975
+ /**
1976
+ * Acquire an advisory, mkdir-based lock at `root/locks/<name>.lock`. mkdir (not O_EXCL on a file) is
1977
+ * the primitive here because a lock also needs to carry its holder metadata (pid + timestamp) for
1978
+ * staleness detection, and a directory gives that a natural home (`holder.json` inside it) without a
1979
+ * second file to keep in sync; `mkdirSync` on a non-existent path is exactly as atomic as an
1980
+ * exclusive file create on every filesystem this tool targets (POSIX; NTFS via Node's Win32 mkdir).
1981
+ *
1982
+ * Blocks (busy-retries) until acquired or `timeoutMs` elapses, reclaiming a dead holder's lock along
1983
+ * the way (see `tryReclaimStale`) but NEVER a live one — contention with a live holder always waits
1984
+ * it out or times out, never steals.
1985
+ */
1986
+ function acquireLock(root, name, opts = {}) {
1987
+ const retryDelayMs = opts.retryDelayMs ?? 20;
1988
+ const timeoutMs = opts.timeoutMs ?? 5e3;
1989
+ const dir = lockDirFor(root, name);
1990
+ mkdirSync(locksDir(root), { recursive: true });
1991
+ const deadline = Date.now() + timeoutMs;
1992
+ for (;;) try {
1993
+ mkdirSync(dir);
1994
+ writeFileSync(join(dir, "holder.json"), JSON.stringify({
1995
+ pid: process.pid,
1996
+ acquiredAt: Date.now()
1997
+ }));
1998
+ return { release: () => rmSync(dir, {
1999
+ recursive: true,
2000
+ force: true
2001
+ }) };
2002
+ } catch (err) {
2003
+ if (err.code !== "EEXIST") throw err;
2004
+ const holder = readHolder(dir);
2005
+ if (holder && !heldByLiveOrUnknownPid(holder.pid) && tryReclaimStale(dir)) continue;
2006
+ if (Date.now() > deadline) throw new LockTimeoutError(name);
2007
+ sleepSync(retryDelayMs);
2008
+ }
2009
+ }
2010
+ /** Acquire `name`, run `fn`, and always release — the shape every genuine read-modify-write in the
2011
+ * store should use (`setMainPane`, `identity.ts`'s `claimPresence`/`clearPresence` today). */
2012
+ function withLock(root, name, fn, opts) {
2013
+ const handle = acquireLock(root, name, opts);
2014
+ try {
2015
+ return fn();
2016
+ } finally {
2017
+ handle.release();
2018
+ }
2019
+ }
2020
+ //#endregion
2021
+ //#region src/store/file-store.ts
2022
+ /** Parse a record file's content, wrapping a `JSON.parse` failure in a typed, file-named
2023
+ * `CorruptRecordError` instead of letting a bare `SyntaxError` bubble up from deep inside
2024
+ * `listInbox`/`listAgents`/`getAgent` with no indication of WHICH file broke. A file that doesn't
2025
+ * exist is a caller error — callers check existence first, so this only ever sees files known to
2026
+ * be present but possibly torn (a crash mid-write predating the atomic-write fix, or on-disk
2027
+ * tampering) — see evidence.md item 5, "no silent success on a broken store". */
2028
+ function readJsonRecord(file) {
2029
+ const raw = readFileSync(file, "utf8");
2030
+ try {
2031
+ return JSON.parse(raw);
2032
+ } catch (err) {
2033
+ throw new CorruptRecordError(file, err);
2034
+ }
2035
+ }
2036
+ function readMessages(dir) {
2037
+ if (!existsSync(dir)) return [];
2038
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f)));
2039
+ }
2040
+ /** Write `data` to `file` crash-safely: write to a sibling temp file first, then `renameSync` into
2041
+ * place. `rename` is atomic within a filesystem (POSIX guarantees this; Node's Win32 rename is too
2042
+ * for same-volume paths, which every path here is — always under the same store root), so any
2043
+ * reader either sees the OLD complete content or the NEW complete content, never a truncated
2044
+ * in-between. Before this fix, `putMessage`/`putAgent`/`putPaneIndex`/`setMainPane` called
2045
+ * `writeFileSync` directly on the final path — only `ackMessage`'s move (a rename of an already-
2046
+ * complete file) was atomic. A process crashing mid-`writeFileSync`, or a reader racing a writer on
2047
+ * a large record, could hand `JSON.parse` a truncated file and throw an uncaught `SyntaxError` deep
2048
+ * inside `listInbox`/`listAgents`/`getAgent`.
2049
+ *
2050
+ * Deliberately no `fsync` before the rename: this tool defends against a process CRASHING mid-write
2051
+ * (the actual, observed risk in a daemonless multi-process CLI), where the page cache alone is
2052
+ * sufficient, not against a power-loss/OS-crash losing unflushed pages, which cyberlegion doesn't
2053
+ * claim to survive today (no writer here holds data the user can't just re-send). Add fsync if that
2054
+ * durability bar ever changes.
2055
+ *
2056
+ * The temp name embeds pid + a counter so two writers to the SAME final path never collide on their
2057
+ * own temp files mid-write (each writer's temp file is unique to it). */
2058
+ let tmpCounter = 0;
2059
+ function writeFileAtomic(file, data) {
2060
+ mkdirSync(dirname(file), { recursive: true });
2061
+ const tmp = `${file}.${process.pid}.${tmpCounter++}.tmp`;
2062
+ writeFileSync(tmp, data);
2063
+ renameSync(tmp, file);
2064
+ }
2065
+ function writeJson(file, data) {
2066
+ writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`);
2067
+ }
2068
+ function writeText(file, text) {
2069
+ writeFileAtomic(file, text);
2070
+ }
2071
+ /** Build a path via `build()` for a READ-only lookup, treating a syntactically-invalid id
2072
+ * (`InvalidIdError`) the same as "not found" rather than throwing. Reads are the wrong place to
2073
+ * enforce id-shape: `resolveAgent`/`resolveRecipient` (identity.ts) speculatively probe an arbitrary
2074
+ * ref — which may legitimately be a worktree BRANCH NAME containing `/` — as a candidate id before
2075
+ * falling back to handle/branch lookup, so `getAgent('cyberlegion/unit-abc')` must fail soft, not
2076
+ * throw, or that fallback chain breaks. The traversal risk this guards against (an id escaping the
2077
+ * store root) only bites on a WRITE — nothing is ever created or overwritten by a probe that finds
2078
+ * nothing — so a hard reject stays reserved for the write paths below (`putAgent`, `putMessage`,
2079
+ * `putPaneIndex`, `writeBrief`), where a malformed id would otherwise put a stray file wherever it
2080
+ * pointed. */
2081
+ function readPathOrUndefined(build) {
2082
+ try {
2083
+ return build();
2084
+ } catch (err) {
2085
+ if (err instanceof InvalidIdError) return void 0;
2086
+ throw err;
2087
+ }
2088
+ }
2089
+ /** The on-disk `Store` implementation — current per-writer sharded `.json` layout (ADR-0020):
2090
+ * one file per message/agent, collision-free filenames, ack = atomic rename into `read/`. */
2091
+ var FileStore = class {
2092
+ root;
2093
+ constructor(root) {
2094
+ this.root = root;
2095
+ }
2096
+ ensureMarker() {
2097
+ ensureMarker(this.root);
2098
+ }
2099
+ putMessage(toId, msg) {
2100
+ writeFileAtomic(paths.messageFile(this.root, toId, msg.id), `${JSON.stringify(msg, null, 2)}\n`);
2101
+ }
2102
+ listInbox(id) {
2103
+ const inbox = readPathOrUndefined(() => paths.inboxDir(this.root, id));
2104
+ const read = readPathOrUndefined(() => paths.inboxReadDir(this.root, id));
2105
+ return {
2106
+ unread: inbox ? readMessages(inbox) : [],
2107
+ read: read ? readMessages(read) : []
2108
+ };
2109
+ }
2110
+ ackMessage(id, msgId) {
2111
+ const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
2112
+ if (!src || !existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
2113
+ const msg = readJsonRecord(src);
2114
+ const dest = paths.messageReadFile(this.root, id, msgId);
2115
+ mkdirSync(dirname(dest), { recursive: true });
2116
+ renameSync(src, dest);
2117
+ return msg;
2118
+ }
2119
+ removeMessage(id, msgId) {
2120
+ const unreadFile = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
2121
+ if (unreadFile && existsSync(unreadFile)) {
2122
+ rmSync(unreadFile);
2123
+ return;
2124
+ }
2125
+ const readFile = readPathOrUndefined(() => paths.messageReadFile(this.root, id, msgId));
2126
+ if (readFile && existsSync(readFile)) {
2127
+ rmSync(readFile);
2128
+ return;
2129
+ }
2130
+ throw new Error(`"${msgId}" is not a message in this inbox`);
2131
+ }
2132
+ putAgent(rec) {
2133
+ writeJson(paths.agentFile(this.root, rec.id), rec);
2134
+ }
2135
+ getAgent(id) {
2136
+ const file = readPathOrUndefined(() => paths.agentFile(this.root, id));
2137
+ if (!file || !existsSync(file)) return void 0;
2138
+ return readJsonRecord(file);
2139
+ }
2140
+ listAgents() {
2141
+ const dir = paths.agentsDir(this.root);
2142
+ if (!existsSync(dir)) return [];
2143
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f))).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2144
+ }
2145
+ removeAgent(id) {
2146
+ rmSync(paths.agentFile(this.root, id), { force: true });
2147
+ }
2148
+ removeAgentData(id) {
2149
+ rmSync(paths.dataDir(this.root, id), {
2150
+ recursive: true,
2151
+ force: true
2152
+ });
2153
+ }
2154
+ putProject(rec) {
2155
+ writeJson(paths.projectFile(this.root, rec.id), rec);
2156
+ }
2157
+ getProject(id) {
2158
+ const file = readPathOrUndefined(() => paths.projectFile(this.root, id));
2159
+ if (!file || !existsSync(file)) return void 0;
2160
+ return readJsonRecord(file);
2161
+ }
2162
+ listProjects() {
2163
+ const dir = paths.projectsDir(this.root);
2164
+ if (!existsSync(dir)) return [];
2165
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f))).sort((a, b) => a.registeredAt.localeCompare(b.registeredAt));
2166
+ }
2167
+ putServiceLease(lease) {
2168
+ writeJson(paths.serviceLeaseFile(this.root, lease.project, lease.service), lease);
2169
+ }
2170
+ getServiceLease(project, service) {
2171
+ const file = readPathOrUndefined(() => paths.serviceLeaseFile(this.root, project, service));
2172
+ if (!file || !existsSync(file)) return void 0;
2173
+ return readJsonRecord(file);
2174
+ }
2175
+ putPaneIndex(pane, agentId) {
2176
+ writeText(paths.paneFile(this.root, pane), agentId);
2177
+ }
2178
+ resolvePaneId(pane) {
2179
+ const file = paths.paneFile(this.root, pane);
2180
+ return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
2181
+ }
2182
+ findPaneByAgentId(agentId) {
2183
+ const dir = paths.panesDir(this.root);
2184
+ if (!existsSync(dir)) return void 0;
2185
+ for (const f of readdirSync(dir)) {
2186
+ if (!f.endsWith(".id")) continue;
2187
+ if (readFileSync(join(dir, f), "utf8").trim() === agentId) return f.slice(0, -3);
2188
+ }
2189
+ }
2190
+ removePaneIndex(pane) {
2191
+ rmSync(paths.paneFile(this.root, pane), { force: true });
2192
+ }
2193
+ writeBrief(agentId, text) {
2194
+ writeText(paths.briefFile(this.root, agentId), text);
2195
+ }
2196
+ readBrief(agentId) {
2197
+ const file = readPathOrUndefined(() => paths.briefFile(this.root, agentId));
2198
+ return file && existsSync(file) ? readFileSync(file, "utf8") : void 0;
2199
+ }
2200
+ setMainPane(pane) {
2201
+ withLock(this.root, "main-pane", () => {
2202
+ const file = paths.mainPaneFile(this.root);
2203
+ if (pane) {
2204
+ writeText(file, pane);
2205
+ return;
2206
+ }
2207
+ rmSync(file, { force: true });
2208
+ });
2209
+ }
2210
+ getMainPane() {
2211
+ const file = paths.mainPaneFile(this.root);
2212
+ return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
2213
+ }
2214
+ withLock(name, fn) {
2215
+ return withLock(this.root, name, fn);
2216
+ }
2217
+ };
2218
+ //#endregion
2219
+ //#region src/wake/await.ts
2220
+ /** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
2221
+ const POLL_MS$1 = 1e3;
2222
+ const DEFAULT_TIMEOUT_MS = 6e5;
2223
+ const DEFAULT_MAX_WAIT_S = 240;
2224
+ /**
2225
+ * Block on the caller's UNREAD inbox until a message matching `thread` (and optional `from`)
2226
+ * arrives, polling on a fixed ~1s internal interval. Three unambiguous outcomes:
2227
+ *
2228
+ * - `matched` — a message arrived; it is printed by the caller AND acked here (moved to read/), so
2229
+ * it leaves the unread set. Block-then-read: each await consumes exactly its round.
2230
+ * - `waiting` — this call's internal poll cycle hit its `maxWaitS` cap (default 240s, always well
2231
+ * under a harness tool-timeout SIGKILL) with no match yet. A clean, non-error return the caller
2232
+ * re-arms on (calls `awaitReply` again) — NOT the same as giving up.
2233
+ * - `timed-out` — the caller's overall `timeoutMs` (default 600_000ms / 600s; `0` = wait forever)
2234
+ * elapsed across re-arms with no match. The caller should exit non-zero.
2235
+ */
2236
+ async function awaitReply(ctx, input) {
2237
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2238
+ const maxWaitMs = (input.maxWaitS ?? DEFAULT_MAX_WAIT_S) * 1e3;
2239
+ const now = input.now ?? (() => Date.now());
2240
+ const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
2241
+ const start = now();
2242
+ const cycleDeadline = start + maxWaitMs;
2243
+ for (;;) {
2244
+ const items = inbox(ctx, {
2245
+ meId: input.meId,
2246
+ unread: true,
2247
+ thread: input.thread,
2248
+ from: input.from
2249
+ });
2250
+ if (items.length > 0) {
2251
+ const message = items[0];
2252
+ ack(ctx, input.meId, message.id);
2253
+ return {
2254
+ kind: "matched",
2255
+ message
2256
+ };
2257
+ }
2258
+ const elapsed = now() - start;
2259
+ if (timeoutMs !== 0 && elapsed >= timeoutMs) return { kind: "timed-out" };
2260
+ if (now() >= cycleDeadline) return { kind: "waiting" };
2261
+ await sleep(POLL_MS$1);
2262
+ }
2263
+ }
2264
+ //#endregion
2265
+ //#region src/wake/watch.ts
2266
+ const POLL_MS = 1e3;
2267
+ /**
2268
+ * A continuous foreground observer: prints each NEW matching message as it arrives, polling on a
2269
+ * fixed ~1s interval, until interrupted (or `maxIterations` in tests). Never acks — a message
2270
+ * `watch` prints stays unread and still surfaces later in `mail inbox`/`mail await`.
2271
+ */
2272
+ async function watchMail(ctx, input, onMessage, opts = {}) {
2273
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
2274
+ const seen = /* @__PURE__ */ new Set();
2275
+ for (const m of inbox(ctx, {
2276
+ meId: input.meId,
2277
+ thread: input.thread,
2278
+ from: input.from
2279
+ })) seen.add(m.id);
2280
+ let iterations = 0;
2281
+ for (;;) {
2282
+ const items = inbox(ctx, {
2283
+ meId: input.meId,
2284
+ thread: input.thread,
2285
+ from: input.from
2286
+ });
2287
+ for (const m of items) {
2288
+ if (seen.has(m.id)) continue;
2289
+ seen.add(m.id);
2290
+ onMessage(m);
2291
+ }
2292
+ iterations++;
2293
+ if (opts.maxIterations != null && iterations >= opts.maxIterations) return;
2294
+ await sleep(POLL_MS);
2295
+ }
2296
+ }
2297
+ //#endregion
2298
+ export { DEFAULT_RESERVATION_TTL_MS, FileStore, ServiceOwnershipError, ack, acquireService, assertDistinctFromPrimary, awaitReply, bindService, bumpLastSeen, decommission, deleteMessage, detectHarness, emit, ensureMarker, fail, gitWorktreeAdapter, handoffService, inbox, injectInbox, install, listAgentDefs, listAgents, listProjects, loadAgent, nextStep, paths, peek, probeMultiplexer, projectRoot, prune, realExec, realizeLaunch, register, registerProject, releaseService, resolveAgent, resolveAgentDef, resolveBody, resolvePrimaryRoot, resolveProject, resolveProjectLocalRoot, resolveRecipient, resolveRoot, resolveSelfId, resolveService, selectSessionAdapter, send, serviceEndpointId, spawn, spawnAndWake, startService, toonList, toonObject, touch, verifyOwnership, watchMail, withOwnership };