cyberlegion 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,4 +1,2214 @@
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 { 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 fullLaunch = `${muxEnvPrefix(sessionAdapter.name)}${launch}`;
750
+ const from = callerPane(sessionAdapter, normalizedEnv);
751
+ let cwd;
752
+ let worktree;
753
+ let target;
754
+ if (input.cwd) {
755
+ if (!existsSync(input.cwd)) throw new Error(`--cwd directory must already exist: ${input.cwd}`);
756
+ cwd = resolve(input.cwd);
757
+ assertDistinctFromPrimary$1(cwd, primaryRoot);
758
+ worktree = null;
759
+ const at = input.at ?? "tab";
760
+ target = sessionAdapter.open(exec, {
761
+ cwd,
762
+ launch: fullLaunch,
763
+ at,
764
+ from,
765
+ ...labelFor(at, input, brief, id)
766
+ });
767
+ } else {
768
+ const branch = input.branch ?? `cyberlegion/unit-${id}`;
769
+ const at = input.at ?? "workspace";
770
+ const worktreePath = input.worktreePath ?? resolveUnitWorktreePath(primaryRoot, id.slice(0, 6));
771
+ assertDistinctFromPrimary$1(resolve(worktreePath), primaryRoot);
772
+ if (at === "workspace" && sessionAdapter.worktree) {
773
+ const opened = sessionAdapter.worktree.createInWorkspace(exec, {
774
+ primaryRoot,
775
+ branch,
776
+ path: worktreePath,
777
+ launch: fullLaunch,
778
+ ...labelFor(at, input, brief, id)
779
+ });
780
+ assertDistinctFromPrimary$1(opened.worktree.root, primaryRoot);
781
+ ensureMarker(join(opened.worktree.root, ".agents", "cyberlegion"));
782
+ cwd = opened.worktree.root;
783
+ worktree = opened.worktree;
784
+ target = opened.target;
785
+ } else {
786
+ const added = gitWorktreeAdapter$1.add(exec, {
787
+ primaryRoot,
788
+ path: worktreePath,
789
+ branch
790
+ });
791
+ assertDistinctFromPrimary$1(added.root, primaryRoot);
792
+ ensureMarker(join(added.root, ".agents", "cyberlegion"));
793
+ cwd = added.root;
794
+ worktree = added;
795
+ target = sessionAdapter.open(exec, {
796
+ cwd,
797
+ launch: fullLaunch,
798
+ at,
799
+ from,
800
+ ...labelFor(at, input, brief, id)
801
+ });
802
+ }
803
+ }
804
+ const ts = new Date(ctx.now?.() ?? Date.now()).toISOString();
805
+ const muxName = sessionAdapter.name;
806
+ const rec = {
807
+ id,
808
+ handle: input.handle ?? id.slice(0, 6),
809
+ harness,
810
+ cwd,
811
+ worktree,
812
+ pane: muxName === "tmux" || muxName === "herdr" ? {
813
+ mux: muxName,
814
+ id: target.id
815
+ } : null,
816
+ status: "active",
817
+ createdAt: ts,
818
+ lastSeen: ts,
819
+ brief: paths.briefFile(ctx.store.root, id),
820
+ ...resolveSelfId(ctx) ? { spawnedBy: resolveSelfId(ctx) } : {}
821
+ };
822
+ saveAgent(ctx.store, rec);
823
+ ctx.store.putPaneIndex(target.id, id);
824
+ ctx.store.writeBrief(id, brief);
825
+ return {
826
+ agent: rec,
827
+ pane: target.id,
828
+ launch
829
+ };
830
+ }
831
+ /**
832
+ * `unit spawn` end to end: open the peer, then deliver its first turn.
833
+ *
834
+ * The two acts live together here rather than at the CLI call site so the **brief path the doorbell
835
+ * names is derived from the record `spawn` just wrote**, not handed in by a caller. That is the
836
+ * point of the seam: a caller cannot ring with the wrong path because it supplies no path — an
837
+ * empty string, a stale path, or the brief's whole body are all unrepresentable here, rather than
838
+ * merely untested.
839
+ *
840
+ * The ring is best-effort on top of the guaranteed spawn effect (worktree + session + registry
841
+ * record): `--no-wake` rings nothing, and a ring that never completes is reported as a warning on
842
+ * the result rather than thrown, so it can never fail a spawn that already landed.
843
+ */
844
+ async function spawnAndWake(ctx, input, options = {}) {
845
+ const res = spawn(ctx, input);
846
+ const env = ctx.env ?? process.env;
847
+ const exec = ctx.exec ?? realExec;
848
+ const briefPath = res.agent.brief;
849
+ const wake = await wakeSpawn(() => selectSessionAdapter(env, exec), exec, {
850
+ target: { id: res.pane },
851
+ briefPath: briefPath ?? "",
852
+ noWake: options.noWake || !briefPath
853
+ }, options.nudgeOpts);
854
+ return {
855
+ ...res,
856
+ rung: wake.rung,
857
+ ...wake.warning ? { warning: wake.warning } : {}
858
+ };
859
+ }
860
+ /**
861
+ * The label a `workspace` placement opens under — the short human-scannable name that makes a unit's
862
+ * own visible space findable by eye (`console/workspace-label.ts` owns the naming rule). Only the
863
+ * `workspace` placement carries one: a pane or tab lives inside a space the caller is already
864
+ * looking at, so it has nothing of its own to name. Spread into the adapter options, so a
865
+ * non-workspace placement passes no `label` key at all rather than an explicit `undefined`.
866
+ *
867
+ * The gate is load-bearing, not defensive: cyber-mux names whatever tier `at` opens — a workspace,
868
+ * a tab, or a pane — so passing a label on a `tab` placement would rename the caller's own tab.
869
+ * Exported so that gate is tested directly rather than only through a backend's arguments.
870
+ */
871
+ function labelFor(at, input, brief, id) {
872
+ if (at !== "workspace") return {};
873
+ return { label: deriveWorkspaceLabel({
874
+ brief,
875
+ handle: input.handle,
876
+ id
877
+ }) };
878
+ }
879
+ /**
880
+ * The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
881
+ * multiplexer fast-path and never has to run its own ancestry discovery (`$CYBER_MUX` /
882
+ * `$CYBER_MUX_PANE`). `VAR=val cmd` scopes the vars to that one process (and its children)
883
+ * without needing `export`. tmux natively sets `$TMUX_PANE` for a pane's own processes, so the
884
+ * pane var is expanded by the child's own shell rather than baked in here.
885
+ */
886
+ function muxEnvPrefix(muxName) {
887
+ if (muxName === "tmux") return "CYBER_MUX=tmux CYBER_MUX_PANE=$TMUX_PANE ";
888
+ if (muxName === "herdr") return "CYBER_MUX=herdr ";
889
+ return "";
890
+ }
891
+ /** Resolve a spawn brief from --brief-file, --task -, or --task <text>; null if no source given. */
892
+ function resolveBrief(input, readStdin = () => readFileSync(0, "utf8")) {
893
+ if (input.briefFile) return readFileSync(input.briefFile, "utf8");
894
+ if (input.task === "-") return readStdin();
895
+ if (input.task != null && input.task !== "") return input.task;
896
+ return null;
897
+ }
898
+ //#endregion
899
+ //#region src/agentdef/resolve.ts
900
+ function parseAgentDefFile(text) {
901
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/.exec(text);
902
+ if (!m) return {
903
+ fm: {},
904
+ instructions: text.trim()
905
+ };
906
+ const [, block, rest] = m;
907
+ const fm = {};
908
+ const lines = block.split("\n");
909
+ let i = 0;
910
+ while (i < lines.length) {
911
+ const line = lines[i].replace(/\r$/, "");
912
+ if (line.trim() === "" || line.trim().startsWith("#")) {
913
+ i++;
914
+ continue;
915
+ }
916
+ if (line.length - line.trimStart().length > 0) {
917
+ i++;
918
+ continue;
919
+ }
920
+ const idx = line.indexOf(":");
921
+ if (idx === -1) {
922
+ i++;
923
+ continue;
924
+ }
925
+ const key = line.slice(0, idx).trim();
926
+ let value = line.slice(idx + 1).trim();
927
+ i++;
928
+ if (value === ">" || value === ">-" || value === "|" || value === "|-") {
929
+ const parts = [];
930
+ while (i < lines.length) {
931
+ const l = lines[i].replace(/\r$/, "");
932
+ if (l.trim() === "") {
933
+ i++;
934
+ continue;
935
+ }
936
+ if (l.length - l.trimStart().length === 0) break;
937
+ parts.push(l.trim());
938
+ i++;
939
+ }
940
+ value = parts.join(value.startsWith("|") ? "\n" : " ");
941
+ }
942
+ fm[key] = unquote(value);
943
+ }
944
+ return {
945
+ fm,
946
+ instructions: rest.trim()
947
+ };
948
+ }
949
+ function unquote(v) {
950
+ if (v.length >= 2 && (v.startsWith("\"") && v.endsWith("\"") || v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1);
951
+ return v;
952
+ }
953
+ function toBool(v) {
954
+ if (v === void 0) return void 0;
955
+ if (v === "true") return true;
956
+ if (v === "false") return false;
957
+ }
958
+ function toAgentDef(path, text, fallbackName) {
959
+ const { fm, instructions } = parseAgentDefFile(text);
960
+ return {
961
+ name: fm.name ?? fallbackName,
962
+ description: fm.description,
963
+ model: fm.model,
964
+ effort: fm.effort,
965
+ harness: fm.harness,
966
+ warm: toBool(fm.warm),
967
+ interactive: toBool(fm.interactive),
968
+ instructions,
969
+ path
970
+ };
971
+ }
972
+ /** The project's own `.agents/agents/` dir — the convention `agent list`/name-search scans. */
973
+ function projectAgentsDir(cwd) {
974
+ return join(projectRoot(cwd), ".agents", "agents");
975
+ }
976
+ /** Resolve one agent definition, by explicit file or by name search. Throws a clear error when
977
+ * neither locates a def — never returns a partial/empty result. */
978
+ function resolveAgentDef(input) {
979
+ if (input.file) {
980
+ const path = resolve(input.file);
981
+ if (!existsSync(path)) throw new Error(`agent def file "${input.file}" does not exist`);
982
+ return toAgentDef(path, readFileSync(path, "utf8"), basename(path).replace(/\.md$/, ""));
983
+ }
984
+ if (!input.name) throw new Error("resolveAgentDef needs a --agent <name> or --agent-file <path>");
985
+ const roots = [...input.searchRoots ?? [], projectAgentsDir(input.cwd)];
986
+ for (const root of roots) {
987
+ const candidate = join(root, `${input.name}.md`);
988
+ if (existsSync(candidate)) return toAgentDef(candidate, readFileSync(candidate, "utf8"), input.name);
989
+ }
990
+ throw new Error(`no agent definition named "${input.name}" found under .agents/agents/`);
991
+ }
992
+ /** List every resolvable agent def under `.agents/agents/` (+ any extra search roots). Definitive
993
+ * empty array when the dir is absent or empty — never throws for "none found" here (unlike a
994
+ * named resolve, listing is inherently zero-or-more). */
995
+ function listAgentDefs(input = {}) {
996
+ const roots = [...input.searchRoots ?? [], projectAgentsDir(input.cwd)];
997
+ const seen = /* @__PURE__ */ new Set();
998
+ const defs = [];
999
+ for (const root of roots) {
1000
+ if (!existsSync(root)) continue;
1001
+ for (const file of readdirSync(root)) {
1002
+ if (!file.endsWith(".md")) continue;
1003
+ const path = join(root, file);
1004
+ if (seen.has(path)) continue;
1005
+ seen.add(path);
1006
+ defs.push(toAgentDef(path, readFileSync(path, "utf8"), file.replace(/\.md$/, "")));
1007
+ }
1008
+ }
1009
+ return defs;
1010
+ }
1011
+ //#endregion
1012
+ //#region src/agentdef/realize.ts
1013
+ const DEFAULT_HARNESS = "claude";
1014
+ /** POSIX single-quote a value for safe inclusion in a shell command line. */
1015
+ function shellQuote(value) {
1016
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1017
+ }
1018
+ /** Build the harness launch invocation for a def — explicit `model`/`harness` win over the def's
1019
+ * own tags, which win over the harness default. Every harness gets the same shape (bin,
1020
+ * `--model` when known, `--append-system-prompt` carrying the def's instructions body) since the
1021
+ * exact per-harness flag surface is realized later by the launching caller, not by cyberlegion. */
1022
+ function realizeLaunch(def, opts = {}) {
1023
+ const harness = opts.harness ?? def.harness ?? DEFAULT_HARNESS;
1024
+ const model = opts.model ?? def.model;
1025
+ const parts = [LAUNCH_MAP[harness]];
1026
+ if (model) parts.push("--model", shellQuote(model));
1027
+ if (def.instructions) parts.push("--append-system-prompt", shellQuote(def.instructions));
1028
+ return {
1029
+ harness,
1030
+ command: parts.join(" ")
1031
+ };
1032
+ }
1033
+ //#endregion
1034
+ //#region src/decommission.ts
1035
+ /**
1036
+ * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses
1037
+ * the primary checkout (absolute — neither `--force` nor `--keep-worktree` overrides it) and a
1038
+ * dirty worktree unless `--force`. Teardown always precedes reap: an already-gone worktree or pane
1039
+ * is tolerated, but a genuine worktree-removal failure aborts and leaves the record intact so the
1040
+ * operation is retryable.
1041
+ *
1042
+ * `keepWorktree` reaps everything else — pane, record, pane pointer, brief — and leaves the
1043
+ * checkout on disk, reporting it as `retainedWorktree`.
1044
+ */
1045
+ function decommission(ctx, input) {
1046
+ const rec = loadAgent(ctx.store, input.id);
1047
+ if (!rec) throw new Error(`no unit registered as agents/${input.id}.json — nothing to decommission`);
1048
+ if (rec.kind === "service") throw new Error(`refusing to decommission "${input.id}" — it is a service endpoint, not a unit`);
1049
+ const exec = ctx.exec ?? realExec;
1050
+ const env = ctx.env ?? process.env;
1051
+ const worktreeRoot = rec.worktree?.root;
1052
+ const primaryRoot = worktreeRoot ? resolvePrimaryRoot$1(exec) : void 0;
1053
+ 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`);
1054
+ const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
1055
+ const removesWorktree = worktreeExists && !input.keepWorktree;
1056
+ if (removesWorktree && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
1057
+ const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
1058
+ if (removesWorktree) try {
1059
+ gitWorktreeAdapter$1.remove(exec, worktreeRoot, { primaryRoot });
1060
+ } catch (err) {
1061
+ throw new Error(`decommission "${input.id}" aborted — worktree removal failed, record left intact for retry: ${err instanceof Error ? err.message : String(err)}`);
1062
+ }
1063
+ if (pane) try {
1064
+ selectSessionAdapter(env, exec).teardown(exec, { id: pane });
1065
+ } catch {}
1066
+ ctx.store.removeAgent(input.id);
1067
+ if (pane) ctx.store.removePaneIndex(pane);
1068
+ ctx.store.removeAgentData(input.id);
1069
+ return {
1070
+ agent: rec,
1071
+ worktreeRoot,
1072
+ retainedWorktree: worktreeExists && input.keepWorktree ? worktreeRoot : void 0,
1073
+ pane
1074
+ };
1075
+ }
1076
+ function isDirty(exec, worktreeRoot) {
1077
+ return !!exec("git", [
1078
+ "-C",
1079
+ worktreeRoot,
1080
+ "status",
1081
+ "--porcelain"
1082
+ ]);
1083
+ }
1084
+ //#endregion
1085
+ //#region src/install.ts
1086
+ const PIN_TOKEN = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
1087
+ function validatePin(pin) {
1088
+ 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)`);
1089
+ }
1090
+ const hookCommand = (event, pin) => pin ? `npx cyberlegion@${pin} mail hook --event ${event}` : `npx cyberlegion mail hook --event ${event}`;
1091
+ function hookTarget(command) {
1092
+ return command.match(/^(?:npx cyberlegion(?:@[^\s]+)?|cyberlegion) (mail hook --event \S+)$/)?.[1];
1093
+ }
1094
+ const VENDORS = {
1095
+ claude: {
1096
+ file: ".claude/settings.json",
1097
+ shape: "claude",
1098
+ events: {
1099
+ SessionStart: "SessionStart",
1100
+ PostToolUse: "PostToolUse"
1101
+ }
1102
+ },
1103
+ cursor: {
1104
+ file: ".cursor/hooks.json",
1105
+ shape: "cursor",
1106
+ events: { SessionStart: "sessionStart" }
1107
+ },
1108
+ codex: {
1109
+ file: ".codex/hooks.json",
1110
+ shape: "cursor",
1111
+ events: {
1112
+ SessionStart: "SessionStart",
1113
+ PostToolUse: "PostToolUse"
1114
+ }
1115
+ }
1116
+ };
1117
+ function readJson(file) {
1118
+ if (!existsSync(file)) return {};
1119
+ try {
1120
+ return JSON.parse(readFileSync(file, "utf8"));
1121
+ } catch {
1122
+ return {};
1123
+ }
1124
+ }
1125
+ function writeJson$1(file, data) {
1126
+ mkdirSync(dirname(file), { recursive: true });
1127
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
1128
+ }
1129
+ /** Register the surfacing hook into one harness's config, idempotently. */
1130
+ function install(harness, projectDir = process.cwd(), pin) {
1131
+ const spec = VENDORS[harness];
1132
+ if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
1133
+ if (pin !== void 0) validatePin(pin);
1134
+ const file = join(projectDir, spec.file);
1135
+ const settings = readJson(file);
1136
+ const results = [];
1137
+ for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
1138
+ const command = hookCommand(canonical, pin);
1139
+ const status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
1140
+ results.push({
1141
+ harness,
1142
+ event: canonical,
1143
+ vendorEvent,
1144
+ file,
1145
+ status
1146
+ });
1147
+ }
1148
+ writeJson$1(file, settings);
1149
+ return results;
1150
+ }
1151
+ function upsertClaude(settings, event, command) {
1152
+ const hooks = settings.hooks ??= {};
1153
+ const groups = hooks[event] ??= [];
1154
+ const target = hookTarget(command);
1155
+ for (const g of groups) for (const h of g.hooks ?? []) {
1156
+ if (h.command === command) return "already present";
1157
+ if (target && hookTarget(h.command) === target) {
1158
+ h.command = command;
1159
+ return "already present";
1160
+ }
1161
+ }
1162
+ const group = { hooks: [{
1163
+ type: "command",
1164
+ command
1165
+ }] };
1166
+ if (event === "PostToolUse") group.matcher = "Write|Edit";
1167
+ groups.push(group);
1168
+ return "registered";
1169
+ }
1170
+ function upsertCursor(settings, event, command) {
1171
+ if (settings.version == null) settings.version = 1;
1172
+ const hooks = settings.hooks ??= {};
1173
+ const list = hooks[event] ??= [];
1174
+ const target = hookTarget(command);
1175
+ for (const h of list) {
1176
+ if (h.command === command) return "already present";
1177
+ if (target && hookTarget(h.command) === target) {
1178
+ h.command = command;
1179
+ return "already present";
1180
+ }
1181
+ }
1182
+ list.push({ command });
1183
+ return "registered";
1184
+ }
1185
+ //#endregion
1186
+ //#region src/message.ts
1187
+ /** Write one message into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
1188
+ function send(ctx, input) {
1189
+ const toId = resolveRecipient(ctx.store, input.to);
1190
+ const from = loadAgent(ctx.store, input.fromId);
1191
+ const ts = ctx.now?.() ?? Date.now();
1192
+ const msg = {
1193
+ id: `${ts}-${randomBytes(3).toString("hex")}`,
1194
+ from: input.fromId,
1195
+ fromHandle: from?.handle ?? input.fromId,
1196
+ to: toId,
1197
+ ...input.subject ? { subject: input.subject } : {},
1198
+ body: input.body,
1199
+ ...input.thread ? { thread: input.thread } : {},
1200
+ ...input.replyTo ? { replyTo: input.replyTo } : {},
1201
+ ts,
1202
+ sentAt: new Date(ts).toISOString()
1203
+ };
1204
+ ctx.store.putMessage(toId, msg);
1205
+ return msg;
1206
+ }
1207
+ /** Resolve a message body from the --body flag, a --body-file path, or stdin (--body-file -). */
1208
+ function resolveBody(body, bodyFile, readStdin = () => readFileSync(0, "utf8")) {
1209
+ if (body != null) return body;
1210
+ if (bodyFile) return bodyFile === "-" ? readStdin() : readFileSync(bodyFile, "utf8");
1211
+ throw new Error("provide --body <text> or --body-file <path|->");
1212
+ }
1213
+ /** List the caller's mail, chronological (lexical id sort == time order). */
1214
+ function inbox(ctx, q) {
1215
+ const snap = ctx.store.listInbox(q.meId);
1216
+ const unread = snap.unread.map((m) => ({
1217
+ ...m,
1218
+ read: false
1219
+ }));
1220
+ const acked = q.unread ? [] : snap.read.map((m) => ({
1221
+ ...m,
1222
+ read: true
1223
+ }));
1224
+ let items = [...unread, ...acked];
1225
+ if (q.from) items = items.filter((m) => m.from === q.from || m.fromHandle === q.from);
1226
+ if (q.thread) items = items.filter((m) => m.thread === q.thread);
1227
+ return items.sort((a, b) => a.id.localeCompare(b.id));
1228
+ }
1229
+ /** Peek at a message (unread or already-acked) without changing its state. */
1230
+ function peek(ctx, meId, msgId) {
1231
+ const snap = ctx.store.listInbox(meId);
1232
+ return [...snap.unread, ...snap.read].find((m) => m.id === msgId);
1233
+ }
1234
+ /** Acknowledge a message by moving it out of the unread set. Errors if not currently unread. */
1235
+ function ack(ctx, meId, msgId) {
1236
+ return ctx.store.ackMessage(meId, msgId);
1237
+ }
1238
+ /** Permanently remove a message (unread or already-acked) from the caller's inbox. */
1239
+ function deleteMessage(ctx, meId, msgId) {
1240
+ ctx.store.removeMessage(meId, msgId);
1241
+ }
1242
+ //#endregion
1243
+ //#region src/output.ts
1244
+ function stringifyCell(v) {
1245
+ if (v == null) return "";
1246
+ const s = String(v);
1247
+ return /[,\n"]/.test(s) ? `"${s.replace(/"/g, "\"\"")}"` : s;
1248
+ }
1249
+ /** One TOON-encoded object: `key: value` lines, blank/undefined fields dropped. */
1250
+ function toonObject(fields) {
1251
+ return Object.entries(fields).filter(([, v]) => v != null).map(([k, v]) => `${k}: ${v}`).join("\n");
1252
+ }
1253
+ /** One TOON-encoded list: a `name[N]{field,...}:` header plus one row per item, then an aggregate
1254
+ * summary line. Definitive on empty — still emits `name[0]{...}:` plus the summary line. */
1255
+ function toonList(name, items, fields, summary) {
1256
+ return [
1257
+ `${name}[${items.length}]{${fields.map((f) => f.key).join(",")}}:`,
1258
+ ...items.map((item) => fields.map((f) => stringifyCell(f.get(item))).join(",")).map((r) => ` ${r}`),
1259
+ summary
1260
+ ].join("\n");
1261
+ }
1262
+ /** Print a command's result to stdout in the requested format. */
1263
+ function emit(format, payload) {
1264
+ if (format === "json") console.log(JSON.stringify(payload.json, null, 2));
1265
+ else console.log(payload.toon);
1266
+ }
1267
+ /** A stderr-only next-step suggestion — never part of the machine-readable stdout result. */
1268
+ function nextStep(msg) {
1269
+ console.error(`→ ${msg}`);
1270
+ }
1271
+ /** A structured, fail-loud error: stderr + exit 1. Never prompts, never partially writes. */
1272
+ function fail(msg) {
1273
+ console.error(JSON.stringify({ error: msg }));
1274
+ process.exit(1);
1275
+ }
1276
+ //#endregion
1277
+ //#region src/project.ts
1278
+ /**
1279
+ * The canonical git common dir for `dir`, or undefined outside a repository. The common dir is the
1280
+ * one path every checkout of a repository shares — the default checkout and each linked worktree
1281
+ * all report the same `.git` — so it is what makes "same project from two worktrees" one reference.
1282
+ * Realpath'd so a symlinked path to the same repository cannot mint a second id.
1283
+ */
1284
+ function commonDirOf(exec, dir) {
1285
+ const out = exec("git", [
1286
+ "-C",
1287
+ dir,
1288
+ "rev-parse",
1289
+ "--path-format=absolute",
1290
+ "--git-common-dir"
1291
+ ]);
1292
+ if (!out) return void 0;
1293
+ return realOrResolved(out);
1294
+ }
1295
+ /**
1296
+ * Derive a project's stable reference from its canonical common dir. Deterministic rather than
1297
+ * minted, so two worktrees registering the same project at the same instant converge on one id with
1298
+ * no lock, and two unrelated repositories that merely share a directory name never collide.
1299
+ */
1300
+ function projectIdOf(commonDir) {
1301
+ return `prj-${createHash("sha256").update(commonDir).digest("hex").slice(0, 16)}`;
1302
+ }
1303
+ function realOrResolved(path) {
1304
+ try {
1305
+ return realpathSync(path);
1306
+ } catch {
1307
+ return resolve(path);
1308
+ }
1309
+ }
1310
+ /**
1311
+ * The default checkout's root, and whether that answer is authoritative. Asked from the default
1312
+ * checkout itself (its git dir IS the common dir), `--show-toplevel` is exact wherever the git dir
1313
+ * lives. Asked from a linked worktree, git has no pointer back to the default checkout when its git
1314
+ * dir was separated (`git init --separate-git-dir`) — even `git worktree list` then reports the git
1315
+ * dir — so the common dir's parent is only a guess.
1316
+ */
1317
+ function defaultCheckoutOf(exec, dir, commonDir) {
1318
+ const gitDir = exec("git", [
1319
+ "-C",
1320
+ dir,
1321
+ "rev-parse",
1322
+ "--path-format=absolute",
1323
+ "--git-dir"
1324
+ ]);
1325
+ const top = exec("git", [
1326
+ "-C",
1327
+ dir,
1328
+ "rev-parse",
1329
+ "--show-toplevel"
1330
+ ]);
1331
+ if (gitDir && top && realOrResolved(gitDir) === commonDir) return {
1332
+ root: realOrResolved(top),
1333
+ exact: true
1334
+ };
1335
+ return {
1336
+ root: dirname(commonDir),
1337
+ exact: false
1338
+ };
1339
+ }
1340
+ /**
1341
+ * Register (or idempotently refresh) the project containing `dir` — any checkout of it, default or
1342
+ * linked. Keeps the first `registeredAt`, so re-registering from a worktree is a no-op in effect.
1343
+ */
1344
+ function registerProject(ctx, input = {}) {
1345
+ const exec = ctx.exec ?? realExec;
1346
+ const dir = resolve(input.dir ?? process.cwd());
1347
+ const commonDir = commonDirOf(exec, dir);
1348
+ if (!commonDir) throw new Error(`cannot register a project at "${dir}" — not inside a git repository`);
1349
+ ctx.store.ensureMarker();
1350
+ const id = projectIdOf(commonDir);
1351
+ const existing = ctx.store.getProject(id);
1352
+ const checkout = defaultCheckoutOf(exec, dir, commonDir);
1353
+ const root = checkout.exact || !existing ? checkout.root : existing.root;
1354
+ const rec = {
1355
+ id,
1356
+ name: basename(root),
1357
+ root,
1358
+ commonDir,
1359
+ registeredAt: existing?.registeredAt ?? new Date(ctx.now?.() ?? Date.now()).toISOString()
1360
+ };
1361
+ ctx.store.putProject(rec);
1362
+ return rec;
1363
+ }
1364
+ function listProjects(store) {
1365
+ return store.listProjects();
1366
+ }
1367
+ function looksLikePath(ref) {
1368
+ return isAbsolute(ref) || ref.startsWith(".") || ref.includes(sep) || ref.includes("/");
1369
+ }
1370
+ function isDirectory(path) {
1371
+ return existsSync(path) && statSync(path).isDirectory();
1372
+ }
1373
+ /**
1374
+ * Resolve a project from anywhere — by id, by a path inside any of its checkouts, or by name when
1375
+ * exactly one registered project carries it. A path registers its project on first use: git has
1376
+ * already confirmed it is a repository, and registering is idempotent, so there is nothing a typo
1377
+ * could mint. A name never registers — nothing can be found by a name no checkout has reported —
1378
+ * and an ambiguous name names its candidates rather than picking one.
1379
+ */
1380
+ function resolveProject(ctx, ref) {
1381
+ const byId = ctx.store.getProject(ref);
1382
+ if (byId) return byId;
1383
+ const asPath = resolve(ref);
1384
+ if (looksLikePath(ref) && isDirectory(asPath)) return byPath(ctx, asPath);
1385
+ const named = ctx.store.listProjects().filter((p) => p.name === ref);
1386
+ if (named.length === 1) return named[0];
1387
+ 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`);
1388
+ if (isDirectory(asPath)) return byPath(ctx, asPath);
1389
+ throw new Error(`no registered project "${ref}" (tried id, path, and name) — pass a path inside the repository to register it`);
1390
+ }
1391
+ function byPath(ctx, dir) {
1392
+ const commonDir = commonDirOf(ctx.exec ?? realExec, dir);
1393
+ if (!commonDir) throw new Error(`"${dir}" is not inside a git repository`);
1394
+ return ctx.store.getProject(projectIdOf(commonDir)) ?? registerProject(ctx, { dir });
1395
+ }
1396
+ //#endregion
1397
+ //#region src/runtime/inject-inbox.ts
1398
+ const EVENTS = ["SessionStart", "PostToolUse"];
1399
+ /**
1400
+ * Resolve the calling agent, gather its unread mail (and a standing owner's, when this session is
1401
+ * the hub's main pane), and return the SessionStart-style injection payload — or null when there is
1402
+ * nothing to inject (an unregistered caller or an empty inbox never fails the harness hook).
1403
+ *
1404
+ * This never injects a brief. Brief delivery is the spawn wake's job: the first-turn doorbell
1405
+ * carries the instruction and names the brief's file path, so pickup does not depend on this hook
1406
+ * firing in the child (`unit/lifecycle`, superseding ADR-0027).
1407
+ */
1408
+ function injectInbox(ctx, event) {
1409
+ if (!EVENTS.includes(event)) throw new Error(`unsupported --event "${event}" (expected ${EVENTS.join(" | ")})`);
1410
+ let meId = resolveSelfId(ctx);
1411
+ if (!meId) {
1412
+ if (currentPane(normalizeMuxEnv(ctx.env ?? process.env))) try {
1413
+ register(ctx, {});
1414
+ meId = resolveSelfId(ctx);
1415
+ } catch {
1416
+ return null;
1417
+ }
1418
+ if (!meId) return null;
1419
+ }
1420
+ const parts = [];
1421
+ const rec = loadAgent(ctx.store, meId);
1422
+ const unread = inbox({ store: ctx.store }, {
1423
+ meId,
1424
+ unread: true
1425
+ });
1426
+ if (unread.length > 0) {
1427
+ const lines = unread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
1428
+ parts.push(`## Unread mail (${unread.length})\n\n${lines.join("\n")}`);
1429
+ }
1430
+ const cur = currentPane(normalizeMuxEnv(ctx.env ?? process.env));
1431
+ if (rec && !rec.spawnedBy) try {
1432
+ const bound = ctx.store.getMainPane();
1433
+ if (!bound || cur?.pane === bound) {
1434
+ const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
1435
+ for (const owner of standing) {
1436
+ const ownerUnread = inbox({ store: ctx.store }, {
1437
+ meId: owner.id,
1438
+ unread: true
1439
+ });
1440
+ if (ownerUnread.length === 0) continue;
1441
+ const lines = ownerUnread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
1442
+ parts.push(`## Owner mail — ${owner.handle} (${ownerUnread.length})\n\n${lines.join("\n")}`);
1443
+ }
1444
+ }
1445
+ } catch {}
1446
+ if (rec && !rec.spawnedBy) try {
1447
+ 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.");
1448
+ } catch {}
1449
+ if (parts.length === 0) return null;
1450
+ return { hookSpecificOutput: {
1451
+ hookEventName: event,
1452
+ additionalContext: parts.join("\n\n")
1453
+ } };
1454
+ }
1455
+ //#endregion
1456
+ //#region src/service.ts
1457
+ /** A refused ownership transition. `stale` means the caller's generation, token, or holder claim is
1458
+ * not the current one — the caller is not (or no longer) the authority it claims to be. */
1459
+ var ServiceOwnershipError = class extends Error {
1460
+ code;
1461
+ constructor(code, message) {
1462
+ super(message);
1463
+ this.code = code;
1464
+ this.name = "ServiceOwnershipError";
1465
+ }
1466
+ };
1467
+ /** How long a reservation holds before another caller may take over a start that never bound. */
1468
+ const DEFAULT_RESERVATION_TTL_MS = 3e5;
1469
+ const SERVICE_NAME = /^[a-z0-9][a-z0-9_-]{0,62}$/;
1470
+ function assertServiceName(name) {
1471
+ if (!SERVICE_NAME.test(name)) throw new Error(`invalid service name "${name}" — use lowercase letters, digits, "-" or "_" (max 63)`);
1472
+ }
1473
+ function serviceEndpointId(projectId, name) {
1474
+ return `svc-${projectId}-${name}`;
1475
+ }
1476
+ const nowMs = (ctx) => ctx.now?.() ?? Date.now();
1477
+ const iso = (ms) => new Date(ms).toISOString();
1478
+ function lockName(projectId, name) {
1479
+ return `service-${projectId}-${name}`;
1480
+ }
1481
+ function isLive(ctx, unit) {
1482
+ if (unit.status === "exited") return false;
1483
+ return ctx.isLive ? ctx.isLive(unit) : sessionLive(ctx, unit);
1484
+ }
1485
+ function ensureEndpoint(ctx, project, name) {
1486
+ const id = serviceEndpointId(project.id, name);
1487
+ const existing = loadAgent(ctx.store, id);
1488
+ if (existing) return existing;
1489
+ ctx.store.ensureMarker();
1490
+ const ts = iso(nowMs(ctx));
1491
+ const rec = {
1492
+ id,
1493
+ handle: `${name}@${project.name}`,
1494
+ kind: "service",
1495
+ service: {
1496
+ project: project.id,
1497
+ name
1498
+ },
1499
+ cwd: project.root,
1500
+ pane: null,
1501
+ status: "active",
1502
+ createdAt: ts,
1503
+ lastSeen: ts
1504
+ };
1505
+ saveAgent(ctx.store, rec);
1506
+ return rec;
1507
+ }
1508
+ function view(ctx, project, endpoint, lease) {
1509
+ const base = {
1510
+ project,
1511
+ endpoint,
1512
+ lease
1513
+ };
1514
+ if (lease.state === "vacant") return {
1515
+ ...base,
1516
+ health: "vacant",
1517
+ control: "none"
1518
+ };
1519
+ if (lease.state === "reserved") {
1520
+ const expired = nowMs(ctx) > Date.parse(lease.reservation?.expiresAt ?? "");
1521
+ return {
1522
+ ...base,
1523
+ health: expired ? "expired" : "starting",
1524
+ control: "none",
1525
+ ...expired ? { note: "the reservation expired without a bound owner; the next acquire takes over" } : {}
1526
+ };
1527
+ }
1528
+ const owner = lease.holder ? loadAgent(ctx.store, lease.holder) : void 0;
1529
+ if (!owner) return {
1530
+ ...base,
1531
+ health: "unhealthy",
1532
+ control: "none",
1533
+ note: "the owner has no unit record"
1534
+ };
1535
+ const control = owner.pane || ctx.store.findPaneByAgentId(owner.id) ? "pane" : "none";
1536
+ 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;
1537
+ if (!isLive(ctx, owner)) return {
1538
+ ...base,
1539
+ owner,
1540
+ health: "unhealthy",
1541
+ control,
1542
+ note: "the owner session is gone"
1543
+ };
1544
+ return {
1545
+ ...base,
1546
+ owner,
1547
+ health: "healthy",
1548
+ control,
1549
+ ...controlNote ? { note: controlNote } : {}
1550
+ };
1551
+ }
1552
+ function vacantLease(ctx, projectId, name) {
1553
+ return {
1554
+ project: projectId,
1555
+ service: name,
1556
+ endpoint: serviceEndpointId(projectId, name),
1557
+ generation: 0,
1558
+ state: "vacant",
1559
+ updatedAt: iso(nowMs(ctx))
1560
+ };
1561
+ }
1562
+ /** Load the project, service endpoint, and lease — creating the endpoint and a vacant lease when
1563
+ * `create` is set, else throwing for a service that was never started. */
1564
+ function load(ctx, projectRef, name, create) {
1565
+ assertServiceName(name);
1566
+ const project = resolveProject(ctx, projectRef);
1567
+ const lease = ctx.store.getServiceLease(project.id, name);
1568
+ const endpoint = loadAgent(ctx.store, serviceEndpointId(project.id, name));
1569
+ if (lease && endpoint) return {
1570
+ project,
1571
+ endpoint,
1572
+ lease
1573
+ };
1574
+ if (!create) throw new Error(`no service "${name}" in project ${project.name} (${project.id})`);
1575
+ return {
1576
+ project,
1577
+ endpoint: ensureEndpoint(ctx, project, name),
1578
+ lease: lease ?? vacantLease(ctx, project.id, name)
1579
+ };
1580
+ }
1581
+ /** Run `fn` on the service's current state under its lock — every ownership transition goes here. */
1582
+ function transition(ctx, projectRef, name, create, fn) {
1583
+ assertServiceName(name);
1584
+ const project = resolveProject(ctx, projectRef);
1585
+ return ctx.store.withLock(lockName(project.id, name), () => fn(load(ctx, project.id, name, create)));
1586
+ }
1587
+ function write(ctx, lease) {
1588
+ const next = {
1589
+ ...lease,
1590
+ updatedAt: iso(nowMs(ctx))
1591
+ };
1592
+ ctx.store.putServiceLease(next);
1593
+ return next;
1594
+ }
1595
+ function stale(message) {
1596
+ return new ServiceOwnershipError("stale", message);
1597
+ }
1598
+ /** Read a service's ownership without changing anything. Throws for a service never started. */
1599
+ function resolveService(ctx, projectRef, name) {
1600
+ const { project, endpoint, lease } = load(ctx, projectRef, name, false);
1601
+ return view(ctx, project, endpoint, lease);
1602
+ }
1603
+ /**
1604
+ * Contact-or-start: resolve the service's healthy owner, or reserve the right to start one. Exactly
1605
+ * one of any number of concurrent callers gets `reserved`; the rest see `starting` (a start is in
1606
+ * progress) or `resolved`. A vacant service, an unhealthy owner, and an expired reservation are all
1607
+ * reservable, each under a new generation that fences out whatever held the old one.
1608
+ */
1609
+ function acquireService(ctx, projectRef, name, input = {}) {
1610
+ return transition(ctx, projectRef, name, true, ({ project, endpoint, lease }) => {
1611
+ const current = view(ctx, project, endpoint, lease);
1612
+ if (input.force) {
1613
+ if (input.force.generation !== lease.generation) throw stale(`cannot force: generation ${input.force.generation} is not the current ${lease.generation}`);
1614
+ } else if (current.health === "healthy") return {
1615
+ outcome: "resolved",
1616
+ lease,
1617
+ view: current
1618
+ };
1619
+ else if (current.health === "starting") return {
1620
+ outcome: "starting",
1621
+ lease,
1622
+ view: current
1623
+ };
1624
+ const now = nowMs(ctx);
1625
+ const token = randomBytes(8).toString("hex");
1626
+ const next = write(ctx, {
1627
+ project: project.id,
1628
+ service: name,
1629
+ endpoint: endpoint.id,
1630
+ generation: lease.generation + 1,
1631
+ state: "reserved",
1632
+ reservation: {
1633
+ token,
1634
+ ...input.by ? { by: input.by } : {},
1635
+ at: iso(now),
1636
+ expiresAt: iso(now + (input.ttlMs ?? 3e5)),
1637
+ ...input.force ? { forced: true } : {}
1638
+ },
1639
+ updatedAt: iso(now)
1640
+ });
1641
+ return {
1642
+ outcome: "reserved",
1643
+ lease: next,
1644
+ view: view(ctx, project, endpoint, next),
1645
+ token
1646
+ };
1647
+ });
1648
+ }
1649
+ function liveUnit(ctx, id) {
1650
+ const unit = loadAgent(ctx.store, id);
1651
+ if (!unit) throw new ServiceOwnershipError("unknown-unit", `no unit "${id}"`);
1652
+ if (!isLive(ctx, unit)) throw new ServiceOwnershipError("unit-not-live", `unit "${id}" has no live session`);
1653
+ return unit;
1654
+ }
1655
+ /**
1656
+ * Complete a reservation: make `unit` the owner at the reserved generation. Refused when the
1657
+ * reservation is no longer the current one (it expired and someone else re-reserved, or it was
1658
+ * released) — the starter then holds a runtime that is not the authority and must stop it. A
1659
+ * reservation that expired with nobody taking over is still bindable: nothing replaced it.
1660
+ */
1661
+ function bindService(ctx, projectRef, name, input) {
1662
+ return transition(ctx, projectRef, name, false, ({ project, endpoint, lease }) => {
1663
+ 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})`);
1664
+ liveUnit(ctx, input.unit);
1665
+ return view(ctx, project, endpoint, write(ctx, {
1666
+ project: lease.project,
1667
+ service: lease.service,
1668
+ endpoint: lease.endpoint,
1669
+ generation: lease.generation,
1670
+ state: "active",
1671
+ holder: input.unit,
1672
+ updatedAt: lease.updatedAt
1673
+ }));
1674
+ });
1675
+ }
1676
+ /**
1677
+ * Give the service up: abandon a reservation (a failed start — pass its `token`) or step down as
1678
+ * owner (pass the holder `unit`). Either must match the current generation. The generation is kept;
1679
+ * the next acquire bumps it.
1680
+ */
1681
+ function releaseService(ctx, projectRef, name, input) {
1682
+ return transition(ctx, projectRef, name, false, ({ project, endpoint, lease }) => {
1683
+ 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})`);
1684
+ return view(ctx, project, endpoint, write(ctx, {
1685
+ project: lease.project,
1686
+ service: lease.service,
1687
+ endpoint: lease.endpoint,
1688
+ generation: lease.generation,
1689
+ state: "vacant",
1690
+ updatedAt: lease.updatedAt
1691
+ }));
1692
+ });
1693
+ }
1694
+ /**
1695
+ * Transfer ownership from the current holder to another live unit, under a new generation. Only the
1696
+ * holder at the current generation can hand off, and afterwards it is stale.
1697
+ */
1698
+ function handoffService(ctx, projectRef, name, input) {
1699
+ return transition(ctx, projectRef, name, false, ({ project, endpoint, lease }) => {
1700
+ if (lease.state !== "active" || lease.generation !== input.generation || lease.holder !== input.from) throw stale(`"${input.from}" is not the owner at generation ${input.generation}`);
1701
+ liveUnit(ctx, input.to);
1702
+ return view(ctx, project, endpoint, write(ctx, {
1703
+ ...lease,
1704
+ generation: lease.generation + 1,
1705
+ holder: input.to
1706
+ }));
1707
+ });
1708
+ }
1709
+ /**
1710
+ * The fencing check: succeed only when `unit` owns the service at exactly `generation`. A runtime
1711
+ * calls this before acting as the service's authority; a stale runtime — replaced, handed off, or
1712
+ * recovered past — is refused.
1713
+ */
1714
+ function verifyOwnership(ctx, projectRef, name, input) {
1715
+ const { project, endpoint, lease } = load(ctx, projectRef, name, false);
1716
+ 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})`);
1717
+ return view(ctx, project, endpoint, lease);
1718
+ }
1719
+ /**
1720
+ * Run `fn` as the verified owner, holding the service lock so no transition can interleave between
1721
+ * the check and the act. For short critical sections only — the lock is bounded-wait for everyone
1722
+ * else — and never reentrant: `fn` must not call another service transition on the same service.
1723
+ */
1724
+ function withOwnership(ctx, projectRef, name, input, fn) {
1725
+ assertServiceName(name);
1726
+ const project = resolveProject(ctx, projectRef);
1727
+ return ctx.store.withLock(lockName(project.id, name), () => fn(verifyOwnership(ctx, project.id, name, input)));
1728
+ }
1729
+ /**
1730
+ * Resolve-or-start end to end: acquire, and only when this caller wins the reservation, launch a
1731
+ * runtime and bind it. A launch that throws releases the reservation (best-effort — an expiry covers
1732
+ * a release that itself fails) so the start is retryable at once. A launch that finishes after its
1733
+ * reservation was superseded is refused at bind: the launched unit is not the owner, and the error
1734
+ * names it so the caller can stop it.
1735
+ */
1736
+ async function startService(ctx, projectRef, name, input) {
1737
+ const acquired = acquireService(ctx, projectRef, name, input);
1738
+ if (acquired.outcome !== "reserved") return {
1739
+ outcome: acquired.outcome,
1740
+ view: acquired.view
1741
+ };
1742
+ const { generation, endpoint } = acquired.lease;
1743
+ let launched;
1744
+ try {
1745
+ launched = await input.launch({
1746
+ generation,
1747
+ endpoint
1748
+ });
1749
+ } catch (err) {
1750
+ try {
1751
+ releaseService(ctx, projectRef, name, {
1752
+ generation,
1753
+ token: acquired.token
1754
+ });
1755
+ } catch {}
1756
+ throw err;
1757
+ }
1758
+ try {
1759
+ return {
1760
+ outcome: "started",
1761
+ view: bindService(ctx, projectRef, name, {
1762
+ generation,
1763
+ token: acquired.token,
1764
+ unit: launched.unit
1765
+ }),
1766
+ launched
1767
+ };
1768
+ } catch (err) {
1769
+ if (err instanceof ServiceOwnershipError) throw new ServiceOwnershipError(err.code, `launched unit "${launched.unit}" could not become the owner — ${err.message}; stop it`);
1770
+ throw err;
1771
+ }
1772
+ }
1773
+ //#endregion
1774
+ //#region src/store/errors.ts
1775
+ /** A record file exists but its content didn't parse as JSON — a torn write (crash mid-`writeFileSync`,
1776
+ * pre-atomic-write code path) or on-disk tampering. Carries the file path and the original parse
1777
+ * error so a caller can report exactly what's broken and where, rather than "Unexpected token" with
1778
+ * no location. */
1779
+ var CorruptRecordError = class extends Error {
1780
+ file;
1781
+ constructor(file, cause) {
1782
+ super(`corrupt record at "${file}": ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
1783
+ this.file = file;
1784
+ this.name = "CorruptRecordError";
1785
+ }
1786
+ };
1787
+ //#endregion
1788
+ //#region src/store/process-liveness.ts
1789
+ /**
1790
+ * Probe whether `pid` is alive using `process.kill(pid, 0)` — the POSIX "does this process exist"
1791
+ * idiom; it sends no signal, it only tests deliverability. `ESRCH` (no such process) is the only
1792
+ * errno that means "definitely dead". Every other errno — `EPERM` above all, common under sandboxes
1793
+ * and containers where a genuinely live process can be unsignalable by this one, or any process
1794
+ * owned by a different uid — reads as `'unknown'`, never `'dead'`: collapsing `EPERM` into "dead" is
1795
+ * exactly the defect this three-state return exists to make structurally impossible to reintroduce
1796
+ * at a new call site. Same-pid is trivially `'alive'` (a process checking its own prior identity,
1797
+ * e.g. after a crash-and-restart that reused nothing).
1798
+ */
1799
+ function probeProcess(pid) {
1800
+ if (pid === process.pid) return "alive";
1801
+ try {
1802
+ process.kill(pid, 0);
1803
+ return "alive";
1804
+ } catch (err) {
1805
+ return err.code === "ESRCH" ? "dead" : "unknown";
1806
+ }
1807
+ }
1808
+ //#endregion
1809
+ //#region src/store/lock.ts
1810
+ var LockTimeoutError = class extends Error {
1811
+ name;
1812
+ constructor(name) {
1813
+ super(`timed out waiting for lock "${name}"`);
1814
+ this.name = name;
1815
+ this.name = "LockTimeoutError";
1816
+ }
1817
+ };
1818
+ function locksDir(root) {
1819
+ return join(root, "locks");
1820
+ }
1821
+ function lockDirFor(root, name) {
1822
+ return join(locksDir(root), `${name}.lock`);
1823
+ }
1824
+ /** Read a lock dir's recorded holder. Absent/corrupt/mid-acquire (holder.json not written yet) all
1825
+ * read as "unknown" — never grounds to steal. An ambiguous read must never look like a green light. */
1826
+ function readHolder(dir) {
1827
+ try {
1828
+ return JSON.parse(readFileSync(join(dir, "holder.json"), "utf8"));
1829
+ } catch {
1830
+ return;
1831
+ }
1832
+ }
1833
+ /** Locking's policy on `probeProcess`'s `'unknown'` state: treat it exactly like `'alive'` — i.e.
1834
+ * NOT `'dead'`. The one invariant that must never break here is stealing a lock a live holder still
1835
+ * holds, and `'unknown'` means "cannot rule out alive", so it can only ever be safe to fold it into
1836
+ * the "do not steal" side. This is a local policy decision, not `probeProcess`'s — a different call
1837
+ * site (a staleness reaper, say) folding `'unknown'` into "not provably alive" instead would be
1838
+ * reading the SAME three states toward the opposite default, which is exactly the bug class this
1839
+ * three-state type exists to force each call site to decide explicitly (see process-liveness.ts). */
1840
+ function heldByLiveOrUnknownPid(pid) {
1841
+ return probeProcess(pid) !== "dead";
1842
+ }
1843
+ function sleepSync(ms) {
1844
+ if (ms <= 0) return;
1845
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1846
+ }
1847
+ /**
1848
+ * Attempt to reclaim an abandoned lock dir without ever discarding a LIVE one out from under its
1849
+ * holder — the exact defect agmsg/firstmate's lock suites exist to catch (evidence.md items 3-4).
1850
+ *
1851
+ * `mkdirSync` on the lock path itself already arbitrates ordinary contention (EEXIST = someone else
1852
+ * got there first); the harder race is a STEAL: two processes independently deciding the same lock
1853
+ * looks dead. `renameSync` gives the same exclusivity `mkdirSync` gives for a fresh path, but for an
1854
+ * EXISTING one — of any number of processes racing to rename the SAME source path away, exactly one
1855
+ * succeeds and the rest get ENOENT. So the rename-away is the sole arbiter of "who gets to attempt
1856
+ * the steal", not the earlier staleness read.
1857
+ *
1858
+ * That still leaves one race: the content we read as stale might not be the content we actually
1859
+ * capture, if the true holder released and a NEW, live holder re-acquired at this same path between
1860
+ * our staleness read and our rename. So after winning the rename, re-check the pid we ACTUALLY
1861
+ * captured (not the one from the earlier read) — if it's alive, this was a live lock we grabbed by
1862
+ * accident; put it back immediately (best-effort — if a third party has since re-mkdir'd the path,
1863
+ * the invariant we're protecting already holds, so just drop our capture) and refuse to finalize.
1864
+ */
1865
+ function tryReclaimStale(dir) {
1866
+ const grave = `${dir}.stale-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1867
+ try {
1868
+ renameSync(dir, grave);
1869
+ } catch (err) {
1870
+ if (err.code === "ENOENT") return false;
1871
+ throw err;
1872
+ }
1873
+ const captured = readHolder(grave);
1874
+ if (captured && heldByLiveOrUnknownPid(captured.pid)) {
1875
+ try {
1876
+ renameSync(grave, dir);
1877
+ } catch {
1878
+ rmSync(grave, {
1879
+ recursive: true,
1880
+ force: true
1881
+ });
1882
+ }
1883
+ return false;
1884
+ }
1885
+ rmSync(grave, {
1886
+ recursive: true,
1887
+ force: true
1888
+ });
1889
+ return true;
1890
+ }
1891
+ /**
1892
+ * Acquire an advisory, mkdir-based lock at `root/locks/<name>.lock`. mkdir (not O_EXCL on a file) is
1893
+ * the primitive here because a lock also needs to carry its holder metadata (pid + timestamp) for
1894
+ * staleness detection, and a directory gives that a natural home (`holder.json` inside it) without a
1895
+ * second file to keep in sync; `mkdirSync` on a non-existent path is exactly as atomic as an
1896
+ * exclusive file create on every filesystem this tool targets (POSIX; NTFS via Node's Win32 mkdir).
1897
+ *
1898
+ * Blocks (busy-retries) until acquired or `timeoutMs` elapses, reclaiming a dead holder's lock along
1899
+ * the way (see `tryReclaimStale`) but NEVER a live one — contention with a live holder always waits
1900
+ * it out or times out, never steals.
1901
+ */
1902
+ function acquireLock(root, name, opts = {}) {
1903
+ const retryDelayMs = opts.retryDelayMs ?? 20;
1904
+ const timeoutMs = opts.timeoutMs ?? 5e3;
1905
+ const dir = lockDirFor(root, name);
1906
+ mkdirSync(locksDir(root), { recursive: true });
1907
+ const deadline = Date.now() + timeoutMs;
1908
+ for (;;) try {
1909
+ mkdirSync(dir);
1910
+ writeFileSync(join(dir, "holder.json"), JSON.stringify({
1911
+ pid: process.pid,
1912
+ acquiredAt: Date.now()
1913
+ }));
1914
+ return { release: () => rmSync(dir, {
1915
+ recursive: true,
1916
+ force: true
1917
+ }) };
1918
+ } catch (err) {
1919
+ if (err.code !== "EEXIST") throw err;
1920
+ const holder = readHolder(dir);
1921
+ if (holder && !heldByLiveOrUnknownPid(holder.pid) && tryReclaimStale(dir)) continue;
1922
+ if (Date.now() > deadline) throw new LockTimeoutError(name);
1923
+ sleepSync(retryDelayMs);
1924
+ }
1925
+ }
1926
+ /** Acquire `name`, run `fn`, and always release — the shape every genuine read-modify-write in the
1927
+ * store should use (`setMainPane`, `identity.ts`'s `claimPresence`/`clearPresence` today). */
1928
+ function withLock(root, name, fn, opts) {
1929
+ const handle = acquireLock(root, name, opts);
1930
+ try {
1931
+ return fn();
1932
+ } finally {
1933
+ handle.release();
1934
+ }
1935
+ }
1936
+ //#endregion
1937
+ //#region src/store/file-store.ts
1938
+ /** Parse a record file's content, wrapping a `JSON.parse` failure in a typed, file-named
1939
+ * `CorruptRecordError` instead of letting a bare `SyntaxError` bubble up from deep inside
1940
+ * `listInbox`/`listAgents`/`getAgent` with no indication of WHICH file broke. A file that doesn't
1941
+ * exist is a caller error — callers check existence first, so this only ever sees files known to
1942
+ * be present but possibly torn (a crash mid-write predating the atomic-write fix, or on-disk
1943
+ * tampering) — see evidence.md item 5, "no silent success on a broken store". */
1944
+ function readJsonRecord(file) {
1945
+ const raw = readFileSync(file, "utf8");
1946
+ try {
1947
+ return JSON.parse(raw);
1948
+ } catch (err) {
1949
+ throw new CorruptRecordError(file, err);
1950
+ }
1951
+ }
1952
+ function readMessages(dir) {
1953
+ if (!existsSync(dir)) return [];
1954
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f)));
1955
+ }
1956
+ /** Write `data` to `file` crash-safely: write to a sibling temp file first, then `renameSync` into
1957
+ * place. `rename` is atomic within a filesystem (POSIX guarantees this; Node's Win32 rename is too
1958
+ * for same-volume paths, which every path here is — always under the same store root), so any
1959
+ * reader either sees the OLD complete content or the NEW complete content, never a truncated
1960
+ * in-between. Before this fix, `putMessage`/`putAgent`/`putPaneIndex`/`setMainPane` called
1961
+ * `writeFileSync` directly on the final path — only `ackMessage`'s move (a rename of an already-
1962
+ * complete file) was atomic. A process crashing mid-`writeFileSync`, or a reader racing a writer on
1963
+ * a large record, could hand `JSON.parse` a truncated file and throw an uncaught `SyntaxError` deep
1964
+ * inside `listInbox`/`listAgents`/`getAgent`.
1965
+ *
1966
+ * Deliberately no `fsync` before the rename: this tool defends against a process CRASHING mid-write
1967
+ * (the actual, observed risk in a daemonless multi-process CLI), where the page cache alone is
1968
+ * sufficient, not against a power-loss/OS-crash losing unflushed pages, which cyberlegion doesn't
1969
+ * claim to survive today (no writer here holds data the user can't just re-send). Add fsync if that
1970
+ * durability bar ever changes.
1971
+ *
1972
+ * The temp name embeds pid + a counter so two writers to the SAME final path never collide on their
1973
+ * own temp files mid-write (each writer's temp file is unique to it). */
1974
+ let tmpCounter = 0;
1975
+ function writeFileAtomic(file, data) {
1976
+ mkdirSync(dirname(file), { recursive: true });
1977
+ const tmp = `${file}.${process.pid}.${tmpCounter++}.tmp`;
1978
+ writeFileSync(tmp, data);
1979
+ renameSync(tmp, file);
1980
+ }
1981
+ function writeJson(file, data) {
1982
+ writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`);
1983
+ }
1984
+ function writeText(file, text) {
1985
+ writeFileAtomic(file, text);
1986
+ }
1987
+ /** Build a path via `build()` for a READ-only lookup, treating a syntactically-invalid id
1988
+ * (`InvalidIdError`) the same as "not found" rather than throwing. Reads are the wrong place to
1989
+ * enforce id-shape: `resolveAgent`/`resolveRecipient` (identity.ts) speculatively probe an arbitrary
1990
+ * ref — which may legitimately be a worktree BRANCH NAME containing `/` — as a candidate id before
1991
+ * falling back to handle/branch lookup, so `getAgent('cyberlegion/unit-abc')` must fail soft, not
1992
+ * throw, or that fallback chain breaks. The traversal risk this guards against (an id escaping the
1993
+ * store root) only bites on a WRITE — nothing is ever created or overwritten by a probe that finds
1994
+ * nothing — so a hard reject stays reserved for the write paths below (`putAgent`, `putMessage`,
1995
+ * `putPaneIndex`, `writeBrief`), where a malformed id would otherwise put a stray file wherever it
1996
+ * pointed. */
1997
+ function readPathOrUndefined(build) {
1998
+ try {
1999
+ return build();
2000
+ } catch (err) {
2001
+ if (err instanceof InvalidIdError) return void 0;
2002
+ throw err;
2003
+ }
2004
+ }
2005
+ /** The on-disk `Store` implementation — current per-writer sharded `.json` layout (ADR-0020):
2006
+ * one file per message/agent, collision-free filenames, ack = atomic rename into `read/`. */
2007
+ var FileStore = class {
2008
+ root;
2009
+ constructor(root) {
2010
+ this.root = root;
2011
+ }
2012
+ ensureMarker() {
2013
+ ensureMarker(this.root);
2014
+ }
2015
+ putMessage(toId, msg) {
2016
+ writeFileAtomic(paths.messageFile(this.root, toId, msg.id), `${JSON.stringify(msg, null, 2)}\n`);
2017
+ }
2018
+ listInbox(id) {
2019
+ const inbox = readPathOrUndefined(() => paths.inboxDir(this.root, id));
2020
+ const read = readPathOrUndefined(() => paths.inboxReadDir(this.root, id));
2021
+ return {
2022
+ unread: inbox ? readMessages(inbox) : [],
2023
+ read: read ? readMessages(read) : []
2024
+ };
2025
+ }
2026
+ ackMessage(id, msgId) {
2027
+ const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
2028
+ if (!src || !existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
2029
+ const msg = readJsonRecord(src);
2030
+ const dest = paths.messageReadFile(this.root, id, msgId);
2031
+ mkdirSync(dirname(dest), { recursive: true });
2032
+ renameSync(src, dest);
2033
+ return msg;
2034
+ }
2035
+ removeMessage(id, msgId) {
2036
+ const unreadFile = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
2037
+ if (unreadFile && existsSync(unreadFile)) {
2038
+ rmSync(unreadFile);
2039
+ return;
2040
+ }
2041
+ const readFile = readPathOrUndefined(() => paths.messageReadFile(this.root, id, msgId));
2042
+ if (readFile && existsSync(readFile)) {
2043
+ rmSync(readFile);
2044
+ return;
2045
+ }
2046
+ throw new Error(`"${msgId}" is not a message in this inbox`);
2047
+ }
2048
+ putAgent(rec) {
2049
+ writeJson(paths.agentFile(this.root, rec.id), rec);
2050
+ }
2051
+ getAgent(id) {
2052
+ const file = readPathOrUndefined(() => paths.agentFile(this.root, id));
2053
+ if (!file || !existsSync(file)) return void 0;
2054
+ return readJsonRecord(file);
2055
+ }
2056
+ listAgents() {
2057
+ const dir = paths.agentsDir(this.root);
2058
+ if (!existsSync(dir)) return [];
2059
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f))).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2060
+ }
2061
+ removeAgent(id) {
2062
+ rmSync(paths.agentFile(this.root, id), { force: true });
2063
+ }
2064
+ removeAgentData(id) {
2065
+ rmSync(paths.dataDir(this.root, id), {
2066
+ recursive: true,
2067
+ force: true
2068
+ });
2069
+ }
2070
+ putProject(rec) {
2071
+ writeJson(paths.projectFile(this.root, rec.id), rec);
2072
+ }
2073
+ getProject(id) {
2074
+ const file = readPathOrUndefined(() => paths.projectFile(this.root, id));
2075
+ if (!file || !existsSync(file)) return void 0;
2076
+ return readJsonRecord(file);
2077
+ }
2078
+ listProjects() {
2079
+ const dir = paths.projectsDir(this.root);
2080
+ if (!existsSync(dir)) return [];
2081
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f))).sort((a, b) => a.registeredAt.localeCompare(b.registeredAt));
2082
+ }
2083
+ putServiceLease(lease) {
2084
+ writeJson(paths.serviceLeaseFile(this.root, lease.project, lease.service), lease);
2085
+ }
2086
+ getServiceLease(project, service) {
2087
+ const file = readPathOrUndefined(() => paths.serviceLeaseFile(this.root, project, service));
2088
+ if (!file || !existsSync(file)) return void 0;
2089
+ return readJsonRecord(file);
2090
+ }
2091
+ putPaneIndex(pane, agentId) {
2092
+ writeText(paths.paneFile(this.root, pane), agentId);
2093
+ }
2094
+ resolvePaneId(pane) {
2095
+ const file = paths.paneFile(this.root, pane);
2096
+ return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
2097
+ }
2098
+ findPaneByAgentId(agentId) {
2099
+ const dir = paths.panesDir(this.root);
2100
+ if (!existsSync(dir)) return void 0;
2101
+ for (const f of readdirSync(dir)) {
2102
+ if (!f.endsWith(".id")) continue;
2103
+ if (readFileSync(join(dir, f), "utf8").trim() === agentId) return f.slice(0, -3);
2104
+ }
2105
+ }
2106
+ removePaneIndex(pane) {
2107
+ rmSync(paths.paneFile(this.root, pane), { force: true });
2108
+ }
2109
+ writeBrief(agentId, text) {
2110
+ writeText(paths.briefFile(this.root, agentId), text);
2111
+ }
2112
+ readBrief(agentId) {
2113
+ const file = readPathOrUndefined(() => paths.briefFile(this.root, agentId));
2114
+ return file && existsSync(file) ? readFileSync(file, "utf8") : void 0;
2115
+ }
2116
+ setMainPane(pane) {
2117
+ withLock(this.root, "main-pane", () => {
2118
+ const file = paths.mainPaneFile(this.root);
2119
+ if (pane) {
2120
+ writeText(file, pane);
2121
+ return;
2122
+ }
2123
+ rmSync(file, { force: true });
2124
+ });
2125
+ }
2126
+ getMainPane() {
2127
+ const file = paths.mainPaneFile(this.root);
2128
+ return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
2129
+ }
2130
+ withLock(name, fn) {
2131
+ return withLock(this.root, name, fn);
2132
+ }
2133
+ };
2134
+ //#endregion
2135
+ //#region src/wake/await.ts
2136
+ /** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
2137
+ const POLL_MS$1 = 1e3;
2138
+ const DEFAULT_TIMEOUT_MS = 6e5;
2139
+ const DEFAULT_MAX_WAIT_S = 240;
2140
+ /**
2141
+ * Block on the caller's UNREAD inbox until a message matching `thread` (and optional `from`)
2142
+ * arrives, polling on a fixed ~1s internal interval. Three unambiguous outcomes:
2143
+ *
2144
+ * - `matched` — a message arrived; it is printed by the caller AND acked here (moved to read/), so
2145
+ * it leaves the unread set. Block-then-read: each await consumes exactly its round.
2146
+ * - `waiting` — this call's internal poll cycle hit its `maxWaitS` cap (default 240s, always well
2147
+ * under a harness tool-timeout SIGKILL) with no match yet. A clean, non-error return the caller
2148
+ * re-arms on (calls `awaitReply` again) — NOT the same as giving up.
2149
+ * - `timed-out` — the caller's overall `timeoutMs` (default 600_000ms / 600s; `0` = wait forever)
2150
+ * elapsed across re-arms with no match. The caller should exit non-zero.
2151
+ */
2152
+ async function awaitReply(ctx, input) {
2153
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2154
+ const maxWaitMs = (input.maxWaitS ?? DEFAULT_MAX_WAIT_S) * 1e3;
2155
+ const now = input.now ?? (() => Date.now());
2156
+ const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
2157
+ const start = now();
2158
+ const cycleDeadline = start + maxWaitMs;
2159
+ for (;;) {
2160
+ const items = inbox(ctx, {
2161
+ meId: input.meId,
2162
+ unread: true,
2163
+ thread: input.thread,
2164
+ from: input.from
2165
+ });
2166
+ if (items.length > 0) {
2167
+ const message = items[0];
2168
+ ack(ctx, input.meId, message.id);
2169
+ return {
2170
+ kind: "matched",
2171
+ message
2172
+ };
2173
+ }
2174
+ const elapsed = now() - start;
2175
+ if (timeoutMs !== 0 && elapsed >= timeoutMs) return { kind: "timed-out" };
2176
+ if (now() >= cycleDeadline) return { kind: "waiting" };
2177
+ await sleep(POLL_MS$1);
2178
+ }
2179
+ }
2180
+ //#endregion
2181
+ //#region src/wake/watch.ts
2182
+ const POLL_MS = 1e3;
2183
+ /**
2184
+ * A continuous foreground observer: prints each NEW matching message as it arrives, polling on a
2185
+ * fixed ~1s interval, until interrupted (or `maxIterations` in tests). Never acks — a message
2186
+ * `watch` prints stays unread and still surfaces later in `mail inbox`/`mail await`.
2187
+ */
2188
+ async function watchMail(ctx, input, onMessage, opts = {}) {
2189
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
2190
+ const seen = /* @__PURE__ */ new Set();
2191
+ for (const m of inbox(ctx, {
2192
+ meId: input.meId,
2193
+ thread: input.thread,
2194
+ from: input.from
2195
+ })) seen.add(m.id);
2196
+ let iterations = 0;
2197
+ for (;;) {
2198
+ const items = inbox(ctx, {
2199
+ meId: input.meId,
2200
+ thread: input.thread,
2201
+ from: input.from
2202
+ });
2203
+ for (const m of items) {
2204
+ if (seen.has(m.id)) continue;
2205
+ seen.add(m.id);
2206
+ onMessage(m);
2207
+ }
2208
+ iterations++;
2209
+ if (opts.maxIterations != null && iterations >= opts.maxIterations) return;
2210
+ await sleep(POLL_MS);
2211
+ }
2212
+ }
2213
+ //#endregion
2214
+ 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 };