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