cyberlegion 0.0.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.
@@ -0,0 +1,1403 @@
1
+ import { basename, dirname, join, resolve } from "node:path";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
5
+ import { homedir } from "node:os";
6
+ //#region src/identity.ts
7
+ const realExec = (cmd, args) => {
8
+ try {
9
+ return execFileSync(cmd, args, {
10
+ encoding: "utf8",
11
+ stdio: [
12
+ "ignore",
13
+ "pipe",
14
+ "ignore"
15
+ ]
16
+ }).trim();
17
+ } catch {
18
+ return null;
19
+ }
20
+ };
21
+ const nowIso = (ctx) => new Date(ctx.now?.() ?? Date.now()).toISOString();
22
+ function randomId() {
23
+ return randomBytes(8).toString("hex");
24
+ }
25
+ function loadAgent(store, id) {
26
+ return store.getAgent(id);
27
+ }
28
+ function saveAgent(store, rec) {
29
+ store.putAgent(rec);
30
+ }
31
+ function listAgents(store) {
32
+ return store.listAgents();
33
+ }
34
+ /** Detect the harness: explicit wins, then env probes, then the tmux pane command. */
35
+ function detectHarness(explicit, ctx) {
36
+ if (explicit) {
37
+ if (explicit === "claude" || explicit === "cursor" || explicit === "codex") return explicit;
38
+ throw new Error(`unknown --harness "${explicit}" (expected claude | cursor | codex)`);
39
+ }
40
+ const env = ctx.env ?? process.env;
41
+ if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return "claude";
42
+ if (Object.keys(env).some((k) => k.startsWith("CURSOR"))) return "cursor";
43
+ if (Object.keys(env).some((k) => k.startsWith("CODEX"))) return "codex";
44
+ const pane = env.TMUX_PANE;
45
+ if (pane) {
46
+ const cmd = (ctx.exec ?? realExec)("tmux", [
47
+ "display-message",
48
+ "-p",
49
+ "-t",
50
+ pane,
51
+ "#{pane_current_command}"
52
+ ]);
53
+ if (cmd?.includes("cursor")) return "cursor";
54
+ if (cmd?.includes("codex")) return "codex";
55
+ if (cmd?.includes("claude")) return "claude";
56
+ }
57
+ }
58
+ /**
59
+ * Recover the calling agent's own id. In tmux the pane index is authoritative — a pane with no
60
+ * pane entry is simply unregistered and must NOT adopt `$CYBERLEGION_AGENT_ID` (that env fallback
61
+ * applies only when there is no `$TMUX_PANE`). There is no shared bare "self" file — self-id is
62
+ * always pane-keyed or explicit via the env var.
63
+ */
64
+ function resolveSelfId(ctx) {
65
+ const env = ctx.env ?? process.env;
66
+ const pane = env.TMUX_PANE;
67
+ if (pane) return ctx.store.resolvePaneId(pane);
68
+ return env.CYBERLEGION_AGENT_ID || void 0;
69
+ }
70
+ /** Register (or idempotently refresh) this session's identity. */
71
+ function register(ctx, input) {
72
+ ctx.store.ensureMarker();
73
+ const env = ctx.env ?? process.env;
74
+ const harness = detectHarness(input.harness, ctx);
75
+ if (!harness) throw new Error("could not detect harness — pass --harness claude|cursor|codex");
76
+ const existingId = resolveSelfId(ctx);
77
+ const existing = existingId ? loadAgent(ctx.store, existingId) : void 0;
78
+ const id = existing?.id ?? existingId ?? randomId();
79
+ const ts = nowIso(ctx);
80
+ const pane = env.TMUX_PANE;
81
+ const exec = ctx.exec ?? realExec;
82
+ const rec = {
83
+ id,
84
+ handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
85
+ harness,
86
+ cwd: process.cwd(),
87
+ worktree: existing?.worktree ?? gitWorktree(exec),
88
+ tmux: pane ? {
89
+ pane,
90
+ window: exec("tmux", [
91
+ "display-message",
92
+ "-p",
93
+ "-t",
94
+ pane,
95
+ "#{window_id}"
96
+ ]) ?? void 0,
97
+ session: env.TMUX?.split(",")[0]
98
+ } : null,
99
+ status: "active",
100
+ createdAt: existing?.createdAt ?? ts,
101
+ lastSeen: ts,
102
+ ...existing?.brief ? { brief: existing.brief } : {},
103
+ ...existing?.spawnedBy ? { spawnedBy: existing.spawnedBy } : {}
104
+ };
105
+ saveAgent(ctx.store, rec);
106
+ if (pane) ctx.store.putPaneIndex(pane, id);
107
+ return rec;
108
+ }
109
+ function gitWorktree(exec) {
110
+ const root = exec("git", ["rev-parse", "--show-toplevel"]);
111
+ if (!root) return null;
112
+ return {
113
+ root,
114
+ branch: exec("git", [
115
+ "rev-parse",
116
+ "--abbrev-ref",
117
+ "HEAD"
118
+ ]) ?? void 0
119
+ };
120
+ }
121
+ /** Resolve a recipient argument (id or handle) to an agent id. */
122
+ function resolveRecipient(store, to) {
123
+ if (loadAgent(store, to)) return to;
124
+ const match = listAgents(store).find((a) => a.handle === to);
125
+ if (!match) throw new Error(`no agent addressable as "${to}"`);
126
+ return match.id;
127
+ }
128
+ /**
129
+ * Resolve a unit reference by id, handle, or its worktree branch (the unit↔CR join key: an
130
+ * `AgentRecord.worktree.branch` equals the SDD `<cr-ref>` it maps to, when spawned for one). Used
131
+ * by verbs that address "the unit working on CR X" as well as "the unit named X".
132
+ */
133
+ function resolveAgent(store, ref) {
134
+ const byId = loadAgent(store, ref);
135
+ if (byId) return byId;
136
+ const agents = listAgents(store);
137
+ const byHandle = agents.find((a) => a.handle === ref);
138
+ if (byHandle) return byHandle;
139
+ const byBranch = agents.find((a) => a.worktree?.branch === ref);
140
+ if (byBranch) return byBranch;
141
+ throw new Error(`no agent addressable as "${ref}" (tried id, handle, and worktree branch/CR)`);
142
+ }
143
+ function bumpLastSeen(ctx, id) {
144
+ const rec = loadAgent(ctx.store, id);
145
+ if (!rec) return;
146
+ rec.lastSeen = nowIso(ctx);
147
+ saveAgent(ctx.store, rec);
148
+ }
149
+ /** Refresh this session's own last-seen if it is registered — best-effort, never throws. */
150
+ function touch(ctx) {
151
+ const id = resolveSelfId(ctx);
152
+ if (id) bumpLastSeen(ctx, id);
153
+ }
154
+ const STALE_MS = 900 * 1e3;
155
+ /** Mark agents whose pane is gone or whose last-seen is stale as exited. */
156
+ function prune(ctx) {
157
+ const exec = ctx.exec ?? realExec;
158
+ const now = ctx.now?.() ?? Date.now();
159
+ const changed = [];
160
+ for (const rec of listAgents(ctx.store)) {
161
+ if (rec.status === "exited") continue;
162
+ const paneGone = rec.tmux?.pane ? exec("tmux", [
163
+ "has-session",
164
+ "-t",
165
+ rec.tmux.pane
166
+ ]) === null && !(exec("tmux", [
167
+ "list-panes",
168
+ "-a",
169
+ "-F",
170
+ "#{pane_id}"
171
+ ]) ?? "").split("\n").includes(rec.tmux.pane) : false;
172
+ const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
173
+ if (paneGone || stale) {
174
+ rec.status = "exited";
175
+ saveAgent(ctx.store, rec);
176
+ changed.push(rec);
177
+ }
178
+ }
179
+ return changed;
180
+ }
181
+ //#endregion
182
+ //#region src/console/mux-probe.ts
183
+ const KNOWN_MUX = [
184
+ "tmux",
185
+ "herdr",
186
+ "screen",
187
+ "none"
188
+ ];
189
+ function isKnownMux(v) {
190
+ return v != null && KNOWN_MUX.includes(v);
191
+ }
192
+ /**
193
+ * Two-mode multiplexer detection.
194
+ *
195
+ * Fast-path: `$CYBERLEGION_MUX` (tmux | herdr | screen | none) is trusted outright — this also
196
+ * serves as an OVERRIDE (`=none` forces no-mux even inside a real multiplexer). `$CYBERLEGION_MUX_PANE`
197
+ * carries the pane id alongside it.
198
+ *
199
+ * Discovery (else): walk the process ancestry from `$$` via `ps -o ppid=,comm= -p <pid>`, since the
200
+ * tool's own shell may not be the human's pane. `$TMUX`/`$HERDR_ENV` are NOT trusted alone — they
201
+ * are used only as a fast-positive hint the ancestry walk falls back to when the walk itself is
202
+ * inconclusive (e.g. `ps` unavailable), never as a substitute for it.
203
+ */
204
+ function probeMultiplexer(exec, env, opts = {}) {
205
+ if (isKnownMux(env.CYBERLEGION_MUX)) return {
206
+ mux: env.CYBERLEGION_MUX,
207
+ ...env.CYBERLEGION_MUX_PANE ? { pane: env.CYBERLEGION_MUX_PANE } : {},
208
+ via: "env"
209
+ };
210
+ if (opts.discover === false) return {
211
+ mux: "none",
212
+ via: "ancestry"
213
+ };
214
+ return discoverByAncestry(exec, env);
215
+ }
216
+ const MUX_COMM = [
217
+ {
218
+ re: /^tmux(:|$)/,
219
+ mux: "tmux",
220
+ pane: (env) => env.TMUX_PANE
221
+ },
222
+ {
223
+ re: /^herdr(:|$)/,
224
+ mux: "herdr",
225
+ pane: (env) => env.HERDR_PANE
226
+ },
227
+ {
228
+ re: /^screen(:|$)/,
229
+ mux: "screen",
230
+ pane: () => void 0
231
+ }
232
+ ];
233
+ const MAX_ANCESTORS = 32;
234
+ function walkAncestry(exec, env) {
235
+ let pid = process.pid;
236
+ const seen = /* @__PURE__ */ new Set();
237
+ for (let i = 0; i < MAX_ANCESTORS; i++) {
238
+ if (seen.has(pid)) break;
239
+ seen.add(pid);
240
+ const line = exec("ps", [
241
+ "-o",
242
+ "ppid=,comm=",
243
+ "-p",
244
+ String(pid)
245
+ ]);
246
+ if (!line) break;
247
+ const trimmed = line.trim();
248
+ const spaceIdx = trimmed.indexOf(" ");
249
+ const ppidStr = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
250
+ const comm = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
251
+ const ppid = Number.parseInt(ppidStr, 10);
252
+ for (const entry of MUX_COMM) if (entry.re.test(comm)) return {
253
+ mux: entry.mux,
254
+ pane: entry.pane(env),
255
+ via: "ancestry"
256
+ };
257
+ if (!Number.isFinite(ppid) || ppid <= 1) break;
258
+ pid = ppid;
259
+ }
260
+ }
261
+ function discoverByAncestry(exec, env) {
262
+ const found = walkAncestry(exec, env);
263
+ if (found) return found;
264
+ if (env.TMUX) return {
265
+ mux: "tmux",
266
+ pane: env.TMUX_PANE,
267
+ via: "ancestry"
268
+ };
269
+ if (env.HERDR_ENV) return {
270
+ mux: "herdr",
271
+ pane: env.HERDR_PANE,
272
+ via: "ancestry"
273
+ };
274
+ return {
275
+ mux: "none",
276
+ via: "ancestry"
277
+ };
278
+ }
279
+ //#endregion
280
+ //#region src/console/session.herdr.ts
281
+ /**
282
+ * herdr backend — detected via `$HERDR_ENV`. herdr (https://herdr.dev) is an agent-aware terminal
283
+ * multiplexer that also reports real busy-state (working / idle / blocked / done); this adapter
284
+ * only drives its pane lifecycle, not the state feed. Talks to herdr's own CLI (`herdr pane ...`)
285
+ * rather than its Unix-socket API, so it composes with this codebase's synchronous `Exec`
286
+ * convention exactly like the tmux adapter — no new client/transport needed.
287
+ *
288
+ * herdr is optional/experimental and is not installed in this environment; this follows herdr's
289
+ * documented CLI reference (split/run/read/close/focus) but is unverified against a live binary.
290
+ */
291
+ const herdrSessionAdapter = {
292
+ name: "herdr",
293
+ open(exec, opts) {
294
+ const id = exec("herdr", [
295
+ "pane",
296
+ "split",
297
+ "--current",
298
+ "--direction",
299
+ opts.at === "pane:down" ? "down" : "right",
300
+ "--cwd",
301
+ opts.cwd
302
+ ]);
303
+ if (!id) throw new Error("herdr pane split failed");
304
+ const target = { id };
305
+ exec("herdr", [
306
+ "pane",
307
+ "run",
308
+ id,
309
+ opts.launch
310
+ ]);
311
+ return target;
312
+ },
313
+ send(exec, target, text) {
314
+ exec("herdr", [
315
+ "pane",
316
+ "run",
317
+ target.id,
318
+ text
319
+ ]);
320
+ },
321
+ read(exec, target, opts) {
322
+ const args = [
323
+ "pane",
324
+ "read",
325
+ target.id,
326
+ "--source",
327
+ "visible"
328
+ ];
329
+ if (opts?.lines != null) args.push("--lines", String(opts.lines));
330
+ return exec("herdr", args) ?? "";
331
+ },
332
+ focus(exec, target) {
333
+ exec("herdr", [
334
+ "pane",
335
+ "focus",
336
+ target.id
337
+ ]);
338
+ },
339
+ teardown(exec, target) {
340
+ exec("herdr", [
341
+ "pane",
342
+ "close",
343
+ target.id
344
+ ]);
345
+ }
346
+ };
347
+ //#endregion
348
+ //#region src/console/session.tmux.ts
349
+ /** tmux backend — detected via `$TMUX`. */
350
+ const tmuxSessionAdapter = {
351
+ name: "tmux",
352
+ open(exec, opts) {
353
+ const pane = exec("tmux", opts.at === "pane:down" ? [
354
+ "split-window",
355
+ "-v",
356
+ "-c",
357
+ opts.cwd,
358
+ "-P",
359
+ "-F",
360
+ "#{pane_id}"
361
+ ] : opts.at === "tab" || opts.at === "window" ? [
362
+ "new-window",
363
+ "-c",
364
+ opts.cwd,
365
+ "-P",
366
+ "-F",
367
+ "#{pane_id}"
368
+ ] : [
369
+ "split-window",
370
+ "-h",
371
+ "-c",
372
+ opts.cwd,
373
+ "-P",
374
+ "-F",
375
+ "#{pane_id}"
376
+ ]);
377
+ if (!pane) throw new Error("tmux split-window failed");
378
+ const target = { id: pane };
379
+ tmuxSessionAdapter.send(exec, target, opts.launch);
380
+ return target;
381
+ },
382
+ send(exec, target, text) {
383
+ exec("tmux", [
384
+ "send-keys",
385
+ "-t",
386
+ target.id,
387
+ text,
388
+ "Enter"
389
+ ]);
390
+ },
391
+ read(exec, target, opts) {
392
+ const args = [
393
+ "capture-pane",
394
+ "-p",
395
+ "-t",
396
+ target.id
397
+ ];
398
+ if (opts?.lines != null) args.push("-S", `-${opts.lines}`);
399
+ return exec("tmux", args) ?? "";
400
+ },
401
+ focus(exec, target) {
402
+ exec("tmux", [
403
+ "select-pane",
404
+ "-t",
405
+ target.id
406
+ ]);
407
+ },
408
+ teardown(exec, target) {
409
+ exec("tmux", [
410
+ "kill-pane",
411
+ "-t",
412
+ target.id
413
+ ]);
414
+ }
415
+ };
416
+ //#endregion
417
+ //#region src/console/index.ts
418
+ /**
419
+ * Backend selection via the two-mode mux probe (`$CYBERLEGION_MUX` fast-path/override, else
420
+ * ancestry discovery from `$$` falling back to the `$TMUX`/`$HERDR_ENV` hint when the walk is
421
+ * inconclusive) — tmux/herdr map to their existing adapters; anything else is a clear error.
422
+ */
423
+ function selectSessionAdapter(env, exec = realExec) {
424
+ const probe = probeMultiplexer(exec, env);
425
+ if (probe.mux === "tmux") return tmuxSessionAdapter;
426
+ if (probe.mux === "herdr") return herdrSessionAdapter;
427
+ throw new Error("spawn requires a session backend — run inside tmux ($TMUX) or herdr ($HERDR_ENV=1)");
428
+ }
429
+ //#endregion
430
+ //#region src/console/worktree.ts
431
+ /** The only worktree backend at MVP — plain `git worktree`. */
432
+ const gitWorktreeAdapter = {
433
+ add(exec, opts) {
434
+ if (exec("git", [
435
+ "-C",
436
+ opts.primaryRoot,
437
+ "worktree",
438
+ "add",
439
+ "-b",
440
+ opts.branch,
441
+ opts.path
442
+ ]) === null) throw new Error(`git worktree add failed for ${opts.path}`);
443
+ return {
444
+ root: resolve(opts.path),
445
+ branch: opts.branch
446
+ };
447
+ },
448
+ remove(exec, path, opts) {
449
+ if (exec("git", [
450
+ "-C",
451
+ opts.primaryRoot,
452
+ "worktree",
453
+ "remove",
454
+ path,
455
+ "--force"
456
+ ]) === null) throw new Error(`git worktree remove failed for ${path}`);
457
+ }
458
+ };
459
+ /**
460
+ * Resolve the primary checkout's root regardless of whether the caller's cwd is the primary
461
+ * checkout or a linked worktree — `--git-common-dir` always points at the main repo's `.git`.
462
+ */
463
+ function resolvePrimaryRoot(exec) {
464
+ const commonDir = exec("git", [
465
+ "rev-parse",
466
+ "--path-format=absolute",
467
+ "--git-common-dir"
468
+ ]);
469
+ if (!commonDir) throw new Error("cannot resolve the primary checkout — not inside a git repository");
470
+ return dirname(commonDir);
471
+ }
472
+ /**
473
+ * Refuse the primary checkout: a spawned unit's resolved worktree root must never be the primary
474
+ * checkout itself. A unit is never run in the primary checkout.
475
+ */
476
+ function assertDistinctFromPrimary(worktreeRoot, primaryRoot) {
477
+ if (resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error("refusing to run a unit in the primary checkout — spawn a worktree distinct from the primary checkout");
478
+ }
479
+ //#endregion
480
+ //#region src/paths.ts
481
+ /** The tracked marker file that makes a hub root initialized (see ensureMarker). */
482
+ const MARKER_FILE = "config.json";
483
+ /** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
484
+ function projectRoot(cwd = process.cwd()) {
485
+ let dir = resolve(cwd);
486
+ for (;;) {
487
+ if (existsSync(join(dir, ".git"))) return dir;
488
+ const parent = dirname(dir);
489
+ if (parent === dir) return resolve(cwd);
490
+ dir = parent;
491
+ }
492
+ }
493
+ function makeDefaultExec(cwd) {
494
+ return (cmd, args) => {
495
+ try {
496
+ return execFileSync(cmd, args, {
497
+ encoding: "utf8",
498
+ stdio: [
499
+ "ignore",
500
+ "pipe",
501
+ "ignore"
502
+ ],
503
+ cwd
504
+ }).trim();
505
+ } catch {
506
+ return null;
507
+ }
508
+ };
509
+ }
510
+ /**
511
+ * Resolve the cyberlegion hub root. Precedence: explicit --root/--space, then `$CYBERLEGION_ROOT`,
512
+ * then the GLOBAL hub `~/.agents/cyberlegion` (addressable across every project and worktree
513
+ * boundary — identity/mail/registry state lives here by default; `--space` isolates it), falling
514
+ * back to a project-local `.agents/cyberlegion` only when no home directory is resolvable.
515
+ */
516
+ function resolveRoot(opts = {}) {
517
+ const env = opts.env ?? process.env;
518
+ const explicit = opts.root ?? opts.space ?? env.CYBERLEGION_ROOT;
519
+ if (explicit) return resolve(explicit);
520
+ const home = homedir();
521
+ if (home) return join(home, ".agents", "cyberlegion");
522
+ return join(projectRoot(opts.cwd), ".agents", "cyberlegion");
523
+ }
524
+ /**
525
+ * Resolve the primary checkout's project-local cyberlegion dir — where spawned unit worktrees
526
+ * live (never the global hub), keyed off `git worktree`'s common-dir so it resolves the same from
527
+ * the primary checkout or from any of its linked worktrees.
528
+ */
529
+ function resolveProjectLocalRoot(opts = {}) {
530
+ const exec = opts.exec ?? makeDefaultExec(opts.cwd);
531
+ let primary;
532
+ try {
533
+ primary = resolvePrimaryRoot(exec);
534
+ } catch {
535
+ primary = void 0;
536
+ }
537
+ return join(primary ?? projectRoot(opts.cwd), ".agents", "cyberlegion");
538
+ }
539
+ /**
540
+ * Create the tracked `config.json` marker at `root` (a hub root) if it does not already exist.
541
+ * Callers pass the root under which the marker should be created/ensured; this mkdir's it as
542
+ * needed. Idempotent — never overwrites an existing marker.
543
+ */
544
+ function ensureMarker(root) {
545
+ mkdirSync(root, { recursive: true });
546
+ const marker = join(root, MARKER_FILE);
547
+ if (!existsSync(marker)) writeFileSync(marker, `${JSON.stringify({ version: 1 }, null, 2)}\n`);
548
+ }
549
+ const paths = {
550
+ agentsDir: (root) => join(root, "agents"),
551
+ agentFile: (root, id) => join(root, "agents", `${id}.json`),
552
+ panesDir: (root) => join(root, "panes"),
553
+ paneFile: (root, pane) => join(root, "panes", `${sanitizePane(pane)}.id`),
554
+ inboxDir: (root, id) => join(root, "inbox", id),
555
+ inboxReadDir: (root, id) => join(root, "inbox", id, "read"),
556
+ dataDir: (root, id) => join(root, "data", id),
557
+ briefFile: (root, id) => join(root, "data", id, "brief.md"),
558
+ resultFile: (root, id) => join(root, "data", id, "result.json"),
559
+ worktreeDir: (root, id) => join(root, "worktrees", id)
560
+ };
561
+ /** tmux pane ids look like "%3"; make them filesystem-safe. */
562
+ function sanitizePane(pane) {
563
+ return pane.replace(/[^A-Za-z0-9_-]/g, "_");
564
+ }
565
+ //#endregion
566
+ //#region src/session.ts
567
+ /** How each harness's own CLI is launched in the new pane. */
568
+ const LAUNCH_MAP = {
569
+ claude: "claude",
570
+ cursor: "cursor-agent",
571
+ codex: "codex"
572
+ };
573
+ /**
574
+ * Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
575
+ * the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
576
+ * its cwd set to that worktree, pre-register the peer, and drop its brief as a file the peer's own
577
+ * SessionStart hook reads — never typed into its prompt.
578
+ *
579
+ * Spawned unit worktrees live project-local (`<primary>/.agents/cyberlegion/worktrees/<id>`), even
580
+ * though the registry/mailbox itself lives in the global hub — the hub addresses units across
581
+ * project and worktree boundaries, but a unit's checkout is necessarily project-local.
582
+ */
583
+ function spawn(ctx, input) {
584
+ ctx.store.ensureMarker();
585
+ const env = ctx.env ?? process.env;
586
+ const exec = ctx.exec ?? realExec;
587
+ const sessionAdapter = selectSessionAdapter(env, exec);
588
+ const harness = input.harness;
589
+ if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
590
+ const brief = resolveBrief(input);
591
+ if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
592
+ const id = randomId();
593
+ const primaryRoot = resolvePrimaryRoot(exec);
594
+ const branch = input.branch ?? `cyberlegion/unit-${id}`;
595
+ const worktreePath = input.worktreePath ?? paths.worktreeDir(join(primaryRoot, ".agents", "cyberlegion"), id);
596
+ const worktree = gitWorktreeAdapter.add(exec, {
597
+ primaryRoot,
598
+ path: worktreePath,
599
+ branch
600
+ });
601
+ assertDistinctFromPrimary(worktree.root, primaryRoot);
602
+ ensureMarker(join(worktree.root, ".agents", "cyberlegion"));
603
+ const launch = input.command ?? LAUNCH_MAP[harness];
604
+ const target = sessionAdapter.open(exec, {
605
+ cwd: worktree.root,
606
+ launch: `${muxEnvPrefix(sessionAdapter.name)}${launch}`,
607
+ at: input.at
608
+ });
609
+ const ts = new Date(ctx.now?.() ?? Date.now()).toISOString();
610
+ const rec = {
611
+ id,
612
+ handle: input.handle ?? id.slice(0, 6),
613
+ harness,
614
+ cwd: worktree.root,
615
+ worktree,
616
+ tmux: sessionAdapter.name === "tmux" ? { pane: target.id } : null,
617
+ status: "spawning",
618
+ createdAt: ts,
619
+ lastSeen: ts,
620
+ brief: paths.briefFile(ctx.store.root, id),
621
+ ...resolveSelfId(ctx) ? { spawnedBy: resolveSelfId(ctx) } : {}
622
+ };
623
+ saveAgent(ctx.store, rec);
624
+ ctx.store.putPaneIndex(target.id, id);
625
+ ctx.store.writeBrief(id, brief);
626
+ return {
627
+ agent: rec,
628
+ pane: target.id,
629
+ launch
630
+ };
631
+ }
632
+ /**
633
+ * The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
634
+ * multiplexer fast-path and never has to run its own ancestry discovery (`$CYBERLEGION_MUX` /
635
+ * `$CYBERLEGION_MUX_PANE`). `VAR=val cmd` scopes the vars to that one process (and its children)
636
+ * without needing `export`. tmux natively sets `$TMUX_PANE` for a pane's own processes, so the
637
+ * pane var is expanded by the child's own shell rather than baked in here.
638
+ */
639
+ function muxEnvPrefix(muxName) {
640
+ if (muxName === "tmux") return "CYBERLEGION_MUX=tmux CYBERLEGION_MUX_PANE=$TMUX_PANE ";
641
+ if (muxName === "herdr") return "CYBERLEGION_MUX=herdr ";
642
+ return "";
643
+ }
644
+ /** Resolve a spawn brief from --brief-file, --task -, or --task <text>; null if no source given. */
645
+ function resolveBrief(input, readStdin = () => readFileSync(0, "utf8")) {
646
+ if (input.briefFile) return readFileSync(input.briefFile, "utf8");
647
+ if (input.task === "-") return readStdin();
648
+ if (input.task != null && input.task !== "") return input.task;
649
+ return null;
650
+ }
651
+ //#endregion
652
+ //#region src/agentdef/realize.ts
653
+ const DEFAULT_HARNESS = "claude";
654
+ /** POSIX single-quote a value for safe inclusion in a shell command line. */
655
+ function shellQuote(value) {
656
+ return `'${value.replace(/'/g, `'\\''`)}'`;
657
+ }
658
+ /** Build the harness launch invocation for a def — explicit `model`/`harness` win over the def's
659
+ * own tags, which win over the harness default. Every harness gets the same shape (bin,
660
+ * `--model` when known, `--append-system-prompt` carrying the def's instructions body) since the
661
+ * exact per-harness flag surface is realized later by the launching caller, not by cyberlegion. */
662
+ function realizeLaunch(def, opts = {}) {
663
+ const harness = opts.harness ?? def.harness ?? DEFAULT_HARNESS;
664
+ const model = opts.model ?? def.model;
665
+ const parts = [LAUNCH_MAP[harness]];
666
+ if (model) parts.push("--model", shellQuote(model));
667
+ if (def.instructions) parts.push("--append-system-prompt", shellQuote(def.instructions));
668
+ return {
669
+ harness,
670
+ command: parts.join(" ")
671
+ };
672
+ }
673
+ /** Build the instruction string a parent model uses to spawn its OWN harness Task subagent for
674
+ * `def` — cyberlegion never invokes the Task tool itself. Names `subagent_type: <def.name>` for a
675
+ * harness that recognizes it, but always inlines the model + full instructions too so the same
676
+ * instruction string is correct even when the harness has no such named subagent type. */
677
+ function realizeSubagentInstruction(def, opts) {
678
+ const lines = [`Spawn a subagent (subagent_type: ${def.name} if your harness recognizes that name, else a generic one).`];
679
+ if (def.model) lines.push(`Model: ${def.model}.`);
680
+ if (def.effort) lines.push(`Effort: ${def.effort}.`);
681
+ lines.push(`Read the brief at ${opts.briefFile} and write its result JSON to ${opts.resultFile}.`);
682
+ if (def.instructions) lines.push("", "Instructions:", def.instructions);
683
+ return lines.join("\n");
684
+ }
685
+ //#endregion
686
+ //#region src/agentdef/resolve.ts
687
+ function parseAgentDefFile(text) {
688
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/.exec(text);
689
+ if (!m) return {
690
+ fm: {},
691
+ instructions: text.trim()
692
+ };
693
+ const [, block, rest] = m;
694
+ const fm = {};
695
+ const lines = block.split("\n");
696
+ let i = 0;
697
+ while (i < lines.length) {
698
+ const line = lines[i].replace(/\r$/, "");
699
+ if (line.trim() === "" || line.trim().startsWith("#")) {
700
+ i++;
701
+ continue;
702
+ }
703
+ if (line.length - line.trimStart().length > 0) {
704
+ i++;
705
+ continue;
706
+ }
707
+ const idx = line.indexOf(":");
708
+ if (idx === -1) {
709
+ i++;
710
+ continue;
711
+ }
712
+ const key = line.slice(0, idx).trim();
713
+ let value = line.slice(idx + 1).trim();
714
+ i++;
715
+ if (value === ">" || value === ">-" || value === "|" || value === "|-") {
716
+ const parts = [];
717
+ while (i < lines.length) {
718
+ const l = lines[i].replace(/\r$/, "");
719
+ if (l.trim() === "") {
720
+ i++;
721
+ continue;
722
+ }
723
+ if (l.length - l.trimStart().length === 0) break;
724
+ parts.push(l.trim());
725
+ i++;
726
+ }
727
+ value = parts.join(value.startsWith("|") ? "\n" : " ");
728
+ }
729
+ fm[key] = unquote(value);
730
+ }
731
+ return {
732
+ fm,
733
+ instructions: rest.trim()
734
+ };
735
+ }
736
+ function unquote(v) {
737
+ if (v.length >= 2 && (v.startsWith("\"") && v.endsWith("\"") || v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1);
738
+ return v;
739
+ }
740
+ function toBool(v) {
741
+ if (v === void 0) return void 0;
742
+ if (v === "true") return true;
743
+ if (v === "false") return false;
744
+ }
745
+ function toAgentDef(path, text, fallbackName) {
746
+ const { fm, instructions } = parseAgentDefFile(text);
747
+ return {
748
+ name: fm.name ?? fallbackName,
749
+ description: fm.description,
750
+ model: fm.model,
751
+ effort: fm.effort,
752
+ harness: fm.harness,
753
+ warm: toBool(fm.warm),
754
+ interactive: toBool(fm.interactive),
755
+ instructions,
756
+ path
757
+ };
758
+ }
759
+ /** The project's own `.agents/agents/` dir — the convention `agent list`/name-search scans. */
760
+ function projectAgentsDir(cwd) {
761
+ return join(projectRoot(cwd), ".agents", "agents");
762
+ }
763
+ /** Resolve one agent definition, by explicit file or by name search. Throws a clear error when
764
+ * neither locates a def — never returns a partial/empty result. */
765
+ function resolveAgentDef(input) {
766
+ if (input.file) {
767
+ const path = resolve(input.file);
768
+ if (!existsSync(path)) throw new Error(`agent def file "${input.file}" does not exist`);
769
+ return toAgentDef(path, readFileSync(path, "utf8"), basename(path).replace(/\.md$/, ""));
770
+ }
771
+ if (!input.name) throw new Error("resolveAgentDef needs a --agent <name> or --agent-file <path>");
772
+ const roots = [...input.searchRoots ?? [], projectAgentsDir(input.cwd)];
773
+ for (const root of roots) {
774
+ const candidate = join(root, `${input.name}.md`);
775
+ if (existsSync(candidate)) return toAgentDef(candidate, readFileSync(candidate, "utf8"), input.name);
776
+ }
777
+ throw new Error(`no agent definition named "${input.name}" found under .agents/agents/`);
778
+ }
779
+ /** List every resolvable agent def under `.agents/agents/` (+ any extra search roots). Definitive
780
+ * empty array when the dir is absent or empty — never throws for "none found" here (unlike a
781
+ * named resolve, listing is inherently zero-or-more). */
782
+ function listAgentDefs(input = {}) {
783
+ const roots = [...input.searchRoots ?? [], projectAgentsDir(input.cwd)];
784
+ const seen = /* @__PURE__ */ new Set();
785
+ const defs = [];
786
+ for (const root of roots) {
787
+ if (!existsSync(root)) continue;
788
+ for (const file of readdirSync(root)) {
789
+ if (!file.endsWith(".md")) continue;
790
+ const path = join(root, file);
791
+ if (seen.has(path)) continue;
792
+ seen.add(path);
793
+ defs.push(toAgentDef(path, readFileSync(path, "utf8"), file.replace(/\.md$/, "")));
794
+ }
795
+ }
796
+ return defs;
797
+ }
798
+ //#endregion
799
+ //#region src/decommission.ts
800
+ /**
801
+ * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses
802
+ * the primary checkout (absolute — `--force` never overrides it) and a dirty worktree unless
803
+ * `--force`. Teardown always precedes reap: an already-gone worktree or pane is tolerated, but a
804
+ * genuine worktree-removal failure aborts and leaves the record intact so the operation is
805
+ * retryable.
806
+ */
807
+ function decommission(ctx, input) {
808
+ const rec = loadAgent(ctx.store, input.id);
809
+ if (!rec) throw new Error(`no unit registered as agents/${input.id}.json — nothing to decommission`);
810
+ const exec = ctx.exec ?? realExec;
811
+ const env = ctx.env ?? process.env;
812
+ const worktreeRoot = rec.worktree?.root;
813
+ const primaryRoot = worktreeRoot ? resolvePrimaryRoot(exec) : void 0;
814
+ 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`);
815
+ const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
816
+ if (worktreeExists && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
817
+ const pane = rec.tmux?.pane ?? ctx.store.findPaneByAgentId(input.id);
818
+ if (worktreeExists) try {
819
+ gitWorktreeAdapter.remove(exec, worktreeRoot, { primaryRoot });
820
+ } catch (err) {
821
+ throw new Error(`decommission "${input.id}" aborted — worktree removal failed, record left intact for retry: ${err instanceof Error ? err.message : String(err)}`);
822
+ }
823
+ if (pane) try {
824
+ selectSessionAdapter(env, exec).teardown(exec, { id: pane });
825
+ } catch {}
826
+ ctx.store.removeAgent(input.id);
827
+ if (pane) ctx.store.removePaneIndex(pane);
828
+ ctx.store.removeAgentData(input.id);
829
+ return {
830
+ agent: rec,
831
+ worktreeRoot,
832
+ pane
833
+ };
834
+ }
835
+ function isDirty(exec, worktreeRoot) {
836
+ return !!exec("git", [
837
+ "-C",
838
+ worktreeRoot,
839
+ "status",
840
+ "--porcelain"
841
+ ]);
842
+ }
843
+ //#endregion
844
+ //#region src/message.ts
845
+ /** Write one message into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
846
+ function send(ctx, input) {
847
+ const toId = resolveRecipient(ctx.store, input.to);
848
+ const from = loadAgent(ctx.store, input.fromId);
849
+ const ts = ctx.now?.() ?? Date.now();
850
+ const msg = {
851
+ id: `${ts}-${randomBytes(3).toString("hex")}`,
852
+ from: input.fromId,
853
+ fromHandle: from?.handle ?? input.fromId,
854
+ to: toId,
855
+ ...input.subject ? { subject: input.subject } : {},
856
+ body: input.body,
857
+ ...input.thread ? { thread: input.thread } : {},
858
+ ...input.replyTo ? { replyTo: input.replyTo } : {},
859
+ ts,
860
+ sentAt: new Date(ts).toISOString()
861
+ };
862
+ ctx.store.putMessage(toId, msg);
863
+ return msg;
864
+ }
865
+ /** Resolve a message body from the --body flag, a --body-file path, or stdin (--body-file -). */
866
+ function resolveBody(body, bodyFile, readStdin = () => readFileSync(0, "utf8")) {
867
+ if (body != null) return body;
868
+ if (bodyFile) return bodyFile === "-" ? readStdin() : readFileSync(bodyFile, "utf8");
869
+ throw new Error("provide --body <text> or --body-file <path|->");
870
+ }
871
+ /** List the caller's mail, chronological (lexical id sort == time order). */
872
+ function inbox(ctx, q) {
873
+ const snap = ctx.store.listInbox(q.meId);
874
+ const unread = snap.unread.map((m) => ({
875
+ ...m,
876
+ read: false
877
+ }));
878
+ const acked = q.unread ? [] : snap.read.map((m) => ({
879
+ ...m,
880
+ read: true
881
+ }));
882
+ let items = [...unread, ...acked];
883
+ if (q.from) items = items.filter((m) => m.from === q.from || m.fromHandle === q.from);
884
+ if (q.thread) items = items.filter((m) => m.thread === q.thread);
885
+ return items.sort((a, b) => a.id.localeCompare(b.id));
886
+ }
887
+ /** Peek at a message (unread or already-acked) without changing its state. */
888
+ function peek(ctx, meId, msgId) {
889
+ const snap = ctx.store.listInbox(meId);
890
+ return [...snap.unread, ...snap.read].find((m) => m.id === msgId);
891
+ }
892
+ /** Acknowledge a message by moving it out of the unread set. Errors if not currently unread. */
893
+ function ack(ctx, meId, msgId) {
894
+ return ctx.store.ackMessage(meId, msgId);
895
+ }
896
+ /** Permanently remove a message (unread or already-acked) from the caller's inbox. */
897
+ function deleteMessage(ctx, meId, msgId) {
898
+ ctx.store.removeMessage(meId, msgId);
899
+ }
900
+ //#endregion
901
+ //#region src/wake/await.ts
902
+ /** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
903
+ const POLL_MS$1 = 1e3;
904
+ const DEFAULT_TIMEOUT_MS = 6e5;
905
+ const DEFAULT_MAX_WAIT_S = 240;
906
+ /**
907
+ * Block on the caller's UNREAD inbox until a message matching `thread` (and optional `from`)
908
+ * arrives, polling on a fixed ~1s internal interval. Three unambiguous outcomes:
909
+ *
910
+ * - `matched` — a message arrived; it is printed by the caller AND acked here (moved to read/), so
911
+ * it leaves the unread set. Block-then-read: each await consumes exactly its round.
912
+ * - `waiting` — this call's internal poll cycle hit its `maxWaitS` cap (default 240s, always well
913
+ * under a harness tool-timeout SIGKILL) with no match yet. A clean, non-error return the caller
914
+ * re-arms on (calls `awaitReply` again) — NOT the same as giving up.
915
+ * - `timed-out` — the caller's overall `timeoutMs` (default 600_000ms / 600s; `0` = wait forever)
916
+ * elapsed across re-arms with no match. The caller should exit non-zero.
917
+ */
918
+ async function awaitReply(ctx, input) {
919
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
920
+ const maxWaitMs = (input.maxWaitS ?? DEFAULT_MAX_WAIT_S) * 1e3;
921
+ const now = input.now ?? (() => Date.now());
922
+ const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
923
+ const start = now();
924
+ const cycleDeadline = start + maxWaitMs;
925
+ for (;;) {
926
+ const items = inbox(ctx, {
927
+ meId: input.meId,
928
+ unread: true,
929
+ thread: input.thread,
930
+ from: input.from
931
+ });
932
+ if (items.length > 0) {
933
+ const message = items[0];
934
+ ack(ctx, input.meId, message.id);
935
+ return {
936
+ kind: "matched",
937
+ message
938
+ };
939
+ }
940
+ const elapsed = now() - start;
941
+ if (timeoutMs !== 0 && elapsed >= timeoutMs) return { kind: "timed-out" };
942
+ if (now() >= cycleDeadline) return { kind: "waiting" };
943
+ await sleep(POLL_MS$1);
944
+ }
945
+ }
946
+ //#endregion
947
+ //#region src/dispatch/prep.ts
948
+ /** The instruction a caller uses when no agent def is given — still names both slot paths so a
949
+ * generic Task-tool spawn (or a human) can carry out the same contract. */
950
+ function genericInstruction(role, briefFile, resultFile) {
951
+ const lines = [`Spawn a subagent${role ? ` (role: ${role})` : ""}.`];
952
+ lines.push(`Read the brief at ${briefFile} and write its result JSON to ${resultFile}.`);
953
+ return lines.join("\n");
954
+ }
955
+ /**
956
+ * Allocate a dispatch: mint an id (thread defaults to it), write the brief into the Store, compute
957
+ * the result-slot path, and build the instruction from a resolved agent def (or a generic one).
958
+ * Spawns nothing — the envelope is handed to whatever spawns next (a Task tool, `channel()`, a
959
+ * human).
960
+ */
961
+ function prep(ctx, input, readStdin) {
962
+ const id = randomId();
963
+ const thread = input.thread ?? id;
964
+ const briefText = resolveBody(input.briefText, input.briefFile, readStdin);
965
+ ctx.store.writeBrief(id, briefText);
966
+ const briefFile = paths.briefFile(ctx.store.root, id);
967
+ const resultFile = ctx.store.resultPath(id);
968
+ let def;
969
+ if (input.agent || input.agentFile) def = resolveAgentDef({
970
+ name: input.agent,
971
+ file: input.agentFile
972
+ });
973
+ return {
974
+ id,
975
+ thread,
976
+ briefFile,
977
+ resultFile,
978
+ instruction: def ? realizeSubagentInstruction(def, {
979
+ briefFile,
980
+ resultFile
981
+ }) : genericInstruction(input.role, briefFile, resultFile)
982
+ };
983
+ }
984
+ //#endregion
985
+ //#region src/dispatch/verdict.ts
986
+ function primitiveTypeOf(v) {
987
+ if (Array.isArray(v)) return "array";
988
+ return typeof v;
989
+ }
990
+ /**
991
+ * Parse `body` as JSON and, when `schemaFile` is given, structurally check it against a minimal
992
+ * required/type schema. Any failure — invalid JSON, an unreadable/invalid schema file, a missing
993
+ * required key, or a type mismatch — is `{ ok: false }`, never a pass.
994
+ */
995
+ function validateVerdict(body, schemaFile) {
996
+ let value;
997
+ try {
998
+ value = JSON.parse(body);
999
+ } catch (err) {
1000
+ return {
1001
+ ok: false,
1002
+ error: `verdict body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
1003
+ };
1004
+ }
1005
+ if (!schemaFile) return {
1006
+ ok: true,
1007
+ value
1008
+ };
1009
+ let schema;
1010
+ try {
1011
+ schema = JSON.parse(readFileSync(schemaFile, "utf8"));
1012
+ } catch (err) {
1013
+ return {
1014
+ ok: false,
1015
+ error: `verdict schema "${schemaFile}" is not readable/valid JSON: ${err instanceof Error ? err.message : String(err)}`
1016
+ };
1017
+ }
1018
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {
1019
+ ok: false,
1020
+ error: "verdict body must be a JSON object to validate against a schema"
1021
+ };
1022
+ const obj = value;
1023
+ for (const key of schema.required ?? []) if (!(key in obj)) return {
1024
+ ok: false,
1025
+ error: `verdict is missing required key "${key}"`
1026
+ };
1027
+ for (const [key, expected] of Object.entries(schema.properties ?? {})) {
1028
+ if (!(key in obj)) continue;
1029
+ const actual = primitiveTypeOf(obj[key]);
1030
+ if (actual !== expected) return {
1031
+ ok: false,
1032
+ error: `verdict key "${key}" expected type "${expected}" but got "${actual}"`
1033
+ };
1034
+ }
1035
+ return {
1036
+ ok: true,
1037
+ value
1038
+ };
1039
+ }
1040
+ //#endregion
1041
+ //#region src/dispatch/channel.ts
1042
+ /** Thrown by a `--wait` channel call when its internal poll-cycle cap is hit with no reply yet — a
1043
+ * clean, re-armable sentinel (mirrors `mail await`'s "waiting" outcome), never a failure. */
1044
+ var DispatchWaitingError = class extends Error {
1045
+ thread;
1046
+ constructor(thread) {
1047
+ super(`waiting — no reply on thread "${thread}" yet; re-run to keep waiting`);
1048
+ this.thread = thread;
1049
+ }
1050
+ };
1051
+ /**
1052
+ * `prep()` + spawn a peer session realized from the same agent def + (optionally) `awaitReply` on
1053
+ * the thread. Requires `--agent`/`--agent-file` (a channel peer needs a real launch to realize).
1054
+ */
1055
+ async function channel(ctx, input, readStdin) {
1056
+ if (!input.agent && !input.agentFile) throw new Error("dispatch channel needs --agent <name> or --agent-file <path> to realize the peer launch");
1057
+ const envelope = prep(ctx, input, readStdin);
1058
+ const realized = realizeLaunch(resolveAgentDef({
1059
+ name: input.agent,
1060
+ file: input.agentFile
1061
+ }));
1062
+ spawn(ctx, {
1063
+ harness: realized.harness,
1064
+ command: realized.command,
1065
+ briefFile: envelope.briefFile,
1066
+ at: input.at
1067
+ });
1068
+ if (!input.wait) return envelope;
1069
+ const meId = resolveSelfId(ctx);
1070
+ if (!meId) throw new Error("dispatch channel --wait needs an identity — run `cyberlegion identity register` first");
1071
+ const outcome = await awaitReply({
1072
+ store: ctx.store,
1073
+ now: ctx.now
1074
+ }, {
1075
+ meId,
1076
+ thread: envelope.thread,
1077
+ timeoutMs: input.timeoutMs,
1078
+ maxWaitS: input.maxWaitS
1079
+ });
1080
+ if (outcome.kind === "waiting") throw new DispatchWaitingError(envelope.thread);
1081
+ if (outcome.kind === "timed-out") throw new Error(`no reply on thread "${envelope.thread}" within timeout`);
1082
+ const validated = validateVerdict(outcome.message.body, input.verdictSchema);
1083
+ if (!validated.ok) throw new Error(validated.error);
1084
+ return {
1085
+ id: envelope.id,
1086
+ verdict: validated.value,
1087
+ body: outcome.message.body,
1088
+ ts: outcome.message.ts
1089
+ };
1090
+ }
1091
+ //#endregion
1092
+ //#region src/dispatch/collect.ts
1093
+ /** Read + validate a dispatch's result file. Throws (fail-loud) when the file is not yet written,
1094
+ * or when it fails the optional verdict schema. */
1095
+ function collect(ctx, id, verdictSchema) {
1096
+ const body = ctx.store.readResult(id);
1097
+ if (body === void 0) throw new Error(`no result written yet for dispatch "${id}" — the subagent has not written its result file`);
1098
+ const validated = validateVerdict(body, verdictSchema);
1099
+ if (!validated.ok) throw new Error(validated.error);
1100
+ return {
1101
+ id,
1102
+ verdict: validated.value,
1103
+ body,
1104
+ ts: ctx.now?.() ?? Date.now()
1105
+ };
1106
+ }
1107
+ //#endregion
1108
+ //#region src/install.ts
1109
+ const hookCommand = (event) => `cyberlegion mail hook --event ${event}`;
1110
+ const VENDORS = {
1111
+ claude: {
1112
+ file: ".claude/settings.json",
1113
+ shape: "claude",
1114
+ events: {
1115
+ SessionStart: "SessionStart",
1116
+ PostToolUse: "PostToolUse"
1117
+ }
1118
+ },
1119
+ cursor: {
1120
+ file: ".cursor/hooks.json",
1121
+ shape: "cursor",
1122
+ events: { SessionStart: "sessionStart" }
1123
+ },
1124
+ codex: {
1125
+ file: ".codex/hooks.json",
1126
+ shape: "cursor",
1127
+ events: {
1128
+ SessionStart: "SessionStart",
1129
+ PostToolUse: "PostToolUse"
1130
+ }
1131
+ }
1132
+ };
1133
+ function readJson(file) {
1134
+ if (!existsSync(file)) return {};
1135
+ try {
1136
+ return JSON.parse(readFileSync(file, "utf8"));
1137
+ } catch {
1138
+ return {};
1139
+ }
1140
+ }
1141
+ function writeJson$1(file, data) {
1142
+ mkdirSync(dirname(file), { recursive: true });
1143
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
1144
+ }
1145
+ /** Register the surfacing hook into one harness's config, idempotently. */
1146
+ function install(harness, projectDir = process.cwd()) {
1147
+ const spec = VENDORS[harness];
1148
+ if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
1149
+ const file = join(projectDir, spec.file);
1150
+ const settings = readJson(file);
1151
+ const results = [];
1152
+ for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
1153
+ const command = hookCommand(canonical);
1154
+ const status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
1155
+ results.push({
1156
+ harness,
1157
+ event: canonical,
1158
+ vendorEvent,
1159
+ file,
1160
+ status
1161
+ });
1162
+ }
1163
+ writeJson$1(file, settings);
1164
+ return results;
1165
+ }
1166
+ function upsertClaude(settings, event, command) {
1167
+ const hooks = settings.hooks ??= {};
1168
+ const groups = hooks[event] ??= [];
1169
+ if (groups.some((g) => g.hooks?.some((h) => h.command === command))) return "already present";
1170
+ const group = { hooks: [{
1171
+ type: "command",
1172
+ command
1173
+ }] };
1174
+ if (event === "PostToolUse") group.matcher = "Write|Edit";
1175
+ groups.push(group);
1176
+ return "registered";
1177
+ }
1178
+ function upsertCursor(settings, event, command) {
1179
+ if (settings.version == null) settings.version = 1;
1180
+ const hooks = settings.hooks ??= {};
1181
+ const list = hooks[event] ??= [];
1182
+ if (list.some((h) => h.command === command)) return "already present";
1183
+ list.push({ command });
1184
+ return "registered";
1185
+ }
1186
+ //#endregion
1187
+ //#region src/output.ts
1188
+ function stringifyCell(v) {
1189
+ if (v == null) return "";
1190
+ const s = String(v);
1191
+ return /[,\n"]/.test(s) ? `"${s.replace(/"/g, "\"\"")}"` : s;
1192
+ }
1193
+ /** One TOON-encoded object: `key: value` lines, blank/undefined fields dropped. */
1194
+ function toonObject(fields) {
1195
+ return Object.entries(fields).filter(([, v]) => v != null).map(([k, v]) => `${k}: ${v}`).join("\n");
1196
+ }
1197
+ /** One TOON-encoded list: a `name[N]{field,...}:` header plus one row per item, then an aggregate
1198
+ * summary line. Definitive on empty — still emits `name[0]{...}:` plus the summary line. */
1199
+ function toonList(name, items, fields, summary) {
1200
+ return [
1201
+ `${name}[${items.length}]{${fields.map((f) => f.key).join(",")}}:`,
1202
+ ...items.map((item) => fields.map((f) => stringifyCell(f.get(item))).join(",")).map((r) => ` ${r}`),
1203
+ summary
1204
+ ].join("\n");
1205
+ }
1206
+ /** Print a command's result to stdout in the requested format. */
1207
+ function emit(format, payload) {
1208
+ if (format === "json") console.log(JSON.stringify(payload.json, null, 2));
1209
+ else console.log(payload.toon);
1210
+ }
1211
+ /** A stderr-only next-step suggestion — never part of the machine-readable stdout result. */
1212
+ function nextStep(msg) {
1213
+ console.error(`→ ${msg}`);
1214
+ }
1215
+ /** A structured, fail-loud error: stderr + exit 1. Never prompts, never partially writes. */
1216
+ function fail(msg) {
1217
+ console.error(JSON.stringify({ error: msg }));
1218
+ process.exit(1);
1219
+ }
1220
+ //#endregion
1221
+ //#region src/runtime/inject-inbox.ts
1222
+ const EVENTS = ["SessionStart", "PostToolUse"];
1223
+ /**
1224
+ * Resolve the calling agent, gather its pending brief + unread mail, and return the
1225
+ * SessionStart-style injection payload — or null when there is nothing to inject (an
1226
+ * unregistered caller or an empty inbox never fails the harness hook).
1227
+ */
1228
+ function injectInbox(ctx, event) {
1229
+ if (!EVENTS.includes(event)) throw new Error(`unsupported --event "${event}" (expected ${EVENTS.join(" | ")})`);
1230
+ const meId = resolveSelfId(ctx);
1231
+ if (!meId) return null;
1232
+ const parts = [];
1233
+ const rec = loadAgent(ctx.store, meId);
1234
+ if (rec?.status === "spawning") {
1235
+ const brief = ctx.store.readBrief(meId);
1236
+ if (brief) parts.push(`## Your brief\n\n${brief.trim()}`);
1237
+ rec.status = "active";
1238
+ saveAgent(ctx.store, rec);
1239
+ }
1240
+ const unread = inbox({ store: ctx.store }, {
1241
+ meId,
1242
+ unread: true
1243
+ });
1244
+ if (unread.length > 0) {
1245
+ const lines = unread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
1246
+ parts.push(`## Unread mail (${unread.length})\n\n${lines.join("\n")}`);
1247
+ }
1248
+ if (parts.length === 0) return null;
1249
+ return { hookSpecificOutput: {
1250
+ hookEventName: event,
1251
+ additionalContext: parts.join("\n\n")
1252
+ } };
1253
+ }
1254
+ //#endregion
1255
+ //#region src/store/file-store.ts
1256
+ function readMessages(dir) {
1257
+ if (!existsSync(dir)) return [];
1258
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(readFileSync(join(dir, f), "utf8")));
1259
+ }
1260
+ function writeJson(file, data) {
1261
+ mkdirSync(dirname(file), { recursive: true });
1262
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
1263
+ }
1264
+ function writeText(file, text) {
1265
+ mkdirSync(dirname(file), { recursive: true });
1266
+ writeFileSync(file, text);
1267
+ }
1268
+ /** The on-disk `Store` implementation — current per-writer sharded `.json` layout (ADR-0020):
1269
+ * one file per message/agent, collision-free filenames, ack = atomic rename into `read/`. */
1270
+ var FileStore = class {
1271
+ root;
1272
+ constructor(root) {
1273
+ this.root = root;
1274
+ }
1275
+ ensureMarker() {
1276
+ ensureMarker(this.root);
1277
+ }
1278
+ putMessage(toId, msg) {
1279
+ const dir = paths.inboxDir(this.root, toId);
1280
+ mkdirSync(dir, { recursive: true });
1281
+ writeFileSync(join(dir, `${msg.id}.json`), `${JSON.stringify(msg, null, 2)}\n`);
1282
+ }
1283
+ listInbox(id) {
1284
+ return {
1285
+ unread: readMessages(paths.inboxDir(this.root, id)),
1286
+ read: readMessages(paths.inboxReadDir(this.root, id))
1287
+ };
1288
+ }
1289
+ ackMessage(id, msgId) {
1290
+ const src = join(paths.inboxDir(this.root, id), `${msgId}.json`);
1291
+ if (!existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
1292
+ const msg = JSON.parse(readFileSync(src, "utf8"));
1293
+ const dir = paths.inboxReadDir(this.root, id);
1294
+ mkdirSync(dir, { recursive: true });
1295
+ renameSync(src, join(dir, `${msgId}.json`));
1296
+ return msg;
1297
+ }
1298
+ removeMessage(id, msgId) {
1299
+ const unreadFile = join(paths.inboxDir(this.root, id), `${msgId}.json`);
1300
+ if (existsSync(unreadFile)) {
1301
+ rmSync(unreadFile);
1302
+ return;
1303
+ }
1304
+ const readFile = join(paths.inboxReadDir(this.root, id), `${msgId}.json`);
1305
+ if (existsSync(readFile)) {
1306
+ rmSync(readFile);
1307
+ return;
1308
+ }
1309
+ throw new Error(`"${msgId}" is not a message in this inbox`);
1310
+ }
1311
+ putAgent(rec) {
1312
+ writeJson(paths.agentFile(this.root, rec.id), rec);
1313
+ }
1314
+ getAgent(id) {
1315
+ const file = paths.agentFile(this.root, id);
1316
+ if (!existsSync(file)) return void 0;
1317
+ return JSON.parse(readFileSync(file, "utf8"));
1318
+ }
1319
+ listAgents() {
1320
+ const dir = paths.agentsDir(this.root);
1321
+ if (!existsSync(dir)) return [];
1322
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(readFileSync(join(dir, f), "utf8"))).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1323
+ }
1324
+ removeAgent(id) {
1325
+ rmSync(paths.agentFile(this.root, id), { force: true });
1326
+ }
1327
+ removeAgentData(id) {
1328
+ rmSync(paths.dataDir(this.root, id), {
1329
+ recursive: true,
1330
+ force: true
1331
+ });
1332
+ }
1333
+ putPaneIndex(pane, agentId) {
1334
+ writeText(paths.paneFile(this.root, pane), agentId);
1335
+ }
1336
+ resolvePaneId(pane) {
1337
+ const file = paths.paneFile(this.root, pane);
1338
+ return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
1339
+ }
1340
+ findPaneByAgentId(agentId) {
1341
+ const dir = paths.panesDir(this.root);
1342
+ if (!existsSync(dir)) return void 0;
1343
+ for (const f of readdirSync(dir)) {
1344
+ if (!f.endsWith(".id")) continue;
1345
+ if (readFileSync(join(dir, f), "utf8").trim() === agentId) return f.slice(0, -3);
1346
+ }
1347
+ }
1348
+ removePaneIndex(pane) {
1349
+ rmSync(paths.paneFile(this.root, pane), { force: true });
1350
+ }
1351
+ writeBrief(agentId, text) {
1352
+ writeText(paths.briefFile(this.root, agentId), text);
1353
+ }
1354
+ readBrief(agentId) {
1355
+ const file = paths.briefFile(this.root, agentId);
1356
+ return existsSync(file) ? readFileSync(file, "utf8") : void 0;
1357
+ }
1358
+ resultPath(id) {
1359
+ return paths.resultFile(this.root, id);
1360
+ }
1361
+ writeResult(id, value) {
1362
+ writeJson(paths.resultFile(this.root, id), value);
1363
+ }
1364
+ readResult(id) {
1365
+ const file = paths.resultFile(this.root, id);
1366
+ return existsSync(file) ? readFileSync(file, "utf8") : void 0;
1367
+ }
1368
+ };
1369
+ //#endregion
1370
+ //#region src/wake/watch.ts
1371
+ const POLL_MS = 1e3;
1372
+ /**
1373
+ * A continuous foreground observer: prints each NEW matching message as it arrives, polling on a
1374
+ * fixed ~1s interval, until interrupted (or `maxIterations` in tests). Never acks — a message
1375
+ * `watch` prints stays unread and still surfaces later in `mail inbox`/`mail await`.
1376
+ */
1377
+ async function watchMail(ctx, input, onMessage, opts = {}) {
1378
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1379
+ const seen = /* @__PURE__ */ new Set();
1380
+ for (const m of inbox(ctx, {
1381
+ meId: input.meId,
1382
+ thread: input.thread,
1383
+ from: input.from
1384
+ })) seen.add(m.id);
1385
+ let iterations = 0;
1386
+ for (;;) {
1387
+ const items = inbox(ctx, {
1388
+ meId: input.meId,
1389
+ thread: input.thread,
1390
+ from: input.from
1391
+ });
1392
+ for (const m of items) {
1393
+ if (seen.has(m.id)) continue;
1394
+ seen.add(m.id);
1395
+ onMessage(m);
1396
+ }
1397
+ iterations++;
1398
+ if (opts.maxIterations != null && iterations >= opts.maxIterations) return;
1399
+ await sleep(POLL_MS);
1400
+ }
1401
+ }
1402
+ //#endregion
1403
+ export { resolveProjectLocalRoot as A, loadAgent as B, resolveAgentDef as C, ensureMarker as D, spawn as E, selectSessionAdapter as F, resolveRecipient as G, realExec as H, probeMultiplexer as I, resolveSelfId as K, bumpLastSeen as L, assertDistinctFromPrimary as M, gitWorktreeAdapter as N, paths as O, resolvePrimaryRoot as P, detectHarness as R, listAgentDefs as S, realizeSubagentInstruction as T, register as U, prune as V, resolveAgent as W, inbox as _, fail as a, send as b, toonObject as c, DispatchWaitingError as d, channel as f, deleteMessage as g, ack as h, emit as i, resolveRoot as j, projectRoot as k, install as l, awaitReply as m, FileStore as n, nextStep as o, prep as p, touch as q, injectInbox as r, toonList as s, watchMail as t, collect as u, peek as v, realizeLaunch as w, decommission as x, resolveBody as y, listAgents as z };