cyberlegion 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { A as bumpLastSeen, B as registerStanding, C as nudgeUnit, D as selectSessionAdapter, E as spawnAndWake, F as loadAgent, G as resolveStandingOwner, H as resolvePresence, I as prune, J as listAgentDefs, K as touch, L as realExec, M as clearPresence, N as detectHarness, O as DELIVERY_DOORBELL, P as listAgents, R as reconcile, S as focusUnit, V as resolveAgent, W as resolveSelfId, Y as resolveAgentDef, _ as install, a as emit, b as resolveSpawnLaunch, c as toonList, d as deleteMessage, et as resolveRoot, f as inbox, g as send, h as resolveBody, i as injectInbox, j as claimPresence, k as wakeRecipient, l as toonObject, m as readAck, n as awaitReply, o as fail, p as peek, q as normalizeMuxEnv, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, w as readUnit, x as clearUnit, z as register } from "./watch-BZmEAqUo.mjs";
2
+ import { A as bumpLastSeen, B as registerStanding, C as nudgeUnit, D as selectSessionAdapter, E as spawnAndWake, F as loadAgent, G as resolveStandingOwner, H as resolvePresence, I as prune, J as listAgentDefs, K as touch, L as realExec, M as clearPresence, N as detectHarness, O as DELIVERY_DOORBELL, P as listAgents, R as reconcile, S as focusUnit, V as resolveAgent, W as resolveSelfId, Y as resolveAgentDef, _ as install, a as emit, b as resolveSpawnLaunch, c as toonList, d as deleteMessage, et as resolveRoot, f as inbox, g as send, h as resolveBody, i as injectInbox, j as claimPresence, k as wakeRecipient, l as toonObject, m as readAck, n as awaitReply, o as fail, p as peek, q as normalizeMuxEnv, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, w as readUnit, x as clearUnit, z as register } from "./watch-D33fQcUI.mjs";
3
+ import { readFileSync } from "node:fs";
3
4
  import { resolve } from "node:path";
4
5
  import { Command, Option } from "commander";
5
6
  import { currentPane, probeMultiplexer } from "cyber-mux";
@@ -74,7 +75,7 @@ function readCommandOutput(format, result) {
74
75
  }
75
76
  //#endregion
76
77
  //#region src/cli.ts
