relaybrain 0.0.0-stage → 0.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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -2
  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/fixtures.js +85 -0
  25. package/backend/lib/falkor/index.js +146 -0
  26. package/backend/lib/falkor/ingest.js +233 -0
  27. package/backend/lib/falkor/intents.js +32 -0
  28. package/backend/lib/falkor/keys.js +102 -0
  29. package/backend/lib/falkor/migrations.js +56 -0
  30. package/backend/lib/falkor/project.js +307 -0
  31. package/backend/lib/falkor/query.js +344 -0
  32. package/backend/lib/falkor/redact.js +30 -0
  33. package/backend/lib/falkor/routes.js +81 -0
  34. package/backend/lib/falkor/schema.js +108 -0
  35. package/backend/lib/falkor/write.js +281 -0
  36. package/backend/lib/githubLogin.js +108 -0
  37. package/backend/lib/githubOAuth.js +42 -0
  38. package/backend/lib/githubRepo.js +117 -0
  39. package/backend/lib/harvest.js +85 -0
  40. package/backend/lib/httpServer.js +27 -0
  41. package/backend/lib/installAgentRelay.js +235 -0
  42. package/backend/lib/installHooks.js +309 -0
  43. package/backend/lib/lamport.js +14 -0
  44. package/backend/lib/localBrowse.js +109 -0
  45. package/backend/lib/memorySync.js +63 -0
  46. package/backend/lib/notices.js +143 -0
  47. package/backend/lib/ot.js +74 -0
  48. package/backend/lib/patches.js +192 -0
  49. package/backend/lib/paths.js +85 -0
  50. package/backend/lib/peerClash.js +64 -0
  51. package/backend/lib/presence.js +35 -0
  52. package/backend/lib/relayOsBlock.js +84 -0
  53. package/backend/lib/repoSnapshot.js +125 -0
  54. package/backend/lib/room.js +498 -0
  55. package/backend/lib/roomAsk.js +148 -0
  56. package/backend/lib/roomAuth.js +215 -0
  57. package/backend/lib/roomControl.js +226 -0
  58. package/backend/lib/roomLocks.js +201 -0
  59. package/backend/lib/roomSignal.js +265 -0
  60. package/backend/lib/roomSync.js +194 -0
  61. package/backend/lib/seed.js +107 -0
  62. package/backend/lib/sseHub.js +40 -0
  63. package/backend/lib/store.js +79 -0
  64. package/backend/lib/supergraph.js +46 -0
  65. package/backend/lib/transcripts/discover.js +338 -0
  66. package/backend/lib/transcripts/parse.js +558 -0
  67. package/backend/lib/transcripts/sessionId.js +36 -0
  68. package/backend/lib/transcripts/sync.js +291 -0
  69. package/backend/lib/transcripts/util.js +299 -0
  70. package/backend/lib/treeSitterImports.js +219 -0
  71. package/backend/lib/watchWorkspace.js +42 -0
  72. package/backend/mcp/README.md +193 -0
  73. package/backend/mcp/server.js +42 -0
  74. package/backend/mcp/tools.js +484 -0
  75. package/backend/server.js +3839 -0
  76. package/cli/graph.js +278 -0
  77. package/cli/relay.js +419 -0
  78. package/docker-compose.falkor.yml +17 -0
  79. package/docs/MEMORY-GRAPH.md +269 -0
  80. package/hooks/relay-antigravity-post-tool.js +2 -0
  81. package/hooks/relay-antigravity-pre-read.js +2 -0
  82. package/hooks/relay-antigravity-pre-tool.js +2 -0
  83. package/hooks/relay-antigravity-stop.js +2 -0
  84. package/hooks/relay-claude-post-tool.js +2 -0
  85. package/hooks/relay-claude-pre-read.js +2 -0
  86. package/hooks/relay-claude-pre-tool.js +2 -0
  87. package/hooks/relay-claude-stop.js +2 -0
  88. package/hooks/relay-codex-post-tool.js +2 -0
  89. package/hooks/relay-codex-pre-read.js +2 -0
  90. package/hooks/relay-codex-pre-tool.js +2 -0
  91. package/hooks/relay-codex-stop.js +2 -0
  92. package/hooks/relay-copilot-post-tool.js +2 -0
  93. package/hooks/relay-copilot-pre-read.js +2 -0
  94. package/hooks/relay-copilot-pre-tool.js +2 -0
  95. package/hooks/relay-copilot-stop.js +2 -0
  96. package/hooks/relay-cursor-post-tool.js +2 -0
  97. package/hooks/relay-cursor-pre-read.js +2 -0
  98. package/hooks/relay-cursor-pre-tool.js +2 -0
  99. package/hooks/relay-cursor-stop.js +2 -0
  100. package/hooks/relay-hook-lib.js +621 -0
  101. package/mission-control/out/404.html +1 -0
  102. package/mission-control/out/__next.__PAGE__.txt +26 -0
  103. package/mission-control/out/__next._full.txt +21 -0
  104. package/mission-control/out/__next._tree.txt +4 -0
  105. package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
  106. package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  107. package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
  108. package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
  109. package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
  110. package/mission-control/out/_next/static/chunks/25rvg4bnew99f.css +4 -0
  111. package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
  112. package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
  113. package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
  114. package/mission-control/out/_next/static/chunks/3_5x4qn1_b4al.js +1 -0
  115. package/mission-control/out/_next/static/chunks/3o1tiwncjss_k.js +1 -0
  116. package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
  117. package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
  118. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_buildManifest.js +11 -0
  119. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_clientMiddlewareManifest.js +1 -0
  120. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_ssgManifest.js +1 -0
  121. package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
  122. package/mission-control/out/_not-found/__next._full.txt +18 -0
  123. package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
  124. package/mission-control/out/_not-found/__next._tree.txt +3 -0
  125. package/mission-control/out/_not-found.html +1 -0
  126. package/mission-control/out/_not-found.txt +18 -0
  127. package/mission-control/out/agents/antigravity.png +0 -0
  128. package/mission-control/out/agents/claude.png +0 -0
  129. package/mission-control/out/agents/codex.png +0 -0
  130. package/mission-control/out/agents/copilot.png +0 -0
  131. package/mission-control/out/agents/cursor.png +0 -0
  132. package/mission-control/out/icon.svg +4 -0
  133. package/mission-control/out/index.html +1 -0
  134. package/mission-control/out/index.txt +21 -0
  135. package/mission-control/out/logo.png +0 -0
  136. package/mission-control/out/logo_transparent.png +0 -0
  137. package/package.json +74 -4
  138. package/scripts/falkor-rebuild.js +57 -0
  139. package/scripts/falkor-seed-demo.js +157 -0
