relaybrain 0.0.0-stage → 0.2.1

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