relaybrain 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -2
  3. package/backend/coordinator/client.js +364 -0
  4. package/backend/coordinator/lockTable.js +649 -0
  5. package/backend/coordinator/server.js +179 -0
  6. package/backend/lib/centralAuth.js +62 -0
  7. package/backend/lib/centralStore.js +112 -0
  8. package/backend/lib/centralSync.js +88 -0
  9. package/backend/lib/cloneRepo.js +108 -0
  10. package/backend/lib/collisionMetrics.js +231 -0
  11. package/backend/lib/conflicts.js +35 -0
  12. package/backend/lib/dashboard.js +53 -0
  13. package/backend/lib/depGraph.js +393 -0
  14. package/backend/lib/deps.js +46 -0
  15. package/backend/lib/dirtyFiles.js +33 -0
  16. package/backend/lib/editMatchers.js +55 -0
  17. package/backend/lib/falkor/analytics.js +228 -0
  18. package/backend/lib/falkor/bus.js +16 -0
  19. package/backend/lib/falkor/central.js +281 -0
  20. package/backend/lib/falkor/client.js +165 -0
  21. package/backend/lib/falkor/config.js +73 -0
  22. package/backend/lib/falkor/coord.js +299 -0
  23. package/backend/lib/falkor/cursor.js +58 -0
  24. package/backend/lib/falkor/fixtures.js +85 -0
  25. package/backend/lib/falkor/index.js +146 -0
  26. package/backend/lib/falkor/ingest.js +233 -0
  27. package/backend/lib/falkor/intents.js +32 -0
  28. package/backend/lib/falkor/keys.js +102 -0
  29. package/backend/lib/falkor/migrations.js +56 -0
  30. package/backend/lib/falkor/project.js +307 -0
  31. package/backend/lib/falkor/query.js +344 -0
  32. package/backend/lib/falkor/redact.js +30 -0
  33. package/backend/lib/falkor/routes.js +81 -0
  34. package/backend/lib/falkor/schema.js +108 -0
  35. package/backend/lib/falkor/write.js +281 -0
  36. package/backend/lib/githubLogin.js +108 -0
  37. package/backend/lib/githubOAuth.js +42 -0
  38. package/backend/lib/githubRepo.js +117 -0
  39. package/backend/lib/harvest.js +85 -0
  40. package/backend/lib/httpServer.js +27 -0
  41. package/backend/lib/installAgentRelay.js +235 -0
  42. package/backend/lib/installHooks.js +309 -0
  43. package/backend/lib/lamport.js +14 -0
  44. package/backend/lib/localBrowse.js +109 -0
  45. package/backend/lib/memorySync.js +63 -0
  46. package/backend/lib/notices.js +143 -0
  47. package/backend/lib/ot.js +74 -0
  48. package/backend/lib/patches.js +192 -0
  49. package/backend/lib/paths.js +85 -0
  50. package/backend/lib/peerClash.js +64 -0
  51. package/backend/lib/presence.js +35 -0
  52. package/backend/lib/relayOsBlock.js +84 -0
  53. package/backend/lib/repoSnapshot.js +125 -0
  54. package/backend/lib/room.js +498 -0
  55. package/backend/lib/roomAsk.js +148 -0
  56. package/backend/lib/roomAuth.js +215 -0
  57. package/backend/lib/roomControl.js +226 -0
  58. package/backend/lib/roomLocks.js +201 -0
  59. package/backend/lib/roomSignal.js +265 -0
  60. package/backend/lib/roomSync.js +194 -0
  61. package/backend/lib/seed.js +107 -0
  62. package/backend/lib/sseHub.js +40 -0
  63. package/backend/lib/store.js +79 -0
  64. package/backend/lib/supergraph.js +46 -0
  65. package/backend/lib/transcripts/discover.js +338 -0
  66. package/backend/lib/transcripts/parse.js +558 -0
  67. package/backend/lib/transcripts/sessionId.js +36 -0
  68. package/backend/lib/transcripts/sync.js +291 -0
  69. package/backend/lib/transcripts/util.js +299 -0
  70. package/backend/lib/treeSitterImports.js +219 -0
  71. package/backend/lib/watchWorkspace.js +42 -0
  72. package/backend/mcp/README.md +193 -0
  73. package/backend/mcp/server.js +42 -0
  74. package/backend/mcp/tools.js +484 -0
  75. package/backend/server.js +3839 -0
  76. package/cli/graph.js +278 -0
  77. package/cli/relay.js +419 -0
  78. package/docker-compose.falkor.yml +17 -0
  79. package/docs/MEMORY-GRAPH.md +269 -0
  80. package/hooks/relay-antigravity-post-tool.js +2 -0
  81. package/hooks/relay-antigravity-pre-read.js +2 -0
  82. package/hooks/relay-antigravity-pre-tool.js +2 -0
  83. package/hooks/relay-antigravity-stop.js +2 -0
  84. package/hooks/relay-claude-post-tool.js +2 -0
  85. package/hooks/relay-claude-pre-read.js +2 -0
  86. package/hooks/relay-claude-pre-tool.js +2 -0
  87. package/hooks/relay-claude-stop.js +2 -0
  88. package/hooks/relay-codex-post-tool.js +2 -0
  89. package/hooks/relay-codex-pre-read.js +2 -0
  90. package/hooks/relay-codex-pre-tool.js +2 -0
  91. package/hooks/relay-codex-stop.js +2 -0
  92. package/hooks/relay-copilot-post-tool.js +2 -0
  93. package/hooks/relay-copilot-pre-read.js +2 -0
  94. package/hooks/relay-copilot-pre-tool.js +2 -0
  95. package/hooks/relay-copilot-stop.js +2 -0
  96. package/hooks/relay-cursor-post-tool.js +2 -0
  97. package/hooks/relay-cursor-pre-read.js +2 -0
  98. package/hooks/relay-cursor-pre-tool.js +2 -0
  99. package/hooks/relay-cursor-stop.js +2 -0
  100. package/hooks/relay-hook-lib.js +621 -0
  101. package/mission-control/out/404.html +1 -0
  102. package/mission-control/out/__next.__PAGE__.txt +26 -0
  103. package/mission-control/out/__next._full.txt +21 -0
  104. package/mission-control/out/__next._tree.txt +4 -0
  105. package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
  106. package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  107. package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
  108. package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
  109. package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
  110. package/mission-control/out/_next/static/chunks/25rvg4bnew99f.css +4 -0
  111. package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
  112. package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
  113. package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
  114. package/mission-control/out/_next/static/chunks/3_5x4qn1_b4al.js +1 -0
  115. package/mission-control/out/_next/static/chunks/3o1tiwncjss_k.js +1 -0
  116. package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
  117. package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
  118. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_buildManifest.js +11 -0
  119. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_clientMiddlewareManifest.js +1 -0
  120. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_ssgManifest.js +1 -0
  121. package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
  122. package/mission-control/out/_not-found/__next._full.txt +18 -0
  123. package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
  124. package/mission-control/out/_not-found/__next._tree.txt +3 -0
  125. package/mission-control/out/_not-found.html +1 -0
  126. package/mission-control/out/_not-found.txt +18 -0
  127. package/mission-control/out/agents/antigravity.png +0 -0
  128. package/mission-control/out/agents/claude.png +0 -0
  129. package/mission-control/out/agents/codex.png +0 -0
  130. package/mission-control/out/agents/copilot.png +0 -0
  131. package/mission-control/out/agents/cursor.png +0 -0
  132. package/mission-control/out/icon.svg +4 -0
  133. package/mission-control/out/index.html +1 -0
  134. package/mission-control/out/index.txt +21 -0
  135. package/mission-control/out/logo.png +0 -0
  136. package/mission-control/out/logo_transparent.png +0 -0
  137. package/package.json +74 -4
  138. package/scripts/falkor-rebuild.js +57 -0
  139. package/scripts/falkor-seed-demo.js +157 -0