@@ -0,0 +1,3839 @@
1
+ #!/usr/bin/env node
2
+ const fs = require("fs");
3
+ const http = require("http");
4
+ const path = require("path");
5
+ const express = require("express");
6
+ const cors = require("cors");
7
+
8
+ const { createCoordinator, listen, writeState } = require("./coordinator/server");
9
+ const { SseHub } = require("./lib/sseHub");
10
+ const {
11
+ loadRegistry,
12
+ saveRegistry,
13
+ loadSession,
14
+ saveSession,
15
+ loadMemory,
16
+ saveMemory,
17
+ id,
18
+ projectDir,
19
+ } = require("./lib/store");
20
+ const { seedIfEmpty, emptyMemory, initials } = require("./lib/seed");
21
+ const { buildDashboard } = require("./lib/dashboard");
22
+ const { homeRelayDir } = require("./lib/paths");
23
+ const { appendStopTurn } = require("./lib/memorySync");
24
+ const { detectConflicts } = require("./lib/conflicts");
25
+ const { createProject, loadProjects, resolveAuth } = require("./lib/centralAuth");
26
+ const { appendEvent, listEvents, jsonContext } = require("./lib/centralStore");
27
+ const { pushLocalEdits, pullCentralChanges } = require("./lib/centralSync");
28
+ const { graphFor, rebuild } = require("./lib/depGraph");
29
+ const { initTreeSitter } = require("./lib/treeSitterImports");
30
+ const { WorkspaceWatcher } = require("./lib/watchWorkspace");
31
+ const os = require("os");
32
+ const { computePatch, recordPatch, applyIncoming, applyPeerFile, rewindTo } = require("./lib/patches");
33
+ const { harvest } = require("./lib/harvest");
34
+ const { syncTranscriptsQueued, latestTranscriptMtime } = require("./lib/transcripts/sync");
35
+ const { transcriptWatchRoots, discoverAll } = require("./lib/transcripts/discover");
36
+ const { normalizeWorkspaceRoot, normalizeCompare } = require("./lib/transcripts/util");
37
+ const { listDir, listGithubRepos } = require("./lib/localBrowse");
38
+ const { ingest: ingestSupergraph } = require("./lib/supergraph");
39
+ const { cloneRepo, addLocal, detectRemoteUrl } = require("./lib/cloneRepo");
40
+ const { installProjectHooks, installGlobalHooks } = require("./lib/installHooks");
41
+ const { loadRoom, saveRoom, clearRoom, share: shareRoom, ensureTunnel, activeTunnelUrl, killCloudflared, upsertMember, markLeft, dropMember, membersView } = require("./lib/room");
42
+ const { localSnapshot, savePeerSnapshot, mergeRoomViews, clearPeers } = require("./lib/roomSync");
43
+ const { compileRoomAsk } = require("./lib/roomAsk");
44
+ const { recordCollision, mergeCollisionStats, loadLocalCollisions } = require("./lib/collisionMetrics");
45
+ const {
46
+ createInvite,
47
+ revokeInvite,
48
+ inviteView,
49
+ redeemInvite,
50
+ markAccepted,
51
+ issueMemberToken,
52
+ authorizeMember,
53
+ revokeMember,
54
+ publicRoom,
55
+ roomToken,
56
+ isRemoteRequest,
57
+ normLogin,
58
+ } = require("./lib/roomAuth");
59
+ const {
60
+ repoKeyFromRemote,
61
+ publishSignal,
62
+ findSignal,
63
+ discoverRooms,
64
+ createProof,
65
+ deleteGist,
66
+ verifyProof,
67
+ GIST_SCOPE_HINT,
68
+ } = require("./lib/roomSignal");
69
+ const { RoomLockMirror } = require("./lib/roomLocks");
70
+ const { RoomControl, sendSocket, broadcast } = require("./lib/roomControl");
71
+ const { handleRpc: mcpRpc } = require("./mcp/tools");
72
+ const { treeFiles, writeTree } = require("./lib/repoSnapshot");
73
+ const { whoami } = require("./lib/githubLogin");
74
+ const { fetchCollaborators: fetchGithubCollaborators, peekCollaborators } = require("./lib/githubRepo");
75
+ const { skipPublish, applyClash, aliveLock } = require("./lib/peerClash");
76
+ const { dirtyFiles } = require("./lib/dirtyFiles");
77
+ const { authorizeUrl, exchangeCode } = require("./lib/githubOAuth");
78
+ const {
79
+ listNotices,
80
+ unreadCount,
81
+ pushNotice,
82
+ markRead,
83
+ markAllRead,
84
+ dismissNotice,
85
+ dismissByKey,
86
+ } = require("./lib/notices");
87
+ const { WebSocketServer, WebSocket } = require("ws");
88
+
89
+ const VERSION = "0.1.0";
90
+ const UI_ORIGIN = process.env.RELAY_UI_ORIGIN || "http://localhost:3002";
91
+
92
+ function parsePort(argv) {
93
+ const idx = argv.indexOf("--port");
94
+ if (idx >= 0) return Number(argv[idx + 1]);
95
+ return Number(process.env.RELAY_PORT || 3001);
96
+ }
97
+
98
+ function requireCentral(req, res, next) {
99
+ const auth = resolveAuth(req.headers.authorization);
100
+ if (!auth.ok) return res.status(401).json({ error: "unauthorized" });
101
+ req.central = auth;
102
+ next();
103
+ }
104
+
105
+ async function main() {
106
+ await initTreeSitter();
107
+ seedIfEmpty();
108
+ const app = express();
109
+ app.use(
110
+ cors({
111
+ origin: [
112
+ UI_ORIGIN,
113
+ "http://127.0.0.1:3002",
114
+ "http://localhost:3000",
115
+ "http://127.0.0.1:3000",
116
+ /\.trycloudflare\.com$/,
117
+ /\.ts\.net$/,
118
+ /\.vercel\.app$/,
119
+ /\.up\.railway\.app$/,
120
+ /\.onrender\.com$/,
121
+ /\.fly\.dev$/,
122
+ /\.ngrok(-free)?\.app$/,
123
+ /\.ngrok\.io$/,
124
+ ],
125
+ credentials: true,
126
+ })
127
+ );
128
+ // Hosted public demo guard (#14): when RELAY_PUBLIC_READONLY=1 is set, any
129
+ // non-loopback request may only read (GET/HEAD/OPTIONS, read-only /api/graph/query/*,
130
+ // or POST /mcp which already enforces READ_ONLY for remote callers).
131
+ if (process.env.RELAY_PUBLIC_READONLY === "1" || process.env.RELAY_PUBLIC_READONLY === "true") {
132
+ app.use((req, res, next) => {
133
+ if (!isRemoteRequest(req)) return next();
134
+ if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return next();
135
+ if (req.path === "/mcp" || req.path.startsWith("/api/graph/query/")) return next();
136
+ return res.status(403).json({ ok: false, error: "public_readonly", reason: "Hosted demo instance is read-only for remote callers." });
137
+ });
138
+ }
139
+ // A peer's chat history is far larger than any other request body. Mounting
140
+ // its parser first means the 2mb default below sees an already-parsed body and
141
+ // skips it, so one big route does not raise the ceiling for every other.
142
+ app.use("/api/room/history", express.json({ limit: "32mb" }));
143
+ app.use(express.json({ limit: "2mb" }));
144
+
145
+ // Visibility into hook traffic: without this, a hook that fails silently
146
+ // (fail-open by design) leaves zero trace that it ever fired, which makes
147
+ // "did the coordinator even see this?" impossible to answer from the
148
+ // terminal running `relay serve`.
149
+ const HOOK_ROUTES = new Set([
150
+ "/api/ensure-workspace",
151
+ "/api/ingest-stop",
152
+ "/api/flush-file",
153
+ "/api/flush-owned",
154
+ "/api/coord/claim",
155
+ "/api/coord/release",
156
+ "/api/coord/release-all",
157
+ "/api/coord/heartbeat",
158
+ ]);
159
+ app.use((req, res, next) => {
160
+ if (!HOOK_ROUTES.has(req.path)) return next();
161
+ const body = req.body || {};
162
+ const ws = body.workspacePath || body.workspaceId || "";
163
+ const detail = [
164
+ body.agentId ? `agent=${body.agentId}` : "",
165
+ body.file ? `file=${body.file}` : "",
166
+ Array.isArray(body.files) && body.files.length ? `files=${body.files.join(",")}` : "",
167
+ ]
168
+ .filter(Boolean)
169
+ .join(" ");
170
+ const started = Date.now();
171
+ res.on("finish", () => {
172
+ console.log(
173
+ `[relay-hook] ${req.method} ${req.path} -> ${res.statusCode} (${Date.now() - started}ms) ` +
174
+ `workspace=${ws}${detail ? ` ${detail}` : ""}`
175
+ );
176
+ });
177
+ next();
178
+ });
179
+
180
+ const sse = new SseHub();
181
+ try {
182
+ if (process.env.RELAY_SKIP_GLOBAL_HOOKS !== "1") installGlobalHooks();
183
+ } catch {
184
+ /* global configs are best-effort */
185
+ }
186
+
187
+ const { app: coordApp, server: coordServer, lockTable, presence } = createCoordinator({
188
+ workspacePath: process.cwd(),
189
+ preferredPort: process.env.RELAY_COORDINATOR_PORT || 0,
190
+ });
191
+ const coordPort = await listen(coordServer, Number(process.env.RELAY_COORDINATOR_PORT || 0));
192
+ coordApp.set("port", coordPort);
193
+ require("./lib/falkor").init(app, {
194
+ lockTable,
195
+ sse,
196
+ projectForPath: (p) => projectForPath(p),
197
+ currentUser: () => currentUser(),
198
+ requireRoomMember: (req, res, next) => requireRoomMember(req, res, next),
199
+ fetchHost: (url, pathname, init) => fetchHost(url, pathname, init),
200
+ });
201
+ // NOTE: coordinator state is published only once the API port is bound (see
202
+ // server.listen below). Writing it here means a second `relay serve` that
203
+ // dies on EADDRINUSE still overwrites the live instance's pointer with a port
204
+ // that is about to disappear, and every hook then silently degrades to
205
+ // filesystem-only locking.
206
+ function lockScope(projectId) {
207
+ const project =
208
+ loadRegistry().projects.find(
209
+ (p) => p.id === projectId || normalizeCompare(p.path) === normalizeCompare(projectId)
210
+ ) || (projectId ? null : currentProject());
211
+ if (!project) return null;
212
+ const room = loadRoom(project.path) || loadRoom();
213
+ const aliases = [];
214
+ if (
215
+ room &&
216
+ (room.hostProjectId === project.id ||
217
+ room.projectId === project.id ||
218
+ normalizeCompare(room.projectPath) === normalizeCompare(project.path) ||
219
+ normalizeCompare(room.hostWorkspacePath) === normalizeCompare(project.path))
220
+ ) {
221
+ aliases.push(room.hostWorkspacePath, room.projectPath, room.hostProjectId, room.projectId);
222
+ }
223
+ return { id: project.id, path: project.path, aliases: aliases.filter(Boolean) };
224
+ }
225
+
226
+ lockTable.on("change", (evt) => {
227
+ const ws = evt?.entry?.workspaceId || evt?.workspaceId;
228
+ const room = loadRoom();
229
+ const project = ws
230
+ ? loadRegistry().projects.find((p) => normalizeCompare(p.path) === normalizeCompare(ws)) ||
231
+ loadRegistry().projects.find((p) => p.id === room?.hostProjectId)
232
+ : null;
233
+ // A guest's locks are namespaced under the HOST's workspace path, so no
234
+ // local project matches and this fell through to `locks: []` — with no
235
+ // workspaceId to scope it, Mission Control applied that blank to whatever
236
+ // it was showing and the Coordinator board emptied itself. Resolve the room
237
+ // workspace instead, and publish the same merged view every other reader
238
+ // gets rather than the raw local table.
239
+ const scope = project
240
+ ? lockScope(project.id)
241
+ : room?.role === "guest" && room.projectId
242
+ ? lockScope(room.projectId) || { id: room.projectId, path: room.projectPath, aliases: [room.hostWorkspacePath] }
243
+ : null;
244
+ if (!scope?.id) return;
245
+ const locks = locksFor(scope);
246
+ const reads = readsFor(scope);
247
+ sse.emit("locks", { workspaceId: scope.id, locks, reads });
248
+ // Control plane only — never share a TCP stream with chat snapshots.
249
+ broadcastLocks(locks, reads);
250
+ });
251
+
252
+ const watcher = new WorkspaceWatcher();
253
+ const applyingRemote = new Set();
254
+ let wss = null;
255
+ let wssControl = null;
256
+ const roomControl = new RoomControl();
257
+
258
+ function publishGraph(project) {
259
+ if (!project?.path) return;
260
+ const g = rebuild(project.path);
261
+ const snap = g ? g.snapshot() : { edges: [] };
262
+ ingestSupergraph(project.id, { userId: loadSession().login, edges: snap.edges || [] });
263
+ sse.emit("graph", { workspaceId: project.id, graph: snap });
264
+ }
265
+
266
+ // Returns null rather than falling back to the currently selected project: a
267
+ // hook that could not report its workspace must not have its edits filed
268
+ // under whichever workspace happens to be open in Mission Control.
269
+ function ensureWorkspace(workspacePath) {
270
+ const resolved = normalizeWorkspaceRoot(workspacePath) || workspacePath;
271
+ if (!resolved) return null;
272
+ const reg = loadRegistry();
273
+ let project = reg.projects.find((p) => normalizeCompare(p.path) === normalizeCompare(resolved));
274
+ if (!project) {
275
+ project = reg.projects.find(
276
+ (p) => normalizeCompare(normalizeWorkspaceRoot(p.path) || p.path) === normalizeCompare(resolved)
277
+ );
278
+ if (project && project.path !== resolved) {
279
+ project.path = resolved;
280
+ saveRegistry(reg);
281
+ }
282
+ }
283
+ if (!project) {
284
+ project = {
285
+ id: id("proj"),
286
+ name: path.basename(resolved),
287
+ initials: initials(resolved),
288
+ color: "#34d399",
289
+ path: resolved,
290
+ remoteUrl: null,
291
+ apiKey: "rl_" + require("crypto").randomBytes(12).toString("hex"),
292
+ createdAt: Date.now(),
293
+ lastSyncAt: Date.now(),
294
+ };
295
+ try {
296
+ fs.mkdirSync(path.join(resolved, ".relay"), { recursive: true });
297
+ installProjectHooks(resolved);
298
+ } catch {
299
+ /* ignore */
300
+ }
301
+ reg.projects.push(project);
302
+ saveRegistry(reg);
303
+ saveMemory(project.id, emptyMemory());
304
+ sse.emit("projects", { projects: reg.projects });
305
+ }
306
+ watcher.watch(project.path, project.id);
307
+ lockTable.track(project.path);
308
+ watchTranscripts(project);
309
+ return project;
310
+ }
311
+
312
+ let hostSocket = null;
313
+ // A guest that stops retrying stops receiving the host's edits, silently — and
314
+ // one dropped tunnel request is all it takes. Back off instead of giving up,
315
+ // and cap so a host that is genuinely gone is not hammered.
316
+ let hostBackoffMs = 200;
317
+ let hostRetryTimer = null;
318
+ const HOST_BACKOFF_MAX_MS = 8_000;
319
+ // Filled in once the room helpers below exist. Until then a retry just
320
+ // reconnects to the URL already on disk.
321
+ let refreshGuestRoom = async (room) => room;
322
+
323
+ /** An existing socket already open, or still connecting, to this exact URL. */
324
+ function socketLive(sock, wsUrl) {
325
+ return Boolean(
326
+ sock && sock.relayUrl === wsUrl && (sock.readyState === WebSocket.OPEN || sock.readyState === WebSocket.CONNECTING)
327
+ );
328
+ }
329
+
330
+ function connectHostPatches(room) {
331
+ if (!room?.url || room.role !== "guest") return;
332
+ const wsUrl = `${room.url.replace(/^http/, "ws").replace(/\/$/, "")}/ws/patches${
333
+ room.memberToken ? `?token=${encodeURIComponent(room.memberToken)}` : ""
334
+ }`;
335
+ if (socketLive(hostSocket, wsUrl)) return;
336
+ if (hostRetryTimer) {
337
+ clearTimeout(hostRetryTimer);
338
+ hostRetryTimer = null;
339
+ }
340
+ if (hostSocket) {
341
+ try {
342
+ hostSocket.close();
343
+ } catch {
344
+ /* ignore */
345
+ }
346
+ }
347
+ let socket;
348
+ try {
349
+ socket = new WebSocket(wsUrl, {
350
+ headers: {
351
+ "ngrok-skip-browser-warning": "relay",
352
+ ...(room.memberToken ? { "x-relay-room-token": room.memberToken } : {}),
353
+ },
354
+ });
355
+ socket.relayUrl = wsUrl;
356
+ hostSocket = socket;
357
+ } catch (err) {
358
+ console.warn(`[relay-room] could not open ${wsUrl}: ${err.message || err}`);
359
+ scheduleRetry();
360
+ return;
361
+ }
362
+ let retried = false;
363
+ function scheduleRetry() {
364
+ if (retried || (socket && hostSocket !== socket)) return;
365
+ retried = true;
366
+ const wait = hostBackoffMs;
367
+ hostBackoffMs = Math.min(HOST_BACKOFF_MAX_MS, Math.round(hostBackoffMs * 1.8));
368
+ hostRetryTimer = setTimeout(() => {
369
+ hostRetryTimer = null;
370
+ const retry = async () => {
371
+ let latest = loadRoom();
372
+ if (latest?.role !== "guest") return;
373
+ latest = (await refreshGuestRoom(latest)) || latest;
374
+ connectHostPatches(latest);
375
+ connectHostControl(latest);
376
+ };
377
+ retry().catch(() => undefined);
378
+ }, wait);
379
+ if (hostRetryTimer.unref) hostRetryTimer.unref();
380
+ }
381
+ hostSocket.on("open", () => {
382
+ hostBackoffMs = 200;
383
+ try {
384
+ hostSocket.send(JSON.stringify({ type: "join" }));
385
+ } catch {
386
+ /* first snapshot arrives on connect from the host anyway */
387
+ }
388
+ console.log(`[relay-room] streaming room from ${room.url}`);
389
+ });
390
+ // Without an 'error' listener, ws's EventEmitter rethrows connection
391
+ // failures (e.g. a stale/offline host returning a non-101 response)
392
+ // as an uncaught exception and kills the whole `relay serve` process.
393
+ hostSocket.on("error", (err) => {
394
+ if (hostSocket !== socket) return;
395
+ console.warn(`[relay-room] host connection failed (${wsUrl}): ${err.message || err}`);
396
+ scheduleRetry();
397
+ });
398
+ hostSocket.on("message", (raw) => {
399
+ try {
400
+ const msg = JSON.parse(String(raw));
401
+ if (msg.type === "pong" || msg.type === "ping") {
402
+ if (msg.type === "ping") {
403
+ try {
404
+ hostSocket.send(JSON.stringify({ type: "pong" }));
405
+ } catch {
406
+ /* closed */
407
+ }
408
+ }
409
+ return;
410
+ }
411
+ if (msg.type === "tunnel" && msg.url) {
412
+ const current = loadRoom();
413
+ if (current?.role === "guest" && msg.url !== current.url) {
414
+ saveRoom({ ...current, url: msg.url }, current.projectPath);
415
+ connectHostPatches(loadRoom());
416
+ connectHostControl(loadRoom());
417
+ }
418
+ return;
419
+ }
420
+ if (msg.type === "locks") {
421
+ const current = loadRoom();
422
+ if (msg.workspaceId && current?.hostProjectId && msg.workspaceId !== current.hostProjectId) return;
423
+ roomLocks.accept(msg.locks);
424
+ return;
425
+ }
426
+ if (msg.type === "state" || msg.type === "hello") {
427
+ const project = currentProject();
428
+ const current = loadRoom();
429
+ if (project && current?.hostProjectId) {
430
+ savePeerSnapshot(project.id, "host", msg, msg.hostLogin || current.hostLogin || "host");
431
+ sse.emit("history", {
432
+ workspaceId: project.id,
433
+ lastSyncAt: Date.now(),
434
+ chats: msg.chats,
435
+ timeline: msg.timeline,
436
+ activity: msg.activity,
437
+ edits: msg.edits,
438
+ agents: msg.agents,
439
+ from: msg.hostLogin,
440
+ });
441
+ }
442
+ return;
443
+ }
444
+ if (msg.type !== "patch" || !msg.patch) return;
445
+ const result = acceptPeerPatch(msg.patch, {
446
+ remoteUrl: msg.remoteUrl,
447
+ name: msg.name,
448
+ pathName: msg.pathName,
449
+ }, { skipIfOrigin: true });
450
+ // The guest wrote the file but told nobody: without this the teammate's
451
+ // edit only surfaced on the next full refetch, which is exactly the
452
+ // "live edits never show up" report.
453
+ if (result?.recorded && result.projectId) {
454
+ sse.emit("patch", {
455
+ workspaceId: result.projectId,
456
+ patch: msg.patch,
457
+ incoming: true,
458
+ edit: editRowFor(msg.patch, false),
459
+ originId: msg.patch.originId,
460
+ });
461
+ }
462
+ } catch {
463
+ /* ignore */
464
+ }
465
+ });
466
+ hostSocket.on("close", scheduleRetry);
467
+ }
468
+
469
+ let controlSocket = null;
470
+ let controlRetryTimer = null;
471
+ let controlBackoffMs = 200;
472
+
473
+ function connectHostControl(room) {
474
+ if (!room?.url || room.role !== "guest") return;
475
+ const wsUrl = `${room.url.replace(/^http/, "ws").replace(/\/$/, "")}/ws/control${
476
+ room.memberToken ? `?token=${encodeURIComponent(room.memberToken)}` : ""
477
+ }`;
478
+ // Startup, guest-room refresh and the patches socket's retry all call this. Tearing
479
+ // down a socket that is already open or still connecting to the same host meant
480
+ // the control channel was closed before it could ever finish its handshake.
481
+ if (socketLive(controlSocket, wsUrl)) return;
482
+ if (controlRetryTimer) {
483
+ clearTimeout(controlRetryTimer);
484
+ controlRetryTimer = null;
485
+ }
486
+ if (controlSocket) {
487
+ try {
488
+ controlSocket.close();
489
+ } catch {
490
+ /* ignore */
491
+ }
492
+ }
493
+ let socket;
494
+ try {
495
+ socket = new WebSocket(wsUrl, {
496
+ headers: {
497
+ "ngrok-skip-browser-warning": "relay",
498
+ ...(room.memberToken ? { "x-relay-room-token": room.memberToken } : {}),
499
+ },
500
+ });
501
+ socket.relayUrl = wsUrl;
502
+ controlSocket = socket;
503
+ } catch (err) {
504
+ console.warn(`[relay-room] control socket failed: ${err.message || err}`);
505
+ scheduleControlRetry();
506
+ return;
507
+ }
508
+ roomControl.attach(controlSocket);
509
+ let retried = false;
510
+ function scheduleControlRetry() {
511
+ // A socket that has already been replaced must not schedule a reconnect: that
512
+ // reconnect would close the current, healthy socket and loop forever.
513
+ if (retried || (socket && controlSocket !== socket)) return;
514
+ retried = true;
515
+ const wait = controlBackoffMs;
516
+ controlBackoffMs = Math.min(HOST_BACKOFF_MAX_MS, Math.round(controlBackoffMs * 1.8));
517
+ controlRetryTimer = setTimeout(() => {
518
+ controlRetryTimer = null;
519
+ const latest = loadRoom();
520
+ if (latest?.role === "guest") {
521
+ refreshGuestRoom(latest)
522
+ .then((next) => connectHostControl(next || latest))
523
+ .catch(() => connectHostControl(latest));
524
+ }
525
+ }, wait);
526
+ if (controlRetryTimer.unref) controlRetryTimer.unref();
527
+ }
528
+ controlSocket.on("open", () => {
529
+ if (controlSocket !== socket) return;
530
+ controlBackoffMs = 200;
531
+ roomControl.send({ type: "join" });
532
+ console.log(`[relay-room] control stream ${room.url}`);
533
+ // Join first, then settle up: the host has to know who we are before it
534
+ // will accept a release from us.
535
+ if (roomControl.queued) {
536
+ const pending = roomControl.queued;
537
+ roomControl
538
+ .flush()
539
+ .then(({ replayed, rejected, requeued }) => {
540
+ console.log(
541
+ `[relay-room] replayed ${replayed.length}/${pending} queued lock ${
542
+ pending === 1 ? "mutation" : "mutations"
543
+ }${rejected.length ? `, ${rejected.length} rejected` : ""}${
544
+ requeued.length ? `, ${requeued.length} held for the next reconnect` : ""
545
+ }`
546
+ );
547
+ })
548
+ .catch((err) => console.warn(`[relay-room] replay failed: ${err.message || err}`));
549
+ }
550
+ });
551
+ controlSocket.on("error", (err) => {
552
+ if (controlSocket !== socket) return;
553
+ console.warn(`[relay-room] control failed: ${err.message || err}`);
554
+ scheduleControlRetry();
555
+ });
556
+ controlSocket.on("message", (raw) => {
557
+ try {
558
+ const msg = JSON.parse(String(raw));
559
+ if (msg.type === "ping") {
560
+ roomControl.send({ type: "pong" });
561
+ return;
562
+ }
563
+ if (roomControl.handle(msg)) return;
564
+ } catch {
565
+ /* ignore */
566
+ }
567
+ });
568
+ controlSocket.on("close", scheduleControlRetry);
569
+ }
570
+
571
+ /**
572
+ * Push the room's lock table to every joined guest.
573
+ *
574
+ * Only a host has anything to say here: it owns the one table the whole room
575
+ * arbitrates against. Guests receive this and hand it to their mirror, so the
576
+ * board they draw is the host's table itself rather than a periodic guess at
577
+ * it.
578
+ */
579
+ function broadcastLocks(locks, reads) {
580
+ const room = loadRoom();
581
+ if (room?.role !== "host" || !room.url) return;
582
+ const project = currentProject();
583
+ const scope = lockScope(room.hostProjectId || project?.id);
584
+ broadcast(wssControl, {
585
+ type: "locks",
586
+ workspaceId: room.hostProjectId || project?.id || null,
587
+ locks,
588
+ reads: Array.isArray(reads) ? reads : readsFor(scope),
589
+ });
590
+ }
591
+
592
+ function broadcastRoom(obj) {
593
+ broadcast(wss, obj);
594
+ }
595
+
596
+ function hostWireOpen() {
597
+ return Boolean(hostSocket && hostSocket.readyState === WebSocket.OPEN);
598
+ }
599
+
600
+ function sendToHost(obj) {
601
+ if (!hostWireOpen()) return false;
602
+ try {
603
+ hostSocket.send(JSON.stringify(obj));
604
+ return true;
605
+ } catch {
606
+ return false;
607
+ }
608
+ }
609
+
610
+ function roomStatePayload(project, type = "state") {
611
+ const memory = loadMemory(project.id);
612
+ const view = mergeRoomViews(memory);
613
+ const login = currentUser().login || "local";
614
+ return {
615
+ type,
616
+ hostLogin: login,
617
+ projectId: project.id,
618
+ lastTranscriptSyncAt: memory.lastTranscriptSyncAt || 0,
619
+ chats: view.chats,
620
+ timeline: view.timeline,
621
+ activity: view.activity,
622
+ edits: view.edits,
623
+ agents: view.agents,
624
+ stats: memory.stats || {},
625
+ roomCollisions: mergeCollisionStats(memory, { localLogin: login, projectId: project.id }),
626
+ };
627
+ }
628
+
629
+ let stateTick = null;
630
+ let lastChatBulkAt = 0;
631
+ function queueRoomState(project) {
632
+ if (!project) return;
633
+ clearTimeout(stateTick);
634
+ stateTick = setTimeout(() => {
635
+ setImmediate(() => {
636
+ const room = loadRoom();
637
+ if (room?.role !== "host" || !room.url) return;
638
+ const payload = roomStatePayload(project);
639
+ const includeChats = Date.now() - lastChatBulkAt > 900;
640
+ if (includeChats) lastChatBulkAt = Date.now();
641
+ else delete payload.chats;
642
+ broadcastRoom(payload);
643
+ setImmediate(() => sse.emit("history", { workspaceId: project.id, lastSyncAt: Date.now(), ...payload }));
644
+ });
645
+ }, 40);
646
+ if (stateTick.unref) stateTick.unref();
647
+ }
648
+
649
+ function ingestPeerHistory(login, snapshot, project) {
650
+ if (!login || !project) return;
651
+ savePeerSnapshot(project.id, login, snapshot, login);
652
+ queueRoomState(project);
653
+ }
654
+
655
+ /**
656
+ * The activity row a patch produces, shaped exactly like the ones
657
+ * `recordPatch`/`applyPeerFile` write into memory.edits.
658
+ *
659
+ * The dashboard renders `edits`, not `patches`, so a stream that carried only
660
+ * the patch left the feed frozen until the next full refetch — an agent's work
661
+ * was landing on disk while the UI insisted nothing had happened.
662
+ */
663
+ function editRowFor(patch, mine) {
664
+ return {
665
+ id: patch.id,
666
+ agent: patch.agent,
667
+ ownerLogin: patch.ownerLogin,
668
+ mine,
669
+ file: patch.file,
670
+ ts: patch.ts,
671
+ diff: patch.diff || `sha256:${patch.sha256}`,
672
+ lamport: patch.lamport,
673
+ binary: patch.binary,
674
+ };
675
+ }
676
+
677
+ /**
678
+ * The dashboard's patch list, without the payload nobody renders.
679
+ *
680
+ * A stored patch carries `content` — the whole file body — so it can be
681
+ * replayed onto disk. Mission Control only ever reads the row fields (it types
682
+ * `patches` as CodeEdit), but the route shipped the raw records: 2.2 MB of the
683
+ * 2.9 MB response, taking seconds to serialise and send. Every streamed
684
+ * `history` event asks the UI to refetch that, so the transport was live while
685
+ * the thing it triggered was not — which is what made streaming look broken.
686
+ */
687
+ const MAX_DIFF_BYTES = 8000;
688
+
689
+ function patchRows(patches) {
690
+ const rows = [];
691
+ for (const p of patches.slice(-PATCH_ROWS)) {
692
+ const diff = typeof p.diff === "string" ? p.diff : "";
693
+ rows.push({
694
+ id: p.id,
695
+ agent: p.agent,
696
+ ownerLogin: p.ownerLogin,
697
+ mine: p.mine,
698
+ file: p.file,
699
+ ts: p.ts,
700
+ lamport: p.lamport,
701
+ binary: p.binary,
702
+ diff: diff.length > MAX_DIFF_BYTES ? `${diff.slice(0, MAX_DIFF_BYTES)}\n… truncated` : diff,
703
+ });
704
+ }
705
+ return rows;
706
+ }
707
+
708
+ const PATCH_ROWS = 100;
709
+
710
+ function broadcastPatch(payload) {
711
+ const edit = payload.patch ? editRowFor(payload.patch, !payload.incoming) : null;
712
+ sse.emit("patch", edit ? { ...payload, edit } : payload);
713
+ broadcastRoom({ type: "patch", ...payload });
714
+ if (payload.patch) notifyWaiters(payload);
715
+ }
716
+
717
+ /**
718
+ * Hand the finished file to whoever was told to wait for it.
719
+ *
720
+ * An agent refused a claim was left to poll or simply give up, so it carried
721
+ * on against a stale copy of a file that had since changed underneath it —
722
+ * the exact divergence locking exists to prevent. The moment the edit lands,
723
+ * everyone who wanted that file learns it is free and what changed.
724
+ */
725
+ function notifyWaiters({ workspaceId, patch }) {
726
+ const project = loadRegistry().projects.find((p) => p.id === workspaceId);
727
+ if (!project || !patch?.file) return;
728
+ const waiters = lockTable.takeWaiters(patch.file, project.path);
729
+ if (!waiters.length) return;
730
+ const item = {
731
+ workspaceId: project.id,
732
+ file: patch.file,
733
+ lamport: patch.lamport,
734
+ sha256: patch.sha256,
735
+ by: patch.agent,
736
+ waiters: waiters.map((w) => w.agentId),
737
+ ts: Date.now(),
738
+ };
739
+ sse.emit("file-ready", item);
740
+ broadcastRoom({ type: "file-ready", ...item });
741
+ emitNotice(
742
+ {
743
+ type: "file-ready",
744
+ key: `file-ready:${project.id}:${patch.file}`,
745
+ title: `${patch.file} is free`,
746
+ body: `${patch.agent} finished editing it — ${waiters.length} agent(s) were waiting.`,
747
+ workspaceId: project.id,
748
+ payload: { file: patch.file, lamport: patch.lamport },
749
+ },
750
+ { revive: true }
751
+ );
752
+ }
753
+
754
+ function fanOutLocalFile(project, file, originAgentId) {
755
+ if (!project?.path || !file) return { flushed: false };
756
+ const rel = file.replace(/\\/g, "/");
757
+ const session = loadSession();
758
+ const patch = computePatch({
759
+ workspacePath: project.path,
760
+ file: rel,
761
+ agent: "fs.watch",
762
+ ownerLogin: session.login || "local",
763
+ workspaceId: project.id,
764
+ });
765
+ if (!patch) return { flushed: false };
766
+ // Second half of the echo guard in applyPatchToDisk: a watcher event whose
767
+ // content matches the newest patch we already know for this file is our own
768
+ // write coming back, not a new edit worth sending to the room.
769
+ const known = (loadMemory(project.id).patches || []).filter((p) => p.file === rel).pop();
770
+ if (known && known.sha256 === patch.sha256) return { flushed: false, unchanged: true, file: rel };
771
+ patch.originId = originId();
772
+ patch.originAgentId = originAgentId || originId();
773
+ recordPatch(project.id, patch);
774
+ const envelope = {
775
+ workspaceId: project.id,
776
+ patch,
777
+ remoteUrl: project.remoteUrl,
778
+ name: project.name,
779
+ pathName: path.basename(project.path),
780
+ originId: patch.originId,
781
+ };
782
+ broadcastPatch(envelope);
783
+ const room = loadRoom(project.path);
784
+ if (room?.url && room.role !== "host") {
785
+ const body = { ...envelope, workspaceId: room.hostProjectId || envelope.workspaceId, type: "patch" };
786
+ if (!sendToHost(body)) {
787
+ fetch(`${room.url.replace(/\/$/, "")}/api/patches`, {
788
+ method: "POST",
789
+ headers: {
790
+ "Content-Type": "application/json",
791
+ "ngrok-skip-browser-warning": "relay",
792
+ ...(room.memberToken ? { "x-relay-room-token": room.memberToken } : {}),
793
+ },
794
+ body: JSON.stringify(body),
795
+ })
796
+ .then((r) => {
797
+ if (!r.ok) console.warn(`[relay-room] host rejected patch for ${rel}: ${r.status}`);
798
+ })
799
+ .catch((err) => console.warn(`[relay-room] could not send patch for ${rel}: ${err.message || err}`));
800
+ }
801
+ }
802
+ const memory = loadMemory(project.id);
803
+ pushLocalEdits(project.path, project.id, memory).then(() => pullCentralChanges(project.path)).catch(() => undefined);
804
+ publishGraph(project);
805
+ return { flushed: true, file: rel };
806
+ }
807
+
808
+ function projectForPath(workspacePath) {
809
+ if (!workspacePath) return null;
810
+ return (
811
+ loadRegistry().projects.find((p) => normalizeCompare(p.path) === normalizeCompare(workspacePath)) ||
812
+ ensureWorkspace(workspacePath)
813
+ );
814
+ }
815
+
816
+ // Watcher events parked behind a live local lock, replayed once the holder
817
+ // lets go. fanOutLocalFile is idempotent (it drops a patch whose sha matches
818
+ // the newest one recorded for the file), so a post-tool flush that did arrive
819
+ // simply makes the replay a no-op.
820
+ const deferredEdits = new Map();
821
+ const DEFERRED_MAX_MS = 5 * 60 * 1000;
822
+
823
+ /**
824
+ * Trailing-edge, not leading-edge.
825
+ *
826
+ * This used to publish the first event for a file and drop every further one
827
+ * inside a 40ms window. An agent that writes a file in more than one chunk —
828
+ * which the fast Gemini-class models do constantly — therefore published a
829
+ * snapshot taken mid-write and then threw away the event carrying the
830
+ * finished content. There is no later event to correct it, so the edit either
831
+ * never appeared or appeared truncated. Waiting for the file to stop changing
832
+ * costs one debounce interval and always publishes the final state.
833
+ */
834
+ const WATCH_SETTLE_MS = 60;
835
+ const watchTimers = new Map();
836
+
837
+ watcher.on("change", (evt) => {
838
+ const key = `${evt.workspacePath}:${evt.file}`;
839
+ clearTimeout(watchTimers.get(key));
840
+ watchTimers.set(
841
+ key,
842
+ setTimeout(() => {
843
+ watchTimers.delete(key);
844
+ onFileSettled(evt);
845
+ }, WATCH_SETTLE_MS)
846
+ );
847
+ });
848
+
849
+ function onFileSettled({ workspacePath, workspaceId, file }) {
850
+ const now = Date.now();
851
+ if (workspaceId && applyingRemote.has(workspaceId)) return;
852
+ const project =
853
+ loadRegistry().projects.find((p) => p.id === workspaceId || p.path === workspacePath) ||
854
+ ensureWorkspace(workspacePath);
855
+ if (!project) return;
856
+ if (applyingRemote.has(project.id) || applyingRemote.has(`${project.id}:${file}`)) return;
857
+ const rel = file.replace(/\\/g, "/");
858
+ const publish = skipPublish(lockTable, project.path, rel);
859
+ if (publish.skip) {
860
+ bumpCollision(project.id, "patch_skipped", {
861
+ file: rel,
862
+ holder: publish.held?.agentId,
863
+ reason: publish.reason,
864
+ });
865
+ return;
866
+ }
867
+ const held = publish.held;
868
+ if (held) lockTable.heartbeat({ agentId: held.agentId, file: rel, workspaceId: project.path });
869
+ if (publish.defer) {
870
+ // Deferring is correct — a file being written under a live local lock is
871
+ // still mid-edit, and the agent's post-tool hook publishes the finished
872
+ // result. But that made publication conditional on a hook we do not
873
+ // control: if PostToolUse never fires (or fires with a payload shape we
874
+ // cannot read a path out of), the watcher event is consumed here, the
875
+ // lock quietly expires, and the edit is never published at all. Remember
876
+ // it instead and let the lock's own release or expiry release the edit.
877
+ deferredEdits.set(`${project.id}:${rel}`, { project, rel, agentId: held?.agentId || null, at: now });
878
+ bumpCollision(project.id, "patch_deferred", { file: rel, agentId: held?.agentId });
879
+ return;
880
+ }
881
+ fanOutLocalFile(project, rel, held?.agentId);
882
+ }
883
+
884
+ const deferredSweep = setInterval(() => {
885
+ if (!deferredEdits.size) return;
886
+ const now = Date.now();
887
+ for (const [key, entry] of [...deferredEdits]) {
888
+ const stale = now - entry.at > DEFERRED_MAX_MS;
889
+ if (!stale && aliveLock(lockTable, entry.rel, entry.project.path)) continue;
890
+ deferredEdits.delete(key);
891
+ if (stale) continue;
892
+ try {
893
+ fanOutLocalFile(entry.project, entry.rel, entry.agentId);
894
+ } catch (err) {
895
+ console.warn(`[relay] deferred publish ${entry.rel}: ${err.message || err}`);
896
+ }
897
+ }
898
+ }, 750);
899
+ if (deferredSweep.unref) deferredSweep.unref();
900
+
901
+ const transcriptWatchers = new Map();
902
+
903
+ // A transcript sync is fully synchronous and parses every transcript the
904
+ // workspace owns — tens of megabytes for a long-lived project. Agents rewrite
905
+ // their transcript continuously while a turn is in flight, so without a floor
906
+ // between passes the server spends every tick re-parsing the same files and
907
+ // never gets back to serving HTTP or flushing sockets.
908
+ const INGEST_COOLDOWN_MS = 2500;
909
+ const lastIngest = new Map();
910
+ const ingestAgain = new Set();
911
+
912
+ // Activity is a transcript log, so it appears as soon as the jsonl is parsed.
913
+ // Coordinator only painted live claims, and PostToolUse releases those in
914
+ // ~100ms — the board looked empty while the feed was already full. Mirror
915
+ // recent transcript edits onto the same afterglow the lock table keeps for
916
+ // released claims, so both tabs share one trail. This is display-only:
917
+ // claim() never sees remembered rows.
918
+ function observeTranscriptEdits(project, memory) {
919
+ const cutoff = Date.now() - 45_000;
920
+ let touched = 0;
921
+ for (const edit of memory?.edits || []) {
922
+ if (!edit?.file || (edit.ts || 0) < cutoff) continue;
923
+ lockTable.remember({
924
+ filePath: String(edit.file).replace(/\\/g, "/"),
925
+ agentId: `${edit.agent}:${os.hostname()}:transcript`,
926
+ mode: "write",
927
+ claimedAt: edit.ts,
928
+ lastHeartbeat: edit.ts,
929
+ ttlMs: 15_000,
930
+ workspaceId: project.path,
931
+ holder: { label: edit.agent, login: edit.ownerLogin || null },
932
+ source: "transcript",
933
+ releasedAt: edit.ts,
934
+ });
935
+ touched += 1;
936
+ }
937
+ if (touched) lockTable.emit("change");
938
+ }
939
+
940
+ function ingestTranscripts(project) {
941
+ const since = Date.now() - (lastIngest.get(project.id) || 0);
942
+ if (since < INGEST_COOLDOWN_MS) {
943
+ // Coalesce rather than drop: the newest write still gets ingested, just on
944
+ // the trailing edge instead of once per filesystem event.
945
+ if (!ingestAgain.has(project.id)) {
946
+ ingestAgain.add(project.id);
947
+ setTimeout(() => {
948
+ ingestAgain.delete(project.id);
949
+ ingestTranscripts(project);
950
+ }, INGEST_COOLDOWN_MS - since).unref?.();
951
+ }
952
+ return Promise.resolve();
953
+ }
954
+ lastIngest.set(project.id, Date.now());
955
+ return syncTranscriptsQueued(project, { ownerLogin: loadSession().login || "local" })
956
+ .then((memory) => {
957
+ observeTranscriptEdits(project, memory);
958
+ // Carry the refreshed views on the event itself. A bare notification
959
+ // makes the client turn around and refetch the whole dashboard, so the
960
+ // "stream" was really just a trigger for a multi-megabyte poll.
961
+ sse.emit("history", {
962
+ workspaceId: project.id,
963
+ lastSyncAt: Date.now(),
964
+ chats: memory?.chats,
965
+ timeline: memory?.timeline,
966
+ activity: memory?.activity,
967
+ edits: memory?.edits,
968
+ agents: memory?.agents,
969
+ });
970
+ // Push as soon as the transcript settles rather than waiting for the
971
+ // next dashboard poll, so a teammate sees the turn land in seconds.
972
+ const room = loadRoom(project.path);
973
+ if (room?.role === "host") queueRoomState(project);
974
+ return pushRoomHistory(room, project);
975
+ })
976
+ .catch(() => undefined);
977
+ }
978
+
979
+ /**
980
+ * A brand-new workspace has no transcript directory yet: Cursor and
981
+ * Antigravity only create theirs on the first turn. fs.watch cannot watch a
982
+ * path that does not exist, so a one-shot arm at boot leaves that workspace
983
+ * permanently silent — the "my edits never show up on the new repo" symptom.
984
+ * Re-arming lets a root that appears later still get picked up.
985
+ */
986
+ function watchTranscripts(project) {
987
+ if (!project?.path) return;
988
+ const state = transcriptWatchers.get(project.id) || { roots: new Set(), timer: null };
989
+ transcriptWatchers.set(project.id, state);
990
+ for (const root of transcriptWatchRoots(project.path)) {
991
+ state.roots.add(root);
992
+ armTranscriptRoot(root);
993
+ }
994
+ }
995
+
996
+ /**
997
+ * One recursive watch per directory, shared by every workspace interested in
998
+ * it.
999
+ *
1000
+ * Most transcript roots are not workspace-specific: `~/.codex/sessions` and
1001
+ * Antigravity's `brain/` hold every conversation on the machine, so all eight
1002
+ * registered workspaces resolved to the same two directories and each armed
1003
+ * its own watcher — 23 handles over 9 directories. A single Antigravity write
1004
+ * then fanned out to eight independent transcript syncs, each re-parsing that
1005
+ * workspace's transcripts from scratch. That is the stall: Antigravity writes
1006
+ * its transcript continuously while answering, so the server spent all of its
1007
+ * time re-parsing and none of it serving the dashboard or the lock stream.
1008
+ */
1009
+ const transcriptRootHandles = new Map();
1010
+
1011
+ function armTranscriptRoot(root) {
1012
+ if (transcriptRootHandles.has(root)) return;
1013
+ let handle;
1014
+ try {
1015
+ handle = fs.watch(root, { recursive: true }, (_evt, filename) => {
1016
+ onTranscriptRootChange(root, filename);
1017
+ });
1018
+ } catch {
1019
+ return; // directory not there yet — the sweep retries
1020
+ }
1021
+ handle.on("error", () => {
1022
+ try {
1023
+ handle.close();
1024
+ } catch {
1025
+ /* already gone */
1026
+ }
1027
+ transcriptRootHandles.delete(root);
1028
+ });
1029
+ transcriptRootHandles.set(root, handle);
1030
+ }
1031
+
1032
+ function onTranscriptRootChange(root, filename) {
1033
+ const changed = filename ? path.resolve(root, filename) : null;
1034
+ for (const [projectId, state] of transcriptWatchers) {
1035
+ if (!state.roots.has(root)) continue;
1036
+ const project = loadRegistry().projects.find((p) => p.id === projectId);
1037
+ if (!project) continue;
1038
+ // A shared root fires for conversations belonging to other workspaces.
1039
+ // Ingesting all of them on every event is what multiplied the cost;
1040
+ // ownsTranscript keeps a write charged to the workspace it belongs to.
1041
+ if (changed && !ownsTranscript(project, changed)) continue;
1042
+ clearTimeout(state.timer);
1043
+ state.timer = setTimeout(() => ingestTranscripts(project), 150);
1044
+ }
1045
+ }
1046
+
1047
+ // Cheap membership test against the transcript set discovery already computed,
1048
+ // refreshed lazily so a brand-new conversation still gets picked up.
1049
+ const OWNERSHIP_TTL_MS = 10000;
1050
+ const ownership = new Map();
1051
+
1052
+ function ownsTranscript(project, absFile) {
1053
+ const hit = ownership.get(project.id);
1054
+ let files = hit && Date.now() - hit.at < OWNERSHIP_TTL_MS ? hit.files : null;
1055
+ if (!files) {
1056
+ try {
1057
+ files = new Set(
1058
+ discoverAll(normalizeWorkspaceRoot(project.path) || project.path).map((f) => path.resolve(f.file))
1059
+ );
1060
+ } catch {
1061
+ files = new Set();
1062
+ }
1063
+ ownership.set(project.id, { files, at: Date.now() });
1064
+ }
1065
+ // Unknown file: it may be a conversation that started moments ago, so let it
1066
+ // through once and let the refreshed set decide next time.
1067
+ if (files.has(absFile)) return true;
1068
+ if (hit && Date.now() - hit.at < OWNERSHIP_TTL_MS) return false;
1069
+ return true;
1070
+ }
1071
+
1072
+ for (const p of loadRegistry().projects) {
1073
+ if (p.path) {
1074
+ watcher.watch(p.path, p.id);
1075
+ lockTable.track(p.path);
1076
+ }
1077
+ watchTranscripts(p);
1078
+ }
1079
+
1080
+ // Watchers are the fast path, not the only one. This sweep re-arms roots that
1081
+ // have since been created and ingests when a transcript moved but no watch
1082
+ // event arrived (network drives, editors that write via rename, a agent whose
1083
+ // storage directory did not exist when the workspace was registered).
1084
+ // Every pass costs a full transcript discovery per workspace — hundreds of
1085
+ // milliseconds of synchronous stat/read work across the registry. At 3s that
1086
+ // was a fifth of the event loop spent on a path that only exists to cover
1087
+ // events fs.watch missed. The deduped watchers above are the fast path; this
1088
+ // only has to be eventually-correct.
1089
+ const transcriptSweep = setInterval(() => {
1090
+ for (const project of loadRegistry().projects) {
1091
+ if (!project.path) continue;
1092
+ lockTable.track(project.path);
1093
+ watchTranscripts(project);
1094
+ try {
1095
+ const seen = loadMemory(project.id).lastTranscriptSyncAt || 0;
1096
+ const root = normalizeWorkspaceRoot(project.path) || project.path;
1097
+ if (latestTranscriptMtime(root) > seen) ingestTranscripts(project);
1098
+ } catch {
1099
+ /* keep sweeping the other workspaces */
1100
+ }
1101
+ }
1102
+ }, 10000);
1103
+ if (transcriptSweep.unref) transcriptSweep.unref();
1104
+ try {
1105
+ installProjectHooks(process.cwd());
1106
+ } catch {
1107
+ /* ignore */
1108
+ }
1109
+
1110
+ function originId() {
1111
+ const session = loadSession();
1112
+ return `${os.hostname()}:${session.login || "local"}`;
1113
+ }
1114
+
1115
+ function peerProject(hint = {}) {
1116
+ const reg = loadRegistry();
1117
+ // A joined room pins the workspace incoming patches belong to. Without this
1118
+ // the chain below falls through to currentProject(), and a guest whose
1119
+ // dashboard happens to be on a different repo has the host's files written
1120
+ // into it.
1121
+ const room = loadRoom();
1122
+ if (room?.projectId) {
1123
+ const pinned = reg.projects.find((p) => p.id === room.projectId);
1124
+ if (pinned) return pinned;
1125
+ }
1126
+ const byRemote = hint.remoteUrl && reg.projects.find((p) => p.remoteUrl && p.remoteUrl === hint.remoteUrl);
1127
+ if (byRemote) return byRemote;
1128
+ const byName = hint.name && reg.projects.find((p) => p.name === hint.name);
1129
+ if (byName) return byName;
1130
+ if (hint.pathName) {
1131
+ const byBase = reg.projects.find((p) => path.basename(p.path) === hint.pathName);
1132
+ if (byBase) return byBase;
1133
+ }
1134
+ return currentProject();
1135
+ }
1136
+
1137
+ function acceptPeerPatch(patch, hint, { skipIfOrigin } = {}) {
1138
+ if (!patch?.file) return { applied: false };
1139
+ if (skipIfOrigin && patch.originId && patch.originId === originId()) {
1140
+ return { applied: false, self: true };
1141
+ }
1142
+ const project = peerProject(hint);
1143
+ if (!project) return { applied: false, reason: "no_local_repo" };
1144
+ const clash = applyClash(lockTable, project.path, patch.file, patch);
1145
+ if (clash) {
1146
+ if (project?.id) {
1147
+ bumpCollision(project.id, "patch_blocked", {
1148
+ file: patch.file,
1149
+ holder: clash.holder,
1150
+ agent: patch.agent,
1151
+ reason: clash.reason,
1152
+ });
1153
+ }
1154
+ return { applied: false, projectId: project.id, ...clash };
1155
+ }
1156
+ applyingRemote.add(`${project.id}:${patch.file}`);
1157
+ const result = applyPeerFile(project.id, patch, project.path);
1158
+ applyingRemote.delete(`${project.id}:${patch.file}`);
1159
+ return { ...result, projectId: project.id };
1160
+ }
1161
+
1162
+ const currentProject = () => {
1163
+ const reg = loadRegistry();
1164
+ const session = loadSession();
1165
+ return reg.projects.find((p) => p.id === session.projectId) || reg.projects[0] || null;
1166
+ };
1167
+
1168
+ function currentUser() {
1169
+ const { session, user } = whoami();
1170
+ if (user) {
1171
+ return {
1172
+ id: user.id,
1173
+ login: user.login,
1174
+ name: user.name || user.login,
1175
+ avatarUrl: user.avatarUrl || `https://github.com/${user.login}.png`,
1176
+ };
1177
+ }
1178
+ const login = session.login || os.userInfo().username || "local";
1179
+ return { id: `local_${login}`, login, name: login, avatarUrl: "" };
1180
+ }
1181
+
1182
+ // Every dashboard poll fans out to the room host. Without a deadline, one
1183
+ // unreachable tunnel stalls the request the UI refreshes on every 8s, and the
1184
+ // whole dashboard reads as "not loading".
1185
+ const HOST_TIMEOUT_MS = 2500;
1186
+ // A full-tree seed is megabytes over a tunnel; the dashboard deadline is far
1187
+ // too short for it.
1188
+ const SEED_TIMEOUT_MS = 120_000;
1189
+
1190
+ async function fetchHost(url, pathname, init = {}) {
1191
+ // Every room request carries the member token the host minted at redeem
1192
+ // time; without it an invite-only host answers 401.
1193
+ const token = init.token === null ? null : init.token || loadRoom()?.memberToken;
1194
+ const res = await fetch(`${String(url).replace(/\/$/, "")}${pathname}`, {
1195
+ ...init,
1196
+ signal: AbortSignal.timeout(init.timeoutMs || HOST_TIMEOUT_MS),
1197
+ headers: {
1198
+ "Content-Type": "application/json",
1199
+ "ngrok-skip-browser-warning": "relay",
1200
+ ...(token ? { "x-relay-room-token": token } : {}),
1201
+ ...(init.headers || {}),
1202
+ },
1203
+ });
1204
+ if (!res.ok) {
1205
+ const err = new Error(`${pathname} ${res.status}`);
1206
+ err.status = res.status;
1207
+ throw err;
1208
+ }
1209
+ return res;
1210
+ }
1211
+
1212
+ const sameLogin = (a, b) => Boolean(a) && Boolean(b) && String(a).toLowerCase() === String(b).toLowerCase();
1213
+
1214
+ /**
1215
+ * Team tab roster: GitHub collaborators are the stable rows. Room presence
1216
+ * overlays on top by login. Kick/leave drop the room overlay, never the
1217
+ * GitHub identity.
1218
+ */
1219
+ function overlayTeam(ghList, roomMembers) {
1220
+ const roomByLogin = new Map();
1221
+ for (const m of roomMembers || []) {
1222
+ const key = String(m?.login || "").toLowerCase();
1223
+ if (key) roomByLogin.set(key, m);
1224
+ }
1225
+ const seen = new Set();
1226
+ const rows = [];
1227
+ for (const gh of ghList || []) {
1228
+ const key = String(gh.login || "").toLowerCase();
1229
+ if (!key || seen.has(key)) continue;
1230
+ seen.add(key);
1231
+ const live = roomByLogin.get(key);
1232
+ const inRoom = Boolean(live);
1233
+ rows.push({
1234
+ id: gh.id || live?.id || `gh_${gh.login}`,
1235
+ login: gh.login,
1236
+ name: (inRoom && live.name) || gh.name || gh.login,
1237
+ avatarUrl: (inRoom && live.avatarUrl) || gh.avatarUrl || "",
1238
+ permission: gh.permission || null,
1239
+ source: "github",
1240
+ inRoom,
1241
+ role: inRoom ? live.role : undefined,
1242
+ online: inRoom ? Boolean(live.online) : false,
1243
+ agentActive: inRoom ? Boolean(live.agentActive) : false,
1244
+ agentLabel: inRoom ? live.agentLabel || null : null,
1245
+ });
1246
+ }
1247
+ for (const live of roomMembers || []) {
1248
+ const key = String(live.login || "").toLowerCase();
1249
+ if (!key || seen.has(key)) continue;
1250
+ seen.add(key);
1251
+ rows.push({
1252
+ ...live,
1253
+ source: "room",
1254
+ inRoom: true,
1255
+ online: Boolean(live.online),
1256
+ agentActive: Boolean(live.agentActive),
1257
+ agentLabel: live.agentLabel || null,
1258
+ });
1259
+ }
1260
+ return rows;
1261
+ }
1262
+
1263
+ /**
1264
+ * Is this person a collaborator on the repo being shared?
1265
+ *
1266
+ * `verified: false` means we could not find out — no GitHub remote, `gh` not
1267
+ * installed or not authed. It does NOT mean "not a collaborator", and the
1268
+ * caller must not treat it as a rejection: the invite code is itself a
1269
+ * host-issued, single-use, expiring credential, so an unverifiable repo
1270
+ * degrades to invite-only rather than becoming unjoinable.
1271
+ *
1272
+ * The public contributors fallback in githubRepo is deliberately not accepted
1273
+ * as proof here — it is activity-derived public data, not an access list.
1274
+ */
1275
+ async function collaboratorCheck(project, login) {
1276
+ try {
1277
+ const list = await fetchGithubCollaborators(project?.remoteUrl);
1278
+ const authoritative = list.filter((c) => c.source === "collaborators");
1279
+ if (!authoritative.length) return { verified: false, allowed: true, reason: "unverifiable" };
1280
+ const match = authoritative.find((c) => sameLogin(c.login, login));
1281
+ return match
1282
+ ? { verified: true, allowed: true, permission: match.permission || null }
1283
+ : { verified: true, allowed: false, reason: "not_a_collaborator" };
1284
+ } catch {
1285
+ return { verified: false, allowed: true, reason: "lookup_failed" };
1286
+ }
1287
+ }
1288
+
1289
+ // Guards everything a room exposes through the tunnel. Local Mission Control
1290
+ // and local hooks reach the same routes over loopback and are left alone, so
1291
+ // solo use is unaffected by any of this.
1292
+ function requireRoomMember(req, res, next) {
1293
+ const room = loadRoom();
1294
+ const hosting = Boolean(room?.url) && room.role === "host";
1295
+ // Identify the caller from its credential whenever one is present, even on a
1296
+ // loopback request. Downstream scoping depends on knowing "this is a peer",
1297
+ // and inferring that from proxy headers alone is a guess; the token is proof.
1298
+ if (hosting) {
1299
+ const member = authorizeMember(room, roomToken(req));
1300
+ if (member) req.roomMember = member;
1301
+ }
1302
+ if (!isRemoteRequest(req)) return next();
1303
+ if (!hosting) return next();
1304
+ if (room.open) return next();
1305
+ if (!req.roomMember) {
1306
+ return res.status(401).json({
1307
+ error: "not_a_member",
1308
+ hint: "This room is invite-only. Ask the host to send you an invite link.",
1309
+ });
1310
+ }
1311
+ return next();
1312
+ }
1313
+
1314
+ function activeAgent(projectId) {
1315
+ const memory = loadMemory(projectId);
1316
+ const live = (memory.agents || [])
1317
+ .filter((a) => a.status === "connected" && Date.now() - (a.lastActiveAt || 0) < 120_000)
1318
+ .sort((a, b) => (b.lastActiveAt || 0) - (a.lastActiveAt || 0))[0];
1319
+ return live ? { agentActive: true, agentLabel: live.label } : { agentActive: false, agentLabel: null };
1320
+ }
1321
+
1322
+ async function announceToHost(room, user = currentUser()) {
1323
+ if (!room?.url || room.role !== "guest") return null;
1324
+ const res = await fetchHost(room.url, "/api/room/hello", {
1325
+ method: "POST",
1326
+ body: JSON.stringify({
1327
+ user: { ...user, ...(room.projectId ? activeAgent(room.projectId) : {}) },
1328
+ role: "guest",
1329
+ // Without this the host resolved the room from whichever project it
1330
+ // happens to have selected, so a guest's hello could land on the wrong
1331
+ // workspace's roster — or 409 and leave the guest with no roster at all.
1332
+ hostProjectId: room.hostProjectId,
1333
+ }),
1334
+ });
1335
+ return res.json();
1336
+ }
1337
+
1338
+ // The roster a guest last heard from the host, cached in its own room.json.
1339
+ // The Team tab is rendered from a live round trip, so without a cache a single
1340
+ // slow or dropped tunnel request blanks the whole collaborator list.
1341
+ function rememberRoster(workspacePath, roster) {
1342
+ const room = loadRoom(workspacePath);
1343
+ if (!room) return;
1344
+ const now = Date.now();
1345
+ saveRoom(
1346
+ {
1347
+ ...room,
1348
+ // membersView recomputes online from lastSeen, so stamping only the
1349
+ // members the host reported as online lets presence decay naturally if
1350
+ // the host becomes unreachable, instead of freezing everyone online.
1351
+ members: roster.map((m) => ({ ...m, lastSeen: m.online ? now : m.lastSeen || 0 })),
1352
+ },
1353
+ workspacePath
1354
+ );
1355
+ }
1356
+
1357
+ // Locks live on the host while a room is joined. Mirroring them locally keeps
1358
+ // /api/locks, the Coordinator tab's SSE stream and the dashboard consistent
1359
+ // instead of each reporting a different (and mostly empty) answer.
1360
+ const roomLocks = new RoomLockMirror({
1361
+ fetchLocks: async (room) => {
1362
+ const body = await fetchHost(room.url, `/api/locks?projectId=${encodeURIComponent(room.hostProjectId)}`).then((r) =>
1363
+ r.json()
1364
+ );
1365
+ return { locks: body.locks || [], reads: body.reads || [] };
1366
+ },
1367
+ });
1368
+ /**
1369
+ * A replayed release the host would not take. The agent that queued it has
1370
+ * long since moved on and is not watching the board, so this has to be a
1371
+ * notice rather than a socket event nobody is listening for.
1372
+ */
1373
+ roomControl.onReplay = ({ rejected }) => {
1374
+ // Only a host verdict is worth a notice. A transport failure has been put
1375
+ // back on the queue and will go out on the next reconnect, so reporting it
1376
+ // would be crying wolf about work that is still in hand.
1377
+ if (!rejected?.length) return;
1378
+ const room = loadRoom();
1379
+ const files = [...new Set(rejected.map((r) => r.body?.file).filter(Boolean))];
1380
+ emitNotice(
1381
+ {
1382
+ type: "replay_rejected",
1383
+ key: `replay_rejected:${room?.roomId || "room"}`,
1384
+ title: `${rejected.length} offline ${rejected.length === 1 ? "release" : "releases"} did not reach the host`,
1385
+ body: files.length
1386
+ ? `Still held room-wide until they expire: ${files.slice(0, 3).join(", ")}${
1387
+ files.length > 3 ? ` and ${files.length - 3} more` : ""
1388
+ }.`
1389
+ : "They will expire on their own TTL.",
1390
+ workspaceId: room?.projectId || currentProject()?.id || null,
1391
+ },
1392
+ { revive: true }
1393
+ );
1394
+ };
1395
+
1396
+ roomControl.onLocks = (msg) => {
1397
+ const current = loadRoom();
1398
+ if (msg.workspaceId && current?.hostProjectId && msg.workspaceId !== current.hostProjectId) return;
1399
+ roomLocks.accept(msg.locks, { reads: msg.reads });
1400
+ };
1401
+ roomLocks.on("change", ({ locks, reads }) => {
1402
+ const room = loadRoom();
1403
+ const workspaceId = room?.projectId || currentProject()?.id;
1404
+ sse.emit("locks", {
1405
+ workspaceId,
1406
+ locks,
1407
+ reads: Array.isArray(reads) ? reads : readsFor(lockScope(workspaceId)),
1408
+ });
1409
+ const health = roomLocks.health;
1410
+ if (health.joined && !health.reachable && roomLocks.failures >= 3) {
1411
+ emitNotice(
1412
+ {
1413
+ type: "host_offline",
1414
+ key: `host_offline:${room?.roomId || "room"}`,
1415
+ title: `@${room?.hostLogin || "host"} went offline`,
1416
+ body: "Waiting to reconnect to the shared room.",
1417
+ workspaceId: room?.projectId || currentProject()?.id || null,
1418
+ },
1419
+ { revive: true }
1420
+ );
1421
+ }
1422
+ if (health.joined && health.reachable) {
1423
+ const key = `host_offline:${room?.roomId || "room"}`;
1424
+ if (dismissByKey(key)) {
1425
+ emitNotice(
1426
+ {
1427
+ type: "host_online",
1428
+ key: `host_online:${room?.roomId || "room"}`,
1429
+ title: `@${room?.hostLogin || "host"} is back`,
1430
+ body: "Reconnected to the shared room.",
1431
+ workspaceId: room?.projectId || currentProject()?.id || null,
1432
+ },
1433
+ { revive: true }
1434
+ );
1435
+ }
1436
+ }
1437
+ });
1438
+
1439
+ function syncRoomLocks() {
1440
+ roomLocks.start(loadRoom());
1441
+ }
1442
+
1443
+ function shareExtras(project) {
1444
+ const user = currentUser();
1445
+ return {
1446
+ hostProjectId: project?.id,
1447
+ workspacePath: project?.path,
1448
+ projectName: project?.name,
1449
+ hostUser: user,
1450
+ hostLogin: user.login,
1451
+ repoKey: repoKeyFromRemote(project?.remoteUrl),
1452
+ };
1453
+ }
1454
+
1455
+ const discoverState = { at: 0, key: "", rooms: [] };
1456
+
1457
+ async function refreshOpenRooms(project, { force } = {}) {
1458
+ const key = repoKeyFromRemote(project?.remoteUrl);
1459
+ if (!key) return [];
1460
+ if (!force && discoverState.key === key && Date.now() - discoverState.at < 12_000) return discoverState.rooms;
1461
+ const me = currentUser().login;
1462
+ let collabs = [];
1463
+ try {
1464
+ collabs = await fetchGithubCollaborators(project.remoteUrl);
1465
+ } catch {
1466
+ collabs = [];
1467
+ }
1468
+ const rooms = await discoverRooms({
1469
+ repoKey: key,
1470
+ logins: collabs.map((c) => c.login),
1471
+ myLogin: me,
1472
+ });
1473
+ discoverState.at = Date.now();
1474
+ discoverState.key = key;
1475
+ discoverState.rooms = rooms;
1476
+ syncInviteNotices(project, rooms);
1477
+ return rooms;
1478
+ }
1479
+
1480
+ function emitNotice(input, opts) {
1481
+ const notice = pushNotice(input, opts);
1482
+ if (notice) sse.emit("notice", notice);
1483
+ return notice;
1484
+ }
1485
+
1486
+ function syncInviteNotices(project, rooms) {
1487
+ const current = loadRoom(project?.path);
1488
+ const live = new Set();
1489
+ for (const room of rooms || []) {
1490
+ if (!room?.roomId) continue;
1491
+ const key = `invite:${room.roomId}`;
1492
+ live.add(key);
1493
+ if (current?.roomId && current.roomId === room.roomId) continue;
1494
+ emitNotice({
1495
+ type: "invite",
1496
+ key,
1497
+ title: `@${room.hostLogin} invited you`,
1498
+ body: `Join the shared room${room.projectName ? ` for ${room.projectName}` : ""}.`,
1499
+ action: "join",
1500
+ workspaceId: project?.id || null,
1501
+ payload: {
1502
+ hostLogin: room.hostLogin,
1503
+ roomId: room.roomId,
1504
+ gistId: room.gistId || null,
1505
+ url: room.url,
1506
+ projectName: room.projectName || project?.name || null,
1507
+ projectId: project?.id || null,
1508
+ },
1509
+ });
1510
+ }
1511
+ for (const n of listNotices()) {
1512
+ if (n.type === "invite" && n.key && !live.has(n.key) && !(current?.roomId && n.key === `invite:${current.roomId}`)) {
1513
+ dismissByKey(n.key);
1514
+ }
1515
+ }
1516
+ }
1517
+
1518
+ let tunnelWatchTimer = null;
1519
+ let tunnelMaintainInFlight = null;
1520
+ let lastTunnelProbeAt = 0;
1521
+
1522
+ function stopTunnelWatch() {
1523
+ if (tunnelWatchTimer) clearInterval(tunnelWatchTimer);
1524
+ tunnelWatchTimer = null;
1525
+ }
1526
+
1527
+ function startTunnelWatch() {
1528
+ if (tunnelWatchTimer) return;
1529
+ tunnelWatchTimer = setInterval(() => maintainHostTunnel().catch(() => undefined), 12000);
1530
+ if (tunnelWatchTimer.unref) tunnelWatchTimer.unref();
1531
+ }
1532
+
1533
+ async function publishAndWatch(room, project) {
1534
+ let warning = null;
1535
+ try {
1536
+ const published = await publishSignal(room, {
1537
+ repoKey: repoKeyFromRemote(project?.remoteUrl) || room.repoKey,
1538
+ hostLogin: currentUser().login,
1539
+ });
1540
+ if (published?.ok && published.gistId && published.gistId !== room.signalGistId) {
1541
+ room.signalGistId = published.gistId;
1542
+ saveRoom(room, project?.path || room.projectPath);
1543
+ } else if (!published?.ok) {
1544
+ warning = GIST_SCOPE_HINT;
1545
+ }
1546
+ } catch {
1547
+ warning = GIST_SCOPE_HINT;
1548
+ }
1549
+ startTunnelWatch();
1550
+ return warning;
1551
+ }
1552
+
1553
+ async function maintainHostTunnel() {
1554
+ if (tunnelMaintainInFlight) return tunnelMaintainInFlight;
1555
+ tunnelMaintainInFlight = maintainHostTunnelOnce().finally(() => {
1556
+ tunnelMaintainInFlight = null;
1557
+ });
1558
+ return tunnelMaintainInFlight;
1559
+ }
1560
+
1561
+ async function maintainHostTunnelOnce() {
1562
+ const project = currentProject();
1563
+ const room = loadRoom(project?.path);
1564
+ if (room?.role !== "host") {
1565
+ stopTunnelWatch();
1566
+ return;
1567
+ }
1568
+ const port = parsePort(process.argv);
1569
+ let listedUrl = activeTunnelUrl();
1570
+ if (!listedUrl) {
1571
+ try {
1572
+ const res = await fetch("http://127.0.0.1:4040/api/tunnels");
1573
+ if (res.ok) {
1574
+ const tunnels = (await res.json()).tunnels || [];
1575
+ listedUrl =
1576
+ (tunnels.find((t) => String(t.public_url || "").startsWith("https://")) || tunnels[0] || {}).public_url || null;
1577
+ }
1578
+ } catch {
1579
+ listedUrl = null;
1580
+ }
1581
+ }
1582
+ // Active tunnel (cloudflared / RELAY_PUBLIC_URL / inspector) already live: reuse it.
1583
+ let url = listedUrl;
1584
+ if (!listedUrl) {
1585
+ const now = Date.now();
1586
+ if (now - lastTunnelProbeAt < 8000) return;
1587
+ lastTunnelProbeAt = now;
1588
+ url = await ensureTunnel(port);
1589
+ }
1590
+ if (!url) return;
1591
+ if (url === room.url && room.live !== false) {
1592
+ if (!room.signalGistId) await publishAndWatch(room, project);
1593
+ return;
1594
+ }
1595
+ const previous = room.url;
1596
+ room.url = url;
1597
+ room.live = true;
1598
+ saveRoom(room, project?.path);
1599
+ const warning = await publishAndWatch(room, project);
1600
+ if (previous !== url) console.log(`[relay-room] tunnel is ${url}`);
1601
+ if (warning) console.warn(`[relay-room] ${warning}`);
1602
+ if (previous !== url) {
1603
+ sse.emit("presence", { workspaceId: project?.id, tunnel: url });
1604
+ broadcastRoom({ type: "tunnel", url });
1605
+ }
1606
+ }
1607
+
1608
+ refreshGuestRoom = async function refreshGuestRoomNow(room) {
1609
+ if (room?.role !== "guest") return room;
1610
+ const project = currentProject();
1611
+ const repoKey = room.repoKey || repoKeyFromRemote(project?.remoteUrl);
1612
+ try {
1613
+ const signal = await findSignal({
1614
+ gistId: room.signalGistId,
1615
+ hostLogin: room.hostLogin,
1616
+ repoKey,
1617
+ roomId: room.roomId,
1618
+ });
1619
+ if (!signal?.url || signal.live === false) return room;
1620
+ if (signal.url === room.url) return room;
1621
+ const next = saveRoom(
1622
+ {
1623
+ ...room,
1624
+ url: signal.url,
1625
+ roomId: signal.roomId || room.roomId,
1626
+ signalGistId: signal.gistId || room.signalGistId,
1627
+ repoKey: signal.repoKey || repoKey,
1628
+ hostLogin: signal.hostLogin || room.hostLogin,
1629
+ },
1630
+ room.projectPath || project?.path
1631
+ );
1632
+ console.log(`[relay-room] host moved to ${signal.url}`);
1633
+ sse.emit("presence", { workspaceId: room.projectId || project?.id, reconnect: true });
1634
+ emitNotice(
1635
+ {
1636
+ type: "host_online",
1637
+ key: `host_online:${room.roomId || "room"}`,
1638
+ title: `@${room.hostLogin || "host"} is back`,
1639
+ body: "Reconnected to the shared room.",
1640
+ workspaceId: room.projectId || project?.id || null,
1641
+ },
1642
+ { revive: true }
1643
+ );
1644
+ dismissByKey(`host_offline:${room.roomId || "room"}`);
1645
+ syncRoomLocks();
1646
+ return next;
1647
+ } catch {
1648
+ return room;
1649
+ }
1650
+ };
1651
+
1652
+ /**
1653
+ * What this machine should show as "the locks".
1654
+ *
1655
+ * A guest's own table is normally empty — its hooks claim against the host —
1656
+ * so the mirror leads. But a hook that hit the room while the tunnel was down
1657
+ * falls back to the local coordinator (see coordinator/client.js), and those
1658
+ * claims exist nowhere else: returning the mirror alone made the guest's own
1659
+ * in-progress edits invisible on its own board.
1660
+ *
1661
+ * Merge instead. A local row is dropped only when the host already has the
1662
+ * same file under the same agent — that is the same lock seen twice. A local
1663
+ * row held by a DIFFERENT agent is the genuine double-hold a tunnel outage
1664
+ * opens up (both sides fail open by design), and dropping it is what made
1665
+ * that conflict invisible on the one screen that should be shouting about it.
1666
+ */
1667
+ function readsFor(scope) {
1668
+ try {
1669
+ if (!scope) return [];
1670
+ const { filterKeys } = require("./coordinator/lockTable");
1671
+ const local = lockTable.readsFor(filterKeys(scope));
1672
+ const mirrored = typeof roomLocks.listReads === "function" ? roomLocks.listReads() : null;
1673
+ if (!mirrored) return local;
1674
+ const onHost = new Set(mirrored.map((r) => `${r.filePath}::${r.agentId}`));
1675
+ return [...mirrored, ...local.filter((r) => !onHost.has(`${r.filePath}::${r.agentId}`))];
1676
+ } catch (err) {
1677
+ console.warn(`[relay] readsFor: ${err.message || err}`);
1678
+ return [];
1679
+ }
1680
+ }
1681
+
1682
+ function locksFor(scope) {
1683
+ try {
1684
+ const mirrored = roomLocks.list();
1685
+ const local = scope ? lockTable.list(scope) : [];
1686
+ if (!mirrored) return local;
1687
+ const onHost = new Set(mirrored.map((l) => `${l.filePath}::${l.agentId}`));
1688
+ return [...mirrored, ...local.filter((l) => !onHost.has(`${l.filePath}::${l.agentId}`))];
1689
+ } catch (err) {
1690
+ console.warn(`[relay] locksFor: ${err.message || err}`);
1691
+ return [];
1692
+ }
1693
+ }
1694
+
1695
+ // Only pushed when local transcripts actually moved. The live WebSocket is the
1696
+ // delivery path; HTTP is the fallback when the tunnel is down.
1697
+ const lastPushedSync = new Map();
1698
+
1699
+ // Only guests push. The host is already the aggregator — sending its own
1700
+ // history out through its tunnel and back would be a pointless round trip.
1701
+ async function pushRoomSnapshot(room, project, { force } = {}) {
1702
+ if (room?.role !== "guest" || !room.url || !project) return;
1703
+ const target = room.hostProjectId;
1704
+ if (!target) return;
1705
+ const memory = loadMemory(project.id);
1706
+ const stamp = memory.lastTranscriptSyncAt || 0;
1707
+ const collisionStamp = loadLocalCollisions(project.id).updatedAt || 0;
1708
+ const pushKey = `${stamp}:${collisionStamp}`;
1709
+ if (!force && lastPushedSync.get(project.id) === pushKey) return;
1710
+ lastPushedSync.set(project.id, pushKey);
1711
+ const login = currentUser().login;
1712
+ const snapshot = localSnapshot(memory, login, project.id);
1713
+ const payload = { type: "history", projectId: target, login, snapshot };
1714
+ if (sendToHost(payload)) return;
1715
+ try {
1716
+ await fetchHost(room.url, "/api/room/history", {
1717
+ method: "POST",
1718
+ timeoutMs: 8000,
1719
+ body: JSON.stringify(payload),
1720
+ });
1721
+ } catch {
1722
+ lastPushedSync.delete(project.id);
1723
+ }
1724
+ }
1725
+
1726
+ async function pushRoomHistory(room, project) {
1727
+ return pushRoomSnapshot(room, project, { force: false });
1728
+ }
1729
+
1730
+ function notifyCollisionMetrics(project) {
1731
+ if (!project?.id) return;
1732
+ const room = loadRoom(project.path);
1733
+ const login = currentUser().login || "local";
1734
+ const merged = mergeCollisionStats(loadMemory(project.id), { localLogin: login, projectId: project.id });
1735
+ sse.emit("metrics", { workspaceId: project.id, collisions: merged });
1736
+ if (room?.role === "host" && room.url) queueRoomState(project);
1737
+ else if (room?.role === "guest" && room.url) pushRoomSnapshot(room, project, { force: true }).catch(() => undefined);
1738
+ }
1739
+
1740
+ function bumpCollision(projectId, kind, detail = {}) {
1741
+ recordCollision(projectId, kind, detail);
1742
+ const project = loadRegistry().projects.find((p) => p.id === projectId);
1743
+ if (project) notifyCollisionMetrics(project);
1744
+ }
1745
+
1746
+ async function pullRoomHistory(room, project) {
1747
+ if (room?.role !== "guest" || !room.url || !room.hostProjectId || !project) return;
1748
+ const login = currentUser().login;
1749
+ const body = await fetchHost(
1750
+ room.url,
1751
+ `/api/room/state?projectId=${encodeURIComponent(room.hostProjectId)}&exclude=${encodeURIComponent(login)}`
1752
+ ).then((r) => r.json());
1753
+ if (body?.ok) savePeerSnapshot(project.id, "host", body, body.hostLogin || "host");
1754
+ }
1755
+
1756
+ app.get("/api/health", (_req, res) => {
1757
+ const session = loadSession();
1758
+ res.json({
1759
+ ok: true,
1760
+ version: VERSION,
1761
+ coordinatorPort: coordPort,
1762
+ auth: Boolean(session.userId),
1763
+ room: publicRoom(loadRoom()),
1764
+ });
1765
+ });
1766
+
1767
+ app.get("/api/session", (_req, res) => {
1768
+ const reg = loadRegistry();
1769
+ const session = loadSession();
1770
+ const user = session.userId ? reg.users.find((u) => u.id === session.userId) || null : null;
1771
+ res.json({
1772
+ user,
1773
+ users: reg.users,
1774
+ projectId: session.projectId || currentProject()?.id,
1775
+ oauthConfigured: Boolean(process.env.GITHUB_CLIENT_ID),
1776
+ });
1777
+ });
1778
+
1779
+ app.post("/api/auth/login", (req, res) => {
1780
+ const reg = loadRegistry();
1781
+ const login = req.body?.login;
1782
+ const user = reg.users.find((u) => u.login === login) || reg.users[0];
1783
+ const session = { ...loadSession(), userId: user.id, login: user.login };
1784
+ saveSession(session);
1785
+ sse.emit("presence", { workspaceId: currentProject()?.id, user });
1786
+ res.json({ user });
1787
+ });
1788
+
1789
+ app.get("/api/auth/github", (req, res) => {
1790
+ const redirect = `${req.protocol}://${req.get("host")}/api/auth/github/callback`;
1791
+ const url = authorizeUrl(redirect);
1792
+ if (!url) return res.status(501).json({ error: "oauth_not_configured", hint: "Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET" });
1793
+ res.redirect(url);
1794
+ });
1795
+
1796
+ app.get("/api/auth/github/callback", async (req, res) => {
1797
+ const redirect = `${req.protocol}://${req.get("host")}/api/auth/github/callback`;
1798
+ try {
1799
+ const result = await exchangeCode(req.query.code, redirect);
1800
+ if (result.error) return res.status(400).json(result);
1801
+ const reg = loadRegistry();
1802
+ let user = reg.users.find((u) => u.login === result.user.login);
1803
+ if (!user) {
1804
+ user = result.user;
1805
+ reg.users.push(user);
1806
+ saveRegistry(reg);
1807
+ }
1808
+ saveSession({ ...loadSession(), userId: user.id, login: user.login });
1809
+ res.redirect(UI_ORIGIN);
1810
+ } catch (err) {
1811
+ res.status(500).json({ error: String(err.message || err) });
1812
+ }
1813
+ });
1814
+
1815
+ app.post("/api/auth/logout", (_req, res) => {
1816
+ saveSession({ userId: null, login: null, projectId: loadSession().projectId });
1817
+ res.json({ ok: true });
1818
+ });
1819
+
1820
+ app.get("/api/projects", (_req, res) => {
1821
+ res.json({ projects: loadRegistry().projects });
1822
+ });
1823
+
1824
+ app.post("/api/projects", async (req, res) => {
1825
+ const { name, path: workspacePath, remoteUrl, mode } = req.body || {};
1826
+ try {
1827
+ let project;
1828
+ if (mode === "clone") {
1829
+ if (!remoteUrl) return res.status(400).json({ error: "remoteUrl required" });
1830
+ const out = await cloneRepo(remoteUrl, workspacePath);
1831
+ project = out.project;
1832
+ if (name && name !== project.name) {
1833
+ project.name = name;
1834
+ const reg = loadRegistry();
1835
+ const row = reg.projects.find((p) => p.id === project.id);
1836
+ if (row) row.name = name;
1837
+ saveRegistry(reg);
1838
+ }
1839
+ } else {
1840
+ if (!workspacePath) return res.status(400).json({ error: "path required" });
1841
+ project = addLocal(workspacePath, name);
1842
+ }
1843
+ watcher.watch(project.path, project.id);
1844
+ sse.emit("projects", { projects: loadRegistry().projects });
1845
+ res.status(201).json({ project });
1846
+ } catch (err) {
1847
+ res.status(400).json({ error: String(err.message || err) });
1848
+ }
1849
+ });
1850
+
1851
+ app.get("/api/projects/:id", (req, res) => {
1852
+ const project = loadRegistry().projects.find((p) => p.id === req.params.id);
1853
+ if (!project) return res.status(404).json({ error: "not_found" });
1854
+ res.json({ project });
1855
+ });
1856
+
1857
+ app.patch("/api/projects/:id", (req, res) => {
1858
+ const reg = loadRegistry();
1859
+ const project = reg.projects.find((p) => p.id === req.params.id);
1860
+ if (!project) return res.status(404).json({ error: "not_found" });
1861
+ if (req.body?.name) project.name = req.body.name;
1862
+ saveRegistry(reg);
1863
+ res.json({ project });
1864
+ });
1865
+
1866
+ function stopTranscriptWatch(projectId) {
1867
+ const state = transcriptWatchers.get(projectId);
1868
+ if (state && typeof state === "object" && state.timer) {
1869
+ try {
1870
+ clearTimeout(state.timer);
1871
+ } catch {
1872
+ /* ignore */
1873
+ }
1874
+ }
1875
+ transcriptWatchers.delete(projectId);
1876
+ lastIngest.delete(projectId);
1877
+ ingestAgain.delete(projectId);
1878
+ ownership.delete(projectId);
1879
+ }
1880
+
1881
+ app.delete("/api/projects/:id", (req, res) => {
1882
+ const mode = String(req.query.mode || req.body?.mode || "leave");
1883
+ const reply = (nextId) => res.json({ ok: true, mode, nextId: nextId || null });
1884
+ try {
1885
+ const reg = loadRegistry();
1886
+ const idx = reg.projects.findIndex((p) => p.id === req.params.id);
1887
+ if (idx < 0) return res.status(404).json({ ok: false, error: "not_found", nextId: null });
1888
+ const project = reg.projects[idx];
1889
+ try {
1890
+ if (project.path) watcher.unwatch(project.path);
1891
+ } catch {
1892
+ /* ignore */
1893
+ }
1894
+ stopTranscriptWatch(project.id);
1895
+ // Leave keeps workspace files on disk. Remove only drops Relay's own
1896
+ // metadata — never the checkout — so a missing path/room cannot 500.
1897
+ reg.projects.splice(idx, 1);
1898
+ saveRegistry(reg);
1899
+ if (mode === "remove") {
1900
+ try {
1901
+ fs.rmSync(projectDir(project.id), { recursive: true, force: true });
1902
+ } catch {
1903
+ /* ignore */
1904
+ }
1905
+ }
1906
+ const session = loadSession();
1907
+ if (session.projectId === project.id) {
1908
+ session.projectId = reg.projects[0]?.id || null;
1909
+ saveSession(session);
1910
+ }
1911
+ try {
1912
+ sse.emit("projects", { projects: reg.projects });
1913
+ } catch {
1914
+ /* ignore */
1915
+ }
1916
+ return reply(session.projectId || reg.projects[0]?.id || null);
1917
+ } catch {
1918
+ // Last-ditch unregister so leave/remove never surface as a 500.
1919
+ try {
1920
+ const reg = loadRegistry();
1921
+ const idx = reg.projects.findIndex((p) => p.id === req.params.id);
1922
+ if (idx >= 0) {
1923
+ const project = reg.projects[idx];
1924
+ stopTranscriptWatch(project.id);
1925
+ try {
1926
+ if (project.path) watcher.unwatch(project.path);
1927
+ } catch {
1928
+ /* ignore */
1929
+ }
1930
+ reg.projects.splice(idx, 1);
1931
+ saveRegistry(reg);
1932
+ const session = loadSession();
1933
+ if (session.projectId === project.id) {
1934
+ session.projectId = reg.projects[0]?.id || null;
1935
+ saveSession(session);
1936
+ }
1937
+ return reply(session.projectId || reg.projects[0]?.id || null);
1938
+ }
1939
+ return reply(loadSession().projectId || loadRegistry().projects[0]?.id || null);
1940
+ } catch {
1941
+ return reply(null);
1942
+ }
1943
+ }
1944
+ });
1945
+
1946
+ app.post("/api/projects/:id/select", (req, res) => {
1947
+ const project = loadRegistry().projects.find((p) => p.id === req.params.id);
1948
+ if (!project) return res.status(404).json({ error: "not_found" });
1949
+ saveSession({ ...loadSession(), projectId: project.id });
1950
+ res.json({ project, dashboard: buildDashboard(project) });
1951
+ });
1952
+
1953
+ app.post("/api/projects/:id/sync", async (req, res) => {
1954
+ const project = loadRegistry().projects.find((p) => p.id === req.params.id);
1955
+ if (!project) return res.status(404).json({ error: "not_found" });
1956
+ const memory = await syncTranscriptsQueued(project, {
1957
+ ownerLogin: loadSession().login || "local",
1958
+ });
1959
+ project.lastSyncAt = Date.now();
1960
+ const reg = loadRegistry();
1961
+ const idx = reg.projects.findIndex((p) => p.id === project.id);
1962
+ reg.projects[idx] = project;
1963
+ saveRegistry(reg);
1964
+ Promise.all([
1965
+ pushLocalEdits(project.path, project.id, memory),
1966
+ pullCentralChanges(project.path),
1967
+ ])
1968
+ .then(() => sse.emit("history", { workspaceId: project.id, lastSyncAt: project.lastSyncAt }))
1969
+ .catch(() => sse.emit("history", { workspaceId: project.id, lastSyncAt: project.lastSyncAt }));
1970
+ publishGraph(project);
1971
+ res.json({
1972
+ ok: true,
1973
+ lastSyncAt: project.lastSyncAt,
1974
+ chats: (memory.chats || []).length,
1975
+ history: (memory.history || []).length,
1976
+ });
1977
+ });
1978
+
1979
+ app.get("/api/projects/:id/dashboard", async (req, res) => {
1980
+ const project = loadRegistry().projects.find((p) => p.id === req.params.id);
1981
+ if (!project) return res.status(404).json({ error: "not_found" });
1982
+ const room = loadRoom(project.path);
1983
+ // A room is the only thing that makes this workspace anything other than
1984
+ // local: everything below (peer history, shared locks, remote presence) is
1985
+ // skipped entirely when no tunnel is joined.
1986
+ if (room?.url) {
1987
+ syncRoomLocks();
1988
+ const live = room.role === "guest" ? hostWireOpen() : Boolean(wss && [...wss.clients].some((c) => c.readyState === 1));
1989
+ if (!live) {
1990
+ // Do not block the dashboard on ngrok. Sync already owns transcripts;
1991
+ // waiting here is what made Coordinator freeze and then go blank.
1992
+ pullRoomHistory(room, project).catch(() => undefined);
1993
+ pushRoomHistory(room, project).catch(() => undefined);
1994
+ }
1995
+ }
1996
+ const dashboard = buildDashboard(project);
1997
+ dashboard.locks = locksFor({ id: project.id, path: project.path });
1998
+ dashboard.reads = readsFor({ id: project.id, path: project.path });
1999
+ const graph = fs.existsSync(project.path) ? graphFor(project.path) : null;
2000
+ // Released rows are on the list for the activity trail only — treating them
2001
+ // as held would paint the dependency graph with locks nobody is holding.
2002
+ const lockedFiles = dashboard.locks
2003
+ .filter((l) => !l.released)
2004
+ .map((l) => (l.filePath.includes("::") ? l.filePath.split("::")[1] : l.filePath));
2005
+ dashboard.graph = graph
2006
+ ? graph.snapshot(lockedFiles)
2007
+ : { nodes: [], edges: [], cycles: [], unresolvable: [], fileCount: 0, edgeCount: 0, lockDepth: 1 };
2008
+ const mem = loadMemory(project.id);
2009
+ dashboard.patches = patchRows(mem.patches || mem.edits || []);
2010
+ dashboard.lastAppliedLamport = mem.lastAppliedLamport || 0;
2011
+ let roomMembers = [];
2012
+ if (room?.role === "host") {
2013
+ // Re-upserting on every poll is what keeps the host's own lastSeen fresh;
2014
+ // membersView reads it as online/offline, so skipping it when the row
2015
+ // already exists made the host show as offline in its own room after 45s.
2016
+ saveRoom(upsertMember(room, { ...currentUser(), ...activeAgent(project.id) }, "host"), project.path);
2017
+ roomMembers = membersView(loadRoom(project.path));
2018
+ }
2019
+ if (room?.role === "guest" && room.url) {
2020
+ roomMembers = membersView(loadRoom(project.path));
2021
+ announceToHost(room)
2022
+ .then((hello) => {
2023
+ const roster = Array.isArray(hello?.members) && hello.members.length ? hello.members : null;
2024
+ if (roster) rememberRoster(project.path, roster);
2025
+ })
2026
+ .catch(() => {
2027
+ refreshGuestRoom(room)
2028
+ .then((next) => {
2029
+ if (next?.url && next.url !== room.url) {
2030
+ connectHostPatches(next);
2031
+ connectHostControl(next);
2032
+ }
2033
+ })
2034
+ .catch(() => undefined);
2035
+ });
2036
+ if (room.hostProjectId) {
2037
+ fetchHost(room.url, `/api/projects/${room.hostProjectId}/graph`)
2038
+ .then((r) => r.json())
2039
+ .then((remoteGraph) => {
2040
+ if (remoteGraph?.nodes) sse.emit("graph", { workspaceId: project.id, graph: remoteGraph });
2041
+ })
2042
+ .catch(() => undefined);
2043
+ }
2044
+ }
2045
+ // Projects registered before remoteUrl auto-detection existed (or via
2046
+ // "Add local repository", which used to always store null) never got a
2047
+ // remoteUrl — backfill it once from the workspace's own git remote so
2048
+ // the GitHub-collaborators lookup below has something to work with.
2049
+ if (!project.remoteUrl) {
2050
+ const detected = detectRemoteUrl(project.path);
2051
+ if (detected) {
2052
+ project.remoteUrl = detected;
2053
+ const reg = loadRegistry();
2054
+ const idx = reg.projects.findIndex((p) => p.id === project.id);
2055
+ if (idx >= 0) {
2056
+ reg.projects[idx] = project;
2057
+ saveRegistry(reg);
2058
+ }
2059
+ }
2060
+ }
2061
+ if (room?.url) {
2062
+ const me = currentUser();
2063
+ const myRole = room.role === "host" ? "host" : "guest";
2064
+ const mine = { ...me, role: myRole, online: true, ...activeAgent(project.id) };
2065
+ const rest = roomMembers.filter((c) => !sameLogin(c.login, me.login));
2066
+ const previous = roomMembers.find((c) => sameLogin(c.login, me.login));
2067
+ roomMembers = [{ ...previous, ...mine }, ...rest];
2068
+ }
2069
+ let ghCollaborators = peekCollaborators(project.remoteUrl) || [];
2070
+ fetchGithubCollaborators(project.remoteUrl).catch(() => []);
2071
+ dashboard.collaborators = overlayTeam(ghCollaborators, roomMembers);
2072
+ // hostReachable lets the Coordinator board say "the host is not answering"
2073
+ // instead of rendering the same empty state it shows when simply nobody is
2074
+ // editing — the two look identical from the guest's side otherwise.
2075
+ const health = roomLocks.health;
2076
+ dashboard.room = publicRoom(room)
2077
+ ? { ...publicRoom(room), hostReachable: health.reachable, lastHostContactAt: health.lastContactAt }
2078
+ : null;
2079
+ // Only the host manages invites, and only its own Mission Control sees them.
2080
+ dashboard.invites = room?.role === "host" && !isRemoteRequest(req) ? inviteView(room) : [];
2081
+ dashboard.openRooms =
2082
+ !isRemoteRequest(req) && discoverState.key === repoKeyFromRemote(project.remoteUrl) ? discoverState.rooms : [];
2083
+ if (!isRemoteRequest(req) && room?.role !== "host") {
2084
+ refreshOpenRooms(project)
2085
+ .then((rooms) => {
2086
+ if (rooms.length) sse.emit("presence", { workspaceId: project.id, openRooms: true });
2087
+ })
2088
+ .catch(() => undefined);
2089
+ }
2090
+ res.json(dashboard);
2091
+ });
2092
+
2093
+ app.get("/api/projects/:id/graph", requireRoomMember, (req, res) => {
2094
+ const project = loadRegistry().projects.find((p) => p.id === req.params.id);
2095
+ if (!project) return res.status(404).json({ error: "not_found" });
2096
+ const g = rebuild(project.path) || graphFor(project.path);
2097
+ const lockedFiles = lockTable.list({ id: project.id, path: project.path }).map((l) =>
2098
+ l.filePath.includes("::") ? l.filePath.split("::")[1] : l.filePath
2099
+ );
2100
+ res.json(g ? g.snapshot(lockedFiles) : { nodes: [], edges: [] });
2101
+ });
2102
+
2103
+ app.get("/api/projects/:id/history", (req, res) => {
2104
+ const project = loadRegistry().projects.find((p) => p.id === req.params.id);
2105
+ if (!project) return res.status(404).json({ error: "not_found" });
2106
+ const memory = loadMemory(project.id);
2107
+ delete memory.ir;
2108
+ res.json({
2109
+ history: memory.history || [],
2110
+ chats: memory.chats || [],
2111
+ timeline: memory.timeline || [],
2112
+ });
2113
+ });
2114
+
2115
+ app.get("/api/projects/:id/conflicts", (req, res) => {
2116
+ const project = loadRegistry().projects.find((p) => p.id === req.params.id);
2117
+ if (!project) return res.status(404).json({ error: "not_found" });
2118
+ res.json({ conflicts: detectConflicts(loadMemory(project.id)) });
2119
+ });
2120
+
2121
+ app.post("/api/ingest-stop", async (req, res) => {
2122
+ const { workspacePath, agent, ownerLogin, sessionId, messages, edits, transcript_path } = req.body || {};
2123
+ const project = ensureWorkspace(workspacePath);
2124
+ if (!project) return res.status(400).json({ error: "workspace_path_required" });
2125
+ const login = ownerLogin || loadSession().login || "local";
2126
+ const memory = await syncTranscriptsQueued(project, {
2127
+ ownerLogin: login,
2128
+ hint: {
2129
+ agent,
2130
+ sessionId,
2131
+ conversationId: req.body?.conversationId,
2132
+ transcript_path,
2133
+ },
2134
+ });
2135
+ if (!(memory.history || []).length && ((messages && messages.length) || (edits && edits.length))) {
2136
+ const harvested = harvest({
2137
+ transcript_path,
2138
+ sessionId,
2139
+ conversationId: req.body?.conversationId,
2140
+ });
2141
+ appendStopTurn(project.id, {
2142
+ agent: agent || "Agent",
2143
+ ownerLogin: login,
2144
+ sessionId,
2145
+ messages: (messages && messages.length ? messages : harvested.messages) || [],
2146
+ edits: (edits && edits.length ? edits : harvested.edits) || [],
2147
+ });
2148
+ }
2149
+ sse.emit("history", { workspaceId: project.id, lastSyncAt: Date.now() });
2150
+ pushLocalEdits(project.path, project.id, loadMemory(project.id))
2151
+ .then(() => pullCentralChanges(project.path))
2152
+ .catch(() => undefined);
2153
+ pushRoomHistory(loadRoom(project.path), project).catch(() => undefined);
2154
+ publishGraph(project);
2155
+ res.json({
2156
+ ok: true,
2157
+ harvested: Boolean((memory.history || []).length),
2158
+ chats: (memory.chats || []).length,
2159
+ history: (memory.history || []).length,
2160
+ });
2161
+ });
2162
+
2163
+ app.post("/api/handshake", (req, res) => {
2164
+ const { projectId, agent } = req.body || {};
2165
+ const project = loadRegistry().projects.find((p) => p.id === projectId) || currentProject();
2166
+ if (!project) return res.status(404).json({ error: "no_project" });
2167
+ const memory = loadMemory(project.id);
2168
+ const row = memory.agents.find((a) => a.label === agent || a.id === agent);
2169
+ if (row) {
2170
+ row.status = "handshaking";
2171
+ row.lastActiveAt = Date.now();
2172
+ }
2173
+ saveMemory(project.id, memory);
2174
+ const token = id("hs");
2175
+ try {
2176
+ fs.writeFileSync(
2177
+ path.join(project.path, ".relay", `.handshake_${(agent || "agent").replace(/\s+/g, "_")}`),
2178
+ token
2179
+ );
2180
+ } catch {
2181
+ fs.mkdirSync(path.join(homeRelayDir(), "handshakes"), { recursive: true });
2182
+ }
2183
+ sse.emit("agents", { workspaceId: project.id, agents: memory.agents });
2184
+ res.json({ ok: true, status: "handshaking", token });
2185
+ });
2186
+
2187
+ app.post("/api/connect", (req, res) => {
2188
+ const { projectId, agent } = req.body || {};
2189
+ const project = loadRegistry().projects.find((p) => p.id === projectId) || currentProject();
2190
+ const memory = loadMemory(project.id);
2191
+ const row = memory.agents.find((a) => a.label === agent || a.id === agent);
2192
+ if (row) {
2193
+ row.status = "connected";
2194
+ row.lastActiveAt = Date.now();
2195
+ row.sessionId = id("sess").slice(-5);
2196
+ }
2197
+ saveMemory(project.id, memory);
2198
+ watcher.watch(project.path, project.id);
2199
+ sse.emit("agents", { workspaceId: project.id, agents: memory.agents });
2200
+ res.json({ ok: true, status: "connected", agent: row });
2201
+ });
2202
+
2203
+ app.get("/api/memory", (req, res) => {
2204
+ const projectId = req.query.projectId || currentProject()?.id;
2205
+ res.json(loadMemory(projectId));
2206
+ });
2207
+
2208
+ app.get("/api/locks", requireRoomMember, (req, res) => {
2209
+ try {
2210
+ const scope = lockScope(req.query.projectId || currentProject()?.id);
2211
+ res.json({
2212
+ locks: locksFor(scope),
2213
+ reads: readsFor(scope),
2214
+ raw: scope ? lockTable.status(scope) : { locks: {} },
2215
+ });
2216
+ } catch (err) {
2217
+ console.warn(`[relay] /api/locks: ${err.message || err}`);
2218
+ res.json({ locks: [], reads: [], raw: { locks: {} } });
2219
+ }
2220
+ });
2221
+
2222
+ app.get("/api/locks/stream", (req, res) => {
2223
+ res.setHeader("Content-Type", "text/event-stream");
2224
+ res.setHeader("Cache-Control", "no-cache");
2225
+ res.setHeader("Connection", "keep-alive");
2226
+ res.flushHeaders?.();
2227
+ const workspaceId = req.query.projectId;
2228
+ const send = () => {
2229
+ try {
2230
+ const scope = lockScope(workspaceId);
2231
+ const payload = {
2232
+ locks: locksFor(scope),
2233
+ reads: readsFor(scope),
2234
+ raw: scope ? lockTable.status(scope) : { locks: {} },
2235
+ };
2236
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
2237
+ } catch (err) {
2238
+ console.warn(`[relay] locks/stream: ${err.message || err}`);
2239
+ }
2240
+ };
2241
+ send();
2242
+ const listener = () => send();
2243
+ lockTable.on("change", listener);
2244
+ roomLocks.on("change", listener);
2245
+ req.on("close", () => {
2246
+ lockTable.off("change", listener);
2247
+ roomLocks.off("change", listener);
2248
+ });
2249
+ });
2250
+
2251
+ app.get("/api/events", (req, res) => {
2252
+ const workspaceId = req.query.projectId;
2253
+ const session = loadSession();
2254
+ const userId = req.headers["x-relay-user"] || session.login;
2255
+ if (workspaceId && userId) {
2256
+ const out = presence.join(workspaceId, userId);
2257
+ if (out.escalated) {
2258
+ const scope = lockScope(workspaceId);
2259
+ for (const lock of lockTable.list(scope || workspaceId)) lockTable.escalateRegister(lock);
2260
+ // Merged view, not the raw table: on a guest the local table is empty
2261
+ // and this would blank the board for every viewer of the workspace.
2262
+ sse.emit("locks", { workspaceId, locks: locksFor(scope), reads: readsFor(scope) });
2263
+ }
2264
+ sse.emit("presence", { workspaceId, userId, count: out.count, escalated: out.escalated });
2265
+ }
2266
+ sse.subscribe(res, { workspaceId });
2267
+ req.on("close", () => {
2268
+ if (workspaceId && userId) presence.leave(workspaceId, userId);
2269
+ });
2270
+ });
2271
+
2272
+ app.post("/api/ensure-workspace", (req, res) => {
2273
+ const project = ensureWorkspace(req.body?.workspacePath);
2274
+ if (!project) return res.status(400).json({ error: "workspace_path_required" });
2275
+ res.json({ ok: true, project });
2276
+ });
2277
+
2278
+ app.post("/api/heartbeat", (req, res) => {
2279
+ res.json(lockTable.heartbeat(req.body || {}));
2280
+ });
2281
+
2282
+ /**
2283
+ * Scopes lock traffic arriving from a remote room member to the host's own
2284
+ * workspace path.
2285
+ *
2286
+ * The lock table keys every entry by absolute workspace path, and no two
2287
+ * machines agree on that path — Alice's `C:/projects/repo` and Bob's
2288
+ * `/home/bob/repo` produce different keys for the same file, so both agents
2289
+ * are granted the same lock and the coordinator arbitrates nothing.
2290
+ *
2291
+ * coordinator/client.js already rewrites the path on the way out, but that
2292
+ * only holds when the peer is on a current build and its agent was launched
2293
+ * in exactly the mirrored root; it no-ops for a subdirectory, or for a room
2294
+ * that predates hostWorkspacePath. A room shares exactly one project, so any
2295
+ * lock traffic from a member is about that project by definition — deciding
2296
+ * that here makes the shared mutex independent of the peer's version and of
2297
+ * where it happened to start its agent.
2298
+ */
2299
+ function roomScopedBody(req, body) {
2300
+ // A caller holding a member token is a peer no matter which interface it
2301
+ // arrived on; anything else is this machine's own agent, already using the
2302
+ // host's path.
2303
+ if (!req.roomMember && !isRemoteRequest(req)) return body;
2304
+ const room = loadRoom();
2305
+ if (!room?.url || room.role !== "host") return body;
2306
+ const shared = loadRegistry().projects.find((p) => p.id === room.hostProjectId) || currentProject();
2307
+ if (!shared?.path) return body;
2308
+ if (normalizeCompare(body?.workspaceId) === normalizeCompare(shared.path)) return body;
2309
+ return { ...body, workspaceId: shared.path, peerWorkspacePath: body?.workspaceId || null };
2310
+ }
2311
+
2312
+ // The same identity rule the control socket applies, for the HTTP fallback a
2313
+ // guest uses when the tunnel is down. Both paths must stamp a peer's agentId
2314
+ // identically or a failover would let one agent hold the same file twice.
2315
+ function httpLockSession(req) {
2316
+ const remote = Boolean(req.roomMember) || isRemoteRequest(req);
2317
+ return { remote, login: req.roomMember?.login || null, roomId: loadRoom()?.roomId || null };
2318
+ }
2319
+
2320
+ app.post("/api/coord/claim", requireRoomMember, async (req, res) => {
2321
+ const room = loadRoom();
2322
+ const body = roomScopedBody(req, req.body || {});
2323
+ if (room?.role === "guest") {
2324
+ const payload = {
2325
+ ...body,
2326
+ workspaceId: room.hostWorkspacePath || body.workspaceId,
2327
+ peerWorkspacePath: body.workspaceId,
2328
+ };
2329
+ try {
2330
+ // Never claim across an in-flight replay. A release queued during the
2331
+ // outage and this claim ride the same socket, and the host's `release`
2332
+ // only checks `sameOwner` — so a claim that overtakes the replay is
2333
+ // granted and then quietly released out from under the agent by its own
2334
+ // stale release. Letting the queue drain first makes the order the one
2335
+ // the agent actually meant.
2336
+ await roomControl.settled();
2337
+ const result = await roomControl.rpc("claim", payload);
2338
+ const answer = { ...(result || { allowed: false }), source: "host" };
2339
+ return res.status(answer.allowed ? 200 : 409).json(answer);
2340
+ } catch {
2341
+ // Tunnel down. The local table is NOT the shared mutex, and it does not
2342
+ // even mirror it: host lock pushes land in `roomLocks` for the board,
2343
+ // never in `lockTable`. Granting a write lock from it would not be
2344
+ // optimistic, it would be uninformed — the file another member is
2345
+ // holding looks free here every time. Say so instead of inventing a
2346
+ // grant the room never issued, and let the caller drop down to room
2347
+ // HTTP, which is a different wire and may well still be up.
2348
+ if ((body.mode || "write") !== "read") {
2349
+ return res.status(503).json({
2350
+ allowed: false,
2351
+ degraded: true,
2352
+ source: "local_fallback",
2353
+ reason: "control_offline",
2354
+ detail: "Host unreachable — this claim was never adjudicated by the room.",
2355
+ });
2356
+ }
2357
+ /* a read is advisory and cannot corrupt anything: record it locally */
2358
+ }
2359
+ }
2360
+ const scoped = sanitizeLockBody(body, httpLockSession(req));
2361
+ if (!scoped) return res.status(400).json({ allowed: false, error: "agent_required" });
2362
+ const files = Array.isArray(scoped.files) && scoped.files.length ? scoped.files : [scoped.file];
2363
+ const warnings = [];
2364
+ for (const file of files.filter(Boolean)) {
2365
+ const blocked = require("./lib/depGraph").softBlock(lockTable, scoped.workspaceId, scoped.agentId, file);
2366
+ const held = blocked?.file ? lockTable.getLock(blocked.file, scoped.workspaceId) : null;
2367
+ const { sameOwner } = require("./coordinator/lockTable");
2368
+ if (blocked && !(held && sameOwner(held.agentId, scoped.agentId))) {
2369
+ warnings.push(blocked.reason || blocked.holder || String(blocked));
2370
+ }
2371
+ }
2372
+ const result = lockTable.claim(scoped);
2373
+ if (warnings.length && result.allowed) result.warning = result.warning || warnings[0];
2374
+ if (!result.allowed) {
2375
+ const project = projectForPath(scoped.workspaceId) || currentProject();
2376
+ if (project?.id) {
2377
+ bumpCollision(project.id, "claim_blocked", {
2378
+ file: scoped.file,
2379
+ agentId: scoped.agentId,
2380
+ holder: result.holder,
2381
+ reason: result.reason,
2382
+ });
2383
+ }
2384
+ }
2385
+ res.status(result.allowed ? 200 : 409).json(result);
2386
+ });
2387
+ app.post("/api/coord/release", requireRoomMember, async (req, res) => {
2388
+ const room = loadRoom();
2389
+ const body = roomScopedBody(req, req.body || {});
2390
+ if (room?.role === "guest") {
2391
+ try {
2392
+ return res.json({ ...(await roomControl.rpc("release", body)), source: "host" });
2393
+ } catch {
2394
+ // Queue it: unlike a claim, a release is a statement of fact that stays
2395
+ // true. Without the replay the host holds the lock until its TTL runs
2396
+ // out, which is up to five minutes of a file nobody is actually editing.
2397
+ const queued = roomControl.enqueue("release", body);
2398
+ const local = runLockRpc("release", body, httpLockSession(req));
2399
+ return res.json({ ...local, source: "local_fallback", degraded: true, queued });
2400
+ }
2401
+ }
2402
+ res.json(runLockRpc("release", body, httpLockSession(req)));
2403
+ });
2404
+ app.post("/api/coord/heartbeat", requireRoomMember, async (req, res) => {
2405
+ const room = loadRoom();
2406
+ const body = roomScopedBody(req, req.body || {});
2407
+ if (room?.role === "guest") {
2408
+ try {
2409
+ return res.json({ ...(await roomControl.rpc("heartbeat", body, 400)), source: "host" });
2410
+ } catch {
2411
+ // Not queued on purpose: a heartbeat asserts liveness *now*, so a
2412
+ // replayed one would revive a lock the host has already expired.
2413
+ const local = runLockRpc("heartbeat", body, httpLockSession(req));
2414
+ return res.json({ ...local, source: "local_fallback", degraded: true });
2415
+ }
2416
+ }
2417
+ res.json(runLockRpc("heartbeat", body, httpLockSession(req)));
2418
+ });
2419
+ app.post("/api/coord/release-all", requireRoomMember, (req, res) => {
2420
+ const body = roomScopedBody(req, req.body || {});
2421
+ res.json(runLockRpc("release-all", body, httpLockSession(req)));
2422
+ });
2423
+ /**
2424
+ * A file was read, not claimed.
2425
+ *
2426
+ * Deliberately separate from /claim: this can never fail, never blocks, and
2427
+ * never denies anyone. It exists so the room can see the whole picture of what
2428
+ * every agent is working through, not just the few files being written.
2429
+ */
2430
+ app.post("/api/coord/read", requireRoomMember, async (req, res) => {
2431
+ const room = loadRoom();
2432
+ const body = roomScopedBody(req, req.body || {});
2433
+ let degradedRead = false;
2434
+ if (room?.role === "guest") {
2435
+ try {
2436
+ return res.json(
2437
+ await roomControl.rpc("read", { ...body, workspaceId: room.hostWorkspacePath || body.workspaceId }, 300)
2438
+ );
2439
+ } catch {
2440
+ /* tunnel down — record locally so this machine's board still lights up */
2441
+ degradedRead = true;
2442
+ }
2443
+ }
2444
+ const scoped = sanitizeLockBody(body, httpLockSession(req));
2445
+ if (!scoped) return res.json({ ok: false, error: "agent_required" });
2446
+ const files = (Array.isArray(scoped.files) && scoped.files.length ? scoped.files : [scoped.file]).filter(Boolean);
2447
+ const project = projectForPath(scoped.workspaceId);
2448
+ for (const file of files) {
2449
+ lockTable.noteRead({
2450
+ agentId: scoped.agentId,
2451
+ filePath: file,
2452
+ workspaceId: scoped.workspaceId,
2453
+ holder: scoped.holder,
2454
+ });
2455
+ const item = readActivity(scoped, file, project);
2456
+ sse.emit("activity", { workspaceId: project?.id, item });
2457
+ broadcastRoom({ type: "read", workspaceId: project?.id, item });
2458
+ }
2459
+ res.json({ ok: true, files, ...(degradedRead ? { source: "local_fallback", degraded: true } : {}) });
2460
+ });
2461
+
2462
+ function readActivity(scoped, file, project) {
2463
+ const label = scoped.holder?.label || String(scoped.agentId).split(":")[0];
2464
+ return {
2465
+ id: `r_${project?.id || "ws"}_${file}_${scoped.agentId}_${Date.now()}`,
2466
+ kind: "read",
2467
+ agent: label,
2468
+ ownerLogin: scoped.holder?.login || loadSession().login || "local",
2469
+ mine: true,
2470
+ file,
2471
+ text: `${label} read ${file}`,
2472
+ ts: Date.now(),
2473
+ };
2474
+ }
2475
+
2476
+ app.get("/api/coord/status", (req, res) => {
2477
+ const scope = lockScope(req.query.projectId);
2478
+ res.json(scope ? lockTable.status(scope) : lockTable.status());
2479
+ });
2480
+
2481
+ // publicRoom strips invite and member-token hashes: this route answers
2482
+ // through the tunnel, and the raw room file is a credential store.
2483
+ app.get("/api/room", (_req, res) => res.json({ room: publicRoom(loadRoom()) }));
2484
+ app.post("/api/room/share", async (req, res) => {
2485
+ const project = currentProject();
2486
+ const existing = loadRoom(project?.path);
2487
+ if (existing?.role === "guest") {
2488
+ return res.status(409).json({
2489
+ error: "already_guest",
2490
+ hint: "Leave this room first if you want to host your own.",
2491
+ });
2492
+ }
2493
+ const customUrl = req.body?.url ? String(req.body.url).trim().replace(/\/$/, "") : null;
2494
+ const result = await shareRoom(parsePort(process.argv), { ...shareExtras(project), ...(customUrl ? { url: customUrl } : {}) });
2495
+ if (result.ok) {
2496
+ syncRoomLocks();
2497
+ result.warning = await publishAndWatch(result.room, project);
2498
+ result.room = publicRoom(loadRoom(project?.path));
2499
+ }
2500
+ res.status(result.ok ? 200 : 503).json(result);
2501
+ });
2502
+
2503
+ // --- Room membership -----------------------------------------------------
2504
+ // Guests announce themselves on every dashboard poll; membersView treats a
2505
+ // member as online for 45s after its last hello, which is what drives the
2506
+ // online/offline dot in the Team tab on both sides of the tunnel.
2507
+ function hostRoomFor(projectId) {
2508
+ const project = projectId ? loadRegistry().projects.find((p) => p.id === projectId) : currentProject();
2509
+ const room = loadRoom(project?.path);
2510
+ return { project: project || currentProject(), room };
2511
+ }
2512
+
2513
+ app.post("/api/room/hello", requireRoomMember, (req, res) => {
2514
+ const { project, room } = hostRoomFor(req.body?.hostProjectId);
2515
+ if (!room?.url) return res.status(409).json({ error: "no_room" });
2516
+ const user = req.body?.user;
2517
+ if (!user?.login) return res.status(400).json({ error: "user_required" });
2518
+ const next = upsertMember(room, user, req.body?.role === "host" ? "host" : "guest");
2519
+ // The host is a member of its own room; without this it never appears in
2520
+ // the roster a guest renders.
2521
+ upsertMember(next, { ...currentUser(), ...activeAgent(project?.id) }, "host");
2522
+ saveRoom(next, project?.path);
2523
+ sse.emit("presence", { workspaceId: project?.id, userId: user.login });
2524
+ res.json({
2525
+ ok: true,
2526
+ members: membersView(next),
2527
+ hostProjectId: room.hostProjectId || project?.id || null,
2528
+ hostWorkspacePath: room.hostWorkspacePath || project?.path || null,
2529
+ hostLogin: currentUser().login,
2530
+ project: project
2531
+ ? { id: project.id, name: project.name, pathName: path.basename(project.path), remoteUrl: project.remoteUrl }
2532
+ : null,
2533
+ });
2534
+ });
2535
+
2536
+ // Issuing and revoking invites is the host's own privilege. These routes are
2537
+ // reachable through the tunnel like everything else, so a member must not be
2538
+ // able to call them and mint credentials for others.
2539
+ function requireLocal(req, res, next) {
2540
+ if (isRemoteRequest(req)) return res.status(403).json({ error: "host_only" });
2541
+ return next();
2542
+ }
2543
+
2544
+ function inviteLink(room, code, project) {
2545
+ const base = String(room.url || "").replace(/\/$/, "");
2546
+ const u = new URL(`${base}/`);
2547
+ u.searchParams.set("relay_invite", code);
2548
+ const host = room.hostLogin || currentUser().login;
2549
+ const repo = room.repoKey || repoKeyFromRemote(project?.remoteUrl);
2550
+ if (host) u.searchParams.set("relay_host", host);
2551
+ if (repo) u.searchParams.set("relay_repo", repo);
2552
+ if (room.roomId) u.searchParams.set("relay_room", room.roomId);
2553
+ return u.toString();
2554
+ }
2555
+
2556
+ async function ensureHosting(project) {
2557
+ const existing = loadRoom(project?.path);
2558
+ if (existing?.role === "guest") {
2559
+ return {
2560
+ ok: false,
2561
+ status: 409,
2562
+ error: "already_guest",
2563
+ hint: "Leave this room first if you want to host your own.",
2564
+ };
2565
+ }
2566
+ if (existing?.url && existing.role === "host") return { ok: true, room: existing, warning: null };
2567
+ const result = await shareRoom(parsePort(process.argv), shareExtras(project));
2568
+ if (!result.ok) return { ...result, status: 503 };
2569
+ syncRoomLocks();
2570
+ const warning = await publishAndWatch(result.room, project);
2571
+ return { ok: true, room: loadRoom(project?.path), warning };
2572
+ }
2573
+
2574
+ app.post("/api/room/invite", requireLocal, async (req, res) => {
2575
+ const project = currentProject();
2576
+ const hosted = await ensureHosting(project);
2577
+ if (!hosted.ok) return res.status(hosted.status || 409).json(hosted);
2578
+ const room = hosted.room;
2579
+ const login = normLogin(req.body?.login);
2580
+ if (!login) return res.status(400).json({ error: "login_required" });
2581
+ // Warn — but do not block — when the invitee is not on the repo's
2582
+ // collaborator list. The host may be inviting someone they are about to add,
2583
+ // and the same check runs again at redeem time, which is what actually
2584
+ // decides admission.
2585
+ const check = await collaboratorCheck(project, login);
2586
+ const { ok, invite, code, error } = createInvite(room, { login, invitedBy: currentUser().login });
2587
+ if (!ok) return res.status(400).json({ error });
2588
+ saveRoom(room, project?.path);
2589
+ const warning = (await publishAndWatch(room, project)) || hosted.warning;
2590
+ res.status(201).json({
2591
+ ok: true,
2592
+ invite: inviteView(room).find((i) => i.id === invite.id),
2593
+ code,
2594
+ link: inviteLink(room, code, project),
2595
+ collaborator: check,
2596
+ warning,
2597
+ room: publicRoom(loadRoom(project?.path)),
2598
+ });
2599
+ });
2600
+
2601
+ app.get("/api/room/invites", requireLocal, (req, res) => {
2602
+ const room = loadRoom(currentProject()?.path);
2603
+ res.json({ invites: room ? inviteView(room) : [], open: Boolean(room?.open) });
2604
+ });
2605
+
2606
+ app.post("/api/room/invites/:id/revoke", requireLocal, (req, res) => {
2607
+ const project = currentProject();
2608
+ const room = loadRoom(project?.path);
2609
+ if (!room?.url) return res.status(409).json({ error: "not_hosting" });
2610
+ const result = revokeInvite(room, req.params.id);
2611
+ if (!result.ok) return res.status(404).json(result);
2612
+ saveRoom(room, project?.path);
2613
+ res.json({ ok: true, invites: inviteView(room) });
2614
+ });
2615
+
2616
+ app.post("/api/room/kick", requireLocal, (req, res) => {
2617
+ const project = currentProject();
2618
+ const room = loadRoom(project?.path);
2619
+ if (!room?.url) return res.status(409).json({ error: "not_hosting" });
2620
+ const login = normLogin(req.body?.login);
2621
+ if (!login) return res.status(400).json({ error: "login_required" });
2622
+ revokeMember(room, login);
2623
+ saveRoom(dropMember(room, login), project?.path);
2624
+ sse.emit("presence", { workspaceId: project?.id, userId: login, left: true });
2625
+ emitNotice(
2626
+ {
2627
+ type: "left",
2628
+ key: `left:${room.roomId || "room"}:${String(login).toLowerCase()}`,
2629
+ title: `@${login} left the room`,
2630
+ body: "Removed from the shared room — they stay on the GitHub team list.",
2631
+ workspaceId: project?.id || null,
2632
+ payload: { login },
2633
+ },
2634
+ { revive: true }
2635
+ );
2636
+ res.json({ ok: true, members: membersView(loadRoom(project?.path)), invites: inviteView(room) });
2637
+ });
2638
+
2639
+ // The one room route a non-member may call, and only with a valid invite code.
2640
+ // Success mints the member token every other room request requires.
2641
+ app.post("/api/room/redeem", async (req, res) => {
2642
+ const project = currentProject();
2643
+ const room = loadRoom(project?.path);
2644
+ if (!room?.url || room.role !== "host") return res.status(409).json({ error: "not_hosting" });
2645
+ const user = req.body?.user;
2646
+ if (!user?.login) return res.status(400).json({ error: "user_required" });
2647
+
2648
+ if (!room.open) {
2649
+ const code = req.body?.code;
2650
+ const proofGistId = req.body?.proofGistId;
2651
+ if (!code) {
2652
+ if (!proofGistId || !room.roomId || !room.nonce) {
2653
+ return res.status(403).json({
2654
+ error: "invite_required",
2655
+ hint: "This room is invite-only. Ask the host to invite you from the Team tab.",
2656
+ });
2657
+ }
2658
+ const proof = await verifyProof({
2659
+ gistId: proofGistId,
2660
+ roomId: room.roomId,
2661
+ nonce: room.nonce,
2662
+ login: user.login,
2663
+ });
2664
+ if (!proof.ok) {
2665
+ return res.status(403).json({
2666
+ error: proof.reason,
2667
+ hint: "Could not verify your GitHub identity. Ask the host to invite you from the Team tab.",
2668
+ });
2669
+ }
2670
+ }
2671
+ const redeemed = redeemInvite(room, code, user.login);
2672
+ if (!redeemed.ok) {
2673
+ return res.status(403).json({
2674
+ error: redeemed.reason,
2675
+ invitedLogin: redeemed.invitedLogin,
2676
+ hint:
2677
+ redeemed.reason === "invite_wrong_user"
2678
+ ? `That invite was issued to @${redeemed.invitedLogin}.`
2679
+ : "Ask the host to invite you from the Team tab.",
2680
+ });
2681
+ }
2682
+ const check = await collaboratorCheck(project, user.login);
2683
+ if (!check.allowed) {
2684
+ return res.status(403).json({
2685
+ error: "not_a_collaborator",
2686
+ hint: `@${user.login} is not a collaborator on this repository.`,
2687
+ });
2688
+ }
2689
+ if (!redeemed.rejoin) markAccepted(redeemed.invite, user.login);
2690
+ }
2691
+
2692
+ const token = issueMemberToken(room, user.login);
2693
+ upsertMember(room, user, "guest");
2694
+ upsertMember(room, { ...currentUser(), ...activeAgent(project?.id) }, "host");
2695
+ saveRoom(room, project?.path);
2696
+ sse.emit("presence", { workspaceId: project?.id, userId: user.login });
2697
+ console.log(`[relay-room] @${user.login} joined the room`);
2698
+ emitNotice(
2699
+ {
2700
+ type: "joined",
2701
+ key: `joined:${room.roomId || "room"}:${String(user.login).toLowerCase()}`,
2702
+ title: `@${user.login} joined the room`,
2703
+ body: `They're in ${project?.name || "this workspace"} with you.`,
2704
+ workspaceId: project?.id || null,
2705
+ payload: { login: user.login, roomId: room.roomId || null },
2706
+ },
2707
+ { revive: true }
2708
+ );
2709
+ res.json({
2710
+ ok: true,
2711
+ token,
2712
+ roomId: room.roomId || null,
2713
+ members: membersView(room),
2714
+ hostProjectId: room.hostProjectId || project?.id || null,
2715
+ hostWorkspacePath: room.hostWorkspacePath || project?.path || null,
2716
+ hostLogin: currentUser().login,
2717
+ project: project
2718
+ ? { id: project.id, name: project.name, pathName: path.basename(project.path), remoteUrl: project.remoteUrl }
2719
+ : null,
2720
+ });
2721
+ });
2722
+
2723
+ app.get("/api/room/members", requireRoomMember, (req, res) => {
2724
+ const { room } = hostRoomFor(req.query.projectId);
2725
+ res.json({ members: membersView(room) });
2726
+ });
2727
+
2728
+ app.post("/api/room/bye", requireRoomMember, (req, res) => {
2729
+ const { project, room } = hostRoomFor(req.body?.hostProjectId);
2730
+ if (!room?.url || !req.body?.login) return res.json({ ok: true });
2731
+ saveRoom(dropMember(room, req.body.login), project?.path);
2732
+ sse.emit("presence", { workspaceId: project?.id, userId: req.body.login, left: true });
2733
+ emitNotice(
2734
+ {
2735
+ type: "left",
2736
+ key: `left:${room.roomId || "room"}:${String(req.body.login).toLowerCase()}`,
2737
+ title: `@${req.body.login} left the room`,
2738
+ body: "They disconnected from the shared coordinator.",
2739
+ workspaceId: project?.id || null,
2740
+ },
2741
+ { revive: true }
2742
+ );
2743
+ res.json({ ok: true });
2744
+ });
2745
+
2746
+ // --- Shared chat history --------------------------------------------------
2747
+ // The aggregate a guest pulls: this machine's own transcripts plus every other
2748
+ // member's, so guests see each other and not only the host.
2749
+ app.get("/api/room/state", requireRoomMember, (req, res) => {
2750
+ const project = req.query.projectId
2751
+ ? loadRegistry().projects.find((p) => p.id === req.query.projectId) || currentProject()
2752
+ : currentProject();
2753
+ if (!project) return res.status(404).json({ error: "no_local_repo" });
2754
+ const login = currentUser().login || "local";
2755
+ const memory = loadMemory(project.id);
2756
+ const view = mergeRoomViews(memory, { exclude: req.query.exclude || null });
2757
+ res.json({
2758
+ ok: true,
2759
+ hostLogin: login,
2760
+ projectId: project.id,
2761
+ lastTranscriptSyncAt: memory.lastTranscriptSyncAt || 0,
2762
+ chats: view.chats,
2763
+ timeline: view.timeline,
2764
+ activity: view.activity,
2765
+ edits: view.edits,
2766
+ agents: view.agents,
2767
+ stats: memory.stats || {},
2768
+ roomCollisions: mergeCollisionStats(memory, { localLogin: login, exclude: req.query.exclude || null, projectId: project.id }),
2769
+ });
2770
+ });
2771
+
2772
+ app.post("/api/room/history", requireRoomMember, (req, res) => {
2773
+ const project = req.body?.projectId
2774
+ ? loadRegistry().projects.find((p) => p.id === req.body.projectId) || currentProject()
2775
+ : currentProject();
2776
+ if (!project) return res.status(404).json({ error: "no_local_repo" });
2777
+ // Same rule as the socket path: a member may only publish its own history. A remote
2778
+ // caller is whoever its member token says; the login in the body is only trusted locally.
2779
+ const remote = Boolean(req.roomMember) || isRemoteRequest(req);
2780
+ const login = remote ? req.roomMember?.login || "" : String(req.body?.login || "").trim();
2781
+ if (!login) return res.status(remote ? 401 : 400).json({ error: remote ? "member_token_required" : "login_required" });
2782
+ ingestPeerHistory(login, req.body?.snapshot, project);
2783
+ res.json({ ok: true });
2784
+ });
2785
+ // --- MCP over HTTP ---------------------------------------------------------
2786
+ // The same tool surface `backend/mcp/server.js` serves over stdio, reachable
2787
+ // through the room tunnel — so an agent on another machine, or one with no
2788
+ // relay installed at all, can read what the room knows. Stateless: no session
2789
+ // id is issued, so there is nothing to expire and nothing to clean up.
2790
+ //
2791
+ // A caller that arrived from off-machine gets the read-only subset. Writing a
2792
+ // lock or a Central event as this process would attribute it to the host's
2793
+ // workspace and the host's user, and would put a second writer on the lock
2794
+ // table; guests do those through their own relay, which is also the path that
2795
+ // still works when the tunnel does not.
2796
+ app.post("/mcp", requireRoomMember, async (req, res) => {
2797
+ const project = currentProject();
2798
+ const remote = isRemoteRequest(req);
2799
+ const viewer = req.roomMember?.login || null;
2800
+ const ctx = {
2801
+ workspace: project?.path,
2802
+ projectId: project?.id,
2803
+ agentId: `mcp:http:${viewer || (remote ? "guest" : "local")}`,
2804
+ user: currentUser().login,
2805
+ owner: currentUser().login,
2806
+ readOnly: remote,
2807
+ viewer,
2808
+ };
2809
+ const batch = Array.isArray(req.body);
2810
+ const messages = batch ? req.body : [req.body];
2811
+ const replies = [];
2812
+ for (const msg of messages) {
2813
+ try {
2814
+ const reply = await mcpRpc(msg, ctx);
2815
+ if (reply) replies.push({ jsonrpc: "2.0", id: reply.id, result: reply.result });
2816
+ } catch (err) {
2817
+ // A failed tool is a JSON-RPC error, not an HTTP one: the client is
2818
+ // still talking, and a 500 would make it drop the whole connection.
2819
+ replies.push({ jsonrpc: "2.0", id: msg?.id ?? null, error: { code: -32000, message: err.message } });
2820
+ }
2821
+ }
2822
+ // Nothing but notifications in the batch — the spec wants an empty 202.
2823
+ if (!replies.length) return res.status(202).end();
2824
+ res.json(batch ? replies : replies[0]);
2825
+ });
2826
+
2827
+ // The optional server-to-client stream and the session teardown. Declining
2828
+ // both is allowed, and a client that gets 405 here simply stops asking.
2829
+ app.get("/mcp", (_req, res) => res.status(405).json({ error: "streaming_not_supported" }));
2830
+ app.delete("/mcp", (_req, res) => res.status(405).json({ error: "stateless_no_sessions" }));
2831
+
2832
+ app.post("/api/room/adopt", (req, res) => {
2833
+ const url = String(req.body?.url || "").replace(/\/$/, "");
2834
+ if (!url) return res.status(400).json({ error: "url required" });
2835
+ const project = currentProject();
2836
+ const room = saveRoom(
2837
+ upsertMember(
2838
+ {
2839
+ role: "host",
2840
+ url,
2841
+ hostProjectId: project?.id || null,
2842
+ hostWorkspacePath: project?.path || null,
2843
+ hostProjectName: project?.name || null,
2844
+ projectId: project?.id || null,
2845
+ projectPath: project?.path || null,
2846
+ startedAt: new Date().toISOString(),
2847
+ members: [],
2848
+ },
2849
+ currentUser(),
2850
+ "host"
2851
+ ),
2852
+ project?.path
2853
+ );
2854
+ syncRoomLocks();
2855
+ res.json({ ok: true, room });
2856
+ });
2857
+ // Where a guest's copy of the host's repo lands when it has no matching
2858
+ // workspace of its own.
2859
+ function defaultJoinDir(name) {
2860
+ return path.join(os.homedir(), "Documents", "Relay", String(name || "workspace").replace(/[<>:"|?*]/g, "-"));
2861
+ }
2862
+
2863
+ // Resolves the workspace a joining guest will mirror into: an explicit path,
2864
+ // an existing workspace for the same repo, or a fresh folder named after the
2865
+ // host's. Registering it up front is what lets patches, locks and the seed all
2866
+ // agree on one target instead of falling back to "whatever is selected".
2867
+ function resolveJoinProject(hostMeta, explicitPath) {
2868
+ const reg = loadRegistry();
2869
+ if (explicitPath) {
2870
+ const resolved = normalizeWorkspaceRoot(explicitPath) || explicitPath;
2871
+ fs.mkdirSync(resolved, { recursive: true });
2872
+ return ensureWorkspace(resolved);
2873
+ }
2874
+ const match =
2875
+ (hostMeta.remoteUrl && reg.projects.find((p) => p.remoteUrl && p.remoteUrl === hostMeta.remoteUrl)) ||
2876
+ (hostMeta.name && reg.projects.find((p) => p.name === hostMeta.name)) ||
2877
+ (hostMeta.pathName && reg.projects.find((p) => path.basename(p.path) === hostMeta.pathName));
2878
+ if (match) return match;
2879
+ const target = defaultJoinDir(hostMeta.pathName || hostMeta.name);
2880
+ fs.mkdirSync(target, { recursive: true });
2881
+ return ensureWorkspace(target);
2882
+ }
2883
+
2884
+ async function seedFromHost(url, hostProjectId, project) {
2885
+ const snap = await fetchHost(url, `/api/snapshot?full=1&projectId=${encodeURIComponent(hostProjectId || "")}`, {
2886
+ timeoutMs: SEED_TIMEOUT_MS,
2887
+ }).then((r) => r.json());
2888
+ applyingRemote.add(project.id);
2889
+ let written = [];
2890
+ let failed = [];
2891
+ try {
2892
+ ({ written, failed } = writeTree(project.path, snap.files || []));
2893
+ } finally {
2894
+ applyingRemote.delete(project.id);
2895
+ }
2896
+ // Record what we just wrote as known content, so the filesystem watcher does
2897
+ // not immediately publish the whole seeded tree back to the host as if the
2898
+ // guest had authored it.
2899
+ const session = loadSession();
2900
+ for (const file of written) {
2901
+ const patch = computePatch({
2902
+ workspacePath: project.path,
2903
+ file,
2904
+ agent: "seed",
2905
+ ownerLogin: session.login || "local",
2906
+ workspaceId: project.id,
2907
+ });
2908
+ if (patch) {
2909
+ patch.originId = originId();
2910
+ recordPatch(project.id, patch);
2911
+ }
2912
+ }
2913
+ return { files: written.length, failed: failed.length, truncated: Boolean(snap.truncated) };
2914
+ }
2915
+
2916
+ // An invite link is the room URL plus a code, with host/repo/room stamped so
2917
+ // a guest can still find the room after ngrok recycles the URL.
2918
+ function decodeParam(value) {
2919
+ if (!value) return null;
2920
+ try {
2921
+ return decodeURIComponent(value);
2922
+ } catch {
2923
+ return value;
2924
+ }
2925
+ }
2926
+
2927
+ function splitInviteUrl(raw) {
2928
+ const text = String(raw || "").trim();
2929
+ const grab = (name) => {
2930
+ const m = text.match(new RegExp(`[?&#]${name}=([^&#\\s]+)`));
2931
+ return m ? decodeParam(m[1]) : null;
2932
+ };
2933
+ const code = grab("relay_invite");
2934
+ const host = grab("relay_host");
2935
+ const repo = grab("relay_repo");
2936
+ const roomId = grab("relay_room");
2937
+ let url = text;
2938
+ const first = text.search(/[?&#]relay_(invite|host|repo|room)=/);
2939
+ if (first >= 0) url = text.slice(0, first).replace(/[?#]$/, "").replace(/\/$/, "");
2940
+ if (!/^https?:\/\//i.test(url)) url = "";
2941
+ return { url, code, host, repo, roomId };
2942
+ }
2943
+
2944
+ async function resolveJoinTarget(body) {
2945
+ const parsed = splitInviteUrl(body?.url);
2946
+ const code = body?.code || parsed.code;
2947
+ const hostLogin = body?.hostLogin || parsed.host;
2948
+ const repoKey = body?.repoKey || parsed.repo || repoKeyFromRemote(currentProject()?.remoteUrl);
2949
+ const roomId = body?.roomId || parsed.roomId;
2950
+ let url = parsed.url;
2951
+ if (!url && body?.url && /^https?:\/\//i.test(String(body.url).trim())) {
2952
+ url = String(body.url).replace(/\/$/, "");
2953
+ }
2954
+ const lookup = () =>
2955
+ findSignal({
2956
+ gistId: body?.gistId,
2957
+ hostLogin,
2958
+ repoKey,
2959
+ roomId,
2960
+ });
2961
+ let signal = null;
2962
+ if (url) {
2963
+ try {
2964
+ await fetchHost(url, "/api/health", { token: null, timeoutMs: 4000 });
2965
+ } catch {
2966
+ signal = await lookup().catch(() => null);
2967
+ if (signal?.url) url = signal.url;
2968
+ else {
2969
+ const err = new Error("unreachable");
2970
+ err.status = 502;
2971
+ throw err;
2972
+ }
2973
+ }
2974
+ } else {
2975
+ signal = await lookup();
2976
+ if (!signal?.url) return { error: "room_not_found", hint: "The host is not sharing right now." };
2977
+ url = signal.url;
2978
+ }
2979
+ if (!signal && (hostLogin || body?.gistId)) {
2980
+ signal = await lookup().catch(() => null);
2981
+ }
2982
+ return { url, code, signal, hostLogin, repoKey, roomId };
2983
+ }
2984
+
2985
+ app.get("/api/room/discover", requireLocal, async (_req, res) => {
2986
+ const project = currentProject();
2987
+ try {
2988
+ const rooms = await refreshOpenRooms(project);
2989
+ res.json({ ok: true, rooms });
2990
+ } catch (err) {
2991
+ res.json({ ok: false, rooms: [], error: String(err.message || err) });
2992
+ }
2993
+ });
2994
+
2995
+ app.get("/api/notices", requireLocal, (_req, res) => {
2996
+ res.json({ ok: true, notices: listNotices(), unread: unreadCount() });
2997
+ });
2998
+
2999
+ app.post("/api/notices/read-all", requireLocal, (_req, res) => {
3000
+ res.json({ ok: true, notices: markAllRead(), unread: 0 });
3001
+ });
3002
+
3003
+ app.post("/api/notices/:id/read", requireLocal, (req, res) => {
3004
+ markRead(req.params.id);
3005
+ res.json({ ok: true, notices: listNotices(), unread: unreadCount() });
3006
+ });
3007
+
3008
+ app.post("/api/notices/dismiss", requireLocal, (req, res) => {
3009
+ if (req.body?.id) dismissNotice(req.body.id);
3010
+ else if (req.body?.key) dismissByKey(req.body.key);
3011
+ res.json({ ok: true, notices: listNotices(), unread: unreadCount() });
3012
+ });
3013
+
3014
+ app.post("/api/room/join", async (req, res) => {
3015
+ const target = await resolveJoinTarget(req.body || {}).catch((err) => ({ error: "unreachable", detail: String(err.message || err) }));
3016
+ if (target.error) {
3017
+ return res.status(target.error === "room_not_found" ? 404 : 502).json(target);
3018
+ }
3019
+ const { url, code, signal } = target;
3020
+ const seed = req.body?.seed !== false;
3021
+ try {
3022
+ // /api/health needs no credential, and tells us whether this host is even
3023
+ // running a room before we spend an invite on it.
3024
+ const health = await fetchHost(url, "/api/health", { token: null }).then((r) => r.json());
3025
+
3026
+ let proofGistId = null;
3027
+ try {
3028
+ if (signal?.nonce && signal?.roomId) {
3029
+ proofGistId = await createProof({
3030
+ roomId: signal.roomId,
3031
+ nonce: signal.nonce,
3032
+ login: currentUser().login,
3033
+ });
3034
+ }
3035
+ } catch {
3036
+ /* invite code still works without a proof gist */
3037
+ }
3038
+
3039
+ // Redeem before anything else: until the host mints a member token, every
3040
+ // other room route answers 401 and there is nothing useful to do.
3041
+ let admission;
3042
+ try {
3043
+ admission = await fetchHost(url, "/api/room/redeem", {
3044
+ token: null,
3045
+ method: "POST",
3046
+ body: JSON.stringify({ code, user: currentUser(), proofGistId }),
3047
+ }).then((r) => r.json());
3048
+ } catch (err) {
3049
+ if (err.status === 403 || err.status === 409) {
3050
+ const detail = await fetch(`${url}/api/room/redeem`, {
3051
+ method: "POST",
3052
+ headers: { "Content-Type": "application/json", "ngrok-skip-browser-warning": "relay" },
3053
+ body: JSON.stringify({ code, user: currentUser(), proofGistId }),
3054
+ })
3055
+ .then((r) => r.json())
3056
+ .catch(() => ({}));
3057
+ return res.status(403).json({
3058
+ error: detail.error || "invite_required",
3059
+ hint: detail.hint || "This room is invite-only. Ask the host to invite you from the Team tab.",
3060
+ });
3061
+ }
3062
+ throw err;
3063
+ } finally {
3064
+ if (proofGistId) deleteGist(proofGistId).catch(() => undefined);
3065
+ }
3066
+
3067
+ const hostProject = admission.project || null;
3068
+ const hostMeta = {
3069
+ remoteUrl: hostProject?.remoteUrl || null,
3070
+ name: hostProject?.name || null,
3071
+ pathName: hostProject?.pathName || hostProject?.name || null,
3072
+ };
3073
+ const project = resolveJoinProject(hostMeta, req.body?.path);
3074
+ if (!project) return res.status(400).json({ error: "no_local_workspace" });
3075
+
3076
+ const room = saveRoom(
3077
+ {
3078
+ role: "guest",
3079
+ url,
3080
+ memberToken: admission.token || null,
3081
+ roomId: admission.roomId || signal?.roomId || null,
3082
+ signalGistId: signal?.gistId || null,
3083
+ repoKey: signal?.repoKey || repoKeyFromRemote(hostMeta.remoteUrl) || target.repoKey || null,
3084
+ hostProjectId: admission.hostProjectId || health.room?.hostProjectId || null,
3085
+ hostWorkspacePath: admission.hostWorkspacePath || health.room?.hostWorkspacePath || null,
3086
+ hostProjectName: hostMeta.name,
3087
+ hostLogin: admission.hostLogin || signal?.hostLogin || target.hostLogin || null,
3088
+ projectId: project.id,
3089
+ projectPath: project.path,
3090
+ startedAt: new Date().toISOString(),
3091
+ members: Array.isArray(admission.members) ? admission.members : [],
3092
+ },
3093
+ project.path
3094
+ );
3095
+
3096
+ connectHostPatches(room);
3097
+ connectHostControl(room);
3098
+ syncRoomLocks();
3099
+
3100
+ let seeded = null;
3101
+ if (seed) {
3102
+ try {
3103
+ seeded = await seedFromHost(url, room.hostProjectId, project);
3104
+ } catch (err) {
3105
+ seeded = { error: String(err.message || err) };
3106
+ }
3107
+ }
3108
+
3109
+ // Uncommitted work the host has on top of the seed.
3110
+ const pulled = [];
3111
+ try {
3112
+ const snap = await fetchHost(url, "/api/snapshot", { timeoutMs: SEED_TIMEOUT_MS }).then((r) => r.json());
3113
+ const hint = { remoteUrl: snap.remoteUrl, name: snap.name, pathName: snap.pathName };
3114
+ for (const patch of snap.patches || []) {
3115
+ const result = acceptPeerPatch(patch, hint, { skipIfOrigin: true });
3116
+ if (result.applied) pulled.push(patch.file);
3117
+ }
3118
+ } catch {
3119
+ /* guest can relay pull later */
3120
+ }
3121
+
3122
+ try {
3123
+ await pullRoomHistory(room, project);
3124
+ } catch {
3125
+ /* history arrives on the next poll */
3126
+ }
3127
+ pushRoomHistory(room, project).catch(() => undefined);
3128
+ publishGraph(project);
3129
+ sse.emit("projects", { projects: loadRegistry().projects });
3130
+ if (room.roomId) dismissByKey(`invite:${room.roomId}`);
3131
+ emitNotice({
3132
+ type: "joined",
3133
+ key: `in:${room.roomId || "room"}`,
3134
+ title: `You're in @${room.hostLogin || "host"}'s room`,
3135
+ body: room.hostProjectName ? `Sharing ${room.hostProjectName}.` : "Locks and live patches are shared.",
3136
+ workspaceId: project.id,
3137
+ });
3138
+
3139
+ res.json({ ok: true, room: publicRoom(room), project, seeded, pulled });
3140
+ } catch (err) {
3141
+ res.status(502).json({ error: "unreachable", detail: String(err.message || err) });
3142
+ }
3143
+ });
3144
+ app.post("/api/room/leave", async (_req, res) => {
3145
+ const room = loadRoom();
3146
+ if (room?.role === "host") {
3147
+ stopTunnelWatch();
3148
+ killCloudflared();
3149
+ try {
3150
+ await publishSignal(
3151
+ { ...room, live: false },
3152
+ {
3153
+ repoKey: room.repoKey || repoKeyFromRemote(currentProject()?.remoteUrl),
3154
+ hostLogin: currentUser().login,
3155
+ }
3156
+ );
3157
+ } catch {
3158
+ /* leaving still clears locally */
3159
+ }
3160
+ }
3161
+ if (room?.role === "guest" && room.url) {
3162
+ // Tell the host before tearing down, so the roster flips this member to
3163
+ // offline immediately instead of after the 45s presence timeout.
3164
+ await fetchHost(room.url, "/api/room/bye", {
3165
+ method: "POST",
3166
+ body: JSON.stringify({ login: currentUser().login, hostProjectId: room.hostProjectId }),
3167
+ }).catch(() => undefined);
3168
+ }
3169
+ // saveRoom() stamps room.json into every workspace it touches, so clearing
3170
+ // only the selected one leaves other workspaces still pointed at a room the
3171
+ // user just left.
3172
+ clearRoom(currentProject()?.path);
3173
+ for (const p of loadRegistry().projects) {
3174
+ clearRoom(p.path);
3175
+ // A peer's chats are only meaningful while the room lasts; leaving them
3176
+ // behind makes a solo workspace look like it still has collaborators.
3177
+ clearPeers(p.id);
3178
+ }
3179
+ lastPushedSync.clear();
3180
+ roomLocks.start(null);
3181
+ if (hostRetryTimer) {
3182
+ clearTimeout(hostRetryTimer);
3183
+ hostRetryTimer = null;
3184
+ }
3185
+ if (hostSocket) {
3186
+ try {
3187
+ hostSocket.close();
3188
+ } catch {
3189
+ /* ignore */
3190
+ }
3191
+ hostSocket = null;
3192
+ }
3193
+ res.json({ ok: true, room: null });
3194
+ });
3195
+
3196
+ app.get("/api/snapshot", requireRoomMember, (req, res) => {
3197
+ const project = req.query.projectId
3198
+ ? loadRegistry().projects.find((p) => p.id === req.query.projectId) || currentProject()
3199
+ : req.query.path
3200
+ ? projectForPath(req.query.path)
3201
+ : currentProject();
3202
+ if (!project) return res.status(404).json({ error: "no_local_repo" });
3203
+ const session = loadSession();
3204
+ // A joining guest needs the whole tree, not just what git calls dirty:
3205
+ // the dirty-only payload leaves anyone without a prior clone staring at an
3206
+ // empty folder.
3207
+ if (req.query.full === "1" || req.query.full === "true") {
3208
+ const { files, truncated } = treeFiles(project.path);
3209
+ return res.json({
3210
+ ok: true,
3211
+ full: true,
3212
+ remoteUrl: project.remoteUrl,
3213
+ name: project.name,
3214
+ pathName: path.basename(project.path),
3215
+ workspacePath: project.path,
3216
+ files,
3217
+ truncated,
3218
+ });
3219
+ }
3220
+ const patches = dirtyFiles(project.path)
3221
+ .map((file) =>
3222
+ computePatch({
3223
+ workspacePath: project.path,
3224
+ file,
3225
+ agent: "snapshot",
3226
+ ownerLogin: session.login || "local",
3227
+ workspaceId: project.id,
3228
+ })
3229
+ )
3230
+ .filter(Boolean)
3231
+ .map((patch) => {
3232
+ patch.originId = originId();
3233
+ patch.originAgentId = originId();
3234
+ return patch;
3235
+ });
3236
+ res.json({
3237
+ ok: true,
3238
+ remoteUrl: project.remoteUrl,
3239
+ name: project.name,
3240
+ pathName: path.basename(project.path),
3241
+ patches,
3242
+ });
3243
+ });
3244
+
3245
+ app.post("/api/ask", async (req, res) => {
3246
+ const project = req.body?.workspacePath ? projectForPath(req.body.workspacePath) : currentProject();
3247
+ if (!project) return res.status(404).json({ error: "no_local_repo" });
3248
+ const limit = Math.min(80, Math.max(5, Number(req.body?.limit) || 30));
3249
+ const writeFile = req.body?.write !== false;
3250
+ const room = loadRoom(project.path);
3251
+ try {
3252
+ await syncTranscriptsQueued(project, { ownerLogin: currentUser().login || "local" });
3253
+ } catch {
3254
+ /* transcripts are best-effort */
3255
+ }
3256
+ if (room?.role === "guest" && room.url) {
3257
+ await pullRoomHistory(room, project).catch(() => undefined);
3258
+ }
3259
+ const memory = loadMemory(project.id);
3260
+ const view = mergeRoomViews(memory);
3261
+ const user = currentUser();
3262
+ const brief = compileRoomAsk({
3263
+ view,
3264
+ room,
3265
+ login: user.login,
3266
+ locks: locksFor({ id: project.id, path: project.path }),
3267
+ limit,
3268
+ });
3269
+ let written = null;
3270
+ if (writeFile) {
3271
+ const out = path.join(project.path, ".relay", "relay_ask.md");
3272
+ try {
3273
+ fs.mkdirSync(path.dirname(out), { recursive: true });
3274
+ fs.writeFileSync(out, brief.markdown);
3275
+ written = out;
3276
+ } catch (err) {
3277
+ return res.status(500).json({ error: "write_failed", detail: String(err.message || err) });
3278
+ }
3279
+ }
3280
+ res.json({ ok: true, written, markdown: brief.markdown, data: brief.json });
3281
+ });
3282
+
3283
+ app.post("/api/push", (req, res) => {
3284
+ const project = req.body?.workspacePath ? projectForPath(req.body.workspacePath) : currentProject();
3285
+ if (!project) return res.status(404).json({ error: "no_local_repo" });
3286
+ const files = dirtyFiles(project.path);
3287
+ const flushed = files.map((file) => fanOutLocalFile(project, file, originId()));
3288
+ res.json({
3289
+ ok: true,
3290
+ files: flushed.filter((r) => r.flushed).map((r) => r.file),
3291
+ skipped: flushed.filter((r) => !r.flushed).length,
3292
+ });
3293
+ });
3294
+
3295
+ app.post("/api/pull", async (req, res) => {
3296
+ const project = req.body?.workspacePath ? projectForPath(req.body.workspacePath) : currentProject();
3297
+ const room = loadRoom(project?.path);
3298
+ if (!room?.url) return res.status(400).json({ error: "not_in_a_room", hint: "Join a shared room first" });
3299
+ if (room.role === "host") {
3300
+ return res.json({ ok: true, files: [], note: "host already has the working tree" });
3301
+ }
3302
+ try {
3303
+ const snap = await fetch(`${room.url.replace(/\/$/, "")}/api/snapshot`).then((r) => r.json());
3304
+ const hint = { remoteUrl: snap.remoteUrl, name: snap.name, pathName: snap.pathName };
3305
+ const applied = [];
3306
+ for (const patch of snap.patches || []) {
3307
+ const result = acceptPeerPatch(patch, hint, { skipIfOrigin: true });
3308
+ if (result.applied) applied.push(patch.file);
3309
+ }
3310
+ res.json({ ok: true, files: applied });
3311
+ } catch (err) {
3312
+ res.status(502).json({ error: "unreachable", detail: String(err.message || err) });
3313
+ }
3314
+ });
3315
+
3316
+ app.post("/api/patches", requireRoomMember, (req, res) => {
3317
+ const patch = req.body?.patch;
3318
+ if (!patch) return res.status(400).json({ error: "patch_required" });
3319
+ const hint = {
3320
+ remoteUrl: req.body.remoteUrl,
3321
+ name: req.body.name,
3322
+ pathName: req.body.pathName,
3323
+ };
3324
+ const result = acceptPeerPatch(patch, hint, { skipIfOrigin: true });
3325
+ broadcastPatch({
3326
+ workspaceId: result.projectId || req.body.workspaceId,
3327
+ patch,
3328
+ incoming: true,
3329
+ remoteUrl: hint.remoteUrl,
3330
+ name: hint.name,
3331
+ pathName: hint.pathName,
3332
+ originId: patch.originId,
3333
+ });
3334
+ res.json({ ok: true, relayed: true, ...result });
3335
+ });
3336
+
3337
+ app.get("/api/projects/:id/patches", (req, res) => {
3338
+ const project = loadRegistry().projects.find((p) => p.id === req.params.id);
3339
+ if (!project) return res.status(404).json({ error: "not_found" });
3340
+ const memory = loadMemory(project.id);
3341
+ res.json({ patches: memory.patches || [], lastAppliedLamport: memory.lastAppliedLamport || 0 });
3342
+ });
3343
+
3344
+ app.post("/api/projects/:id/rewind", (req, res) => {
3345
+ const project = loadRegistry().projects.find((p) => p.id === req.params.id);
3346
+ if (!project) return res.status(404).json({ error: "not_found" });
3347
+ const lamport = Number(req.body?.lamport || 0);
3348
+ applyingRemote.add(project.id);
3349
+ const result = rewindTo(project.id, project.path, lamport);
3350
+ applyingRemote.delete(project.id);
3351
+ sse.emit("rewind", { workspaceId: project.id, lamport, count: result.patches.length });
3352
+ res.json({ ok: true, ...result });
3353
+ });
3354
+
3355
+ app.post("/api/flush-file", (req, res) => {
3356
+ const workspacePath = req.body?.workspacePath;
3357
+ const agentId = req.body?.agentId;
3358
+ const files = Array.isArray(req.body?.files) && req.body.files.length
3359
+ ? req.body.files
3360
+ : req.body?.file
3361
+ ? [req.body.file]
3362
+ : [];
3363
+ const project = projectForPath(workspacePath);
3364
+ if (!project) return res.status(404).json({ error: "no_local_repo" });
3365
+ const flushed = files.map((file) => fanOutLocalFile(project, file, agentId));
3366
+ res.json({ ok: true, flushed });
3367
+ });
3368
+
3369
+ app.post("/api/flush-owned", (req, res) => {
3370
+ const workspacePath = req.body?.workspacePath;
3371
+ const agentId = req.body?.agentId;
3372
+ const project = projectForPath(workspacePath);
3373
+ if (!project) return res.status(404).json({ error: "no_local_repo" });
3374
+ const { sameOwner } = require("./coordinator/lockTable");
3375
+ const held = lockTable.list({ id: project.id, path: project.path }).filter((e) => sameOwner(e.agentId, agentId));
3376
+ const flushed = held.map((e) => fanOutLocalFile(project, e.filePath, agentId));
3377
+ res.json({ ok: true, flushed });
3378
+ });
3379
+
3380
+ app.post("/api/claim", (req, res) => {
3381
+ const result = lockTable.claim(req.body || {});
3382
+ res.status(result.allowed ? 200 : 409).json(result);
3383
+ });
3384
+
3385
+ app.post("/api/release", (req, res) => {
3386
+ res.json(lockTable.release(req.body || {}));
3387
+ });
3388
+
3389
+ app.post("/api/release-all", (req, res) => {
3390
+ res.json(lockTable.releaseAll(req.body?.agentId));
3391
+ });
3392
+
3393
+ app.post("/api/central/projects", requireCentral, (req, res) => {
3394
+ if (!req.central.admin) return res.status(403).json({ error: "admin_only" });
3395
+ res.status(201).json(createProject(req.body?.name));
3396
+ });
3397
+
3398
+ app.get("/api/central/projects", requireCentral, (req, res) => {
3399
+ if (!req.central.admin) return res.status(403).json({ error: "admin_only" });
3400
+ res.json({ projects: loadProjects() });
3401
+ });
3402
+
3403
+ app.get("/api/central/projects/:id", requireCentral, async (req, res) => {
3404
+ if (!req.central.admin && req.central.project?.id !== req.params.id) {
3405
+ return res.status(403).json({ error: "project_mismatch" });
3406
+ }
3407
+ const project = loadProjects().find((p) => p.id === req.params.id);
3408
+ if (!project) return res.status(404).json({ error: "not_found" });
3409
+ res.json({ project });
3410
+ });
3411
+
3412
+ app.post("/api/central/projects/:id/events", requireCentral, async (req, res) => {
3413
+ if (!req.central.admin && req.central.project?.id !== req.params.id) {
3414
+ return res.status(403).json({ error: "project_mismatch" });
3415
+ }
3416
+ const stored = await appendEvent(req.params.id, req.body || {});
3417
+ sse.emit("central", { workspaceId: req.params.id, event: stored });
3418
+ res.json(stored);
3419
+ });
3420
+
3421
+ app.get("/api/central/projects/:id/events", requireCentral, async (req, res) => {
3422
+ if (!req.central.admin && req.central.project?.id !== req.params.id) {
3423
+ return res.status(403).json({ error: "project_mismatch" });
3424
+ }
3425
+ const events = await listEvents(req.params.id, {
3426
+ since: req.query.since,
3427
+ limit: req.query.limit,
3428
+ kind: req.query.kind,
3429
+ });
3430
+ res.json({ events });
3431
+ });
3432
+
3433
+ app.get("/api/central/projects/:id/context", requireCentral, async (req, res) => {
3434
+ if (!req.central.admin && req.central.project?.id !== req.params.id) {
3435
+ return res.status(403).json({ error: "project_mismatch" });
3436
+ }
3437
+ const events = await listEvents(req.params.id, { limit: 1000 });
3438
+ res.json(jsonContext(events));
3439
+ });
3440
+
3441
+ app.get("/api/central/projects/:id/changes", requireCentral, async (req, res) => {
3442
+ if (!req.central.admin && req.central.project?.id !== req.params.id) {
3443
+ return res.status(403).json({ error: "project_mismatch" });
3444
+ }
3445
+ const events = await listEvents(req.params.id, { kind: "change", limit: 200 });
3446
+ res.json({ changes: events });
3447
+ });
3448
+
3449
+ app.get("/api/central/projects/:id/decisions", requireCentral, async (req, res) => {
3450
+ if (!req.central.admin && req.central.project?.id !== req.params.id) {
3451
+ return res.status(403).json({ error: "project_mismatch" });
3452
+ }
3453
+ res.json({ decisions: jsonContext(await listEvents(req.params.id, { limit: 1000 })).decisions });
3454
+ });
3455
+
3456
+ app.get("/api/central/projects/:id/tasks", requireCentral, async (req, res) => {
3457
+ if (!req.central.admin && req.central.project?.id !== req.params.id) {
3458
+ return res.status(403).json({ error: "project_mismatch" });
3459
+ }
3460
+ res.json({ tasks: jsonContext(await listEvents(req.params.id, { limit: 1000 })).tasks });
3461
+ });
3462
+
3463
+ app.get("/api/fs", requireLocal, async (req, res) => {
3464
+ const { status, body } = await listDir(req.query.path ? String(req.query.path) : null);
3465
+ res.status(status).json(body);
3466
+ });
3467
+ app.get("/api/github/repos", requireLocal, async (req, res) => {
3468
+ res.json(await listGithubRepos({ refresh: req.query.refresh === "1" }));
3469
+ });
3470
+
3471
+ const uiDist = path.join(__dirname, "..", "mission-control", "out");
3472
+ if (fs.existsSync(uiDist)) {
3473
+ app.use(express.static(uiDist));
3474
+ app.get("/", (_req, res) => res.sendFile(path.join(uiDist, "index.html")));
3475
+ } else {
3476
+ app.get("/", (_req, res) => {
3477
+ res.type("html").send(`<!doctype html><html><body style="background:#0b0c0f;color:#e7e7ea;font-family:sans-serif;padding:48px">
3478
+ <h1>Relay API</h1>
3479
+ <p>Coordinator on 127.0.0.1:${coordPort}. Open Mission Control at <a href="${UI_ORIGIN}" style="color:#f59e0b">${UI_ORIGIN}</a>.</p>
3480
+ </body></html>`);
3481
+ });
3482
+ }
3483
+
3484
+ const existingRoom = loadRoom();
3485
+ if (existingRoom?.role === "guest") {
3486
+ connectHostPatches(existingRoom);
3487
+ connectHostControl(existingRoom);
3488
+ }
3489
+ if (existingRoom?.url) syncRoomLocks();
3490
+
3491
+ const port = parsePort(process.argv);
3492
+ const bindHost = process.env.RELAY_BIND_HOST || "127.0.0.1";
3493
+ const server = http.createServer(app);
3494
+ // Two servers attached with `{ server, path }` both see every upgrade, and each rejects
3495
+ // the other's path with a 400: /ws/control never connected and /ws/patches got a 400
3496
+ // written into its upgraded socket. One upgrade handler routes by path instead.
3497
+ wss = new WebSocketServer({ noServer: true });
3498
+ wssControl = new WebSocketServer({ noServer: true });
3499
+ server.on("upgrade", (req, socket, head) => {
3500
+ let pathname = "";
3501
+ try {
3502
+ pathname = new URL(req.url, "http://relay.local").pathname;
3503
+ } catch {
3504
+ /* malformed request line */
3505
+ }
3506
+ const target = pathname === "/ws/patches" ? wss : pathname === "/ws/control" ? wssControl : null;
3507
+ if (!target) {
3508
+ socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
3509
+ socket.destroy();
3510
+ return;
3511
+ }
3512
+ target.handleUpgrade(req, socket, head, (ws) => target.emit("connection", ws, req));
3513
+ });
3514
+
3515
+ /**
3516
+ * Identity is decided once, at the handshake, and never re-read from a frame.
3517
+ *
3518
+ * A socket that re-authorized per message would let a member keep talking
3519
+ * after being kicked, and would trust whatever login a frame claimed. The
3520
+ * returned session is the only thing downstream is allowed to believe about
3521
+ * who is on the other end.
3522
+ */
3523
+ function authorizeRoomSocket(socket, req) {
3524
+ const room = loadRoom();
3525
+ const remote = isRemoteRequest(req);
3526
+ if (!remote) return { remote: false, login: null, roomId: room?.roomId || null };
3527
+ if (room?.role !== "host" || !room.url) {
3528
+ socket.close(4403, "not_hosting");
3529
+ return null;
3530
+ }
3531
+ const url = new URL(req.url, "http://localhost");
3532
+ const token = req.headers["x-relay-room-token"] || url.searchParams.get("token");
3533
+ const member = authorizeMember(room, token);
3534
+ if (!member && !room.open) {
3535
+ socket.close(4401, "not_a_member");
3536
+ return null;
3537
+ }
3538
+ return { remote: true, login: member?.login || null, roomId: room.roomId || null };
3539
+ }
3540
+
3541
+ /** Still the member the handshake proved, and the room still the same room. */
3542
+ function sessionValid(session) {
3543
+ if (!session?.remote) return true;
3544
+ const room = loadRoom();
3545
+ if (room?.role !== "host" || !room.url) return false;
3546
+ if (session.roomId && room.roomId && session.roomId !== room.roomId) return false;
3547
+ if (room.open) return true;
3548
+ return Boolean(session.login) && !revokedLogin(room, session.login);
3549
+ }
3550
+
3551
+ function revokedLogin(room, login) {
3552
+ const tokens = Array.isArray(room.memberTokens) ? room.memberTokens : [];
3553
+ return !tokens.some((t) => normLogin(t.login) === normLogin(login));
3554
+ }
3555
+
3556
+ // Only a peer's lock traffic is rewritten onto the host's workspace path. A
3557
+ // local agent on the host machine is already using that path, and forcing the
3558
+ // rewrite on it would file edits made in some other repo against the shared
3559
+ // one.
3560
+ function peerScoped(body, session) {
3561
+ if (!session?.remote) return body;
3562
+ const room = loadRoom();
3563
+ if (!room?.url || room.role !== "host") return body;
3564
+ const shared = loadRegistry().projects.find((p) => p.id === room.hostProjectId) || currentProject();
3565
+ if (!shared?.path) return body;
3566
+ return { ...body, workspaceId: shared.path, peerWorkspacePath: body?.workspaceId || null };
3567
+ }
3568
+
3569
+ const LOCK_OPS = new Set(["claim", "release", "heartbeat", "release-all", "read"]);
3570
+ const MAX_FILES_PER_CLAIM = 64;
3571
+
3572
+ /**
3573
+ * Everything a remote frame is allowed to influence, restated from scratch.
3574
+ *
3575
+ * A lock request is a security boundary, not a data structure: `agentId` names
3576
+ * the holder the whole room arbitrates against, so a member that could send an
3577
+ * arbitrary one could release a teammate's locks and edit the file underneath
3578
+ * them. Stamping the authenticated login onto the agentId makes that
3579
+ * impossible to express.
3580
+ */
3581
+ function sanitizeLockBody(body, session) {
3582
+ const clean = {
3583
+ agentId: String(body?.agentId || "").slice(0, 200),
3584
+ file: typeof body?.file === "string" ? body.file.slice(0, 1024) : undefined,
3585
+ workspaceId: typeof body?.workspaceId === "string" ? body.workspaceId : undefined,
3586
+ ttl: Number(body?.ttl) || undefined,
3587
+ mode: body?.mode === "read" ? "read" : "write",
3588
+ files: Array.isArray(body?.files)
3589
+ ? body.files.filter((f) => typeof f === "string").slice(0, MAX_FILES_PER_CLAIM)
3590
+ : undefined,
3591
+ dependsOn: Array.isArray(body?.dependsOn)
3592
+ ? body.dependsOn.filter((f) => typeof f === "string").slice(0, MAX_FILES_PER_CLAIM)
3593
+ : undefined,
3594
+ holder: body?.holder && typeof body.holder === "object" ? body.holder : null,
3595
+ };
3596
+ if (!clean.agentId) return null;
3597
+ if (session?.remote) {
3598
+ const login = session.login || "guest";
3599
+ clean.agentId = `${clean.agentId}@${login}`;
3600
+ clean.holder = { ...(clean.holder || {}), login };
3601
+ }
3602
+ return clean;
3603
+ }
3604
+
3605
+ function runLockRpc(op, body, session) {
3606
+ if (!LOCK_OPS.has(op)) return { error: "unknown_op" };
3607
+ const clean = sanitizeLockBody(body, session);
3608
+ if (!clean) return { error: "agent_required" };
3609
+ const scoped = peerScoped(clean, session);
3610
+ if (op === "claim") {
3611
+ const result = lockTable.claim(scoped);
3612
+ if (!result.allowed) {
3613
+ const project = projectForPath(scoped.workspaceId) || currentProject();
3614
+ if (project?.id) {
3615
+ bumpCollision(project.id, "claim_blocked", {
3616
+ file: scoped.file,
3617
+ agentId: scoped.agentId,
3618
+ holder: result.holder,
3619
+ reason: result.reason,
3620
+ via: "room_rpc",
3621
+ });
3622
+ }
3623
+ }
3624
+ return result;
3625
+ }
3626
+ if (op === "release") return lockTable.release(scoped);
3627
+ if (op === "heartbeat") return lockTable.heartbeat(scoped);
3628
+ if (op === "read") {
3629
+ const files = (Array.isArray(scoped.files) && scoped.files.length ? scoped.files : [scoped.file]).filter(Boolean);
3630
+ for (const file of files) {
3631
+ lockTable.noteRead({
3632
+ agentId: scoped.agentId,
3633
+ filePath: file,
3634
+ workspaceId: scoped.workspaceId,
3635
+ holder: scoped.holder,
3636
+ });
3637
+ }
3638
+ return { ok: true, files };
3639
+ }
3640
+ return lockTable.releaseAll(scoped.agentId, scoped.workspaceId);
3641
+ }
3642
+ // ws re-emits the underlying http server's 'error' on this instance. Without a
3643
+ // listener here, EventEmitter rethrows it as an uncaught exception — which
3644
+ // preempts the EADDRINUSE handler on `server` below and dumps a raw stack
3645
+ // trace instead of the "port already in use" message.
3646
+ wss.on("error", (err) => {
3647
+ if (err.code === "EADDRINUSE") return; // handled on `server`
3648
+ console.warn(`[relay-ws] ${err.message || err}`);
3649
+ });
3650
+ wss.on("connection", (socket, req) => {
3651
+ const session = authorizeRoomSocket(socket, req);
3652
+ if (!session) return;
3653
+ const room = loadRoom();
3654
+ // Greet a joining guest with the current table. Without this a board stays
3655
+ // blank from connect until the host's next lock change, which on a quiet
3656
+ // repo can be a very long time.
3657
+ if (room?.role === "host" && room.url) {
3658
+ const project = currentProject();
3659
+ if (project) {
3660
+ setImmediate(() => sendSocket(socket, roomStatePayload(project, "hello")));
3661
+ }
3662
+ }
3663
+ const beat = setInterval(() => {
3664
+ if (socket.readyState !== 1) return;
3665
+ try {
3666
+ socket.ping();
3667
+ socket.send(JSON.stringify({ type: "ping" }));
3668
+ } catch {
3669
+ /* closed */
3670
+ }
3671
+ }, 15000);
3672
+ if (beat.unref) beat.unref();
3673
+ socket.on("close", () => clearInterval(beat));
3674
+ socket.on("message", (raw) => {
3675
+ try {
3676
+ if (!sessionValid(session)) {
3677
+ socket.close(4401, "membership_revoked");
3678
+ return;
3679
+ }
3680
+ const msg = JSON.parse(String(raw));
3681
+ if (msg.type === "pong" || msg.type === "ping") {
3682
+ if (msg.type === "ping") sendSocket(socket, { type: "pong" });
3683
+ return;
3684
+ }
3685
+ if (msg.type === "join") {
3686
+ const project = currentProject();
3687
+ if (project && loadRoom()?.role === "host") sendSocket(socket, roomStatePayload(project, "hello"));
3688
+ return;
3689
+ }
3690
+ if (msg.type === "history") {
3691
+ const project = msg.projectId
3692
+ ? loadRegistry().projects.find((p) => p.id === msg.projectId) || currentProject()
3693
+ : currentProject();
3694
+ // A member may only publish its own history. Trusting the login in the
3695
+ // frame would let any member overwrite another's transcript bucket.
3696
+ const login = session.remote ? session.login : String(msg.login || "").trim();
3697
+ if (!login) return;
3698
+ ingestPeerHistory(login, msg.snapshot, project);
3699
+ return;
3700
+ }
3701
+ if (msg.type === "patch" && msg.patch) {
3702
+ acceptPeerPatch(msg.patch, {
3703
+ remoteUrl: msg.remoteUrl,
3704
+ name: msg.name,
3705
+ pathName: msg.pathName,
3706
+ }, { skipIfOrigin: true });
3707
+ broadcastPatch({
3708
+ workspaceId: msg.workspaceId,
3709
+ patch: msg.patch,
3710
+ incoming: true,
3711
+ remoteUrl: msg.remoteUrl,
3712
+ name: msg.name,
3713
+ pathName: msg.pathName,
3714
+ originId: msg.patch.originId,
3715
+ });
3716
+ }
3717
+ } catch {
3718
+ /* ignore malformed */
3719
+ }
3720
+ });
3721
+ });
3722
+ wssControl.on("error", (err) => {
3723
+ if (err.code === "EADDRINUSE") return;
3724
+ console.warn(`[relay-ws-control] ${err.message || err}`);
3725
+ });
3726
+ wssControl.on("connection", (socket, req) => {
3727
+ const session = authorizeRoomSocket(socket, req);
3728
+ if (!session) return;
3729
+ const room = loadRoom();
3730
+ if (room?.role === "host" && room.url) {
3731
+ const shared = lockScope(room.hostProjectId) || (currentProject() ? { id: currentProject().id, path: currentProject().path } : null);
3732
+ if (shared) sendSocket(socket, { type: "locks", workspaceId: room.hostProjectId || shared.id, locks: locksFor(shared), reads: readsFor(shared) });
3733
+ }
3734
+ const beat = setInterval(() => {
3735
+ if (socket.readyState !== 1) return;
3736
+ try {
3737
+ socket.ping();
3738
+ sendSocket(socket, { type: "ping" });
3739
+ } catch {
3740
+ /* closed */
3741
+ }
3742
+ }, 12000);
3743
+ if (beat.unref) beat.unref();
3744
+ socket.on("close", () => clearInterval(beat));
3745
+ // A lock frame is small by construction. Anything larger is not a claim, and
3746
+ // parsing it would only hand an unauthenticated peer a cheap way to burn the
3747
+ // host's event loop — the one thing every other agent's claim waits on.
3748
+ const MAX_FRAME_BYTES = 64 * 1024;
3749
+ const RPC_BURST = 60;
3750
+ let windowStart = Date.now();
3751
+ let inWindow = 0;
3752
+ socket.on("message", (raw) => {
3753
+ try {
3754
+ if (raw.length > MAX_FRAME_BYTES) {
3755
+ socket.close(4413, "frame_too_large");
3756
+ return;
3757
+ }
3758
+ if (!sessionValid(session)) {
3759
+ socket.close(4401, "membership_revoked");
3760
+ return;
3761
+ }
3762
+ const msg = JSON.parse(String(raw));
3763
+ if (msg.type === "pong" || msg.type === "ping") {
3764
+ if (msg.type === "ping") sendSocket(socket, { type: "pong" });
3765
+ return;
3766
+ }
3767
+ if (msg.type === "join") {
3768
+ const hosted = loadRoom();
3769
+ const shared = lockScope(hosted?.hostProjectId) || (currentProject() ? { id: currentProject().id, path: currentProject().path } : null);
3770
+ if (shared) sendSocket(socket, { type: "locks", workspaceId: hosted.hostProjectId || shared.id, locks: locksFor(shared), reads: readsFor(shared) });
3771
+ return;
3772
+ }
3773
+ if (msg.type !== "rpc") return;
3774
+ const now = Date.now();
3775
+ if (now - windowStart > 1000) {
3776
+ windowStart = now;
3777
+ inWindow = 0;
3778
+ }
3779
+ if (++inWindow > RPC_BURST) {
3780
+ sendSocket(socket, { type: "rpc-err", id: msg.id, error: "rate_limited" });
3781
+ return;
3782
+ }
3783
+ try {
3784
+ sendSocket(socket, { type: "rpc-ok", id: msg.id, result: runLockRpc(msg.op, msg.body, session) });
3785
+ } catch (err) {
3786
+ sendSocket(socket, { type: "rpc-err", id: msg.id, error: err.message || "rpc_failed" });
3787
+ }
3788
+ } catch {
3789
+ /* ignore */
3790
+ }
3791
+ });
3792
+ });
3793
+ server.on("error", (err) => {
3794
+ if (err.code === "EADDRINUSE") {
3795
+ console.error(
3796
+ `[relay-api] port ${port} is already in use — another \`relay serve\` is running. ` +
3797
+ `Leaving its coordinator state untouched.`
3798
+ );
3799
+ process.exit(1);
3800
+ }
3801
+ throw err;
3802
+ });
3803
+ server.listen(port, bindHost, () => {
3804
+ writeState(process.cwd(), coordPort, { apiPort: port });
3805
+ console.log(`[relay-api] http://${bindHost}:${port}`);
3806
+ console.log(`[relay-coordinator] 127.0.0.1:${coordPort}`);
3807
+ console.log(`[relay-ui] ${UI_ORIGIN}`);
3808
+ const hosted = loadRoom();
3809
+ if (hosted?.role === "host") {
3810
+ startTunnelWatch();
3811
+ shareRoom(port, shareExtras(currentProject()))
3812
+ .then(async (result) => {
3813
+ if (!result.ok) {
3814
+ console.warn(`[relay-room] could not resume hosting: ${result.hint || result.error}`);
3815
+ return;
3816
+ }
3817
+ const warning = await publishAndWatch(result.room, currentProject());
3818
+ console.log(`[relay-room] resumed hosting at ${result.room.url}`);
3819
+ if (warning) console.warn(`[relay-room] ${warning}`);
3820
+ })
3821
+ .catch((err) => console.warn(`[relay-room] resume failed: ${err.message || err}`));
3822
+ } else if (hosted?.role === "guest") {
3823
+ refreshGuestRoom(hosted)
3824
+ .then((next) => {
3825
+ connectHostPatches(next || hosted);
3826
+ connectHostControl(next || hosted);
3827
+ })
3828
+ .catch(() => {
3829
+ connectHostPatches(hosted);
3830
+ connectHostControl(hosted);
3831
+ });
3832
+ }
3833
+ });
3834
+ }
3835
+
3836
+ main().catch((err) => {
3837
+ console.error(err);
3838
+ process.exit(1);
3839
+ });