relaybrain 0.2.0 → 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 (34) hide show
  1. package/README.md +434 -291
  2. package/backend/lib/falkor/demo.js +221 -0
  3. package/backend/lib/falkor/migrations.js +56 -56
  4. package/backend/lib/falkor/routes.js +52 -8
  5. package/backend/lib/localBrowse.js +109 -109
  6. package/backend/lib/notices.js +7 -0
  7. package/backend/lib/room.js +152 -12
  8. package/backend/lib/transcripts/discover.js +109 -10
  9. package/backend/server.js +62 -7
  10. package/cli/graph.js +8 -0
  11. package/cli/relay.js +64 -8
  12. package/docs/MEMORY-GRAPH.md +57 -12
  13. package/mission-control/out/404.html +1 -1
  14. package/mission-control/out/__next.__PAGE__.txt +3 -3
  15. package/mission-control/out/__next._full.txt +3 -3
  16. package/mission-control/out/__next._tree.txt +2 -2
  17. package/mission-control/out/_next/static/chunks/064ntycm-fx5v.js +1 -0
  18. package/mission-control/out/_next/static/chunks/{25rvg4bnew99f.css → 1-dy52ls2ntb_.css} +1 -1
  19. package/mission-control/out/_next/static/chunks/31qgr-p-oulpb.js +1 -0
  20. package/mission-control/out/_not-found/__next._full.txt +1 -1
  21. package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +1 -1
  22. package/mission-control/out/_not-found/__next._tree.txt +1 -1
  23. package/mission-control/out/_not-found.html +1 -1
  24. package/mission-control/out/_not-found.txt +1 -1
  25. package/mission-control/out/index.html +1 -1
  26. package/mission-control/out/index.txt +3 -3
  27. package/package.json +76 -76
  28. package/scripts/falkor-rebuild.js +8 -27
  29. package/scripts/falkor-seed-demo.js +12 -140
  30. package/mission-control/out/_next/static/chunks/3_5x4qn1_b4al.js +0 -1
  31. package/mission-control/out/_next/static/chunks/3o1tiwncjss_k.js +0 -1
  32. /package/mission-control/out/_next/static/{dglM39XRu0cmneClUkGYd → BuhfCxd0WQE65yIzZJCOM}/_buildManifest.js +0 -0
  33. /package/mission-control/out/_next/static/{dglM39XRu0cmneClUkGYd → BuhfCxd0WQE65yIzZJCOM}/_clientMiddlewareManifest.js +0 -0
  34. /package/mission-control/out/_next/static/{dglM39XRu0cmneClUkGYd → BuhfCxd0WQE65yIzZJCOM}/_ssgManifest.js +0 -0
@@ -132,7 +132,14 @@ function dismissByKey(key) {
132
132
  return count;
133
133
  }
134
134
 
