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,638 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const crypto = require("crypto");
5
+ const { spawn, spawnSync } = require("child_process");
6
+ const { homeRelayDir } = require("./paths");
7
+ const { normalizeWorkspaceRoot } = require("./transcripts/util");
8
+
9
+ function localLanIp() {
10
+ try {
11
+ const nets = os.networkInterfaces() || {};
12
+ const candidates = [];
13
+ for (const [name, addrs] of Object.entries(nets)) {
14
+ if (/vethernet|wsl|docker|vbox|vmware|loopback|tailscale|ZeroTier/i.test(name)) continue;
15
+ for (const a of addrs || []) {
16
+ if ((a.family === "IPv4" || a.family === 4) && !a.internal && a.address) {
17
+ candidates.push(a.address);
18
+ }
19
+ }
20
+ }
21
+ const prio = candidates.find((ip) => /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip));
22
+ return prio || candidates[0] || null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ let cachedProviders = { at: 0, data: null };
29
+
30
+ function checkCliInstalled(cmd, args = ["--version"]) {
31
+ try {
32
+ const r = spawnSync(cmd, args, { encoding: "utf8", windowsHide: true, timeout: 2500 });
33
+ return !r.error && r.status === 0;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function detectProviders(port = 3001) {
40
+ const now = Date.now();
41
+ if (cachedProviders.data && now - cachedProviders.at < 15_000) {
42
+ return { ...cachedProviders.data, lanUrl: localLanIp() ? `http://${localLanIp()}:${port}` : null };
43
+ }
44
+ const lanIp = localLanIp();
45
+ const lanUrl = lanIp ? `http://${lanIp}:${port}` : null;
46
+ const cfInstalled = checkCliInstalled("cloudflared", ["--version"]);
47
+ let tsInstalled = false;
48
+ let tsRunning = false;
49
+ let tsDns = null;
50
+ try {
51
+ const st = spawnSync("tailscale", ["status", "--json"], { encoding: "utf8", windowsHide: true, timeout: 2500 });
52
+ if (!st.error) {
53
+ tsInstalled = true;
54
+ if (st.status === 0) {
55
+ const info = JSON.parse(st.stdout || "{}");
56
+ tsRunning = info.BackendState === "Running";
57
+ tsDns = String(info.Self?.DNSName || "").replace(/\.$/, "") || null;
58
+ }
59
+ }
60
+ } catch {
61
+ /* ignore */
62
+ }
63
+ const ngrokInstalled = checkCliInstalled("ngrok", ["version"]);
64
+ const providers = [
65
+ {
66
+ id: "cloudflared",
67
+ name: "Cloudflare Quick Tunnel",
68
+ badge: "Recommended",
69
+ installed: cfInstalled,
70
+ ready: cfInstalled,
71
+ command: `cloudflared tunnel --url http://127.0.0.1:${port}`,
72
+ installHint: process.platform === "win32" ? "winget install Cloudflare.cloudflared" : "brew install cloudflared",
73
+ description: "Fast global edge HTTPS tunnel — zero signup, no account, no rate limits.",
74
+ },
75
+ {
76
+ id: "tailscale",
77
+ name: "Tailscale Funnel",
78
+ badge: tsRunning ? "Connected" : "Mesh / HTTPS",
79
+ installed: tsInstalled,
80
+ ready: tsInstalled && tsRunning && Boolean(tsDns),
81
+ dns: tsDns ? `https://${tsDns}` : null,
82
+ command: `tailscale funnel --bg ${port}`,
83
+ installHint: "tailscale funnel " + port,
84
+ description: "Private Tailnet mesh or public HTTPS via Tailscale Funnel.",
85
+ },
86
+ {
87
+ id: "lan",
88
+ name: "Direct LAN / Custom URL",
89
+ badge: lanUrl ? "Zero-install LAN" : "Custom URL",
90
+ installed: true,
91
+ ready: Boolean(lanUrl),
92
+ lanIp,
93
+ lanUrl,
94
+ command: lanUrl || "https://your-relay-host.example.com",
95
+ description: "Instant zero-install sharing on the same Wi-Fi/LAN, or paste any self-hosted/tunnel URL (Railway, Fly.io, etc.).",
96
+ },
97
+ {
98
+ id: "ngrok",
99
+ name: "ngrok",
100
+ badge: "Legacy",
101
+ installed: ngrokInstalled,
102
+ ready: ngrokInstalled,
103
+ command: `ngrok http ${port}`,
104
+ installHint: process.platform === "win32" ? "winget install Ngrok.Ngrok" : "brew install ngrok",
105
+ description: "Classic HTTP tunnel (requires ngrok authtoken; subject to free-tier rate limits).",
106
+ },
107
+ ];
108
+ const out = { lanIp, lanUrl, providers };
109
+ cachedProviders = { at: now, data: out };
110
+ return out;
111
+ }
112
+
113
+ function inferProviderFromUrl(url, explicit) {
114
+ if (explicit && explicit !== "auto") return explicit;
115
+ const u = String(url || "").toLowerCase();
116
+ if (u.includes(".trycloudflare.com")) return "cloudflared";
117
+ if (u.includes(".ts.net")) return "tailscale";
118
+ if (u.includes("ngrok")) return "ngrok";
119
+ if (/^https?:\/\/(192\.168\.|10\.|172\.(1[6-9]|2\d|3[0-1])\.|localhost|127\.)/.test(u)) return "lan";
120
+ return explicit || "custom";
121
+ }
122
+
123
+ function roomPath(workspacePath) {
124
+ if (workspacePath) {
125
+ const root = normalizeWorkspaceRoot(workspacePath) || workspacePath;
126
+ return path.join(root, ".relay", "room.json");
127
+ }
128
+ return path.join(homeRelayDir(), "room.json");
129
+ }
130
+
131
+ function loadRoom(workspacePath) {
132
+ const files = [roomPath(workspacePath), roomPath(null)];
133
+ if (process.env.RELAY_ROOM_URL) {
134
+ return { role: process.env.RELAY_ROOM_ROLE || "guest", url: process.env.RELAY_ROOM_URL, hostProjectId: process.env.RELAY_ROOM_PROJECT || null };
135
+ }
136
+ for (const file of files) {
137
+ try {
138
+ const room = JSON.parse(fs.readFileSync(file, "utf8"));
139
+ if (room?.url || room?.roomId) return room;
140
+ } catch {
141
+ /* next */
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function saveRoom(room, workspacePath) {
148
+ const targets = [roomPath(null)];
149
+ if (workspacePath) targets.push(roomPath(workspacePath));
150
+ for (const file of targets) {
151
+ try {
152
+ fs.mkdirSync(path.dirname(file), { recursive: true });
153
+ fs.writeFileSync(file, JSON.stringify(room, null, 2));
154
+ } catch {
155
+ /* skip a target that cannot be created (e.g. stale registry path) */
156
+ }
157
+ }
158
+ return room;
159
+ }
160
+
161
+ function clearRoom(workspacePath) {
162
+ for (const file of [roomPath(workspacePath), roomPath(null)]) {
163
+ try {
164
+ fs.unlinkSync(file);
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ }
169
+ }
170
+
171
+ function publicUrl() {
172
+ return loadRoom()?.url || null;
173
+ }
174
+
175
+ function upsertMember(room, user, role) {
176
+ if (!room || !user?.login) return room;
177
+ const members = Array.isArray(room.members) ? room.members : [];
178
+ const row = {
179
+ id: user.id || `user_${user.login}`,
180
+ login: user.login,
181
+ name: user.name || user.login,
182
+ avatarUrl: user.avatarUrl || "",
183
+ role: role || user.role || "guest",
184
+ online: true,
185
+ lastSeen: Date.now(),
186
+ agentActive: Boolean(user.agentActive),
187
+ agentLabel: user.agentLabel || null,
188
+ };
189
+ const i = members.findIndex((m) => m.login === row.login || m.id === row.id);
190
+ if (i >= 0) members[i] = { ...members[i], ...row };
191
+ else members.push(row);
192
+ room.members = members;
193
+ return room;
194
+ }
195
+
196
+ function markLeft(room, login) {
197
+ if (!room?.members || !login) return room;
198
+ const target = String(login).toLowerCase();
199
+ room.members = room.members.map((m) =>
200
+ String(m.login || "").toLowerCase() === target ? { ...m, online: false, lastSeen: Date.now() - 60_000 } : m
201
+ );
202
+ return room;
203
+ }
204
+
205
+ function dropMember(room, login) {
206
+ if (!room?.members || !login) return room;
207
+ const target = String(login).toLowerCase();
208
+ room.members = room.members.filter((m) => String(m.login || "").toLowerCase() !== target);
209
+ return room;
210
+ }
211
+
212
+ function membersView(room) {
213
+ const now = Date.now();
214
+ return (room?.members || []).map((m) => ({
215
+ id: m.id,
216
+ login: m.login,
217
+ name: m.name,
218
+ avatarUrl: m.avatarUrl,
219
+ role: m.role,
220
+ online: now - (m.lastSeen || 0) < 45_000,
221
+ agentActive: Boolean(m.agentActive),
222
+ agentLabel: m.agentLabel || null,
223
+ }));
224
+ }
225
+
226
+ async function fetchTunnels() {
227
+ const res = await fetch("http://127.0.0.1:4040/api/tunnels");
228
+ if (!res.ok) return [];
229
+ const body = await res.json();
230
+ return body.tunnels || [];
231
+ }
232
+
233
+ function pickHttps(tunnels) {
234
+ const httpTunnels = tunnels.filter((t) => String(t.public_url || "").startsWith("https://"));
235
+ return (httpTunnels[0] || tunnels[0] || {}).public_url || null;
236
+ }
237
+
238
+ async function probeTunnel(url, timeoutMs = 4000) {
239
+ if (!url) return false;
240
+ try {
241
+ const res = await fetch(`${String(url).replace(/\/$/, "")}/api/health`, {
242
+ headers: { "ngrok-skip-browser-warning": "relay" },
243
+ signal: AbortSignal.timeout(timeoutMs),
244
+ });
245
+ return res.ok;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ async function pickLiveHttps(tunnels) {
252
+ const https = tunnels.filter((t) => String(t.public_url || "").startsWith("https://"));
253
+ for (const t of https) {
254
+ if (await probeTunnel(t.public_url)) return t.public_url;
255
+ }
256
+ return null;
257
+ }
258
+
259
+ async function waitForNgrok(timeoutMs = 20000) {
260
+ const started = Date.now();
261
+ let last = null;
262
+ while (Date.now() - started < timeoutMs) {
263
+ try {
264
+ last = pickHttps(await fetchTunnels());
265
+ // Trust the local inspector. Probing the public URL from here used to
266
+ // hammer ngrok-free every 400ms until the session was killed.
267
+ if (last) return last;
268
+ } catch {
269
+ /* ngrok inspector not up yet */
270
+ }
271
+ await new Promise((r) => setTimeout(r, 400));
272
+ }
273
+ return last;
274
+ }
275
+
276
+ let ngrokChild = null;
277
+ let cloudflaredChild = null;
278
+ let cloudflaredUrl = null;
279
+ let ensureInFlight = null;
280
+ let lastSpawnAt = 0;
281
+ let inspectorMisses = 0;
282
+
283
+ function childAlive(child) {
284
+ if (!child?.pid) return false;
285
+ try {
286
+ process.kill(child.pid, 0);
287
+ return true;
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ function ngrokAlive() {
294
+ if (!childAlive(ngrokChild)) {
295
+ ngrokChild = null;
296
+ return false;
297
+ }
298
+ return true;
299
+ }
300
+
301
+ function cloudflaredAlive() {
302
+ if (!childAlive(cloudflaredChild)) {
303
+ cloudflaredChild = null;
304
+ cloudflaredUrl = null;
305
+ return false;
306
+ }
307
+ return true;
308
+ }
309
+
310
+ function killProcess(child) {
311
+ if (!child?.pid) return;
312
+ try {
313
+ if (process.platform === "win32") {
314
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
315
+ stdio: "ignore",
316
+ windowsHide: true,
317
+ shell: false,
318
+ });
319
+ killer.unref();
320
+ } else {
321
+ child.kill("SIGTERM");
322
+ }
323
+ } catch {
324
+ /* already gone */
325
+ }
326
+ }
327
+
328
+ function killNgrok() {
329
+ const child = ngrokChild;
330
+ ngrokChild = null;
331
+ killProcess(child);
332
+ }
333
+
334
+ function killCloudflared() {
335
+ const child = cloudflaredChild;
336
+ cloudflaredChild = null;
337
+ cloudflaredUrl = null;
338
+ killProcess(child);
339
+ }
340
+
341
+ const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/i;
342
+
343
+ function spawnCloudflared(port, timeoutMs = 15000) {
344
+ if (cloudflaredAlive() && cloudflaredUrl) return Promise.resolve(cloudflaredUrl);
345
+ return new Promise((resolve) => {
346
+ let settled = false;
347
+ const finish = (url) => {
348
+ if (settled) return;
349
+ settled = true;
350
+ clearTimeout(timer);
351
+ resolve(url || null);
352
+ };
353
+ const timer = setTimeout(() => finish(cloudflaredUrl), timeoutMs);
354
+ if (timer.unref) timer.unref();
355
+ try {
356
+ const child = spawn(
357
+ "cloudflared",
358
+ ["tunnel", "--url", `http://127.0.0.1:${port}`, "--no-autoupdate"],
359
+ {
360
+ stdio: ["ignore", "pipe", "pipe"],
361
+ windowsHide: true,
362
+ detached: true,
363
+ shell: false,
364
+ }
365
+ );
366
+ const onChunk = (chunk) => {
367
+ const m = String(chunk || "").match(TRYCLOUDFLARE_RE);
368
+ if (m) {
369
+ cloudflaredUrl = m[0];
370
+ finish(cloudflaredUrl);
371
+ }
372
+ };
373
+ child.stdout?.on("data", onChunk);
374
+ child.stderr?.on("data", onChunk);
375
+ child.on("error", () => {
376
+ if (cloudflaredChild === child) {
377
+ cloudflaredChild = null;
378
+ cloudflaredUrl = null;
379
+ }
380
+ finish(null);
381
+ });
382
+ child.on("exit", () => {
383
+ if (cloudflaredChild === child) {
384
+ cloudflaredChild = null;
385
+ cloudflaredUrl = null;
386
+ }
387
+ finish(null);
388
+ });
389
+ if (child.pid) {
390
+ child.unref();
391
+ cloudflaredChild = child;
392
+ } else {
393
+ finish(null);
394
+ }
395
+ } catch {
396
+ finish(null);
397
+ }
398
+ });
399
+ }
400
+
401
+ function tryTailscaleFunnel(port) {
402
+ try {
403
+ const st = spawnSync("tailscale", ["status", "--json"], {
404
+ encoding: "utf8",
405
+ windowsHide: true,
406
+ timeout: 3000,
407
+ });
408
+ if (st.error || st.status !== 0) return null;
409
+ const info = JSON.parse(st.stdout || "{}");
410
+ if (info.BackendState !== "Running") return null;
411
+ const dns = String(info.Self?.DNSName || "").replace(/\.$/, "");
412
+ if (!dns) return null;
413
+ const fn = spawnSync("tailscale", ["funnel", "--bg", String(port)], {
414
+ encoding: "utf8",
415
+ windowsHide: true,
416
+ timeout: 5000,
417
+ });
418
+ if (fn.error || fn.status !== 0) return null;
419
+ return `https://${dns}`;
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
424
+
425
+ function spawnNgrok(port) {
426
+ if (ngrokAlive()) return ngrokChild;
427
+ if (lastSpawnAt && Date.now() - lastSpawnAt < 20_000) return ngrokChild;
428
+ try {
429
+ // Never shell:true on Windows — that opens a visible cmd even with
430
+ // windowsHide. Spawn ngrok.exe directly, hide the console, detach so it
431
+ // outlives accidental parent death, and unref so the API can exit.
432
+ const child = spawn("ngrok", ["http", String(port), "--log=stdout"], {
433
+ stdio: "ignore",
434
+ windowsHide: true,
435
+ detached: true,
436
+ shell: false,
437
+ });
438
+ child.on("error", () => {
439
+ if (ngrokChild === child) ngrokChild = null;
440
+ });
441
+ child.on("exit", () => {
442
+ if (ngrokChild === child) ngrokChild = null;
443
+ });
444
+ if (child.pid) {
445
+ child.unref();
446
+ ngrokChild = child;
447
+ lastSpawnAt = Date.now();
448
+ return child;
449
+ }
450
+ } catch {
451
+ /* caller surfaces install hint */
452
+ }
453
+ return null;
454
+ }
455
+
456
+ const NGROK_HINT =
457
+ "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.";
458
+
459
+ async function listedTunnels() {
460
+ try {
461
+ return await fetchTunnels();
462
+ } catch {
463
+ return [];
464
+ }
465
+ }
466
+
467
+ function activeTunnelUrl() {
468
+ if (process.env.RELAY_PUBLIC_URL) return String(process.env.RELAY_PUBLIC_URL).trim().replace(/\/$/, "");
469
+ if (cloudflaredAlive() && cloudflaredUrl) return cloudflaredUrl;
470
+ return null;
471
+ }
472
+
473
+ /**
474
+ * Returns a reachable public URL for this API.
475
+ *
476
+ * Provider priority (configurable via RELAY_TUNNEL=cloudflared|tailscale|ngrok|custom):
477
+ * 1. RELAY_PUBLIC_URL env var (or already-running cloudflared / ngrok inspector)
478
+ * 2. Cloudflare Quick Tunnel (`cloudflared tunnel --url ...`) — fast, free, no account needed
479
+ * 3. Tailscale Funnel (`tailscale funnel --bg <port>`) — fast if Tailscale is logged in
480
+ * 4. ngrok (`ngrok http <port>`) — legacy fallback
481
+ */
482
+ async function ensureTunnel(port, requestedProvider) {
483
+ if (ensureInFlight) return ensureInFlight;
484
+ ensureInFlight = ensureTunnelOnce(port, requestedProvider).finally(() => {
485
+ ensureInFlight = null;
486
+ });
487
+ return ensureInFlight;
488
+ }
489
+
490
+ async function ensureTunnelOnce(port, requestedProvider) {
491
+ const pref = String(requestedProvider || process.env.RELAY_TUNNEL || "").trim().toLowerCase();
492
+ if (pref === "lan") {
493
+ const ip = localLanIp();
494
+ return ip ? `http://${ip}:${port}` : null;
495
+ }
496
+ if (!requestedProvider && process.env.RELAY_PUBLIC_URL) {
497
+ return String(process.env.RELAY_PUBLIC_URL).trim().replace(/\/$/, "");
498
+ }
499
+ if (cloudflaredAlive() && cloudflaredUrl && (!pref || pref === "auto" || pref === "cloudflared")) {
500
+ return cloudflaredUrl;
501
+ }
502
+
503
+ // Try Cloudflare Quick Tunnel first (unless user explicitly requested tailscale/ngrok).
504
+ if (!pref || pref === "auto" || pref === "cloudflared") {
505
+ const cfUrl = await spawnCloudflared(port);
506
+ if (cfUrl) return cfUrl;
507
+ if (pref === "cloudflared") return null;
508
+ }
509
+
510
+ // Try Tailscale Funnel next.
511
+ if (!pref || pref === "auto" || pref === "tailscale") {
512
+ const tsUrl = tryTailscaleFunnel(port);
513
+ if (tsUrl) return tsUrl;
514
+ if (pref === "tailscale") return null;
515
+ }
516
+
517
+ // Fallback: ngrok local inspector + spawn.
518
+ let tunnels = await listedTunnels();
519
+ let listed = pickHttps(tunnels);
520
+ if (!listed) {
521
+ await new Promise((r) => setTimeout(r, 400));
522
+ tunnels = await listedTunnels();
523
+ listed = pickHttps(tunnels);
524
+ }
525
+ if (listed) {
526
+ inspectorMisses = 0;
527
+ return listed;
528
+ }
529
+ if (ngrokAlive()) {
530
+ const waited = await waitForNgrok(8000);
531
+ if (waited) {
532
+ inspectorMisses = 0;
533
+ return waited;
534
+ }
535
+ listed = pickHttps(await listedTunnels());
536
+ if (listed) {
537
+ inspectorMisses = 0;
538
+ return listed;
539
+ }
540
+ inspectorMisses += 1;
541
+ if (inspectorMisses < 3) return null;
542
+ inspectorMisses = 0;
543
+ killNgrok();
544
+ }
545
+ spawnNgrok(port);
546
+ return (await waitForNgrok()) || pickHttps(await listedTunnels()) || null;
547
+ }
548
+
549
+ function mintRoomId() {
550
+ return `rlr_${crypto.randomBytes(8).toString("hex")}`;
551
+ }
552
+
553
+ function mintNonce() {
554
+ return crypto.randomBytes(16).toString("hex");
555
+ }
556
+
557
+ function newHostRoom(url, extra, existing) {
558
+ const keep = existing?.role === "host" ? existing : {};
559
+ return {
560
+ ...keep,
561
+ role: "host",
562
+ live: true,
563
+ url,
564
+ provider: inferProviderFromUrl(url, extra.provider || keep.provider),
565
+ roomId: keep.roomId || mintRoomId(),
566
+ nonce: keep.nonce || mintNonce(),
567
+ hostProjectId: extra.hostProjectId || keep.hostProjectId || null,
568
+ // Guests rewrite their claims into this path so both sides share one
569
+ // lock namespace, and name their local clone after it.
570
+ hostWorkspacePath: extra.workspacePath || keep.hostWorkspacePath || null,
571
+ hostProjectName: extra.projectName || keep.hostProjectName || null,
572
+ hostLogin: extra.hostLogin || keep.hostLogin || extra.hostUser?.login || null,
573
+ repoKey: extra.repoKey || keep.repoKey || null,
574
+ projectId: extra.hostProjectId || keep.projectId || null,
575
+ projectPath: extra.workspacePath || keep.projectPath || null,
576
+ startedAt: keep.startedAt || new Date().toISOString(),
577
+ members: Array.isArray(keep.members) ? keep.members : extra.members || [],
578
+ invites: Array.isArray(keep.invites) ? keep.invites : [],
579
+ memberTokens: Array.isArray(keep.memberTokens) ? keep.memberTokens : [],
580
+ };
581
+ }
582
+
583
+ function providerErrorHint(provider, port = 3001) {
584
+ if (provider === "cloudflared") {
585
+ return "Cloudflare Quick Tunnel (`cloudflared`) is not installed or could not reach the edge. Install it with `winget install Cloudflare.cloudflared` (Windows) or `brew install cloudflared` (macOS), or choose Direct LAN / Custom URL.";
586
+ }
587
+ if (provider === "tailscale") {
588
+ return `Tailscale Funnel is not active on this machine. Make sure Tailscale is logged in and run \`tailscale funnel ${port}\`, or pick Cloudflare Quick Tunnel / Direct LAN.`;
589
+ }
590
+ if (provider === "lan") {
591
+ return "Could not detect a local LAN IPv4 address. Enter your machine's LAN or custom URL manually.";
592
+ }
593
+ if (provider === "ngrok") {
594
+ return "ngrok could not be started (check `ngrok config add-authtoken` or free-tier limits). Try Cloudflare Quick Tunnel (`cloudflared`) instead.";
595
+ }
596
+ return NGROK_HINT;
597
+ }
598
+
599
+ async function share(port, extra = {}) {
600
+ const explicitUrl = extra.url ? String(extra.url).trim().replace(/\/$/, "") : null;
601
+ const requestedProvider = extra.provider ? String(extra.provider).trim().toLowerCase() : null;
602
+ const url = explicitUrl || (await ensureTunnel(port, requestedProvider));
603
+ if (!url) {
604
+ return {
605
+ ok: false,
606
+ error: "tunnel_unavailable",
607
+ provider: requestedProvider || "auto",
608
+ hint: providerErrorHint(requestedProvider, port),
609
+ };
610
+ }
611
+ const existing = loadRoom(extra.workspacePath);
612
+ const room = saveRoom(
613
+ upsertMember(newHostRoom(url, { ...extra, provider: inferProviderFromUrl(url, requestedProvider) }, existing), extra.hostUser, "host"),
614
+ extra.workspacePath
615
+ );
616
+ return { ok: true, room };
617
+ }
618
+
619
+ module.exports = {
620
+ loadRoom,
621
+ saveRoom,
622
+ clearRoom,
623
+ publicUrl,
624
+ share,
625
+ ensureTunnel,
626
+ activeTunnelUrl,
627
+ killCloudflared,
628
+ probeTunnel,
629
+ roomPath,
630
+ upsertMember,
631
+ markLeft,
632
+ dropMember,
633
+ membersView,
634
+ mintRoomId,
635
+ detectProviders,
636
+ localLanIp,
637
+ NGROK_HINT,
638
+ };