relaybrain 0.0.0-stage → 0.2.1

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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -3
  3. package/backend/coordinator/client.js +364 -0
  4. package/backend/coordinator/lockTable.js +649 -0
  5. package/backend/coordinator/server.js +179 -0
  6. package/backend/lib/centralAuth.js +62 -0
  7. package/backend/lib/centralStore.js +112 -0
  8. package/backend/lib/centralSync.js +88 -0
  9. package/backend/lib/cloneRepo.js +108 -0
  10. package/backend/lib/collisionMetrics.js +231 -0
  11. package/backend/lib/conflicts.js +35 -0
  12. package/backend/lib/dashboard.js +53 -0
  13. package/backend/lib/depGraph.js +393 -0
  14. package/backend/lib/deps.js +46 -0
  15. package/backend/lib/dirtyFiles.js +33 -0
  16. package/backend/lib/editMatchers.js +55 -0
  17. package/backend/lib/falkor/analytics.js +228 -0
  18. package/backend/lib/falkor/bus.js +16 -0
  19. package/backend/lib/falkor/central.js +281 -0
  20. package/backend/lib/falkor/client.js +165 -0
  21. package/backend/lib/falkor/config.js +73 -0
  22. package/backend/lib/falkor/coord.js +299 -0
  23. package/backend/lib/falkor/cursor.js +58 -0
  24. package/backend/lib/falkor/demo.js +221 -0
  25. package/backend/lib/falkor/fixtures.js +85 -0
  26. package/backend/lib/falkor/index.js +146 -0
  27. package/backend/lib/falkor/ingest.js +233 -0
  28. package/backend/lib/falkor/intents.js +32 -0
  29. package/backend/lib/falkor/keys.js +102 -0
  30. package/backend/lib/falkor/migrations.js +56 -0
  31. package/backend/lib/falkor/project.js +307 -0
  32. package/backend/lib/falkor/query.js +344 -0
  33. package/backend/lib/falkor/redact.js +30 -0
  34. package/backend/lib/falkor/routes.js +125 -0
  35. package/backend/lib/falkor/schema.js +108 -0
  36. package/backend/lib/falkor/write.js +281 -0
  37. package/backend/lib/githubLogin.js +108 -0
  38. package/backend/lib/githubOAuth.js +42 -0
  39. package/backend/lib/githubRepo.js +117 -0
  40. package/backend/lib/harvest.js +85 -0
  41. package/backend/lib/httpServer.js +27 -0
  42. package/backend/lib/installAgentRelay.js +235 -0
  43. package/backend/lib/installHooks.js +309 -0
  44. package/backend/lib/lamport.js +14 -0
  45. package/backend/lib/localBrowse.js +109 -0
  46. package/backend/lib/memorySync.js +63 -0
  47. package/backend/lib/notices.js +150 -0
  48. package/backend/lib/ot.js +74 -0
  49. package/backend/lib/patches.js +192 -0
  50. package/backend/lib/paths.js +85 -0
  51. package/backend/lib/peerClash.js +64 -0
  52. package/backend/lib/presence.js +35 -0
  53. package/backend/lib/relayOsBlock.js +84 -0
  54. package/backend/lib/repoSnapshot.js +125 -0
  55. package/backend/lib/room.js +638 -0
  56. package/backend/lib/roomAsk.js +148 -0
  57. package/backend/lib/roomAuth.js +215 -0
  58. package/backend/lib/roomControl.js +226 -0
  59. package/backend/lib/roomLocks.js +201 -0
  60. package/backend/lib/roomSignal.js +265 -0
  61. package/backend/lib/roomSync.js +194 -0
  62. package/backend/lib/seed.js +107 -0
  63. package/backend/lib/sseHub.js +40 -0
  64. package/backend/lib/store.js +79 -0
  65. package/backend/lib/supergraph.js +46 -0
  66. package/backend/lib/transcripts/discover.js +437 -0
  67. package/backend/lib/transcripts/parse.js +558 -0
  68. package/backend/lib/transcripts/sessionId.js +36 -0
  69. package/backend/lib/transcripts/sync.js +291 -0
  70. package/backend/lib/transcripts/util.js +299 -0
  71. package/backend/lib/treeSitterImports.js +219 -0
  72. package/backend/lib/watchWorkspace.js +42 -0
  73. package/backend/mcp/README.md +193 -0
  74. package/backend/mcp/server.js +42 -0
  75. package/backend/mcp/tools.js +484 -0
  76. package/backend/server.js +3894 -0
  77. package/cli/graph.js +286 -0
  78. package/cli/relay.js +475 -0
  79. package/docker-compose.falkor.yml +17 -0
  80. package/docs/MEMORY-GRAPH.md +314 -0
  81. package/hooks/relay-antigravity-post-tool.js +2 -0
  82. package/hooks/relay-antigravity-pre-read.js +2 -0
  83. package/hooks/relay-antigravity-pre-tool.js +2 -0
  84. package/hooks/relay-antigravity-stop.js +2 -0
  85. package/hooks/relay-claude-post-tool.js +2 -0
  86. package/hooks/relay-claude-pre-read.js +2 -0
  87. package/hooks/relay-claude-pre-tool.js +2 -0
  88. package/hooks/relay-claude-stop.js +2 -0
  89. package/hooks/relay-codex-post-tool.js +2 -0
  90. package/hooks/relay-codex-pre-read.js +2 -0
  91. package/hooks/relay-codex-pre-tool.js +2 -0
  92. package/hooks/relay-codex-stop.js +2 -0
  93. package/hooks/relay-copilot-post-tool.js +2 -0
  94. package/hooks/relay-copilot-pre-read.js +2 -0
  95. package/hooks/relay-copilot-pre-tool.js +2 -0
  96. package/hooks/relay-copilot-stop.js +2 -0
  97. package/hooks/relay-cursor-post-tool.js +2 -0
  98. package/hooks/relay-cursor-pre-read.js +2 -0
  99. package/hooks/relay-cursor-pre-tool.js +2 -0
  100. package/hooks/relay-cursor-stop.js +2 -0
  101. package/hooks/relay-hook-lib.js +621 -0
  102. package/mission-control/out/404.html +1 -0
  103. package/mission-control/out/__next.__PAGE__.txt +26 -0
  104. package/mission-control/out/__next._full.txt +21 -0
  105. package/mission-control/out/__next._tree.txt +4 -0
  106. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_buildManifest.js +11 -0
  107. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_clientMiddlewareManifest.js +1 -0
  108. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_ssgManifest.js +1 -0
  109. package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
  110. package/mission-control/out/_next/static/chunks/064ntycm-fx5v.js +1 -0
  111. package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  112. package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
  113. package/mission-control/out/_next/static/chunks/1-dy52ls2ntb_.css +4 -0
  114. package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
  115. package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
  116. package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
  117. package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
  118. package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
  119. package/mission-control/out/_next/static/chunks/31qgr-p-oulpb.js +1 -0
  120. package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
  121. package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
  122. package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
  123. package/mission-control/out/_not-found/__next._full.txt +18 -0
  124. package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
  125. package/mission-control/out/_not-found/__next._tree.txt +3 -0
  126. package/mission-control/out/_not-found.html +1 -0
  127. package/mission-control/out/_not-found.txt +18 -0
  128. package/mission-control/out/agents/antigravity.png +0 -0
  129. package/mission-control/out/agents/claude.png +0 -0
  130. package/mission-control/out/agents/codex.png +0 -0
  131. package/mission-control/out/agents/copilot.png +0 -0
  132. package/mission-control/out/agents/cursor.png +0 -0
  133. package/mission-control/out/icon.svg +4 -0
  134. package/mission-control/out/index.html +1 -0
  135. package/mission-control/out/index.txt +21 -0
  136. package/mission-control/out/logo.png +0 -0
  137. package/mission-control/out/logo_transparent.png +0 -0
  138. package/package.json +76 -6
  139. package/scripts/falkor-rebuild.js +38 -0
  140. package/scripts/falkor-seed-demo.js +29 -0
