@bragi-gmbh/codebus 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/bin/codebus.js +2 -0
  2. package/dist/binding-guard.js +97 -0
  3. package/dist/binding-guard.js.map +1 -0
  4. package/dist/client.js +94 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands/ack.js +47 -0
  7. package/dist/commands/ack.js.map +1 -0
  8. package/dist/commands/archive.js +21 -0
  9. package/dist/commands/archive.js.map +1 -0
  10. package/dist/commands/as.js +299 -0
  11. package/dist/commands/as.js.map +1 -0
  12. package/dist/commands/ask.js +189 -0
  13. package/dist/commands/ask.js.map +1 -0
  14. package/dist/commands/auth.js +33 -0
  15. package/dist/commands/auth.js.map +1 -0
  16. package/dist/commands/checkin.js +235 -0
  17. package/dist/commands/checkin.js.map +1 -0
  18. package/dist/commands/decision.js +52 -0
  19. package/dist/commands/decision.js.map +1 -0
  20. package/dist/commands/digest.js +117 -0
  21. package/dist/commands/digest.js.map +1 -0
  22. package/dist/commands/doctor.js +106 -0
  23. package/dist/commands/doctor.js.map +1 -0
  24. package/dist/commands/factory.js +191 -0
  25. package/dist/commands/factory.js.map +1 -0
  26. package/dist/commands/github-login.js +205 -0
  27. package/dist/commands/github-login.js.map +1 -0
  28. package/dist/commands/graph.js +88 -0
  29. package/dist/commands/graph.js.map +1 -0
  30. package/dist/commands/health.js +34 -0
  31. package/dist/commands/health.js.map +1 -0
  32. package/dist/commands/inbox.js +83 -0
  33. package/dist/commands/inbox.js.map +1 -0
  34. package/dist/commands/ingest.js +553 -0
  35. package/dist/commands/ingest.js.map +1 -0
  36. package/dist/commands/init.js +97 -0
  37. package/dist/commands/init.js.map +1 -0
  38. package/dist/commands/install.js +520 -0
  39. package/dist/commands/install.js.map +1 -0
  40. package/dist/commands/invite.js +152 -0
  41. package/dist/commands/invite.js.map +1 -0
  42. package/dist/commands/jira.js +237 -0
  43. package/dist/commands/jira.js.map +1 -0
  44. package/dist/commands/join.js +96 -0
  45. package/dist/commands/join.js.map +1 -0
  46. package/dist/commands/links.js +66 -0
  47. package/dist/commands/links.js.map +1 -0
  48. package/dist/commands/login.js +84 -0
  49. package/dist/commands/login.js.map +1 -0
  50. package/dist/commands/morning.js +121 -0
  51. package/dist/commands/morning.js.map +1 -0
  52. package/dist/commands/next.js +66 -0
  53. package/dist/commands/next.js.map +1 -0
  54. package/dist/commands/onboarding.js +109 -0
  55. package/dist/commands/onboarding.js.map +1 -0
  56. package/dist/commands/pack.js +276 -0
  57. package/dist/commands/pack.js.map +1 -0
  58. package/dist/commands/plan-gh.js +425 -0
  59. package/dist/commands/plan-gh.js.map +1 -0
  60. package/dist/commands/plan.js +465 -0
  61. package/dist/commands/plan.js.map +1 -0
  62. package/dist/commands/pr.js +88 -0
  63. package/dist/commands/pr.js.map +1 -0
  64. package/dist/commands/question.js +142 -0
  65. package/dist/commands/question.js.map +1 -0
  66. package/dist/commands/read.js +84 -0
  67. package/dist/commands/read.js.map +1 -0
  68. package/dist/commands/refs.js +357 -0
  69. package/dist/commands/refs.js.map +1 -0
  70. package/dist/commands/reply.js +38 -0
  71. package/dist/commands/reply.js.map +1 -0
  72. package/dist/commands/repo.js +1198 -0
  73. package/dist/commands/repo.js.map +1 -0
  74. package/dist/commands/report.js +41 -0
  75. package/dist/commands/report.js.map +1 -0
  76. package/dist/commands/resolve-refs.js +21 -0
  77. package/dist/commands/resolve-refs.js.map +1 -0
  78. package/dist/commands/resolve.js +93 -0
  79. package/dist/commands/resolve.js.map +1 -0
  80. package/dist/commands/send.js +168 -0
  81. package/dist/commands/send.js.map +1 -0
  82. package/dist/commands/sent.js +22 -0
  83. package/dist/commands/sent.js.map +1 -0
  84. package/dist/commands/session.js +426 -0
  85. package/dist/commands/session.js.map +1 -0
  86. package/dist/commands/statusline.js +43 -0
  87. package/dist/commands/statusline.js.map +1 -0
  88. package/dist/commands/task.js +140 -0
  89. package/dist/commands/task.js.map +1 -0
  90. package/dist/commands/tick.js +233 -0
  91. package/dist/commands/tick.js.map +1 -0
  92. package/dist/commands/upgrade.js +76 -0
  93. package/dist/commands/upgrade.js.map +1 -0
  94. package/dist/commands/watch.js +169 -0
  95. package/dist/commands/watch.js.map +1 -0
  96. package/dist/commands/whoami.js +27 -0
  97. package/dist/commands/whoami.js.map +1 -0
  98. package/dist/config.js +103 -0
  99. package/dist/config.js.map +1 -0
  100. package/dist/directives.js +102 -0
  101. package/dist/directives.js.map +1 -0
  102. package/dist/errors.js +110 -0
  103. package/dist/errors.js.map +1 -0
  104. package/dist/git-hooks.js +116 -0
  105. package/dist/git-hooks.js.map +1 -0
  106. package/dist/graph-bootstrap.js +95 -0
  107. package/dist/graph-bootstrap.js.map +1 -0
  108. package/dist/graph-remote.js +889 -0
  109. package/dist/graph-remote.js.map +1 -0
  110. package/dist/graph.js +335 -0
  111. package/dist/graph.js.map +1 -0
  112. package/dist/index.js +154 -0
  113. package/dist/index.js.map +1 -0
  114. package/dist/kit/graph-publish.mjs +400 -0
  115. package/dist/mail-latency.js +120 -0
  116. package/dist/mail-latency.js.map +1 -0
  117. package/dist/mcp-registry.js +859 -0
  118. package/dist/mcp-registry.js.map +1 -0
  119. package/dist/mcp.js +193 -0
  120. package/dist/mcp.js.map +1 -0
  121. package/dist/output.js +12 -0
  122. package/dist/output.js.map +1 -0
  123. package/dist/positioning.js +21 -0
  124. package/dist/positioning.js.map +1 -0
  125. package/dist/rolestore.js +89 -0
  126. package/dist/rolestore.js.map +1 -0
  127. package/dist/session.js +49 -0
  128. package/dist/session.js.map +1 -0
  129. package/dist/sessionstore.js +736 -0
  130. package/dist/sessionstore.js.map +1 -0
  131. package/dist/version-check.js +97 -0
  132. package/dist/version-check.js.map +1 -0
  133. package/package.json +47 -0