77
- const VERSION = "0.0.0";
78
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
78
79
  function ctxOf(opts) {
79
80
  return {
80
81
  store: new FileStore(resolveRoot({ space: opts.space })),
package/dist/index.d.mts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Exec, Exec as Exec$1, MuxAdapter, MuxPlacement, MuxProbe, NudgeOptions, probeMultiplexer } from "cyber-mux";
2
2
  import { assertDistinctFromPrimary, gitWorktreeAdapter, resolvePrimaryRoot } from "cyber-mux/worktree";
3
-
4
3
  //#region src/store/store.d.ts
5
4
  interface Message {
6
5
  id: string;
@@ -103,6 +102,15 @@ interface Store {
103
102
  setMainPane(pane: string | null): void;
104
103
  /** The hub's currently bound main pane, or undefined when none is bound. */
105
104
  getMainPane(): string | undefined;
105
+ /** Run `fn` while holding an exclusive, named advisory lock scoped to this store's root —
106
+ * mutual exclusion for a genuine read-modify-write (read current state, decide, write back),
107
+ * which an atomic single-file write alone can't make safe against a concurrent racer. Blocks
108
+ * until acquired or throws `LockTimeoutError` (`store/lock.ts`) after a bounded wait; never
109
+ * steals a lock a live holder still holds — only one abandoned by a confirmed-dead process.
110
+ * `setMainPane` uses this internally; callers doing their own load-mutate-save against this
111
+ * store (e.g. a standing record's presence rebind) should hold the SAME named lock around that
112
+ * whole sequence, not just around the final write. */
113
+ withLock<T>(name: string, fn: () => T): T;
106
114
  }
107
115
  //#endregion
108
116
  //#region src/identity.d.ts
@@ -364,6 +372,11 @@ declare const paths: {
364
372
  dataDir: (root: string, id: string) => string;
365
373
  briefFile: (root: string, id: string) => string;
366
374
  mainPaneFile: (root: string) => string;
375
+ /** A message's file path within `toId`'s unread/read inbox dir, keyed by its own collision-free
376
+ * id — validated the same as an agent id (it's the same class of risk: a peer- or CLI-controlled
377
+ * string becoming a filename). */
378
+ messageFile: (root: string, toId: string, msgId: string) => string;
379
+ messageReadFile: (root: string, toId: string, msgId: string) => string;
367
380
  };
368
381
  //#endregion
369
382
  //#region src/session.d.ts
@@ -453,6 +466,7 @@ declare class FileStore implements Store {
453
466
  readBrief(agentId: string): string | undefined;
454
467
  setMainPane(pane: string | null): void;
455
468
  getMainPane(): string | undefined;
469
+ withLock<T>(name: string, fn: () => T): T;
456
470
  }
457
471
  //#endregion
458
472
  //#region src/wake/await.d.ts
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as resolveProjectLocalRoot, A as bumpLastSeen, D as selectSessionAdapter, E as spawnAndWake, F as loadAgent, I as prune, J as listAgentDefs, K as touch, L as realExec, N as detectHarness, P as listAgents, Q as projectRoot, T as spawn, U as resolveRecipient, V as resolveAgent, W as resolveSelfId, X as ensureMarker, Y as resolveAgentDef, Z as paths, _ as install, a as emit, c as toonList, d as deleteMessage, et as resolveRoot, f as inbox, g as send, h as resolveBody, i as injectInbox, l as toonObject, n as awaitReply, o as fail, p as peek, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, y as realizeLaunch, z as register } from "./watch-BZmEAqUo.mjs";
1
+ import { $ as resolveProjectLocalRoot, A as bumpLastSeen, D as selectSessionAdapter, E as spawnAndWake, F as loadAgent, I as prune, J as listAgentDefs, K as touch, L as realExec, N as detectHarness, P as listAgents, Q as projectRoot, T as spawn, U as resolveRecipient, V as resolveAgent, W as resolveSelfId, X as ensureMarker, Y as resolveAgentDef, Z as paths, _ as install, a as emit, c as toonList, d as deleteMessage, et as resolveRoot, f as inbox, g as send, h as resolveBody, i as injectInbox, l as toonObject, n as awaitReply, o as fail, p as peek, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, y as realizeLaunch, z as register } from "./watch-D33fQcUI.mjs";
2
2
  import { probeMultiplexer } from "cyber-mux";
3
3
  import { assertDistinctFromPrimary, gitWorktreeAdapter, resolvePrimaryRoot } from "cyber-mux/worktree";
4
4
  export { FileStore, ack, assertDistinctFromPrimary, awaitReply, bumpLastSeen, decommission, deleteMessage, detectHarness, emit, ensureMarker, fail, gitWorktreeAdapter, inbox, injectInbox, install, listAgentDefs, listAgents, loadAgent, nextStep, paths, peek, probeMultiplexer, projectRoot, prune, realExec, realizeLaunch, register, resolveAgent, resolveAgentDef, resolveBody, resolvePrimaryRoot, resolveProjectLocalRoot, resolveRecipient, resolveRoot, resolveSelfId, selectSessionAdapter, send, spawn, spawnAndWake, toonList, toonObject, touch, watchMail };
@@ -1,6 +1,6 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
2
  import { basename, dirname, join, resolve } from "node:path";
2
3
  import { callerPane, currentPane, herdrMuxAdapter, nodeExec, nudge, probeMultiplexer, tmuxMuxAdapter } from "cyber-mux";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { homedir } from "node:os";
6
6
  import { assertDistinctFromPrimary, gitWorktreeAdapter, resolvePrimaryRoot } from "cyber-mux/worktree";
@@ -75,16 +75,55 @@ function ensureMarker(root) {
75
75
  const marker = join(root, MARKER_FILE);
76
76
  if (!existsSync(marker)) writeFileSync(marker, `${JSON.stringify({ version: 1 }, null, 2)}\n`);
77
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
+ }
78
112
  const paths = {
79
113
  agentsDir: (root) => join(root, "agents"),
80
- agentFile: (root, id) => join(root, "agents", `${id}.json`),
114
+ agentFile: (root, id) => join(root, "agents", `${assertSafeId(id, "agent id")}.json`),
81
115
  panesDir: (root) => join(root, "panes"),
82
116
  paneFile: (root, pane) => join(root, "panes", `${sanitizePane(pane)}.id`),
83
- inboxDir: (root, id) => join(root, "inbox", id),
84
- inboxReadDir: (root, id) => join(root, "inbox", id, "read"),
85
- dataDir: (root, id) => join(root, "data", id),
86
- briefFile: (root, id) => join(root, "data", id, "brief.md"),
87
- mainPaneFile: (root) => join(root, "main-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`)
88
127
  };
89
128
  /**
90
129
  * Where a spawned unit's own git worktree is checked out by default — a sibling of the primary
@@ -441,14 +480,18 @@ function resolveStandingOwner(store, handle) {
441
480
  * multiplexer never touches the pointer. Last claim wins: a plain overwrite, no merge.
442
481
  */
443
482
  function claimPresence(ctx, handle) {
444
- const rec = loadAgent(ctx.store, resolveStandingOwner(ctx.store, handle));
445
- if (!rec) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${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}'`);
446
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");
447
486
  const selfId = resolveSelfId(ctx);
448
487
  if (!selfId) throw new Error("no identity in this session — run `cyberlegion unit register` first");
449
- rec.presence = selfId;
450
- saveAgent(ctx.store, rec);
451
- return rec;
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
+ });
452
495
  }