@@ -0,0 +1,498 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
4
+ const { spawn, spawnSync } = require("child_process");
5
+ const { homeRelayDir } = require("./paths");
6
+ const { normalizeWorkspaceRoot } = require("./transcripts/util");
7
+
8
+ function roomPath(workspacePath) {
9
+ if (workspacePath) {
10
+ const root = normalizeWorkspaceRoot(workspacePath) || workspacePath;
11
+ return path.join(root, ".relay", "room.json");
12
+ }
13
+ return path.join(homeRelayDir(), "room.json");
14
+ }
15
+
16
+ function loadRoom(workspacePath) {
17
+ const files = [roomPath(workspacePath), roomPath(null)];
18
+ if (process.env.RELAY_ROOM_URL) {
19
+ return { role: process.env.RELAY_ROOM_ROLE || "guest", url: process.env.RELAY_ROOM_URL, hostProjectId: process.env.RELAY_ROOM_PROJECT || null };
20
+ }
21
+ for (const file of files) {
22
+ try {
23
+ const room = JSON.parse(fs.readFileSync(file, "utf8"));
24
+ if (room?.url || room?.roomId) return room;
25
+ } catch {
26
+ /* next */
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+
32
+ function saveRoom(room, workspacePath) {
33
+ const targets = [roomPath(null)];
34
+ if (workspacePath) targets.push(roomPath(workspacePath));
35
+ for (const file of targets) {
36
+ try {
37
+ fs.mkdirSync(path.dirname(file), { recursive: true });
38
+ fs.writeFileSync(file, JSON.stringify(room, null, 2));
39
+ } catch {
40
+ /* skip a target that cannot be created (e.g. stale registry path) */
41
+ }
42
+ }
43
+ return room;
44
+ }
45
+
46
+ function clearRoom(workspacePath) {
47
+ for (const file of [roomPath(workspacePath), roomPath(null)]) {
48
+ try {
49
+ fs.unlinkSync(file);
50
+ } catch {
51
+ /* ignore */
52
+ }
53
+ }
54
+ }
55
+
56
+ function publicUrl() {
57
+ return loadRoom()?.url || null;
58
+ }
59
+
60
+ function upsertMember(room, user, role) {
61
+ if (!room || !user?.login) return room;
62
+ const members = Array.isArray(room.members) ? room.members : [];
63
+ const row = {
64
+ id: user.id || `user_${user.login}`,
65
+ login: user.login,
66
+ name: user.name || user.login,
67
+ avatarUrl: user.avatarUrl || "",
68
+ role: role || user.role || "guest",
69
+ online: true,
70
+ lastSeen: Date.now(),
71
+ agentActive: Boolean(user.agentActive),
72
+ agentLabel: user.agentLabel || null,
73
+ };
74
+ const i = members.findIndex((m) => m.login === row.login || m.id === row.id);
75
+ if (i >= 0) members[i] = { ...members[i], ...row };
76
+ else members.push(row);
77
+ room.members = members;
78
+ return room;
79
+ }
80
+
81
+ function markLeft(room, login) {
82
+ if (!room?.members || !login) return room;
83
+ const target = String(login).toLowerCase();
84
+ room.members = room.members.map((m) =>
85
+ String(m.login || "").toLowerCase() === target ? { ...m, online: false, lastSeen: Date.now() - 60_000 } : m
86
+ );
87
+ return room;
88
+ }
89
+
90
+ function dropMember(room, login) {
91
+ if (!room?.members || !login) return room;
92
+ const target = String(login).toLowerCase();
93
+ room.members = room.members.filter((m) => String(m.login || "").toLowerCase() !== target);
94
+ return room;
95
+ }
96
+
97
+ function membersView(room) {
98
+ const now = Date.now();
99
+ return (room?.members || []).map((m) => ({
100
+ id: m.id,
101
+ login: m.login,
102
+ name: m.name,
103
+ avatarUrl: m.avatarUrl,
104
+ role: m.role,
105
+ online: now - (m.lastSeen || 0) < 45_000,
106
+ agentActive: Boolean(m.agentActive),
107
+ agentLabel: m.agentLabel || null,
108
+ }));
109
+ }
110
+
111
+ async function fetchTunnels() {
112
+ const res = await fetch("http://127.0.0.1:4040/api/tunnels");
113
+ if (!res.ok) return [];
114
+ const body = await res.json();
115
+ return body.tunnels || [];
116
+ }
117
+
118
+ function pickHttps(tunnels) {
119
+ const httpTunnels = tunnels.filter((t) => String(t.public_url || "").startsWith("https://"));
120
+ return (httpTunnels[0] || tunnels[0] || {}).public_url || null;
121
+ }
122
+
123
+ async function probeTunnel(url, timeoutMs = 4000) {
124
+ if (!url) return false;
125
+ try {
126
+ const res = await fetch(`${String(url).replace(/\/$/, "")}/api/health`, {
127
+ headers: { "ngrok-skip-browser-warning": "relay" },
128
+ signal: AbortSignal.timeout(timeoutMs),
129
+ });
130
+ return res.ok;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ async function pickLiveHttps(tunnels) {
137
+ const https = tunnels.filter((t) => String(t.public_url || "").startsWith("https://"));
138
+ for (const t of https) {
139
+ if (await probeTunnel(t.public_url)) return t.public_url;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ async function waitForNgrok(timeoutMs = 20000) {
145
+ const started = Date.now();
146
+ let last = null;
147
+ while (Date.now() - started < timeoutMs) {
148
+ try {
149
+ last = pickHttps(await fetchTunnels());
150
+ // Trust the local inspector. Probing the public URL from here used to
151
+ // hammer ngrok-free every 400ms until the session was killed.
152
+ if (last) return last;
153
+ } catch {
154
+ /* ngrok inspector not up yet */
155
+ }
156
+ await new Promise((r) => setTimeout(r, 400));
157
+ }
158
+ return last;
159
+ }
160
+
161
+ let ngrokChild = null;
162
+ let cloudflaredChild = null;
163
+ let cloudflaredUrl = null;
164
+ let ensureInFlight = null;
165
+ let lastSpawnAt = 0;
166
+ let inspectorMisses = 0;
167
+
168
+ function childAlive(child) {
169
+ if (!child?.pid) return false;
170
+ try {
171
+ process.kill(child.pid, 0);
172
+ return true;
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ function ngrokAlive() {
179
+ if (!childAlive(ngrokChild)) {
180
+ ngrokChild = null;
181
+ return false;
182
+ }
183
+ return true;
184
+ }
185
+
186
+ function cloudflaredAlive() {
187
+ if (!childAlive(cloudflaredChild)) {
188
+ cloudflaredChild = null;
189
+ cloudflaredUrl = null;
190
+ return false;
191
+ }
192
+ return true;
193
+ }
194
+
195
+ function killProcess(child) {
196
+ if (!child?.pid) return;
197
+ try {
198
+ if (process.platform === "win32") {
199
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
200
+ stdio: "ignore",
201
+ windowsHide: true,
202
+ shell: false,
203
+ });
204
+ killer.unref();
205
+ } else {
206
+ child.kill("SIGTERM");
207
+ }
208
+ } catch {
209
+ /* already gone */
210
+ }
211
+ }
212
+
213
+ function killNgrok() {
214
+ const child = ngrokChild;
215
+ ngrokChild = null;
216
+ killProcess(child);
217
+ }
218
+
219
+ function killCloudflared() {
220
+ const child = cloudflaredChild;
221
+ cloudflaredChild = null;
222
+ cloudflaredUrl = null;
223
+ killProcess(child);
224
+ }
225
+
226
+ const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/i;
227
+
228
+ function spawnCloudflared(port, timeoutMs = 15000) {
229
+ if (cloudflaredAlive() && cloudflaredUrl) return Promise.resolve(cloudflaredUrl);
230
+ return new Promise((resolve) => {
231
+ let settled = false;
232
+ const finish = (url) => {
233
+ if (settled) return;
234
+ settled = true;
235
+ clearTimeout(timer);
236
+ resolve(url || null);
237
+ };
238
+ const timer = setTimeout(() => finish(cloudflaredUrl), timeoutMs);
239
+ if (timer.unref) timer.unref();
240
+ try {
241
+ const child = spawn(
242
+ "cloudflared",
243
+ ["tunnel", "--url", `http://127.0.0.1:${port}`, "--no-autoupdate"],
244
+ {
245
+ stdio: ["ignore", "pipe", "pipe"],
246
+ windowsHide: true,
247
+ detached: true,
248
+ shell: false,
249
+ }
250
+ );
251
+ const onChunk = (chunk) => {
252
+ const m = String(chunk || "").match(TRYCLOUDFLARE_RE);
253
+ if (m) {
254
+ cloudflaredUrl = m[0];
255
+ finish(cloudflaredUrl);
256
+ }
257
+ };
258
+ child.stdout?.on("data", onChunk);
259
+ child.stderr?.on("data", onChunk);
260
+ child.on("error", () => {
261
+ if (cloudflaredChild === child) {
262
+ cloudflaredChild = null;
263
+ cloudflaredUrl = null;
264
+ }
265
+ finish(null);
266
+ });
267
+ child.on("exit", () => {
268
+ if (cloudflaredChild === child) {
269
+ cloudflaredChild = null;
270
+ cloudflaredUrl = null;
271
+ }
272
+ finish(null);
273
+ });
274
+ if (child.pid) {
275
+ child.unref();
276
+ cloudflaredChild = child;
277
+ } else {
278
+ finish(null);
279
+ }
280
+ } catch {
281
+ finish(null);
282
+ }
283
+ });
284
+ }
285
+
286
+ function tryTailscaleFunnel(port) {
287
+ try {
288
+ const st = spawnSync("tailscale", ["status", "--json"], {
289
+ encoding: "utf8",
290
+ windowsHide: true,
291
+ timeout: 3000,
292
+ });
293
+ if (st.error || st.status !== 0) return null;
294
+ const info = JSON.parse(st.stdout || "{}");
295
+ if (info.BackendState !== "Running") return null;
296
+ const dns = String(info.Self?.DNSName || "").replace(/\.$/, "");
297
+ if (!dns) return null;
298
+ const fn = spawnSync("tailscale", ["funnel", "--bg", String(port)], {
299
+ encoding: "utf8",
300
+ windowsHide: true,
301
+ timeout: 5000,
302
+ });
303
+ if (fn.error || fn.status !== 0) return null;
304
+ return `https://${dns}`;
305
+ } catch {
306
+ return null;
307
+ }
308
+ }
309
+
310
+ function spawnNgrok(port) {
311
+ if (ngrokAlive()) return ngrokChild;
312
+ if (lastSpawnAt && Date.now() - lastSpawnAt < 20_000) return ngrokChild;
313
+ try {
314
+ // Never shell:true on Windows — that opens a visible cmd even with
315
+ // windowsHide. Spawn ngrok.exe directly, hide the console, detach so it
316
+ // outlives accidental parent death, and unref so the API can exit.
317
+ const child = spawn("ngrok", ["http", String(port), "--log=stdout"], {
318
+ stdio: "ignore",
319
+ windowsHide: true,
320
+ detached: true,
321
+ shell: false,
322
+ });
323
+ child.on("error", () => {
324
+ if (ngrokChild === child) ngrokChild = null;
325
+ });
326
+ child.on("exit", () => {
327
+ if (ngrokChild === child) ngrokChild = null;
328
+ });
329
+ if (child.pid) {
330
+ child.unref();
331
+ ngrokChild = child;
332
+ lastSpawnAt = Date.now();
333
+ return child;
334
+ }
335
+ } catch {
336
+ /* caller surfaces install hint */
337
+ }
338
+ return null;
339
+ }
340
+
341
+ const NGROK_HINT =
342
+ "No tunnel provider could be started. Install Cloudflare Quick Tunnels (`winget install Cloudflare.cloudflared` / `brew install cloudflared` — zero-signup), enable Tailscale Funnel (`tailscale funnel 3001`), set RELAY_PUBLIC_URL=<https://your-host>, or paste any forwarding URL in the Team tab.";
343
+
344
+ async function listedTunnels() {
345
+ try {
346
+ return await fetchTunnels();
347
+ } catch {
348
+ return [];
349
+ }
350
+ }
351
+
352
+ function activeTunnelUrl() {
353
+ if (process.env.RELAY_PUBLIC_URL) return String(process.env.RELAY_PUBLIC_URL).trim().replace(/\/$/, "");
354
+ if (cloudflaredAlive() && cloudflaredUrl) return cloudflaredUrl;
355
+ return null;
356
+ }
357
+
358
+ /**
359
+ * Returns a reachable public URL for this API.
360
+ *
361
+ * Provider priority (configurable via RELAY_TUNNEL=cloudflared|tailscale|ngrok|custom):
362
+ * 1. RELAY_PUBLIC_URL env var (or already-running cloudflared / ngrok inspector)
363
+ * 2. Cloudflare Quick Tunnel (`cloudflared tunnel --url ...`) — fast, free, no account needed
364
+ * 3. Tailscale Funnel (`tailscale funnel --bg <port>`) — fast if Tailscale is logged in
365
+ * 4. ngrok (`ngrok http <port>`) — legacy fallback
366
+ */
367
+ async function ensureTunnel(port) {
368
+ if (ensureInFlight) return ensureInFlight;
369
+ ensureInFlight = ensureTunnelOnce(port).finally(() => {
370
+ ensureInFlight = null;
371
+ });
372
+ return ensureInFlight;
373
+ }
374
+
375
+ async function ensureTunnelOnce(port) {
376
+ const pref = String(process.env.RELAY_TUNNEL || "").trim().toLowerCase();
377
+ if (process.env.RELAY_PUBLIC_URL) {
378
+ return String(process.env.RELAY_PUBLIC_URL).trim().replace(/\/$/, "");
379
+ }
380
+ if (cloudflaredAlive() && cloudflaredUrl && (!pref || pref === "cloudflared")) {
381
+ return cloudflaredUrl;
382
+ }
383
+
384
+ // Try Cloudflare Quick Tunnel first (unless user explicitly requested tailscale/ngrok).
385
+ if (!pref || pref === "cloudflared") {
386
+ const cfUrl = await spawnCloudflared(port);
387
+ if (cfUrl) return cfUrl;
388
+ if (pref === "cloudflared") return null;
389
+ }
390
+
391
+ // Try Tailscale Funnel next.
392
+ if (!pref || pref === "tailscale") {
393
+ const tsUrl = tryTailscaleFunnel(port);
394
+ if (tsUrl) return tsUrl;
395
+ if (pref === "tailscale") return null;
396
+ }
397
+
398
+ // Fallback: ngrok local inspector + spawn.
399
+ let tunnels = await listedTunnels();
400
+ let listed = pickHttps(tunnels);
401
+ if (!listed) {
402
+ await new Promise((r) => setTimeout(r, 400));
403
+ tunnels = await listedTunnels();
404
+ listed = pickHttps(tunnels);
405
+ }
406
+ if (listed) {
407
+ inspectorMisses = 0;
408
+ return listed;
409
+ }
410
+ if (ngrokAlive()) {
411
+ const waited = await waitForNgrok(8000);
412
+ if (waited) {
413
+ inspectorMisses = 0;
414
+ return waited;
415
+ }
416
+ listed = pickHttps(await listedTunnels());
417
+ if (listed) {
418
+ inspectorMisses = 0;
419
+ return listed;
420
+ }
421
+ inspectorMisses += 1;
422
+ if (inspectorMisses < 3) return null;
423
+ inspectorMisses = 0;
424
+ killNgrok();
425
+ }
426
+ spawnNgrok(port);
427
+ return (await waitForNgrok()) || pickHttps(await listedTunnels()) || null;
428
+ }
429
+
430
+ function mintRoomId() {
431
+ return `rlr_${crypto.randomBytes(8).toString("hex")}`;
432
+ }
433
+
434
+ function mintNonce() {
435
+ return crypto.randomBytes(16).toString("hex");
436
+ }
437
+
438
+ function newHostRoom(url, extra, existing) {
439
+ const keep = existing?.role === "host" ? existing : {};
440
+ return {
441
+ ...keep,
442
+ role: "host",
443
+ live: true,
444
+ url,
445
+ roomId: keep.roomId || mintRoomId(),
446
+ nonce: keep.nonce || mintNonce(),
447
+ hostProjectId: extra.hostProjectId || keep.hostProjectId || null,
448
+ // Guests rewrite their claims into this path so both sides share one
449
+ // lock namespace, and name their local clone after it.
450
+ hostWorkspacePath: extra.workspacePath || keep.hostWorkspacePath || null,
451
+ hostProjectName: extra.projectName || keep.hostProjectName || null,
452
+ hostLogin: extra.hostLogin || keep.hostLogin || extra.hostUser?.login || null,
453
+ repoKey: extra.repoKey || keep.repoKey || null,
454
+ projectId: extra.hostProjectId || keep.projectId || null,
455
+ projectPath: extra.workspacePath || keep.projectPath || null,
456
+ startedAt: keep.startedAt || new Date().toISOString(),
457
+ members: Array.isArray(keep.members) ? keep.members : extra.members || [],
458
+ invites: Array.isArray(keep.invites) ? keep.invites : [],
459
+ memberTokens: Array.isArray(keep.memberTokens) ? keep.memberTokens : [],
460
+ };
461
+ }
462
+
463
+ async function share(port, extra = {}) {
464
+ const explicitUrl = extra.url ? String(extra.url).trim().replace(/\/$/, "") : null;
465
+ const url = explicitUrl || (await ensureTunnel(port));
466
+ if (!url) {
467
+ return {
468
+ ok: false,
469
+ error: "ngrok_unavailable",
470
+ hint: NGROK_HINT,
471
+ };
472
+ }
473
+ const existing = loadRoom(extra.workspacePath);
474
+ const room = saveRoom(
475
+ upsertMember(newHostRoom(url, extra, existing), extra.hostUser, "host"),
476
+ extra.workspacePath
477
+ );
478
+ return { ok: true, room };
479
+ }
480
+
481
+ module.exports = {
482
+ loadRoom,
483
+ saveRoom,
484
+ clearRoom,
485
+ publicUrl,
486
+ share,
487
+ ensureTunnel,
488
+ activeTunnelUrl,
489
+ killCloudflared,
490
+ probeTunnel,
491
+ roomPath,
492
+ upsertMember,
493
+ markLeft,
494
+ dropMember,
495
+ membersView,
496
+ mintRoomId,
497
+ NGROK_HINT,
498
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Room brief for the `/relay ask` pseudo-command — what teammates' agents did recently.
3
+ * Agents fetch this via MCP `relay_room_brief`; humans do not run a CLI for it.
4
+ */
5
+
6
+ function sameLogin(a, b) {
7
+ return Boolean(a) && Boolean(b) && String(a).toLowerCase() === String(b).toLowerCase();
8
+ }
9
+
10
+ function clip(text, max = 600) {
11
+ const s = String(text || "").trim();
12
+ if (!s) return "";
13
+ return s.length > max ? `${s.slice(0, max)}\n…` : s;
14
+ }
15
+
16
+ function formatTs(ts) {
17
+ const n = Number(ts);
18
+ if (!Number.isFinite(n) || n <= 0) return "";
19
+ try {
20
+ return new Date(n).toISOString().replace("T", " ").slice(0, 19);
21
+ } catch {
22
+ return "";
23
+ }
24
+ }
25
+
26
+ function peerRows(rows, login) {
27
+ return (rows || []).filter((row) => row && !row.mine && !sameLogin(row.ownerLogin, login));
28
+ }
29
+
30
+ function threadPeerMessages(chats, login, limit) {
31
+ const lines = [];
32
+ for (const thread of chats || []) {
33
+ if (sameLogin(thread.ownerLogin, login)) continue;
34
+ const who = thread.ownerLogin ? `@${thread.ownerLogin}` : thread.agent || "peer";
35
+ const agent = thread.agent ? ` (${thread.agent})` : "";
36
+ for (const msg of (thread.messages || []).slice(-6)) {
37
+ if (!msg?.text || msg.role === "system") continue;
38
+ const stamp = formatTs(msg.ts || thread.updatedAt);
39
+ lines.push({ ts: msg.ts || thread.updatedAt || 0, line: `- **${who}${agent}** ${stamp}: ${clip(msg.text, 500)}` });
40
+ }
41
+ }
42
+ return lines.sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(0, limit);
43
+ }
44
+
45
+ function formatLocks(locks) {
46
+ const live = (locks || []).filter((l) => l && !l.released);
47
+ if (!live.length) return "_No files locked right now._\n";
48
+ return (
49
+ live
50
+ .slice(0, 20)
51
+ .map((l) => {
52
+ const who = l.holder?.login ? `@${l.holder.login}` : l.holder?.label || l.agentId || "?";
53
+ return `- \`${l.filePath}\` — ${who} (${l.mode || "write"})`;
54
+ })
55
+ .join("\n") + "\n"
56
+ );
57
+ }
58
+
59
+ /**
60
+ * @param {object} opts
61
+ * @param {object} opts.view — mergeRoomViews output
62
+ * @param {object|null} opts.room — loadRoom()
63
+ * @param {string} opts.login — current GitHub login
64
+ * @param {Array} opts.locks — live lock rows
65
+ * @param {number} opts.limit — max peer edit/chat lines
66
+ */
67
+ function compileRoomAsk({ view, room, login, locks = [], limit = 30 }) {
68
+ const peerEdits = peerRows(view.edits, login)
69
+ .sort((a, b) => (b.ts || 0) - (a.ts || 0))
70
+ .slice(0, limit);
71
+ const peerActivity = peerRows(view.activity, login)
72
+ .sort((a, b) => (b.ts || 0) - (a.ts || 0))
73
+ .slice(0, limit);
74
+ const chatLines = threadPeerMessages(view.chats, login, limit);
75
+ const agents = (view.agents || []).filter((a) => a.status === "connected" && !sameLogin(a.ownerLogin, login));
76
+
77
+ const roomLine = room?.url
78
+ ? `Room **${room.role}** · ${room.memberCount || view.peerLogins?.length || 0} member(s) · peers: ${(view.peerLogins || []).join(", ") || "none yet"}`
79
+ : "Not in a shared room — showing local activity only.";
80
+
81
+ const sections = [];
82
+ sections.push("# Relay ask — teammate activity\n");
83
+ sections.push(`Generated: ${new Date().toISOString()}`);
84
+ sections.push(`${roomLine}\n`);
85
+ sections.push("> Agents load this via MCP `relay_room_brief` when the user says `/relay ask`.\n");
86
+
87
+ sections.push("## Connected agents (other machines)\n");
88
+ if (!agents.length) sections.push("_No other connected agents right now._\n");
89
+ else {
90
+ for (const a of agents.slice(0, 12)) {
91
+ const owner = a.ownerLogin ? `@${a.ownerLogin}` : "";
92
+ sections.push(`- **${a.label || a.id}** ${owner}`.trim());
93
+ }
94
+ sections.push("");
95
+ }
96
+
97
+ sections.push("## Recent teammate chat\n");
98
+ if (!chatLines.length) sections.push("_No peer chat synced yet — teammate needs `relay serve` + room join._\n");
99
+ else {
100
+ for (const row of chatLines) sections.push(row.line);
101
+ sections.push("");
102
+ }
103
+
104
+ sections.push("## Recent teammate code edits\n");
105
+ if (!peerEdits.length) sections.push("_No peer edits in the room feed yet._\n");
106
+ else {
107
+ for (const e of peerEdits) {
108
+ const who = e.ownerLogin ? `@${e.ownerLogin}` : "peer";
109
+ const agent = e.agent ? ` · ${e.agent}` : "";
110
+ sections.push(`- \`${e.file || "?"}\` — ${who}${agent} · ${formatTs(e.ts)}`);
111
+ if (e.diff) sections.push(` \`\`\`diff\n${clip(e.diff, 400)}\n \`\`\``);
112
+ }
113
+ sections.push("");
114
+ }
115
+
116
+ sections.push("## Teammate activity (tools)\n");
117
+ if (!peerActivity.length) sections.push("_No peer tool activity._\n");
118
+ else {
119
+ for (const a of peerActivity.slice(0, 15)) {
120
+ const who = a.ownerLogin ? `@${a.ownerLogin}` : "peer";
121
+ sections.push(`- ${who} · ${a.agent || "?"} · ${clip(a.text, 120)} · ${formatTs(a.ts)}`);
122
+ }
123
+ sections.push("");
124
+ }
125
+
126
+ sections.push("## Live locks (whole room)\n");
127
+ sections.push(formatLocks(locks));
128
+
129
+ sections.push("## File sync note\n");
130
+ sections.push(
131
+ "Chat/activity above sync via the room transcript pipeline. **File contents** land on your disk only when post-tool hooks flush patches (`relay serve` + hooks on each machine). If a file is missing locally, ask the editor to re-save it or run `/api/flush-file` — not `relay push` after a git commit.\n"
132
+ );
133
+
134
+ const markdown = sections.join("\n");
135
+ const json = {
136
+ room: room
137
+ ? { role: room.role, url: room.url, peers: view.peerLogins || [], memberCount: room.memberCount }
138
+ : null,
139
+ peerEdits,
140
+ peerActivity: peerActivity.slice(0, 15),
141
+ chatLines: chatLines.map((r) => r.line),
142
+ agents: agents.slice(0, 12),
143
+ locks: (locks || []).filter((l) => !l.released).slice(0, 20),
144
+ };
145
+ return { markdown, json };
146
+ }
147
+
148
+ module.exports = { compileRoomAsk, peerRows, sameLogin };