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.
- package/README.md +434 -291
- package/backend/lib/falkor/demo.js +221 -0
- package/backend/lib/falkor/migrations.js +56 -56
- package/backend/lib/falkor/routes.js +52 -8
- package/backend/lib/localBrowse.js +109 -109
- package/backend/lib/notices.js +7 -0
- package/backend/lib/room.js +152 -12
- package/backend/lib/transcripts/discover.js +109 -10
- package/backend/server.js +62 -7
- package/cli/graph.js +8 -0
- package/cli/relay.js +64 -8
- package/docs/MEMORY-GRAPH.md +57 -12
- package/mission-control/out/404.html +1 -1
- package/mission-control/out/__next.__PAGE__.txt +3 -3
- package/mission-control/out/__next._full.txt +3 -3
- package/mission-control/out/__next._tree.txt +2 -2
- package/mission-control/out/_next/static/chunks/064ntycm-fx5v.js +1 -0
- package/mission-control/out/_next/static/chunks/{25rvg4bnew99f.css → 1-dy52ls2ntb_.css} +1 -1
- package/mission-control/out/_next/static/chunks/31qgr-p-oulpb.js +1 -0
- package/mission-control/out/_not-found/__next._full.txt +1 -1
- package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +1 -1
- package/mission-control/out/_not-found/__next._tree.txt +1 -1
- package/mission-control/out/_not-found.html +1 -1
- package/mission-control/out/_not-found.txt +1 -1
- package/mission-control/out/index.html +1 -1
- package/mission-control/out/index.txt +3 -3
- package/package.json +76 -76
- package/scripts/falkor-rebuild.js +8 -27
- package/scripts/falkor-seed-demo.js +12 -140
- package/mission-control/out/_next/static/chunks/3_5x4qn1_b4al.js +0 -1
- package/mission-control/out/_next/static/chunks/3o1tiwncjss_k.js +0 -1
- /package/mission-control/out/_next/static/{dglM39XRu0cmneClUkGYd → BuhfCxd0WQE65yIzZJCOM}/_buildManifest.js +0 -0
- /package/mission-control/out/_next/static/{dglM39XRu0cmneClUkGYd → BuhfCxd0WQE65yIzZJCOM}/_clientMiddlewareManifest.js +0 -0
- /package/mission-control/out/_next/static/{dglM39XRu0cmneClUkGYd → BuhfCxd0WQE65yIzZJCOM}/_ssgManifest.js +0 -0
package/backend/lib/notices.js
CHANGED
|
@@ -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,
|
package/backend/lib/room.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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: "
|
|
470
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
.readdirSync(
|
|
126
|
+
return fs
|
|
127
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
133
128
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
134
|
-
.map((entry) => path.join(
|
|
129
|
+
.map((entry) => path.join(dir, entry.name));
|
|
135
130
|
} catch {
|
|
136
131
|
return [];
|
|
137
132
|
}
|
|
138
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 --
|
|
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
|
|
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
|
-
//
|
|
76
|
-
//
|
|
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 &&
|
|
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)) {
|