@@ -0,0 +1,736 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { Worker } from "node:worker_threads";
6
+ import { loadRoles } from "./rolestore.js";
7
+ import { SessionStoreError } from "./errors.js";
8
+ // Session-scoped identity: WHO a running agent session is, keyed on a
9
+ // durable per-conversation handle when the host provides one, and on process
10
+ // ancestry only as a last resort for hosts that don't.
11
+ //
12
+ // Why this exists: multiple agents must share one repository (researcher,
13
+ // planner, coder, verifier all working the same checkout), so the directory
14
+ // cannot be the identity key — a dir has one binding slot, and the second
15
+ // checkin used to displace the first (observed live: codex-host-ingress stole
16
+ // legacy-android-host-app from claude-master-planner's open session).
17
+ //
18
+ // THE KEY, in priority order (docs/plans/codebus-v2-reliability.md §7 Phase A,
19
+ // precedent #1 in §2.4 — "key identity to a durable handle, never a process",
20
+ // the tmux server model):
21
+ //
22
+ // 1. A durable per-conversation env id, ALONE — never mixed with pid.
23
+ // CLAUDE_CODE_SESSION_ID (Claude Code transcript UUID) or CODEX_THREAD_ID
24
+ // (Codex resume-thread UUID). Verified stable across days, CLI upgrades,
25
+ // /compact, --resume, and app relaunches — exactly the durability a pid
26
+ // can never offer. NEVER CLAUDE_CODE_HOST_SESSION_ID: that one is
27
+ // per-launch, not durable, and using it would silently reintroduce the
28
+ // mortal-key bug this design eliminates.
29
+ // 2. "pid@lstart" of the nearest non-plumbing ancestor — bare-terminal
30
+ // fallback only, when neither env var is set. start-time disambiguates
31
+ // pid reuse. This tier IS mortal: a restarted process gets a new key,
32
+ // and pruneDead below reaps it once it's actually dead (§2 below).
33
+ //
34
+ // Known coarseness, accepted: in a plain terminal the first non-shell ancestor
35
+ // is the terminal APP (iTerm/Terminal is one process for all tabs), so a
36
+ // terminal-side checkin scopes to the whole terminal app, not the tab. Agent
37
+ // sessions — the actual use case — each have their own durable env id and
38
+ // never hit this tier.
39
+ //
40
+ // EXCEPT hosts whose one process serves many conversations. Codex Desktop
41
+ // (field incident, 2026-08-13): the ChatGPT.app runs ONE core process for ALL
42
+ // conversations, so ancestry alone collapsed to app-wide last-checkin-wins —
43
+ // three lane threads overwrote each other's identity (lane-2 checked in, two
44
+ // minutes later its watch announced lane-1). This is exactly what the env-id
45
+ // tier exists to prevent: every shell command under such a host is stamped
46
+ // with its own thread id, so the key is the thread, full stop — no ancestry
47
+ // walk, no pid, needed or wanted.
48
+ const THREAD_ENV_VARS = ["CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"];
49
+ // A durable env-keyed entry outlives the process that wrote it BY DESIGN (the
50
+ // whole point is to survive restarts) — age is the only prune signal for one,
51
+ // short of an explicit `codebus session forget`. 30 days is generous: a
52
+ // session that hasn't re-checked-in in a month is almost certainly gone for
53
+ // good, and the cost of keeping it a little longer is a few bytes of JSON.
54
+ const ENV_KEY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
55
+ const sessionsFile = () => join(homedir(), ".codebus", "session-roles.json");
56
+ // Plumbing processes between a codebus invocation and the session process.
57
+ const SKIP = new Set(["node", "sh", "zsh", "bash", "dash", "fish", "codebus", "codebus-mcp",
58
+ "python", "python3", "perl", "ruby"]); // script wrappers are plumbing too — observed: a key computed under python3 stopped at python3 itself
59
+ function psLine(pid) {
60
+ try {
61
+ const out = execFileSync("ps", ["-p", String(pid), "-o", "ppid=,lstart=,comm="], {
62
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
63
+ }).trim();
64
+ if (!out)
65
+ return null;
66
+ // "PPID Wed Aug 12 22:39:04 2026 /path/to/comm" — lstart is 5 tokens.
67
+ const m = out.match(/^\s*(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.*)$/);
68
+ if (!m)
69
+ return null;
70
+ return { ppid: Number(m[1]), start: m[2], comm: m[3] };
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ const basename = (p) => p.split("/").filter(Boolean).pop() ?? p;
77
+ /** A key is pid-keyed (mortal, ancestry-based) iff it contains "@" — the
78
+ * separator between pid and lstart. Env-keyed (durable) keys are the raw env
79
+ * value verbatim and never contain "@" in practice (both hosts mint UUIDs). */
80
+ const isPidKeyed = (key) => key.includes("@");
81
+ /**
82
+ * Split a pid-keyed key into its pid and lstart parts, tolerating every key
83
+ * shape this store has ever written (verified against git history —
84
+ * a5db2fc..7920c1f, pre-Phase-A):
85
+ *
86
+ * - "pid@lstart" — the CURRENT format (bare-terminal fallback,
87
+ * sessionKey() above), and also the ORIGINAL
88
+ * format from this store's very first version.
89
+ * - "pid@lstart#<thread-id>" — a since-removed intermediate format
90
+ * (8ae7258 "key sessions on CODEX_THREAD_ID",
91
+ * 7920c1f "generalize to Claude Code") that
92
+ * stamped a per-conversation thread id onto a
93
+ * pid-keyed entry with '#' as the separator.
94
+ * Phase A (51d2011) moved that discriminator
95
+ * onto its OWN durable, env-keyed tier
96
+ * instead (sessionKey() above again) and this
97
+ * suffix shape stopped being WRITTEN — but an
98
+ * entry written by an older CLI, still
99
+ * sitting in ~/.codebus/session-roles.json
100
+ * across an upgrade, keeps this shape until
101
+ * something prunes or overwrites it.
102
+ *
103
+ * A liveness check must parse ONLY the pid@lstart portion in every case:
104
+ * psLine().start is the raw `ps -o lstart=` output and never contains a
105
+ * '#...' suffix, so comparing an un-stripped tail (e.key.slice(at+1), which
106
+ * DOES include '#<thread-id>' for the older shape) against it can never
107
+ * match — every legacy '#'-suffixed entry would be judged dead on the very
108
+ * next write after an upgrade, dropping an already-checked-in session back
109
+ * to a cwd binding (or no identity at all). Returns null for anything that
110
+ * doesn't structurally look like one of the shapes above (a non-numeric
111
+ * pid) — pruneDead treats that the same as "process not found": prune,
112
+ * rather than guess at a shape that was never actually written.
113
+ */
114
+ function parsePidKeyedKey(key) {
115
+ const at = key.indexOf("@");
116
+ if (at < 0)
117
+ return null;
118
+ const pid = Number(key.slice(0, at));
119
+ if (!Number.isInteger(pid) || pid <= 0)
120
+ return null;
121
+ const hash = key.indexOf("#", at + 1);
122
+ const start = hash < 0 ? key.slice(at + 1) : key.slice(at + 1, hash);
123
+ if (!start)
124
+ return null;
125
+ return { pid, start };
126
+ }
127
+ /**
128
+ * The current session's key: a durable per-conversation env id ALONE when the
129
+ * host provides one, or "pid@lstart" of the nearest non-plumbing ancestor for
130
+ * bare terminals. Null when neither is available — callers degrade to the
131
+ * directory-binding tier, never fail.
132
+ */
133
+ export function sessionKey() {
134
+ // Durable handle wins outright — never mixed with pid (precedent §2.4-1:
135
+ // key identity to a durable handle, never a process). First match wins;
136
+ // a host only ever sets one of these.
137
+ const thread = THREAD_ENV_VARS.map((v) => process.env[v]).find(Boolean);
138
+ if (thread)
139
+ return thread;
140
+ // Bare-terminal fallback: mortal, ancestry-based.
141
+ let pid = process.ppid;
142
+ for (let hops = 0; hops < 12 && pid > 1; hops++) {
143
+ const info = psLine(pid);
144
+ if (!info)
145
+ return null;
146
+ if (!SKIP.has(basename(info.comm)))
147
+ return `${pid}@${info.start}`;
148
+ pid = info.ppid;
149
+ }
150
+ return null;
151
+ }
152
+ /** Structural validation — a store that parses as JSON but isn't shaped like
153
+ * one is just as untrustworthy as one that doesn't parse at all (a half
154
+ * written object, an array where entries should be, a stray null). */
155
+ function isValidStore(v) {
156
+ if (!v || typeof v !== "object" || !Array.isArray(v.entries))
157
+ return false;
158
+ return v.entries.every((e) => !!e && typeof e === "object" &&
159
+ typeof e.key === "string" &&
160
+ typeof e.role === "string" &&
161
+ typeof e.added_at === "string");
162
+ }
163
+ /**
164
+ * Load the session store, distinguishing "doesn't exist yet" (ENOENT — a
165
+ * brand-new machine, or every entry has already been pruned/forgotten; the
166
+ * legitimate empty case) from every other failure (permission denied, a disk
167
+ * I/O error, truncated/corrupt JSON, an unexpected shape). Only the first is
168
+ * safe to treat as an empty store: any of the others means this session's
169
+ * entry might well be sitting in there, mangled, and silently returning
170
+ * `{ entries: [] }` is exactly how a truncated write used to make a durable,
171
+ * checked-in session look like it was never checked in at all — resolution
172
+ * would then fall through to a cwd binding and hand this session a DIFFERENT
173
+ * agent's identity (the foreign-identity takeover the session tier exists to
174
+ * eliminate). So anything past ENOENT throws SessionStoreError and resolution
175
+ * fails closed instead of guessing.
176
+ */
177
+ function loadSessions() {
178
+ let raw;
179
+ try {
180
+ raw = readFileSync(sessionsFile(), "utf8");
181
+ }
182
+ catch (e) {
183
+ if (e.code === "ENOENT")
184
+ return { entries: [] };
185
+ throw new SessionStoreError(`could not read ${sessionsFile()}: ${e.message}`);
186
+ }
187
+ let parsed;
188
+ try {
189
+ parsed = JSON.parse(raw);
190
+ }
191
+ catch (e) {
192
+ throw new SessionStoreError(`${sessionsFile()} is not valid JSON (${e.message}) — truncated or corrupt write?`);
193
+ }
194
+ if (!isValidStore(parsed)) {
195
+ throw new SessionStoreError(`${sessionsFile()} has an unexpected shape (missing/invalid 'entries' array)`);
196
+ }
197
+ return parsed;
198
+ }
199
+ /**
200
+ * Keep pid-keyed entries only while their process is still alive with the
201
+ * same start time (pruneDead's original contract). Env-keyed entries are
202
+ * durable BY DESIGN — a checkin's whole point is to survive the process that
203
+ * made it — so death is never a prune signal for them; they age out instead
204
+ * (ENV_KEY_MAX_AGE_MS since their last (re)checkin) or are removed explicitly
205
+ * via `codebus session forget`. Pruning an env-keyed entry on process death
206
+ * was the FM-1/root-cause-2.1 bug: a restarting Codex/Claude process (new pid,
207
+ * SAME durable thread id) would otherwise lose its binding on every restart
208
+ * and silently fall through to the directory tier.
209
+ */
210
+ function pruneDead(store) {
211
+ const now = Date.now();
212
+ store.entries = store.entries.filter((e) => {
213
+ if (!isPidKeyed(e.key)) {
214
+ const age = now - new Date(e.added_at).getTime();
215
+ return !(age > ENV_KEY_MAX_AGE_MS);
216
+ }
217
+ // parsePidKeyedKey strips a legacy '#<thread-id>' suffix before comparing
218
+ // — see its doc comment for the upgrade-path bug this fixes (every
219
+ // pre-Phase-A '#'-suffixed entry used to be pruned as dead unconditionally
220
+ // on the first write after upgrading, regardless of whether its process
221
+ // was actually still alive).
222
+ const parsed = parsePidKeyedKey(e.key);
223
+ if (!parsed)
224
+ return false; // "@" present but not a shape this store ever wrote — can't verify liveness, so treat like a dead/invalid entry
225
+ const info = psLine(parsed.pid);
226
+ return !!info && info.start === parsed.start;
227
+ });
228
+ return store;
229
+ }
230
+ /**
231
+ * Atomic write: a sibling temp file + rename, never a direct write to the
232
+ * live path. A plain writeFileSync to session-roles.json is visible to
233
+ * concurrent readers/writers mid-write — exactly how a reader can observe a
234
+ * truncated/partial file (loadSessions above then has to fail closed on it).
235
+ * rename(2) on the same filesystem is atomic, so every observer sees either
236
+ * the complete old file or the complete new one, never a partial one.
237
+ */
238
+ function saveSessions(store) {
239
+ const dir = join(homedir(), ".codebus");
240
+ mkdirSync(dir, { recursive: true });
241
+ const file = sessionsFile();
242
+ const tmp = join(dir, `.session-roles.json.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
243
+ writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
244
+ renameSync(tmp, file);
245
+ }
246
+ // ── interprocess lock ────────────────────────────────────────────────────────
247
+ // recordSessionRole/forgetSession are read-modify-write: load the whole store,
248
+ // mutate it, write the whole store back. Two such cycles racing (reproduced:
249
+ // 40 concurrent `recordSessionRole` calls survived only 10 entries) each read
250
+ // the same pre-write snapshot and the later save clobbers the earlier one's
251
+ // addition outright — atomic renames make individual writes non-corrupting,
252
+ // but they do nothing for a lost update between two overlapping read-then-write
253
+ // cycles. mkdir is the lock primitive: it's atomic and exclusive across
254
+ // processes on every platform Node runs on (POSIX mkdir(2) fails EEXIST if the
255
+ // directory exists; no separate lockfile library needed for that guarantee).
256
+ //
257
+ // Round-2 review: a stale-lock steal must never let two processes believe
258
+ // they hold the lock at once, and a release must never tear down a lock it
259
+ // no longer owns. The original steal/release both acted on the SAME fixed
260
+ // path (`dir`) independently via a bare, unconditional rmSync — a
261
+ // live-but-slow holder's lock mtime is never refreshed (a periodic heartbeat
262
+ // was ruled out as fragile), so it can look stale and get stolen while
263
+ // genuinely still held; when that original holder FINALLY finishes and calls
264
+ // its own release, an unconditional rmSync(dir) deletes whatever is AT `dir`
265
+ // by then — which by that point is the thief's LIVE lock, not the original
266
+ // holder's dead one — letting a third writer sneak in while the thief still
267
+ // believes it holds the lock.
268
+ //
269
+ // Fix has two layers:
270
+ //
271
+ // 1. Every acquire writes an owner token (pid + timestamp + nonce) INSIDE
272
+ // the lock dir. release() captures its own token in the closure and only
273
+ // ever tries to act on a generation it can verify is still its own.
274
+ //
275
+ // 2. Eviction — by release OR by a steal — never does a bare rmSync(dir)
276
+ // against the shared path. It goes through evictLockDir(): rename `dir`
277
+ // to a private, uniquely-named tomb FIRST. rename(2) atomically removes
278
+ // the source path the instant it succeeds, so of any number of
279
+ // concurrent callers racing to rename away the SAME still-existing
280
+ // `dir`, AT MOST ONE can ever win; everyone else gets ENOENT and does
281
+ // nothing further (no removal, no retry within that call). This is what
282
+ // makes two stealers racing the same stale lock race-free.
283
+ //
284
+ // A release additionally verifies, AFTER winning that race (i.e. once it
285
+ // safely and exclusively holds whatever was at `dir`, in its own private
286
+ // tomb that nobody else can see or touch), that the evicted generation's
287
+ // own owner token still matches its caller's. If it does, this really
288
+ // was the caller's own lock — discard the tomb, done. If it does NOT —
289
+ // meaning release's rename raced ahead of a legitimate steal/re-mkdir and
290
+ // scooped up a DIFFERENT, newer generation by accident — release does
291
+ // NOT discard it. It puts it back (renames the tomb back to `dir`) so
292
+ // the rightful holder's lock is undisturbed, rather than silently
293
+ // destroying a live owner's lock the way the original bug did.
294
+ //
295
+ // Residual, explicitly accepted window: restoring can itself only fail
296
+ // if a THIRD party recreates `dir` in the sub-microsecond gap between
297
+ // release's rename-away and its rename-back — vanishingly narrow (no
298
+ // intervening I/O or event-loop tick separates the two calls). Even
299
+ // then, the invariant that MUST hold still holds: at most one process
300
+ // ever has `dir` mkdir'd at a time (rename's atomicity alone guarantees
301
+ // that, independent of the token check), and no release ever corrupts a
302
+ // DIFFERENT, currently-valid claimant's lock — in that doubly-narrow
303
+ // case the wrongly-evicted generation is simply dropped (not restored),
304
+ // never double-held, and the fail-closed session-store validation (round-
305
+ // 2 finding #1's sibling, round-1 finding #1) means a resulting
306
+ // unreadable/half-written session-roles.json is caught on next read
307
+ // rather than silently misresolved.
308
+ //
309
+ // Round-2 CI failure, second pass: the two layers above make eviction SAFE
310
+ // (never corrupt a different live owner's lock) but don't stop a genuinely
311
+ // LIVE holder's lock from *looking* stale in the first place — the owner
312
+ // token's mtime is written once, at acquire, and never touched again while
313
+ // held. A critical section that legitimately runs longer than LOCK_STALE_MS
314
+ // (observed on a slower CI runner: a deliberately-long-held test lock aged
315
+ // past 10s while still genuinely live) ages exactly like a crashed one would,
316
+ // and a normally-polling second acquirer correctly-by-the-old-rules steals
317
+ // it — this is the reviewer's original "ownership/HEARTBEAT validation"
318
+ // finding; only the ownership half had landed. Fixed with a heartbeat: while
319
+ // a lock is held, a small worker_threads.Worker — on its OWN OS thread,
320
+ // independent of whatever the holder's main thread is doing — re-touches the
321
+ // owner file's mtime every HEARTBEAT_INTERVAL_MS via utimesSync, and
322
+ // staleness is judged against THAT mtime (not the lock dir's own, which only
323
+ // ever changes at creation). A worker thread, not a plain setInterval, is
324
+ // required here on purpose: recordSessionRole/forgetSession's critical
325
+ // section is synchronous by design (sleepSync above included — see its own
326
+ // comment), and a synchronous, non-yielding critical section blocks the
327
+ // entire JS main thread including any of its own timers; a heartbeat that
328
+ // has to keep ticking THROUGH such a critical section cannot live on that
329
+ // same thread. A genuinely crashed holder's heartbeat thread dies with it —
330
+ // nothing keeps refreshing the mtime — so LOCK_STALE_MS since the LAST
331
+ // heartbeat tick (not since acquire) is still exactly the right reap signal
332
+ // for a truly dead holder, while a live one, however long its critical
333
+ // section runs, is never mistaken for one.
334
+ //
335
+ // Round-4 ai-review, the theoretical tail: even with a heartbeat, staleness
336
+ // is fundamentally a HEURISTIC — this is a standard, unavoidable fact of
337
+ // distributed/lease-based locking, not a gap specific to this design. No
338
+ // scheme built on "no liveness signal in N seconds ⇒ presumed dead" can ever
339
+ // fully distinguish a crashed process from one merely stalled longer than N
340
+ // (OS scheduling, CPU starvation, filesystem latency, or — here — the
341
+ // heartbeat WORKER dying without the main thread dying too). So a genuinely
342
+ // live holder whose heartbeat stalls past LOCK_STALE_MS CAN be evicted while
343
+ // its critical section keeps running, unaware, concurrently with the new
344
+ // owner's. Two things follow from accepting that honestly instead of
345
+ // pretending a wider window or a fancier stale-check closes it:
346
+ //
347
+ // 1. BOUND it as tightly as practical: LOCK_STALE_MS below is generous
348
+ // (15 missed heartbeats) — a process stalled that long while genuinely
349
+ // holding this lock is overwhelmingly either dead or the machine is
350
+ // unusable for any other purpose either, evicted or not.
351
+ //
352
+ // 2. Make the ACTUAL WRITE safe regardless: recordSessionRole/forgetSession
353
+ // re-verify (verifyStillOwned, used via the `verifyOwned` callback
354
+ // withSessionLock hands to its critical section) that the lock is STILL
355
+ // theirs immediately before the atomic commit (saveSessions' temp+rename)
356
+ // that follows. An evicted-while-stalled holder that reaches this point
357
+ // gets SessionStoreError instead of a commit — its caller's ordinary
358
+ // error path handles it (the write simply didn't happen; retry the
359
+ // operation). This makes a lost update — the new owner's write being
360
+ // silently clobbered by a stale holder — IMPOSSIBLE: the stale holder
361
+ // either aborts before committing, or (see below) there was no window
362
+ // left in which it could have committed instead.
363
+ //
364
+ // The ONLY window this cannot close is between THIS check and the commit's
365
+ // own renameSync: microseconds, with no I/O or event-loop yield point for
366
+ // anything else to interleave through in between (both are synchronous,
367
+ // back-to-back calls in the same critical section). Closing even that would
368
+ // need a filesystem-level compare-and-swap this design doesn't have — this
369
+ // is the practical limit for a file-based lock; a further reviewer flagging
370
+ // that microsecond gap is an accept-with-documented-bound, not a rework.
371
+ const LOCK_STALE_MS = 30_000; // 15 missed 2s heartbeats ⇒ presumed crashed; stolen after this
372
+ const LOCK_STEAL_GRACE_MS = 250; // re-verify staleness once more before committing to a steal — narrows, but per the above cannot fully close, the false-positive-steal window (a steal landing in the instant between two heartbeat ticks)
373
+ const LOCK_POLL_MS = 15;
374
+ // Must comfortably exceed LOCK_STALE_MS: a waiter has to be willing to sit
375
+ // through one full staleness-detection cycle (so a crashed holder's lock
376
+ // actually gets reaped and handed to it, rather than giving up right before
377
+ // that would happen) AND tolerate a live holder whose legitimate critical
378
+ // section runs a while past LOCK_STALE_MS too (the whole point of the
379
+ // heartbeat above — the CI failure this fixes was exactly a well-behaved
380
+ // waiter's own patience mattering, not just the held lock's judged staleness).
381
+ const LOCK_TIMEOUT_MS = 45_000;
382
+ const HEARTBEAT_INTERVAL_MS = 2_000; // well under LOCK_STALE_MS, so a live holder's marker never has a chance to look stale
383
+ function lockDir() { return `${sessionsFile()}.lock`; }
384
+ function ownerFile(dir) { return join(dir, "owner"); }
385
+ /**
386
+ * Start a heartbeat for a held lock: a worker thread that re-touches the
387
+ * owner file's mtime every HEARTBEAT_INTERVAL_MS for as long as it's kept
388
+ * alive, independent of whatever the holder's own (possibly long-blocking,
389
+ * synchronous) critical section is doing on the main thread — see the
390
+ * section comment above for why this can't be a plain main-thread timer.
391
+ * `.unref()`d so an unterminated heartbeat (a bug, or a process that exits
392
+ * without going through release — should never happen given withSessionLock's
393
+ * try/finally) can never keep the process alive on its own. Errors inside the
394
+ * worker are swallowed: a heartbeat failing is a liveness-signal problem for
395
+ * OTHER processes to (rarely, harmlessly) misjudge as staleness, never a
396
+ * reason to crash the holder that's actually doing useful work.
397
+ *
398
+ * Round-5 ai-review (zombie heartbeat): stopHeartbeat() below fires
399
+ * worker.terminate() but does NOT (cannot — the store API is deliberately
400
+ * synchronous, so release() can't await it) wait for it to land before
401
+ * evictLockDir runs. In the gap between "terminate() requested" and "the
402
+ * worker thread actually stops", the worker can wake for one more tick and
403
+ * utimesSync the owner path — which by then may already belong to a NEW
404
+ * lock generation (this hold evicted-while-stalled, exactly the round-4
405
+ * scenario, and a fresh acquirer already recreated `dir` with its own
406
+ * token). That refresh would keep a lock the old process no longer owns
407
+ * looking alive, blocking anyone from ever reaping it — a zombie heartbeat,
408
+ * narrow but real (worst case: the new owner later crashes, and the dead
409
+ * lock is kept "alive" by the zombie until the OLD process eventually
410
+ * exits and its worker gets torn down with it).
411
+ *
412
+ * Fixed with generation-awareness instead of trying to make release()
413
+ * async: before EVERY touch, the worker reads the owner file's CURRENT
414
+ * content and compares it to the token it was started with. Touch only on
415
+ * an exact match; on a mismatch (a different generation now owns this path)
416
+ * or ENOENT (evicted and the file is gone entirely, or tombed) the worker
417
+ * exits itself — a stale heartbeat is a contradiction in terms once it can
418
+ * see it no longer owns what it's refreshing. stopHeartbeat's terminate()
419
+ * stays as belt-and-braces (the common, fast path: most releases happen
420
+ * long before the worker's next scheduled tick anyway).
421
+ */
422
+ function startHeartbeat(dir, token) {
423
+ const worker = new Worker(`
424
+ const { workerData } = require("node:worker_threads");
425
+ const { utimesSync, readFileSync } = require("node:fs");
426
+ const sab = new SharedArrayBuffer(4);
427
+ const ia = new Int32Array(sab);
428
+ for (;;) {
429
+ Atomics.wait(ia, 0, 0, workerData.intervalMs);
430
+ let current;
431
+ try {
432
+ current = readFileSync(workerData.file, "utf8");
433
+ } catch {
434
+ break; // owner file gone entirely — can't confirm we still own it, so stop rather than guess
435
+ }
436
+ if (current !== workerData.token) break; // a DIFFERENT generation owns this path now — never touch a lock we don't own
437
+ try {
438
+ const now = new Date();
439
+ utimesSync(workerData.file, now, now);
440
+ } catch {
441
+ /* vanished between our read and this write (a steal landed in that
442
+ * gap) — next iteration's read sees the mismatch/ENOENT and stops;
443
+ * nothing to do for THIS tick. */
444
+ }
445
+ }
446
+ `, { eval: true, workerData: { file: ownerFile(dir), intervalMs: HEARTBEAT_INTERVAL_MS, token } });
447
+ worker.unref();
448
+ worker.on("error", () => { });
449
+ return worker;
450
+ }
451
+ /** Stop a heartbeat — best-effort, fire-and-forget (release() is synchronous
452
+ * and must not block waiting for worker teardown to be confirmed). Belt-and-
453
+ * braces alongside the worker's own generation check above (startHeartbeat's
454
+ * doc comment) — this is the fast, common-case path; that's the correctness
455
+ * backstop for the gap this alone can't close. */
456
+ function stopHeartbeat(worker) {
457
+ worker.terminate().catch(() => { });
458
+ }
459
+ /** Synchronous sleep — the lock spin has to block (no callers here are async;
460
+ * recordSessionRole/forgetSession are synchronous, deliberately, so a caller
461
+ * never has to remember to await an identity-binding call). Atomics.wait on a
462
+ * throwaway SharedArrayBuffer is Node's documented way to block the main
463
+ * thread for a bounded time without a busy CPU spin. */
464
+ function sleepSync(ms) {
465
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
466
+ }
467
+ /**
468
+ * Write this acquisition's owner token inside `dir` and return it — load-
469
+ * bearing now (not just diagnostic): release() uses it to verify it's still
470
+ * acting on its OWN generation before discarding anything (see the section
471
+ * comment above). A write failure here means the filesystem can't even be
472
+ * written to right after we just mkdir'd it — fail closed rather than hand
473
+ * back a lock whose ownership can never be verified later.
474
+ */
475
+ function writeOwnerToken(dir) {
476
+ const token = `${process.pid}@${Date.now()}-${Math.random().toString(36).slice(2)}`;
477
+ try {
478
+ writeFileSync(ownerFile(dir), token);
479
+ }
480
+ catch (e) {
481
+ throw new SessionStoreError(`could not write lock owner token in ${dir}: ${e.message}`);
482
+ }
483
+ return token;
484
+ }
485
+ function readOwnerToken(dir) {
486
+ try {
487
+ return readFileSync(ownerFile(dir), "utf8");
488
+ }
489
+ catch {
490
+ return null;
491
+ }
492
+ }
493
+ /**
494
+ * Pre-commit ownership re-check (round-4 ai-review — see the section comment
495
+ * above for the full reasoning). Called immediately before the atomic commit
496
+ * that follows the critical section's mutation: if the lock's CURRENT owner
497
+ * token no longer matches the one captured at acquire (`token`), this holder
498
+ * was evicted as stale while it was still — unknown to it — running, and
499
+ * MUST NOT proceed to write. Throws SessionStoreError instead, which the
500
+ * caller's ordinary error path handles (the write simply never happened;
501
+ * retry the operation). A missing owner file (evicted AND the new owner has
502
+ * already released too) also counts as "not ours anymore" — `null` can never
503
+ * equal `token`.
504
+ */
505
+ function verifyStillOwned(dir, token) {
506
+ if (readOwnerToken(dir) === token)
507
+ return;
508
+ throw new SessionStoreError(`session-store lock ownership lost mid-write (${dir}) — this hold was evicted as stale while still running ` +
509
+ `(a genuinely dead process is indistinguishable from one merely stalled past ${LOCK_STALE_MS}ms; see the lock ` +
510
+ `section in sessionstore.ts). Aborting before committing, to avoid clobbering the new owner's write — retry.`);
511
+ }
512
+ /**
513
+ * Age of the lock, in ms, judged by the owner file's mtime — the heartbeat's
514
+ * target, kept fresh for as long as the holder is genuinely alive (see the
515
+ * section comment above). Falls back to the lock DIR's own mtime only if the
516
+ * owner file can't be read (a sub-millisecond window right after mkdirSync,
517
+ * before writeOwnerToken has run, or the file vanished mid-check) — never
518
+ * treated as an error, just a less-precise age estimate for that instant.
519
+ * Null only if BOTH are unreadable (the dir itself is already gone).
520
+ */
521
+ function lockAgeMs(dir) {
522
+ try {
523
+ return Date.now() - statSync(ownerFile(dir)).mtimeMs;
524
+ }
525
+ catch { /* fall through */ }
526
+ try {
527
+ return Date.now() - statSync(dir).mtimeMs;
528
+ }
529
+ catch {
530
+ return null;
531
+ }
532
+ }
533
+ /**
534
+ * Atomically evict whatever currently lives at `dir` — the ONE primitive
535
+ * used by both a normal release and a steal-a-stale-lock attempt (see the
536
+ * section comment above for why they must share it and how the safety
537
+ * invariant follows from that).
538
+ *
539
+ * - `expectedToken` omitted (the STEAL path): the caller already re-verified
540
+ * staleness immediately before calling this, so once the rename wins,
541
+ * discard unconditionally — a steal doesn't care whose lock it was.
542
+ * - `expectedToken` given (the RELEASE path): only discard if the evicted
543
+ * generation's OWN owner token matches. A mismatch means this call's
544
+ * rename won against a DIFFERENT, newer generation that happened to
545
+ * occupy the same path — put it back (rename the tomb back to `dir`)
546
+ * instead of destroying a live owner's lock. See the residual-window note
547
+ * in the section comment above for the one case restoring can itself fail.
548
+ *
549
+ * Returns true iff this call's rename won the eviction race (regardless of
550
+ * whether the content was ultimately discarded or restored).
551
+ */
552
+ function evictLockDir(dir, expectedToken) {
553
+ const tomb = `${dir}.evicted-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
554
+ try {
555
+ renameSync(dir, tomb);
556
+ }
557
+ catch {
558
+ return false; // lost the race — some other release/steal already evicted this generation
559
+ }
560
+ if (expectedToken !== undefined && readOwnerToken(tomb) !== expectedToken) {
561
+ // We evicted a DIFFERENT, presumably-live generation by accident — try to
562
+ // put it back rather than discard someone else's live lock.
563
+ try {
564
+ renameSync(tomb, dir); // fails only if a third party already recreated `dir` in this sub-microsecond gap (see residual-window note above)
565
+ return true;
566
+ }
567
+ catch {
568
+ /* doubly-narrow race: couldn't restore — fall through and drop it.
569
+ * `dir` currently belongs solely to that third party, untouched by us;
570
+ * the invariant (never two simultaneous holders) still holds. */
571
+ }
572
+ }
573
+ try {
574
+ rmSync(tomb, { recursive: true, force: true });
575
+ }
576
+ catch { /* best-effort cleanup of our own, now-private tomb */ }
577
+ return true;
578
+ }
579
+ /** Acquire the session-store lock, spinning past concurrent holders and
580
+ * stealing a stale one, or throwing SessionStoreError if it can't be had
581
+ * within LOCK_TIMEOUT_MS (a stuck/crashed writer that leaves a lock younger
582
+ * than the stale threshold — vanishingly unlikely, but must fail loud, never
583
+ * hang forever or silently proceed unlocked). Returns the acquired lock's
584
+ * `dir`/`token` (so the caller can pre-commit-verify with them — see
585
+ * verifyStillOwned/withSessionLock below) alongside the release function. */
586
+ function acquireSessionLock() {
587
+ mkdirSync(join(homedir(), ".codebus"), { recursive: true });
588
+ const dir = lockDir();
589
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
590
+ for (;;) {
591
+ try {
592
+ mkdirSync(dir); // no {recursive:true} — MUST fail EEXIST when already held
593
+ const token = writeOwnerToken(dir);
594
+ const heartbeat = startHeartbeat(dir, token);
595
+ // Release stops the heartbeat FIRST, then verifies ownership (via
596
+ // `token`) before discarding anything — see evictLockDir's doc comment.
597
+ return { dir, token, release: () => { stopHeartbeat(heartbeat); evictLockDir(dir, token); } };
598
+ }
599
+ catch (e) {
600
+ if (e instanceof SessionStoreError)
601
+ throw e; // writeOwnerToken's own failure — fail closed, not a retry-able EEXIST
602
+ if (e.code !== "EEXIST") {
603
+ throw new SessionStoreError(`could not acquire lock dir ${dir}: ${e.message}`);
604
+ }
605
+ const age = lockAgeMs(dir); // judged by the owner file's mtime — a live holder's HEARTBEAT keeps this fresh regardless of critical-section length
606
+ if (age !== null && age > LOCK_STALE_MS) {
607
+ // Re-verify once more after a short grace before committing to a
608
+ // steal attempt — narrows, but (see the section comment above)
609
+ // cannot fully close, the false-positive window (a steal landing in
610
+ // the instant between two heartbeat ticks).
611
+ sleepSync(LOCK_STEAL_GRACE_MS);
612
+ const ageAfterGrace = lockAgeMs(dir);
613
+ if (ageAfterGrace !== null && ageAfterGrace > LOCK_STALE_MS && evictLockDir(dir)) {
614
+ continue; // we won the eviction race — loop back and try mkdirSync(dir) fresh
615
+ }
616
+ }
617
+ if (Date.now() > deadline) {
618
+ const owner = readOwnerToken(dir);
619
+ throw new SessionStoreError(`timed out after ${LOCK_TIMEOUT_MS}ms waiting for session-store lock (${dir})` +
620
+ `${owner ? ` — held by ${owner}` : ""} — a stuck writer?`);
621
+ }
622
+ sleepSync(LOCK_POLL_MS);
623
+ }
624
+ }
625
+ }
626
+ /**
627
+ * Run `fn` holding the session-store lock. `fn` receives `verifyOwned` — call
628
+ * it immediately before the critical section's own commit (see
629
+ * verifyStillOwned's doc comment and the round-4 section comment above): it
630
+ * throws SessionStoreError if this hold was evicted as stale while still
631
+ * running, which must abort the write rather than let it proceed.
632
+ * recordSessionRole/forgetSession below both call it this way.
633
+ *
634
+ * Exported for the regression suite too (round-2's steal/release race and
635
+ * round-4's evicted-while-stalled-commit race both need to drive the raw
636
+ * lock from real, separate OS processes with an artificially slow critical
637
+ * section — recordSessionRole's own critical section is too fast to reliably
638
+ * manufacture either race).
639
+ */
640
+ export function withSessionLock(fn) {
641
+ const { dir, token, release } = acquireSessionLock();
642
+ try {
643
+ return fn(() => verifyStillOwned(dir, token));
644
+ }
645
+ finally {
646
+ release();
647
+ }
648
+ }
649
+ /**
650
+ * Bind THIS session (the process tree the caller runs inside, or its durable
651
+ * env id) to a role. Returns the session key, or null if neither an env id
652
+ * nor a readable ancestry was available (caller should say so — silent
653
+ * identity failure is the enemy). The whole read-modify-write cycle runs
654
+ * under the session-store lock so concurrent recordSessionRole calls (from
655
+ * different sessions, different processes) serialize instead of racing.
656
+ * `verifyOwned()` runs immediately before the commit (round-4 ai-review):
657
+ * see withSessionLock's doc comment — an evicted-while-stalled hold aborts
658
+ * here with SessionStoreError instead of writing.
659
+ */
660
+ export function recordSessionRole(role) {
661
+ const key = sessionKey();
662
+ if (!key)
663
+ return null;
664
+ return withSessionLock((verifyOwned) => {
665
+ const store = pruneDead(loadSessions());
666
+ store.entries = store.entries.filter((e) => e.key !== key);
667
+ store.entries.push({ key, role, added_at: new Date().toISOString() });
668
+ verifyOwned();
669
+ saveSessions(store);
670
+ return key;
671
+ });
672
+ }
673
+ /**
674
+ * Explicit forget for THIS session's entry — the escape hatch age-pruning
675
+ * exists alongside for durable env-keyed entries (they otherwise outlive
676
+ * their process by design and are never reaped by death). Returns the
677
+ * forgotten role, or null if this session had no entry to forget. Locked for
678
+ * the same reason as recordSessionRole above — it's the same read-modify-write
679
+ * shape over the same shared file, including the pre-commit `verifyOwned()`.
680
+ */
681
+ export function forgetSession() {
682
+ const key = sessionKey();
683
+ if (!key)
684
+ return null;
685
+ return withSessionLock((verifyOwned) => {
686
+ const store = pruneDead(loadSessions());
687
+ const hit = store.entries.find((e) => e.key === key);
688
+ if (!hit)
689
+ return null;
690
+ store.entries = store.entries.filter((e) => e.key !== key);
691
+ verifyOwned();
692
+ saveSessions(store);
693
+ return hit.role;
694
+ });
695
+ }
696
+ /** True for an env-keyed (durable) entry old enough that the 30-day age
697
+ * policy says it's gone — the same test pruneDead applies at write time,
698
+ * applied here too so resolution enforces it on every read, not only when
699
+ * some other call happens to trigger a prune-and-save. Pid-keyed entries are
700
+ * never subject to this: their prune signal is process liveness, unrelated to
701
+ * age (pruneDead's own contract, unchanged here). */
702
+ function isExpiredEnvEntry(e) {
703
+ if (isPidKeyed(e.key))
704
+ return false;
705
+ return Date.now() - new Date(e.added_at).getTime() > ENV_KEY_MAX_AGE_MS;
706
+ }
707
+ export function resolveSession() {
708
+ const key = sessionKey();
709
+ if (!key)
710
+ return { status: "none" };
711
+ const hit = loadSessions().entries.find((e) => e.key === key);
712
+ if (!hit)
713
+ return { status: "none" };
714
+ // Expired-by-age is resolved as "dead", never "none": this session WAS
715
+ // checked in (an entry exists), so resolution must stop here loudly rather
716
+ // than silently falling through to a cwd binding (§2.4 precedent 3).
717
+ if (isExpiredEnvEntry(hit))
718
+ return { status: "dead", role: hit.role };
719
+ const entry = loadRoles().roles[hit.role];
720
+ if (!entry || (!entry.token && !entry.assume))
721
+ return { status: "dead", role: hit.role };
722
+ return { status: "ok", role: hit.role, entry };
723
+ }
724
+ /**
725
+ * The identity THIS session was checked into, resolved against the role
726
+ * store, or null (covers BOTH "never checked in" and "checked in but dead" —
727
+ * callers that must tell those apart use resolveSession() instead). Sits
728
+ * between env and the directory binding in resolveConfig's precedence: a
729
+ * session's own checkin beats the dir default, which is what lets N agents
730
+ * share one repository.
731
+ */
732
+ export function sessionIdentity() {
733
+ const r = resolveSession();
734
+ return r.status === "ok" ? { role: r.role, entry: r.entry } : null;
735
+ }
736
+ //# sourceMappingURL=sessionstore.js.map