453
496
  /**
454
497
  * Unbind a standing owner's presence. The unknown-handle throw (via `resolveStandingOwner`) wins
@@ -457,13 +500,17 @@ function claimPresence(ctx, handle) {
457
500
  * reporting a clear it never performed.
458
501
  */
459
502
  function clearPresence(ctx, handle) {
460
- const rec = loadAgent(ctx.store, resolveStandingOwner(ctx.store, handle));
461
- if (!rec) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
462
- if (rec.presence !== void 0) {
463
- rec.presence = void 0;
464
- saveAgent(ctx.store, rec);
465
- }
466
- return rec;
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
+ });
467
514
  }
468
515
  /**
469
516
  * The live-only presence rule, keyed off a standing record the caller ALREADY HOLDS — the pointer
@@ -532,7 +579,7 @@ function touch(ctx) {
532
579
  const id = resolveSelfId(ctx);
533
580
  if (id) bumpLastSeen(ctx, id);
534
581
  }
535
- const STALE_MS = 900 * 1e3;
582
+ const STALE_MS = 9e5;
536
583
  /** The per-mux session adapters `prune` consults for pane liveness — each answers with its own
537
584
  * backend primitive so a herdr pane is never probed with a tmux query, and vice versa. */
538
585
  const PANE_ADAPTERS = {
@@ -778,25 +825,8 @@ function selectSessionAdapter(env, exec = realExec) {
778
825
  }
779
826
  //#endregion
780
827
  //#region src/workspace-label.ts
781
- /**
782
- * Workspace labels — the short, human-scannable name a `workspace` placement opens under.
783
- *
784
- * A unit's own visible space is what a human scans to find a session, so a bare backend default
785
- * ("workspace 4") costs a search every time. A label is `<code>-<subject>`, capped at 30 characters
786
- * INCLUDING the code, so it stays readable in a sidebar/status bar at a glance.
787
- *
788
- * The code is a NieR YoRHa unit class picked from the brief's leading action word in one fixed
789
- * order, so the same brief always yields the same code:
790
- * - `A2-` — the action tears down or reverts (A2 is the rogue/attack unit)
791
- * - `9S-` — the action is read-only recon (9S is the Scanner)
792
- * - `2B-` — everything else: build-and-change work, the default (2B is the battle unit)
793
- */
794
- /** Total label budget, code included. */
795
- const LABEL_CAP = 30;
796
- /** Every code is the same width, so the subject budget is a constant. */
797
- const CODE_WIDTH = 3;
798
828
  /** Leading actions that tear down or revert — the `A2-` class, checked first. */
799
- const TEARDOWN_ACTIONS = new Set([
829
+ const TEARDOWN_ACTIONS = /* @__PURE__ */ new Set([
800
830
  "clean",
801
831
  "cleanup",
802
832
  "decommission",
@@ -817,7 +847,7 @@ const TEARDOWN_ACTIONS = new Set([
817
847
  "unregister"
818
848
  ]);
819
849
  /** Leading actions that only look and report — the `9S-` class, checked after teardown. */
820
- const RECON_ACTIONS = new Set([
850
+ const RECON_ACTIONS = /* @__PURE__ */ new Set([
821
851
  "analyze",
822
852
  "assess",
823
853
  "audit",
@@ -852,7 +882,7 @@ const RECON_ACTIONS = new Set([
852
882
  * dropped from it (the code already says "build"), an unrecognized leading word is kept (it is the
853
883
  * subject's own first noun, and dropping it would strip real signal).
854
884
  */
855
- const BUILD_ACTIONS = new Set([
885
+ const BUILD_ACTIONS = /* @__PURE__ */ new Set([
856
886
  "add",
857
887
  "backfill",
858
888
  "build",
@@ -882,7 +912,7 @@ const BUILD_ACTIONS = new Set([
882
912
  "write"
883
913
  ]);
884
914
  /** Dropped when it leads the subject — an article carries no identifying signal. */
885
- const LEADING_ARTICLES = new Set([
915
+ const LEADING_ARTICLES = /* @__PURE__ */ new Set([
886
916
  "a",
887
917
  "an",
888
918
  "the"
@@ -910,7 +940,7 @@ function deriveWorkspaceLabel(input) {
910
940
  subjectWords = leadIsAction ? briefWords.slice(1) : briefWords.slice();
911
941
  if (subjectWords[0] && LEADING_ARTICLES.has(subjectWords[0])) subjectWords.shift();
912
942
  }
913
- const subject = fitWords(subjectWords, LABEL_CAP - CODE_WIDTH) || input.id.slice(0, 6);
943
+ const subject = fitWords(subjectWords, 27) || input.id.slice(0, 6);
914
944
  return `${code}${subject}`;
915
945
  }
916
946
  /** The brief's first line with any content — a leading blank line is not the subject. */
@@ -962,7 +992,7 @@ const RESET_MAP = {
962
992
  * honest fresh-context command for these, so they are refused explicitly rather than lumped in
963
993
  * with a harness that is merely unmapped.
964
994
  */
965
- const FALSE_FRIEND_HARNESSES = new Set(["gemini"]);
995
+ const FALSE_FRIEND_HARNESSES = /* @__PURE__ */ new Set(["gemini"]);
966
996
  /**
967
997
  * Resolve `harness`'s own fresh-context command from the reset map. String-keyed (not `Harness`)
968
998
  * so it also guards a harness present in `LAUNCH_MAP`/`identity.ts`'s `Harness` union but not yet
@@ -1199,7 +1229,7 @@ async function nudgeUnit(ctx, ref, options = {}) {
1199
1229
  function readUnit(ctx, ref, options = {}) {
1200
1230
  const { agent, target } = paneTargetOf(ctx, ref);
1201
1231
  const exec = ctx.exec ?? realExec;
1202
- const output = selectSessionAdapter(ctx.env ?? process.env, exec).read(exec, target, { lines: options.lines });
1232
+ const output = selectSessionAdapter(ctx.env ?? process.env, exec).read(exec, target, { lines: options.lines }).text;
1203
1233
  return {
1204
1234
  agent,
1205
1235
  pane: target.id,
@@ -1592,18 +1622,236 @@ function injectInbox(ctx, event) {
1592
1622
  } };
1593
1623
  }
1594
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
1595
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
+ }
1596
1803
  function readMessages(dir) {
1597
1804
  if (!existsSync(dir)) return [];
1598
- return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(readFileSync(join(dir, f), "utf8")));
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);
1599
1831
  }
1600
1832
  function writeJson(file, data) {
1601
- mkdirSync(dirname(file), { recursive: true });
1602
- writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
1833
+ writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`);
1603
1834
  }
1604
1835
  function writeText(file, text) {
1605
- mkdirSync(dirname(file), { recursive: true });
1606
- writeFileSync(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
+ }
1607
1855
  }
1608
1856
  /** The on-disk `Store` implementation — current per-writer sharded `.json` layout (ADR-0020):
1609
1857
  * one file per message/agent, collision-free filenames, ack = atomic rename into `read/`. */
@@ -1616,33 +1864,33 @@ var FileStore = class {
1616
1864
  ensureMarker(this.root);
1617
1865
  }
1618
1866
  putMessage(toId, msg) {
1619
- const dir = paths.inboxDir(this.root, toId);
1620
- mkdirSync(dir, { recursive: true });
1621
- writeFileSync(join(dir, `${msg.id}.json`), `${JSON.stringify(msg, null, 2)}\n`);
1867
+ writeFileAtomic(paths.messageFile(this.root, toId, msg.id), `${JSON.stringify(msg, null, 2)}\n`);
1622
1868
  }
1623
1869
  listInbox(id) {
1870
+ const inbox = readPathOrUndefined(() => paths.inboxDir(this.root, id));
1871
+ const read = readPathOrUndefined(() => paths.inboxReadDir(this.root, id));
1624
1872
  return {
1625
- unread: readMessages(paths.inboxDir(this.root, id)),
1626
- read: readMessages(paths.inboxReadDir(this.root, id))
1873
+ unread: inbox ? readMessages(inbox) : [],
1874
+ read: read ? readMessages(read) : []
1627
1875
  };
1628
1876
  }
1629
1877
  ackMessage(id, msgId) {
1630
- const src = join(paths.inboxDir(this.root, id), `${msgId}.json`);
1631
- if (!existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
1632
- const msg = JSON.parse(readFileSync(src, "utf8"));
1633
- const dir = paths.inboxReadDir(this.root, id);
1634
- mkdirSync(dir, { recursive: true });
1635
- renameSync(src, join(dir, `${msgId}.json`));
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);
1636
1884
  return msg;
1637
1885
  }
1638
1886
  removeMessage(id, msgId) {
1639
- const unreadFile = join(paths.inboxDir(this.root, id), `${msgId}.json`);
1640
- if (existsSync(unreadFile)) {
1887
+ const unreadFile = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
1888
+ if (unreadFile && existsSync(unreadFile)) {
1641
1889
  rmSync(unreadFile);
1642
1890
  return;
1643
1891
  }
1644
- const readFile = join(paths.inboxReadDir(this.root, id), `${msgId}.json`);
1645
- if (existsSync(readFile)) {
1892
+ const readFile = readPathOrUndefined(() => paths.messageReadFile(this.root, id, msgId));
1893
+ if (readFile && existsSync(readFile)) {
1646
1894
  rmSync(readFile);
1647
1895
  return;
1648
1896
  }
@@ -1652,14 +1900,14 @@ var FileStore = class {
1652
1900
  writeJson(paths.agentFile(this.root, rec.id), rec);
1653
1901
  }
1654
1902
  getAgent(id) {
1655
- const file = paths.agentFile(this.root, id);
1656
- if (!existsSync(file)) return void 0;
1657
- return JSON.parse(readFileSync(file, "utf8"));
1903
+ const file = readPathOrUndefined(() => paths.agentFile(this.root, id));
1904
+ if (!file || !existsSync(file)) return void 0;
1905
+ return readJsonRecord(file);
1658
1906
  }
1659
1907
  listAgents() {
1660
1908
  const dir = paths.agentsDir(this.root);
1661
1909
  if (!existsSync(dir)) return [];
1662
- 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));
1910
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f))).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1663
1911
  }
1664
1912
  removeAgent(id) {
1665
1913
  rmSync(paths.agentFile(this.root, id), { force: true });
@@ -1692,21 +1940,26 @@ var FileStore = class {
1692
1940
  writeText(paths.briefFile(this.root, agentId), text);
1693
1941
  }
1694
1942
  readBrief(agentId) {
1695
- const file = paths.briefFile(this.root, agentId);
1696
- return existsSync(file) ? readFileSync(file, "utf8") : void 0;
1943
+ const file = readPathOrUndefined(() => paths.briefFile(this.root, agentId));
1944
+ return file && existsSync(file) ? readFileSync(file, "utf8") : void 0;
1697
1945
  }
1698
1946
  setMainPane(pane) {
1699
- const file = paths.mainPaneFile(this.root);
1700
- if (pane) {
1701
- writeText(file, pane);
1702
- return;
1703
- }
1704
- rmSync(file, { force: true });
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
+ });
1705
1955
  }
1706
1956
  getMainPane() {
1707
1957
  const file = paths.mainPaneFile(this.root);
1708
1958
  return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
1709
1959
  }
1960
+ withLock(name, fn) {
1961
+ return withLock(this.root, name, fn);
1962
+ }
1710
1963
  };
1711
1964
  //#endregion
1712
1965
  //#region src/wake/await.ts
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 unional
3
+ Copyright (c) 2026 Unional
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberlegion",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Harness-agnostic agent session spawning and messaging over the filesystem",
5
5
  "keywords": [
6
6
  "agent",
@@ -12,7 +12,7 @@
12
12
  ],
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "git+https://github.com/cyberuni/cyberplace.git",
15
+ "url": "git+https://github.com/cyberuni/cyberlegion.git",
16
16
  "directory": "packages/cyberlegion"
17
17
  },
18
18
  "license": "MIT",
@@ -34,13 +34,13 @@
34
34
  ],
35
35
  "dependencies": {
36
36
  "commander": "^14.0.3",
37
- "cyber-mux": "0.3.0"
37
+ "cyber-mux": "0.5.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^24.10.1",
41
41
  "tsdown": "^0.22.0",
42
42
  "tsx": "^4.22.3",
43
- "typescript": "^6.0.0",
43
+ "typescript": "^7.0.2",
44
44
  "vitest": "^4.1.7"
45
45
  },
46
46
  "engines": {
@@ -53,7 +53,6 @@
53
53
  "scripts": {
54
54
  "build": "tsdown",
55
55
  "check:metaphor-free": "tsx src/check-metaphor-free.ts",
56
- "check:spec": "sdd-check-specs",
57
56
  "dev": "tsx src/cli.ts",
58
57
  "test": "vitest run src",
59
58
  "test:watch": "vitest",
package/readme.md CHANGED
@@ -18,7 +18,7 @@ npx cyberlegion <command>
18
18
  Or pin to an exact version for reproducible hooks:
19
19
 
20
20
  ```sh
21
- npx cyberlegion@0.1.0 <command>
21
+ npx cyberlegion@0.3.0 <command>
22
22
  ```
23
23
 
24
24
  ## Commands