135
+ /** The stored row for a key, dismissed or not (raw, with payload). */
136
+ function findNotice(key) {
137
+ if (!key) return null;
138
+ return prune(load()).find((n) => n.key === key) || null;
139
+ }
140
+
135
141
  module.exports = {
142
+ findNotice,
136
143
  listNotices,
137
144
  unreadCount,
138
145
  pushNotice,
@@ -1,10 +1,125 @@
1
1
  const fs = require("fs");
2
+ const os = require("os");
2
3
  const path = require("path");
3
4
  const crypto = require("crypto");
4
5
  const { spawn, spawnSync } = require("child_process");
5
6
  const { homeRelayDir } = require("./paths");
6
7
  const { normalizeWorkspaceRoot } = require("./transcripts/util");
7
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
+
8
123
  function roomPath(workspacePath) {
9
124
  if (workspacePath) {
10
125
  const root = normalizeWorkspaceRoot(workspacePath) || workspacePath;
@@ -364,32 +479,36 @@ function activeTunnelUrl() {
364
479
  * 3. Tailscale Funnel (`tailscale funnel --bg <port>`) — fast if Tailscale is logged in
365
480
  * 4. ngrok (`ngrok http <port>`) — legacy fallback
366
481
  */
367
- async function ensureTunnel(port) {
482
+ async function ensureTunnel(port, requestedProvider) {
368
483
  if (ensureInFlight) return ensureInFlight;
369
- ensureInFlight = ensureTunnelOnce(port).finally(() => {
484
+ ensureInFlight = ensureTunnelOnce(port, requestedProvider).finally(() => {
370
485
  ensureInFlight = null;
371
486
  });
372
487
  return ensureInFlight;
373
488
  }
374
489
 
375
- async function ensureTunnelOnce(port) {
376
- const pref = String(process.env.RELAY_TUNNEL || "").trim().toLowerCase();
377
- if (process.env.RELAY_PUBLIC_URL) {
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) {
378
497
  return String(process.env.RELAY_PUBLIC_URL).trim().replace(/\/$/, "");
379
498
  }
380
- if (cloudflaredAlive() && cloudflaredUrl && (!pref || pref === "cloudflared")) {
499
+ if (cloudflaredAlive() && cloudflaredUrl && (!pref || pref === "auto" || pref === "cloudflared")) {
381
500
  return cloudflaredUrl;
382
501
  }
383
502
 
384
503
  // Try Cloudflare Quick Tunnel first (unless user explicitly requested tailscale/ngrok).
385
- if (!pref || pref === "cloudflared") {
504
+ if (!pref || pref === "auto" || pref === "cloudflared") {
386
505
  const cfUrl = await spawnCloudflared(port);
387
506
  if (cfUrl) return cfUrl;
388
507
  if (pref === "cloudflared") return null;
389
508
  }
390
509
 
391
510
  // Try Tailscale Funnel next.
392
- if (!pref || pref === "tailscale") {
511
+ if (!pref || pref === "auto" || pref === "tailscale") {
393
512
  const tsUrl = tryTailscaleFunnel(port);
394
513
  if (tsUrl) return tsUrl;
395
514
  if (pref === "tailscale") return null;
@@ -442,6 +561,7 @@ function newHostRoom(url, extra, existing) {
442
561
  role: "host",
443
562
  live: true,
444
563
  url,
564
+ provider: inferProviderFromUrl(url, extra.provider || keep.provider),
445
565
  roomId: keep.roomId || mintRoomId(),
446
566
  nonce: keep.nonce || mintNonce(),
447
567
  hostProjectId: extra.hostProjectId || keep.hostProjectId || null,
@@ -460,19 +580,37 @@ function newHostRoom(url, extra, existing) {
460
580
  };
461
581
  }
462
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
+
463
599
  async function share(port, extra = {}) {
464
600
  const explicitUrl = extra.url ? String(extra.url).trim().replace(/\/$/, "") : null;
465
- const url = explicitUrl || (await ensureTunnel(port));
601
+ const requestedProvider = extra.provider ? String(extra.provider).trim().toLowerCase() : null;
602
+ const url = explicitUrl || (await ensureTunnel(port, requestedProvider));
466
603
  if (!url) {
467
604
  return {
468
605
  ok: false,
469
- error: "ngrok_unavailable",
470
- hint: NGROK_HINT,
606
+ error: "tunnel_unavailable",
607
+ provider: requestedProvider || "auto",
608
+ hint: providerErrorHint(requestedProvider, port),
471
609
  };
472
610
  }
473
611
  const existing = loadRoom(extra.workspacePath);
474
612
  const room = saveRoom(
475
- upsertMember(newHostRoom(url, extra, existing), extra.hostUser, "host"),
613
+ upsertMember(newHostRoom(url, { ...extra, provider: inferProviderFromUrl(url, requestedProvider) }, existing), extra.hostUser, "host"),
476
614
  extra.workspacePath
477
615
  );
478
616
  return { ok: true, room };
@@ -494,5 +632,7 @@ module.exports = {
494
632
  dropMember,
495
633
  membersView,
496
634
  mintRoomId,
635
+ detectProviders,
636
+ localLanIp,
497
637
  NGROK_HINT,
498
638
  };
@@ -121,24 +121,122 @@ function claudeTranscriptCwd(file) {
121
121
  return null;
122
122
  }
123
123
 
124
- function discoverClaude(workspacePath) {
125
- const root = path.join(HOME, ".claude", "projects");
126
- const projectDir = path.join(root, claudeSlug(workspacePath));
127
- if (!fs.existsSync(projectDir)) return [];
128
- const target = normalizeCompare(workspacePath);
129
- let files = [];
124
+ function claudeJsonlFiles(dir) {
130
125
  try {
131
- files = fs
132
- .readdirSync(projectDir, { withFileTypes: true })
126
+ return fs
127
+ .readdirSync(dir, { withFileTypes: true })
133
128
  .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
134
- .map((entry) => path.join(projectDir, entry.name));
129
+ .map((entry) => path.join(dir, entry.name));
135
130
  } catch {
136
131
  return [];
137
132
  }
138
- return files.filter((file) => {
133
+ }
134
+
135
+ // Claude project dirs for every folder above the workspace. Opening VS Code (or
136
+ // the CLI) one level up — a monorepo root, a hackathon folder holding the repo —
137
+ // files the session under the parent's slug, so the repo's own slug dir never
138
+ // sees it.
139
+ function claudeAncestorDirs(workspacePath) {
140
+ const root = path.join(HOME, ".claude", "projects");
141
+ const dirs = [];
142
+ let dir = path.resolve(workspacePath);
143
+ for (let parent = path.dirname(dir); parent !== dir; dir = parent, parent = path.dirname(dir)) {
144
+ const candidate = path.join(root, claudeSlug(parent));
145
+ if (fs.existsSync(candidate)) dirs.push({ dir: candidate, cwd: normalizeCompare(parent) });
146
+ }
147
+ return dirs;
148
+ }
149
+
150
+ // Transcripts are append-only, so once a session has mentioned a file inside the
151
+ // workspace it always will; only the bytes added since the last look need reading.
152
+ const touchCache = new Map();
153
+
154
+ // The workspace path as a transcript spells it: plain, JSON-escaped, raw
155
+ // backslashes, or a file:// URL. Matched literally rather than through
156
+ // pathsMentionedIn, whose regex stops at whitespace and so never sees a path
157
+ // under a home folder like "C:\Users\Jane Doe". The trailing separator keeps
158
+ // `.../relay` from matching `.../relay-old`.
159
+ function workspaceSpellings(needle) {
160
+ const fwd = needle.toLowerCase();
161
+ const url = fwd.replace(/ /g, "%20");
162
+ return [
163
+ `${fwd}/`,
164
+ `${fwd.replace(/\//g, "\\")}\\`,
165
+ `${fwd.replace(/\//g, "\\\\")}\\\\`,
166
+ `${url}/`,
167
+ `${url.replace(/^([a-z]):/, "$1%3a")}/`,
168
+ ];
169
+ }
170
+
171
+ function transcriptTouches(file, needle) {
172
+ let size;
173
+ try {
174
+ size = fs.statSync(file).size;
175
+ } catch {
176
+ return false;
177
+ }
178
+ const key = `${file}\n${needle}`;
179
+ const prev = touchCache.get(key);
180
+ if (prev?.matched) return true;
181
+ if (prev && prev.size === size) return false;
182
+ const from = Math.max(0, (prev?.size || 0) - 1024);
183
+ const spellings = workspaceSpellings(needle);
184
+ let matched = false;
185
+ try {
186
+ const fd = fs.openSync(file, "r");
187
+ try {
188
+ const chunk = 4 * 1024 * 1024;
189
+ const buf = Buffer.alloc(chunk);
190
+ for (let pos = from; pos < size && !matched; pos += chunk - 1024) {
191
+ const n = fs.readSync(fd, buf, 0, chunk, pos);
192
+ if (n <= 0) break;
193
+ const text = buf.toString("utf8", 0, n).toLowerCase();
194
+ matched = spellings.some((s) => text.includes(s));
195
+ if (n < chunk) break;
196
+ }
197
+ } finally {
198
+ fs.closeSync(fd);
199
+ }
200
+ } catch {
201
+ return false;
202
+ }
203
+ touchCache.set(key, { size, matched });
204
+ return matched;
205
+ }
206
+
207
+ const CLAUDE_ANCESTOR_MAX_FILES = 40;
208
+
209
+ function discoverClaude(workspacePath) {
210
+ const root = path.join(HOME, ".claude", "projects");
211
+ const projectDir = path.join(root, claudeSlug(workspacePath));
212
+ const target = normalizeCompare(workspacePath);
213
+ const own = claudeJsonlFiles(projectDir).filter((file) => {
139
214
  const cwd = claudeTranscriptCwd(file);
140
215
  return !cwd || normalizeCompare(cwd) === target;
141
216
  });
217
+
218
+ // Sessions started in a parent folder count when they reach into this repo.
219
+ const needle = pathNeedle(workspacePath);
220
+ const inherited = [];
221
+ for (const { dir, cwd: dirCwd } of claudeAncestorDirs(workspacePath)) {
222
+ const recent = claudeJsonlFiles(dir)
223
+ .map((file) => {
224
+ try {
225
+ return { file, mtimeMs: fs.statSync(file).mtimeMs };
226
+ } catch {
227
+ return null;
228
+ }
229
+ })
230
+ .filter(Boolean)
231
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
232
+ .slice(0, CLAUDE_ANCESTOR_MAX_FILES);
233
+ for (const { file } of recent) {
234
+ const cwd = claudeTranscriptCwd(file);
235
+ if (cwd && normalizeCompare(cwd) !== dirCwd) continue;
236
+ if (transcriptTouches(file, needle)) inherited.push(file);
237
+ }
238
+ }
239
+ return [...own, ...inherited];
142
240
  }
143
241
 
144
242
  function discoverCodex(workspacePath) {
@@ -319,6 +417,7 @@ function transcriptWatchRoots(workspacePath) {
319
417
  if (cursorDir) roots.push(cursorDir);
320
418
  const claudeDir = path.join(HOME, ".claude", "projects", claudeSlug(workspacePath));
321
419
  if (fs.existsSync(claudeDir)) roots.push(claudeDir);
420
+ for (const { dir } of claudeAncestorDirs(workspacePath)) roots.push(dir);
322
421
  const codexDir = path.join(HOME, ".codex", "sessions");
323
422
  if (fs.existsSync(codexDir)) roots.push(codexDir);
324
423
  roots.push(...antigravityRoots());
package/backend/server.js CHANGED
@@ -38,7 +38,7 @@ const { listDir, listGithubRepos } = require("./lib/localBrowse");
38
38
  const { ingest: ingestSupergraph } = require("./lib/supergraph");
39
39
  const { cloneRepo, addLocal, detectRemoteUrl } = require("./lib/cloneRepo");
40
40
  const { installProjectHooks, installGlobalHooks } = require("./lib/installHooks");
41
- const { loadRoom, saveRoom, clearRoom, share: shareRoom, ensureTunnel, activeTunnelUrl, killCloudflared, upsertMember, markLeft, dropMember, membersView } = require("./lib/room");
41
+ const { loadRoom, saveRoom, clearRoom, share: shareRoom, ensureTunnel, activeTunnelUrl, killCloudflared, upsertMember, markLeft, dropMember, membersView, detectProviders } = require("./lib/room");
42
42
  const { localSnapshot, savePeerSnapshot, mergeRoomViews, clearPeers } = require("./lib/roomSync");
43
43
  const { compileRoomAsk } = require("./lib/roomAsk");
44
44
  const { recordCollision, mergeCollisionStats, loadLocalCollisions } = require("./lib/collisionMetrics");
@@ -76,6 +76,7 @@ const { skipPublish, applyClash, aliveLock } = require("./lib/peerClash");
76
76
  const { dirtyFiles } = require("./lib/dirtyFiles");
77
77
  const { authorizeUrl, exchangeCode } = require("./lib/githubOAuth");
78
78
  const {
79
+ findNotice,
79
80
  listNotices,
80
81
  unreadCount,
81
82
  pushNotice,
@@ -781,6 +782,10 @@ async function main() {
781
782
  };
782
783
  broadcastPatch(envelope);
783
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
+ }
784
789
  if (room?.url && room.role !== "host") {
785
790
  const body = { ...envelope, workspaceId: room.hostProjectId || envelope.workspaceId, type: "patch" };
786
791
  if (!sendToHost(body)) {
@@ -1134,13 +1139,33 @@ async function main() {
1134
1139
  return currentProject();
1135
1140
  }
1136
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
+
1137
1158
  function acceptPeerPatch(patch, hint, { skipIfOrigin } = {}) {
1138
1159
  if (!patch?.file) return { applied: false };
1139
1160
  if (skipIfOrigin && patch.originId && patch.originId === originId()) {
1140
1161
  return { applied: false, self: true };
1141
1162
  }
1142
1163
  const project = peerProject(hint);
1143
- if (!project) return { applied: false, reason: "no_local_repo" };
1164
+ if (!project) {
1165
+ const result = { applied: false, reason: "no_local_repo" };
1166
+ logPeerPatch(patch, result);
1167
+ return result;
1168
+ }
1144
1169
  const clash = applyClash(lockTable, project.path, patch.file, patch);
1145
1170
  if (clash) {
1146
1171
  if (project?.id) {
@@ -1151,12 +1176,15 @@ async function main() {
1151
1176
  reason: clash.reason,
1152
1177
  });
1153
1178
  }
1154
- return { applied: false, projectId: project.id, ...clash };
1179
+ const result = { applied: false, projectId: project.id, ...clash };
1180
+ logPeerPatch(patch, result);
1181
+ return result;
1155
1182
  }
1156
1183
  applyingRemote.add(`${project.id}:${patch.file}`);
1157
- const result = applyPeerFile(project.id, patch, project.path);
1184
+ const result = { ...applyPeerFile(project.id, patch, project.path), projectId: project.id };
1158
1185
  applyingRemote.delete(`${project.id}:${patch.file}`);
1159
- return { ...result, projectId: project.id };
1186
+ logPeerPatch(patch, result);
1187
+ return result;
1160
1188
  }
1161
1189
 
1162
1190
  const currentProject = () => {
@@ -1491,6 +1519,11 @@ async function main() {
1491
1519
  const key = `invite:${room.roomId}`;
1492
1520
  live.add(key);
1493
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);
1494
1527
  emitNotice({
1495
1528
  type: "invite",
1496
1529
  key,
@@ -1506,7 +1539,7 @@ async function main() {
1506
1539
  projectName: room.projectName || project?.name || null,
1507
1540
  projectId: project?.id || null,
1508
1541
  },
1509
- });
1542
+ }, { revive });
1510
1543
  }
1511
1544
  for (const n of listNotices()) {
1512
1545
  if (n.type === "invite" && n.key && !live.has(n.key) && !(current?.roomId && n.key === `invite:${current.roomId}`)) {
@@ -2080,6 +2113,19 @@ async function main() {
2080
2113
  dashboard.invites = room?.role === "host" && !isRemoteRequest(req) ? inviteView(room) : [];
2081
2114
  dashboard.openRooms =
2082
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
+ }
2083
2129
  if (!isRemoteRequest(req) && room?.role !== "host") {
2084
2130
  refreshOpenRooms(project)
2085
2131
  .then((rooms) => {
@@ -2481,6 +2527,10 @@ async function main() {
2481
2527
  // publicRoom strips invite and member-token hashes: this route answers
2482
2528
  // through the tunnel, and the raw room file is a credential store.
2483
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
+ });
2484
2534
  app.post("/api/room/share", async (req, res) => {
2485
2535
  const project = currentProject();
2486
2536
  const existing = loadRoom(project?.path);
@@ -2491,7 +2541,12 @@ async function main() {
2491
2541
  });
2492
2542
  }
2493
2543
  const customUrl = req.body?.url ? String(req.body.url).trim().replace(/\/$/, "") : null;
2494
- const result = await shareRoom(parsePort(process.argv), { ...shareExtras(project), ...(customUrl ? { url: customUrl } : {}) });
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
+ });
2495
2550
  if (result.ok) {
2496
2551
  syncRoomLocks();
2497
2552
  result.warning = await publishAndWatch(result.room, project);
package/cli/graph.js CHANGED
@@ -264,6 +264,14 @@ function seed(target) {
264
264
 
265
265
  async function graph(args) {
266
266
  const [sub, arg] = args;
267
+ // `relay graph seed --help` must not seed a project called "--help".
268
+ if (args.some((a) => a === "--help" || a === "-h")) return help();
269
+ if (arg && arg.startsWith("-")) {
270
+ console.error(`unknown option ${arg}`);
271
+ help();
272
+ process.exitCode = 1;
273
+ return;
274
+ }
267
275
  if (sub === "up") return up();
268
276
  if (sub === "enable") return enable(arg);
269
277
  if (sub === "disable") return disable();
package/cli/relay.js CHANGED
@@ -1,7 +1,7 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  const fs = require("fs");
3
3
  const path = require("path");
4
- const { spawn } = require("child_process");
4
+ const { spawn, spawnSync } = require("child_process");
5
5
 
6
6
  const root = path.join(__dirname, "..");
7
7
  const cmd = process.argv[2] || "help";
@@ -16,9 +16,9 @@ function help() {
16
16
  relay clone <url> [dir] git clone + register workspace + install agent hooks
17
17
  relay add <path> Attach an existing local repo
18
18
  relay init [path] Wire hooks, MCP, and relay-os instructions (default: cwd)
19
- relay serve Start API on 127.0.0.1:3001 and Mission Control on :3002
19
+ relay serve Start the API and Mission Control on 127.0.0.1:3001 (prebuilt UI, fast start)
20
20
  relay serve --no-ui Start API + coordinator only
21
- relay serve --static Serve the prebuilt Mission Control from the API port (default for npm installs)
21
+ relay serve --dev Run Mission Control with next dev on :3002 (hot reload, slow first load)
22
22
  relay push Send your dirty working-tree files to the shared room
23
23
  relay pull Apply the host's current dirty files onto this clone
24
24
  relay status Health check
@@ -65,18 +65,73 @@ async function waitForApi(url, ms = 30000) {
65
65
  return false;
66
66
  }
67
67
 
68
+ function newestMtime(dir) {
69
+ let newest = 0;
70
+ let entries = [];
71
+ try {
72
+ entries = fs.readdirSync(dir, { withFileTypes: true });
73
+ } catch {
74
+ return 0;
75
+ }
76
+ for (const entry of entries) {
77
+ const full = path.join(dir, entry.name);
78
+ if (entry.isDirectory()) newest = Math.max(newest, newestMtime(full));
79
+ else newest = Math.max(newest, fs.statSync(full).mtimeMs);
80
+ }
81
+ return newest;
82
+ }
83
+
84
+ /** True when there is no prebuilt UI, or the UI source changed after it was built. */
85
+ function staticUiStale() {
86
+ const ui = path.join(root, "mission-control");
87
+ const index = path.join(ui, "out", "index.html");
88
+ if (!fs.existsSync(index)) return true;
89
+ const builtAt = fs.statSync(index).mtimeMs;
90
+ const sources = ["app", "components", "lib", "styles", "public"].map((d) => newestMtime(path.join(ui, d)));
91
+ for (const f of ["next.config.js", "package.json"]) {
92
+ try {
93
+ sources.push(fs.statSync(path.join(ui, f)).mtimeMs);
94
+ } catch {
95
+ /* optional */
96
+ }
97
+ }
98
+ return Math.max(...sources) > builtAt;
99
+ }
100
+
101
+ /** Old bookmarks, docs and invite links point at :3002 — send them to the real UI. */
102
+ function redirectOldUiPort(uiPort, uiOrigin) {
103
+ const http = require("http");
104
+ const server = http.createServer((req, res) => {
105
+ res.writeHead(302, { Location: `${uiOrigin}${req.url || "/"}` });
106
+ res.end();
107
+ });
108
+ server.on("error", () => {
109
+ /* port busy — nothing to redirect */
110
+ });
111
+ server.listen(Number(uiPort), "127.0.0.1");
112
+ server.unref();
113
+ }
114
+
68
115
  async function serve(args) {
69
116
  const noUi = args.includes("--no-ui");
70
- const forwarded = args.filter((a) => a !== "--no-ui" && a !== "--static");
117
+ const dev = args.includes("--dev");
118
+ const forwarded = args.filter((a) => !["--no-ui", "--static", "--dev"].includes(a));
71
119
  const uiPort = process.env.RELAY_UI_PORT || "3002";
72
120
  const port = apiPort(forwarded);
73
121
  const apiOrigin = `http://127.0.0.1:${port}`;
74
122
  const children = [];
75
- // An npm install ships Mission Control prebuilt (mission-control/out) and no Next.js,
76
- // so the API serves the UI itself; a source checkout runs `next dev` as before.
123
+ // Mission Control is served prebuilt (mission-control/out) from the API port:
124
+ // `next dev` compiles the whole UI on first page load, which is a long blank
125
+ // wait. An npm install ships the build; a source checkout rebuilds it only when
126
+ // the UI source is newer. `--dev` keeps hot reload for working on the UI.
77
127
  const nextBin = path.join(root, "node_modules", "next", "dist", "bin", "next");
128
+ if (!noUi && !dev && fs.existsSync(nextBin) && staticUiStale()) {
129
+ console.log("[relay] building Mission Control (once, after UI changes)…");
130
+ const built = spawnSync(process.execPath, [path.join(root, "scripts", "build-ui.js")], { stdio: "inherit", cwd: root });
131
+ if (built.status !== 0) console.error("[relay] UI build failed — falling back to `next dev`");
132
+ }
78
133
  const hasStaticUi = fs.existsSync(path.join(root, "mission-control", "out", "index.html"));
79
- const staticUi = !noUi && hasStaticUi && (args.includes("--static") || !fs.existsSync(nextBin));
134
+ const staticUi = !noUi && !dev && hasStaticUi;
80
135
  const uiOrigin = staticUi ? `http://localhost:${port}` : `http://localhost:${uiPort}`;
81
136
 
82
137
  const api = spawn(process.execPath, [path.join(root, "backend", "server.js"), ...forwarded], {
@@ -106,6 +161,7 @@ async function serve(args) {
106
161
  });
107
162
 
108
163
  if (staticUi) {
164
+ redirectOldUiPort(uiPort, uiOrigin);
109
165
  if (await waitForApi(`${apiOrigin}/api/health`)) console.log(`[relay] Mission Control → ${uiOrigin}`);
110
166
  } else if (!noUi) {
111
167
  if (!fs.existsSync(nextBin)) {