@@ -0,0 +1,649 @@
1
+ const { EventEmitter } = require("events");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const crypto = require("crypto");
5
+ const { locksDirFor } = require("../lib/paths");
6
+ const { normalizeWorkspaceRoot } = require("../lib/transcripts/util");
7
+
8
+ const DEFAULT_TTL_MS = 15_000;
9
+ const MIN_TTL_MS = 5_000;
10
+ const MAX_TTL_MS = 300_000;
11
+ const CLEANUP_MS = 5_000;
12
+
13
+ function clampTtl(ttlMs) {
14
+ const n = Number(ttlMs);
15
+ if (!Number.isFinite(n)) return DEFAULT_TTL_MS;
16
+ return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, n));
17
+ }
18
+
19
+ function normalizeFile(filePath) {
20
+ return String(filePath || "")
21
+ .replace(/\\/g, "/")
22
+ .replace(/^\.?\//, "")
23
+ .trim();
24
+ }
25
+
26
+ function normWorkspace(value) {
27
+ return String(value || "")
28
+ .replace(/\\/g, "/")
29
+ .replace(/\/+$/, "")
30
+ .toLowerCase();
31
+ }
32
+
33
+ function workspaceRoot(value) {
34
+ if (!value || typeof value !== "string") return null;
35
+ if (!/[\\/]/.test(value)) return null;
36
+ return normalizeWorkspaceRoot(value) || path.resolve(value);
37
+ }
38
+
39
+ function lockKey(workspaceId, filePath) {
40
+ return `${normWorkspace(workspaceId)}::${normalizeFile(filePath)}`;
41
+ }
42
+
43
+ function lockHash(filePath, workspaceId) {
44
+ const material = workspaceId ? `${normWorkspace(workspaceId)}::${normalizeFile(filePath)}` : normalizeFile(filePath);
45
+ return crypto.createHash("sha256").update(material).digest("hex").slice(0, 16);
46
+ }
47
+
48
+ // agentId is `label:host:session`.
49
+ function sameOwner(a, b) {
50
+ if (!a || !b) return false;
51
+ if (a === b) return true;
52
+ const pa = String(a).split(":");
53
+ const pb = String(b).split(":");
54
+ const sameHost = (pa[1] || "").toLowerCase() === (pb[1] || "").toLowerCase();
55
+ if (!sameHost) return false;
56
+
57
+ const sa = (pa[2] || "").toLowerCase();
58
+ const sb = (pb[2] || "").toLowerCase();
59
+ const known = (s) => s !== "" && s !== "local";
60
+
61
+ // When both sides name a real session, the session decides and the label is
62
+ // irrelevant. That cuts both ways:
63
+ // - one editor firing two hook configs claims as `Cursor:host:sid` and again
64
+ // as `Claude Code:host:sid`; matching on session stops it deadlocking
65
+ // against itself
66
+ // - two concurrent turns of the SAME product are `Antigravity:host:A` and
67
+ // `Antigravity:host:B`; matching on label alone used to call those one
68
+ // owner, so they silently shared the file and neither was ever blocked
69
+ if (known(sa) && known(sb)) return sa === sb;
70
+
71
+ // At least one side never told us its session. Nothing distinguishes them,
72
+ // so fall back to the label and err towards letting an agent re-take its own
73
+ // lock rather than stranding it behind a lock it cannot identify.
74
+ return pa[0] === pb[0];
75
+ }
76
+
77
+ function sameWorkspace(a, b) {
78
+ if (!a || !b) return false;
79
+ return normWorkspace(a) === normWorkspace(b);
80
+ }
81
+
82
+ function filterKeys(filter) {
83
+ const keys = new Set();
84
+ const add = (value) => {
85
+ if (!value) return;
86
+ keys.add(String(value));
87
+ keys.add(normWorkspace(value));
88
+ const root = workspaceRoot(value);
89
+ if (root) keys.add(normWorkspace(root));
90
+ };
91
+ if (filter && typeof filter === "object") {
92
+ add(filter.id);
93
+ add(filter.path);
94
+ add(filter.workspaceId);
95
+ for (const extra of filter.aliases || []) add(extra);
96
+ } else {
97
+ add(filter);
98
+ }
99
+ return keys;
100
+ }
101
+
102
+ function belongsTo(entry, keys) {
103
+ if (!keys.size) return false;
104
+ const wid = entry.workspaceId;
105
+ if (!wid) return false;
106
+ return keys.has(String(wid)) || keys.has(normWorkspace(wid));
107
+ }
108
+
109
+ function alive(entry, now = Date.now()) {
110
+ const beat = entry.lastHeartbeat || entry.claimedAt;
111
+ return now < beat + entry.ttlMs;
112
+ }
113
+
114
+ // How long a released lock stays on the board, and how many to keep around.
115
+ const RECENT_TTL_MS = 45000;
116
+ const RECENT_MAX = 200;
117
+
118
+ // Reads are presence, not claims: they decay on their own and are capped so a
119
+ // repo-wide grep cannot flood the board.
120
+ const READ_TTL_MS = 30000;
121
+ const READ_MAX = 300;
122
+ // How long an agent that was refused a file stays interested in its next patch.
123
+ const WAITER_TTL_MS = 5 * 60 * 1000;
124
+
125
+ class LockTable extends EventEmitter {
126
+ constructor(opts = {}) {
127
+ super();
128
+ this.locks = new Map();
129
+ this.locksDir = opts.locksDir || null;
130
+ this.defaultWorkspace = opts.workspacePath || null;
131
+ this._timer = null;
132
+ this._loaded = new Map();
133
+ }
134
+
135
+ startCleanup() {
136
+ if (this._timer) return;
137
+ this._timer = setInterval(() => this.sweepExpired(), CLEANUP_MS);
138
+ if (this._timer.unref) this._timer.unref();
139
+ }
140
+
141
+ stopCleanup() {
142
+ if (this._timer) clearInterval(this._timer);
143
+ this._timer = null;
144
+ }
145
+
146
+ dirFor(workspaceId) {
147
+ try {
148
+ const root = workspaceRoot(workspaceId) || this.defaultWorkspace;
149
+ return root ? locksDirFor(root) : this.locksDir;
150
+ } catch {
151
+ return this.locksDir;
152
+ }
153
+ }
154
+
155
+ persist(entry) {
156
+ try {
157
+ const dir = this.dirFor(entry.workspaceId);
158
+ if (!dir) return;
159
+ const serial = { ...entry, readers: [...(entry.readers || [])] };
160
+ fs.writeFileSync(path.join(dir, `${lockHash(entry.filePath, entry.workspaceId)}.lock`), JSON.stringify(serial, null, 2));
161
+ } catch {
162
+ /* in-memory lock still counts; a missing drive must not drop the claim */
163
+ }
164
+ }
165
+
166
+ unpersist(filePath, workspaceId) {
167
+ const dir = this.dirFor(workspaceId);
168
+ if (!dir) return;
169
+ for (const name of [lockHash(filePath, workspaceId), lockHash(filePath)]) {
170
+ try {
171
+ fs.unlinkSync(path.join(dir, `${name}.lock`));
172
+ } catch {
173
+ /* ignore */
174
+ }
175
+ }
176
+ }
177
+
178
+ ingestEntry(entry, fallbackWorkspace) {
179
+ if (!entry?.filePath || !alive(entry)) return;
180
+ entry.workspaceId = workspaceRoot(entry.workspaceId) || entry.workspaceId || fallbackWorkspace || this.defaultWorkspace;
181
+ if (!entry.workspaceId) return;
182
+ entry.readers = new Set(entry.readers || []);
183
+ this.locks.set(lockKey(entry.workspaceId, entry.filePath), entry);
184
+ }
185
+
186
+ /**
187
+ * Fold this workspace's on-disk lock files into the table.
188
+ *
189
+ * This used to run exactly once per workspace per process. But this process is
190
+ * not the only writer: every hook that cannot reach the coordinator falls back
191
+ * to `filesystemClaim`, and each of those runs in its own short-lived node
192
+ * process. Those locks are real — they arbitrate correctly between agents —
193
+ * but a one-shot load meant the long-running server never saw a single one of
194
+ * them, so the Coordinator board showed nothing while agents were in fact
195
+ * locking files against each other.
196
+ *
197
+ * Re-scan instead, gated on the directory's own mtime so a quiet workspace
198
+ * costs one stat.
199
+ */
200
+ loadWorkspace(workspacePath) {
201
+ const root = workspaceRoot(workspacePath) || workspacePath;
202
+ if (!root) return;
203
+ const mark = normWorkspace(root);
204
+ let dir;
205
+ try {
206
+ dir = locksDirFor(root);
207
+ } catch {
208
+ return;
209
+ }
210
+ if (!dir || !fs.existsSync(dir)) return;
211
+ let stamp = 0;
212
+ try {
213
+ stamp = fs.statSync(dir).mtimeMs;
214
+ } catch {
215
+ return;
216
+ }
217
+ const seen = this._loaded.get(mark);
218
+ // Poll the directory at most this often even when its mtime looks unchanged:
219
+ // some filesystems do not bump a directory's mtime when a file inside it is
220
+ // rewritten in place, which is exactly what a heartbeat does.
221
+ if (seen && seen.stamp === stamp && Date.now() - seen.at < 1000) return;
222
+ this._loaded.set(mark, { stamp, at: Date.now() });
223
+ let names = [];
224
+ try {
225
+ names = fs.readdirSync(dir);
226
+ } catch {
227
+ return;
228
+ }
229
+ let added = false;
230
+ for (const name of names) {
231
+ if (!name.endsWith(".lock")) continue;
232
+ try {
233
+ const entry = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
234
+ // An entry this table already owns is authoritative in memory: rereading
235
+ // it would clobber a live heartbeat with whatever last hit the disk.
236
+ const key = lockKey(workspaceRoot(entry.workspaceId) || entry.workspaceId || root, entry.filePath);
237
+ if (this.locks.has(key)) continue;
238
+ const before = this.locks.size;
239
+ this.ingestEntry(entry, root);
240
+ if (this.locks.size !== before) added = true;
241
+ } catch {
242
+ /* a partially written lock file is retried on the next scan */
243
+ }
244
+ }
245
+ // Deferred: loadWorkspace runs inside list()/status(), and a synchronous
246
+ // emit would re-enter whichever read is already in progress.
247
+ if (added) setImmediate(() => this.emit("change", { type: "adopted", workspaceId: root }));
248
+ }
249
+
250
+ loadFromDisk() {
251
+ if (this.defaultWorkspace) this.loadWorkspace(this.defaultWorkspace);
252
+ else if (this.locksDir && fs.existsSync(this.locksDir)) {
253
+ const root = path.resolve(this.locksDir, "..", "..");
254
+ this.loadWorkspace(root);
255
+ }
256
+ }
257
+
258
+ getLock(file, workspaceId) {
259
+ const ws = workspaceRoot(workspaceId) || workspaceId;
260
+ if (!ws) return null;
261
+ this.loadWorkspace(ws);
262
+ return this.locks.get(lockKey(ws, file)) || null;
263
+ }
264
+
265
+ publicEntry(entry) {
266
+ return {
267
+ filePath: entry.filePath,
268
+ agentId: entry.agentId,
269
+ mode: entry.mode || "write",
270
+ claimedAt: entry.claimedAt,
271
+ lastHeartbeat: entry.lastHeartbeat,
272
+ ttlMs: entry.ttlMs,
273
+ expiresAt: (entry.lastHeartbeat || entry.claimedAt) + entry.ttlMs,
274
+ workspaceId: entry.workspaceId || null,
275
+ source: entry.source || "coordinator",
276
+ holder: entry.holder || null,
277
+ readers: [...(entry.readers || [])],
278
+ escalated: Boolean(entry.escalated),
279
+ };
280
+ }
281
+
282
+ status(filter) {
283
+ const keys = filter ? filterKeys(filter) : null;
284
+ const locks = {};
285
+ for (const entry of this.locks.values()) {
286
+ if (keys && !belongsTo(entry, keys)) continue;
287
+ locks[`${normWorkspace(entry.workspaceId)}::${entry.filePath}`] = this.publicEntry(entry);
288
+ }
289
+ return { locks, uptime: process.uptime() };
290
+ }
291
+
292
+ list(workspaceId) {
293
+ const keys = filterKeys(workspaceId);
294
+ const root =
295
+ workspaceRoot(typeof workspaceId === "object" ? workspaceId?.path || workspaceId?.workspaceId : workspaceId) ||
296
+ (typeof workspaceId === "object" ? workspaceRoot(workspaceId?.path) : null);
297
+ if (root) this.loadWorkspace(root);
298
+ const out = [];
299
+ for (const entry of this.locks.values()) {
300
+ if (!belongsTo(entry, keys)) continue;
301
+ out.push(this.publicEntry(entry));
302
+ }
303
+ out.push(...this.recentFor(keys));
304
+ return out.sort((a, b) => (b.releasedAt || b.claimedAt) - (a.releasedAt || a.claimedAt));
305
+ }
306
+
307
+ heartbeat({ agentId, file, workspaceId }) {
308
+ const now = Date.now();
309
+ const ws = workspaceRoot(workspaceId);
310
+ let n = 0;
311
+ for (const entry of this.locks.values()) {
312
+ if (ws && !sameWorkspace(entry.workspaceId, ws)) continue;
313
+ if (!ws) continue;
314
+ const matchFile = !file || normalizeFile(file) === entry.filePath;
315
+ const matchAgent =
316
+ sameOwner(entry.agentId, agentId) ||
317
+ (entry.readers && [...entry.readers].some((r) => sameOwner(r, agentId)));
318
+ if (matchFile && matchAgent) {
319
+ entry.lastHeartbeat = now;
320
+ this.persist(entry);
321
+ n += 1;
322
+ }
323
+ }
324
+ if (n) this.emit("change", { type: "heartbeat", agentId, workspaceId: ws });
325
+ return { ok: true, renewed: n };
326
+ }
327
+
328
+ escalateRegister(entry) {
329
+ const filePath = normalizeFile(entry.filePath);
330
+ const ws = workspaceRoot(entry.workspaceId) || entry.workspaceId;
331
+ if (!ws) return { ok: false, reason: "workspace_required" };
332
+ const key = lockKey(ws, filePath);
333
+ const existing = this.locks.get(key);
334
+ if (existing && alive(existing) && !sameOwner(existing.agentId, entry.agentId)) {
335
+ return { ok: false, winner: existing.agentId };
336
+ }
337
+ const now = Date.now();
338
+ const next = {
339
+ ...entry,
340
+ filePath,
341
+ workspaceId: ws,
342
+ readers: new Set(entry.readers || []),
343
+ claimedAt: entry.claimedAt || now,
344
+ lastHeartbeat: now,
345
+ ttlMs: clampTtl(entry.ttlMs),
346
+ escalated: true,
347
+ source: "escalated",
348
+ };
349
+ this.locks.set(key, next);
350
+ this.persist(next);
351
+ this.emit("change", { type: "escalate", entry: next });
352
+ return { ok: true, lock: this.publicEntry(next) };
353
+ }
354
+
355
+ claim({ agentId, file, ttl, workspaceId, holder, source, dependsOn, files, mode }) {
356
+ const ws = workspaceRoot(workspaceId) || workspaceId;
357
+ if (!ws || !/[\\/]/.test(String(ws))) {
358
+ return { allowed: false, reason: "workspace path is required so locks stay per repository" };
359
+ }
360
+ this.loadWorkspace(ws);
361
+
362
+ const batch = Array.isArray(files) && files.length ? files.map(normalizeFile).filter(Boolean) : null;
363
+ if (batch) {
364
+ const sorted = [...new Set(batch)].sort();
365
+ const held = [];
366
+ for (const f of sorted) {
367
+ const result = this.claim({ agentId, file: f, ttl, workspaceId: ws, holder, source, dependsOn, mode });
368
+ if (!result.allowed) {
369
+ for (const h of held) this.release({ agentId, file: h, workspaceId: ws });
370
+ return result;
371
+ }
372
+ held.push(f);
373
+ }
374
+ return { allowed: true, batch: sorted };
375
+ }
376
+
377
+ const filePath = normalizeFile(file);
378
+ if (!filePath || !agentId) return { allowed: false, reason: "agentId and file are required" };
379
+ const ttlMs = clampTtl(ttl);
380
+ const now = Date.now();
381
+ const lockMode = mode === "read" ? "read" : "write";
382
+ const key = lockKey(ws, filePath);
383
+
384
+ const deps = Array.isArray(dependsOn) ? dependsOn.map(normalizeFile) : [];
385
+ let warning = null;
386
+ for (const dep of deps) {
387
+ const held = this.locks.get(lockKey(ws, dep));
388
+ if (held && held.mode !== "read" && !sameOwner(held.agentId, agentId) && alive(held, now)) {
389
+ warning = `\`${filePath}\` depends on \`${dep}\`, which ${held.holder?.label || held.agentId} is editing`;
390
+ break;
391
+ }
392
+ }
393
+
394
+ // A read is never a lock. It is recorded so the dashboard can show who is
395
+ // looking at what, and it is always allowed — including while someone else
396
+ // holds the write lock, since observing a file cannot corrupt it.
397
+ if (lockMode === "read") {
398
+ this.noteRead({ agentId, filePath, workspaceId: ws, holder });
399
+ return { allowed: true, reading: true };
400
+ }
401
+
402
+ let existing = this.locks.get(key);
403
+ if (existing && !alive(existing, now)) {
404
+ this.unpersist(existing.filePath, existing.workspaceId);
405
+ this.locks.delete(key);
406
+ existing = null;
407
+ }
408
+
409
+ if (existing) {
410
+ if (sameOwner(existing.agentId, agentId) && existing.mode === "write") {
411
+ existing.claimedAt = now;
412
+ existing.lastHeartbeat = now;
413
+ existing.ttlMs = ttlMs;
414
+ existing.workspaceId = ws;
415
+ this.persist(existing);
416
+ this.emit("change", { type: "renew", entry: existing });
417
+ return { allowed: true, renewed: true, warning, lock: this.publicEntry(existing) };
418
+ }
419
+ // Readers never block a writer. Reading a file is not a claim on it: an
420
+ // agent that merely looked at a file has nothing to lose if someone else
421
+ // edits it, and denying the edit meant one agent browsing the repo could
422
+ // stall everyone else. Only a live write lock arbitrates.
423
+ if (existing.mode === "read") {
424
+ this.locks.delete(key);
425
+ this.unpersist(filePath, ws);
426
+ existing = null;
427
+ }
428
+ if (existing && existing.mode === "write") {
429
+ // This agent wanted the file and could not have it, so it is the one
430
+ // that most needs the resulting patch the moment the holder is done.
431
+ this.noteWaiter({ agentId, filePath, workspaceId: ws, holder });
432
+ return {
433
+ allowed: false,
434
+ holder: existing.agentId,
435
+ reason: `File \`${filePath}\` is locked by ${existing.holder?.label || existing.agentId}. Pick a different task.`,
436
+ };
437
+ }
438
+ }
439
+
440
+ const entry = {
441
+ filePath,
442
+ agentId,
443
+ mode: lockMode,
444
+ claimedAt: now,
445
+ lastHeartbeat: now,
446
+ ttlMs,
447
+ workspaceId: ws,
448
+ holder: holder || null,
449
+ source: source || "coordinator",
450
+ readers: lockMode === "read" ? new Set([agentId]) : new Set(),
451
+ escalated: false,
452
+ };
453
+ this.locks.set(key, entry);
454
+ this.persist(entry);
455
+ this.emit("change", { type: "claim", entry });
456
+ return { allowed: true, warning, lock: this.publicEntry(entry) };
457
+ }
458
+
459
+ release({ agentId, file, workspaceId }) {
460
+ const ws = workspaceRoot(workspaceId) || workspaceId;
461
+ if (!ws) return { ok: false, reason: "workspace_required" };
462
+ const filePath = normalizeFile(file);
463
+ const key = lockKey(ws, filePath);
464
+ const existing = this.locks.get(key);
465
+ if (!existing) return { ok: true, released: false };
466
+ if (existing.mode === "read") {
467
+ existing.readers = existing.readers || new Set();
468
+ existing.readers.delete(agentId);
469
+ if (existing.readers.size === 0) {
470
+ this.locks.delete(key);
471
+ this.unpersist(filePath, ws);
472
+ } else {
473
+ this.persist(existing);
474
+ }
475
+ this.emit("change", { type: "release", entry: existing });
476
+ return { ok: true, released: true };
477
+ }
478
+ if (!sameOwner(existing.agentId, agentId)) {
479
+ return { ok: false, reason: "not_holder", holder: existing.agentId };
480
+ }
481
+ this.locks.delete(key);
482
+ this.unpersist(filePath, ws);
483
+ this.remember(existing);
484
+ this.emit("change", { type: "release", entry: existing });
485
+ return { ok: true, released: true };
486
+ }
487
+
488
+ /**
489
+ * Keep a released lock visible for a short while after it is gone.
490
+ *
491
+ * A pre-tool claim and its post-tool release can be ~100ms apart — the fast
492
+ * models turn an edit around faster than any UI samples state. The lock did
493
+ * its job, but the Coordinator board only ever renders locks that are held
494
+ * right now, so those edits looked like nothing happened at all. These rows
495
+ * are display-only: `getLock` (and so every arbitration path) never sees them.
496
+ */
497
+ remember(entry) {
498
+ if (!entry?.filePath) return;
499
+ if (!this.recent) this.recent = new Map();
500
+ this.recent.set(lockKey(entry.workspaceId, entry.filePath), {
501
+ ...entry,
502
+ readers: [...(entry.readers || [])],
503
+ releasedAt: entry.releasedAt || Date.now(),
504
+ });
505
+ if (this.recent.size > RECENT_MAX) this.recent.delete(this.recent.keys().next().value);
506
+ }
507
+
508
+ /**
509
+ * Record that an agent looked at a file.
510
+ *
511
+ * Purely observational: nothing here is consulted when arbitrating a claim.
512
+ * It exists so the dashboard can show the whole room's attention — who is
513
+ * reading what — instead of only the handful of files being written.
514
+ */
515
+ noteRead({ agentId, filePath, workspaceId, holder }) {
516
+ const file = normalizeFile(filePath);
517
+ const ws = workspaceRoot(workspaceId) || workspaceId;
518
+ if (!file || !agentId || !ws) return null;
519
+ if (!this.reads) this.reads = new Map();
520
+ const key = `${normWorkspace(ws)}::${file}::${agentId}`;
521
+ const entry = { filePath: file, agentId, workspaceId: ws, holder: holder || null, at: Date.now() };
522
+ this.reads.delete(key);
523
+ this.reads.set(key, entry);
524
+ if (this.reads.size > READ_MAX) this.reads.delete(this.reads.keys().next().value);
525
+ this.emit("change", { type: "read", entry });
526
+ return entry;
527
+ }
528
+
529
+ readsFor(keys, now = Date.now()) {
530
+ const out = [];
531
+ for (const [key, entry] of this.reads || []) {
532
+ if (now - entry.at > READ_TTL_MS) {
533
+ this.reads.delete(key);
534
+ continue;
535
+ }
536
+ if (keys && !belongsTo(entry, keys)) continue;
537
+ out.push({
538
+ ...entry,
539
+ mode: "read",
540
+ reading: true,
541
+ ttlMs: READ_TTL_MS,
542
+ expiresAt: entry.at + READ_TTL_MS,
543
+ });
544
+ }
545
+ return out.sort((a, b) => b.at - a.at);
546
+ }
547
+
548
+ /**
549
+ * Remember that an agent was turned away from a file, so that when the holder
550
+ * finishes we know exactly who is still waiting for that file's new content.
551
+ */
552
+ noteWaiter({ agentId, filePath, workspaceId, holder }) {
553
+ const file = normalizeFile(filePath);
554
+ const ws = workspaceRoot(workspaceId) || workspaceId;
555
+ if (!file || !agentId || !ws) return;
556
+ if (!this.waiters) this.waiters = new Map();
557
+ const key = lockKey(ws, file);
558
+ const list = this.waiters.get(key) || new Map();
559
+ list.set(agentId, { agentId, holder: holder || null, at: Date.now() });
560
+ this.waiters.set(key, list);
561
+ }
562
+
563
+ /** Waiters for a file, newest first; expired entries are dropped. */
564
+ takeWaiters(filePath, workspaceId) {
565
+ const file = normalizeFile(filePath);
566
+ const ws = workspaceRoot(workspaceId) || workspaceId;
567
+ if (!file || !ws || !this.waiters) return [];
568
+ const key = lockKey(ws, file);
569
+ const list = this.waiters.get(key);
570
+ if (!list) return [];
571
+ const now = Date.now();
572
+ const out = [];
573
+ for (const [agentId, row] of list) {
574
+ if (now - row.at > WAITER_TTL_MS) list.delete(agentId);
575
+ else out.push(row);
576
+ }
577
+ if (!list.size) this.waiters.delete(key);
578
+ return out;
579
+ }
580
+
581
+ recentFor(keys, now = Date.now()) {
582
+ const out = [];
583
+ for (const [key, entry] of this.recent || []) {
584
+ if (now - entry.releasedAt > RECENT_TTL_MS) {
585
+ this.recent.delete(key);
586
+ continue;
587
+ }
588
+ if (this.locks.has(key)) continue;
589
+ if (keys && !belongsTo(entry, keys)) continue;
590
+ out.push({ ...this.publicEntry(entry), released: true, releasedAt: entry.releasedAt });
591
+ }
592
+ return out;
593
+ }
594
+
595
+ releaseAll(agentId, workspaceId) {
596
+ const ws = workspaceRoot(workspaceId) || workspaceId;
597
+ const released = [];
598
+ for (const [key, entry] of [...this.locks]) {
599
+ if (ws && !sameWorkspace(entry.workspaceId, ws)) continue;
600
+ if (!ws) continue;
601
+ if (sameOwner(entry.agentId, agentId) || (entry.readers && [...entry.readers].some((r) => sameOwner(r, agentId)))) {
602
+ this.release({ agentId, file: entry.filePath, workspaceId: entry.workspaceId });
603
+ released.push(entry.filePath);
604
+ }
605
+ }
606
+ return { released };
607
+ }
608
+
609
+ /** Workspaces to re-scan on the cleanup tick, so fail-open locks surface. */
610
+ track(workspacePath) {
611
+ const root = workspaceRoot(workspacePath) || workspacePath;
612
+ if (!root) return;
613
+ if (!this._tracked) this._tracked = new Set();
614
+ this._tracked.add(root);
615
+ }
616
+
617
+ sweepExpired() {
618
+ for (const root of this._tracked || []) {
619
+ try {
620
+ this.loadWorkspace(root);
621
+ } catch {
622
+ /* keep sweeping */
623
+ }
624
+ }
625
+ const now = Date.now();
626
+ let changed = false;
627
+ for (const [key, entry] of [...this.locks]) {
628
+ if (!alive(entry, now)) {
629
+ this.locks.delete(key);
630
+ this.unpersist(entry.filePath, entry.workspaceId);
631
+ this.remember(entry);
632
+ changed = true;
633
+ }
634
+ }
635
+ if (changed) this.emit("change");
636
+ return changed;
637
+ }
638
+ }
639
+
640
+ module.exports = {
641
+ LockTable,
642
+ DEFAULT_TTL_MS,
643
+ normalizeFile,
644
+ lockHash,
645
+ sameOwner,
646
+ normWorkspace,
647
+ workspaceRoot,
648
+ filterKeys,
649
+ };