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
package/cli/relay.js ADDED
@@ -0,0 +1,475 @@
1
+ #!/usr/bin/env node
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { spawn, spawnSync } = require("child_process");
5
+
6
+ const root = path.join(__dirname, "..");
7
+ const cmd = process.argv[2] || "help";
8
+ const rest = process.argv.slice(3);
9
+
10
+ function help() {
11
+ console.log(`Relay — GitHub Desktop for AI agents
12
+
13
+ relay login Sign in with GitHub (uses gh CLI, or device flow if GITHUB_CLIENT_ID is set)
14
+ relay logout Clear the local session
15
+ relay whoami Show the signed-in GitHub user
16
+ relay clone <url> [dir] git clone + register workspace + install agent hooks
17
+ relay add <path> Attach an existing local repo
18
+ relay init [path] Wire hooks, MCP, and relay-os instructions (default: cwd)
19
+ relay serve Start the API and Mission Control on 127.0.0.1:3001 (prebuilt UI, fast start)
20
+ relay serve --no-ui Start API + coordinator only
21
+ relay serve --dev Run Mission Control with next dev on :3002 (hot reload, slow first load)
22
+ relay push Send your dirty working-tree files to the shared room
23
+ relay pull Apply the host's current dirty files onto this clone
24
+ relay status Health check
25
+ relay mcp-url Print the MCP config for this room's shared context endpoint
26
+ relay doctor Why the Coordinator board is empty (run it on the machine that sees nothing)
27
+ relay graph <cmd> Memory graph (FalkorDB): up | enable [url] | disable | status | stats | rebuild [id|all]
28
+ `);
29
+ }
30
+
31
+ function killTree(child) {
32
+ if (!child?.pid) return;
33
+ try {
34
+ if (process.platform === "win32") {
35
+ spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
36
+ stdio: "ignore",
37
+ windowsHide: true,
38
+ shell: false,
39
+ });
40
+ } else {
41
+ child.kill("SIGTERM");
42
+ }
43
+ } catch {
44
+ /* already gone */
45
+ }
46
+ }
47
+
48
+ function apiPort(args) {
49
+ const idx = args.indexOf("--port");
50
+ if (idx >= 0) return Number(args[idx + 1]);
51
+ return Number(process.env.RELAY_PORT || 3001);
52
+ }
53
+
54
+ async function waitForApi(url, ms = 30000) {
55
+ const start = Date.now();
56
+ while (Date.now() - start < ms) {
57
+ try {
58
+ const r = await fetch(url);
59
+ if (r.ok) return true;
60
+ } catch {
61
+ /* still booting */
62
+ }
63
+ await new Promise((resolve) => setTimeout(resolve, 200));
64
+ }
65
+ return false;
66
+ }
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
+
115
+ async function serve(args) {
116
+ const noUi = args.includes("--no-ui");
117
+ const dev = args.includes("--dev");
118
+ const forwarded = args.filter((a) => !["--no-ui", "--static", "--dev"].includes(a));
119
+ const uiPort = process.env.RELAY_UI_PORT || "3002";
120
+ const port = apiPort(forwarded);
121
+ const apiOrigin = `http://127.0.0.1:${port}`;
122
+ const children = [];
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.
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
+ }
133
+ const hasStaticUi = fs.existsSync(path.join(root, "mission-control", "out", "index.html"));
134
+ const staticUi = !noUi && !dev && hasStaticUi;
135
+ const uiOrigin = staticUi ? `http://localhost:${port}` : `http://localhost:${uiPort}`;
136
+
137
+ const api = spawn(process.execPath, [path.join(root, "backend", "server.js"), ...forwarded], {
138
+ stdio: "inherit",
139
+ cwd: root,
140
+ windowsHide: false,
141
+ shell: false,
142
+ env: {
143
+ ...process.env,
144
+ RELAY_PORT: String(port),
145
+ RELAY_UI_ORIGIN: process.env.RELAY_UI_ORIGIN || uiOrigin,
146
+ },
147
+ });
148
+ children.push(api);
149
+
150
+ let shuttingDown = false;
151
+ function shutdown(code = 0) {
152
+ if (shuttingDown) return;
153
+ shuttingDown = true;
154
+ for (const child of children) killTree(child);
155
+ process.exit(code);
156
+ }
157
+ process.on("SIGINT", () => shutdown(0));
158
+ process.on("SIGTERM", () => shutdown(0));
159
+ api.on("exit", (code) => {
160
+ if (!shuttingDown) shutdown(code || 0);
161
+ });
162
+
163
+ if (staticUi) {
164
+ redirectOldUiPort(uiPort, uiOrigin);
165
+ if (await waitForApi(`${apiOrigin}/api/health`)) console.log(`[relay] Mission Control → ${uiOrigin}`);
166
+ } else if (!noUi) {
167
+ if (!fs.existsSync(nextBin)) {
168
+ console.error("Mission Control is not installed — run npm install");
169
+ shutdown(1);
170
+ return;
171
+ }
172
+ const ready = await waitForApi(`${apiOrigin}/api/health`);
173
+ if (!ready) {
174
+ console.error("[relay] API did not become ready on", apiOrigin);
175
+ shutdown(1);
176
+ return;
177
+ }
178
+ const ui = spawn(process.execPath, [nextBin, "dev", "--port", String(uiPort)], {
179
+ stdio: "inherit",
180
+ cwd: path.join(root, "mission-control"),
181
+ windowsHide: false,
182
+ shell: false,
183
+ env: {
184
+ ...process.env,
185
+ NEXT_PUBLIC_RELAY_API: apiOrigin,
186
+ },
187
+ });
188
+ children.push(ui);
189
+ ui.on("exit", (code) => {
190
+ if (!shuttingDown) shutdown(code || 0);
191
+ });
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Walks the exact chain the Coordinator board depends on and says which link is
197
+ * broken. Locks and the board are different subsystems — a hook claims straight
198
+ * against the host over HTTP, while the board is fed by the lock mirror and SSE
199
+ * — so "locking works but I see nothing" is a normal, and otherwise invisible,
200
+ * way for this to fail.
201
+ */
202
+ async function doctor() {
203
+ const api = `http://127.0.0.1:${apiPort(rest)}`;
204
+ const ok = (m) => console.log(` ok ${m}`);
205
+ const bad = (m) => console.log(` BROKE ${m}`);
206
+ const info = (m) => console.log(` ${m}`);
207
+
208
+ console.log("relay doctor — room + Coordinator board\n");
209
+ let hostLockCount = 0;
210
+
211
+ let health;
212
+ try {
213
+ health = await fetch(`${api}/api/health`).then((r) => r.json());
214
+ ok(`local API on ${api}`);
215
+ } catch {
216
+ bad(`no local API on ${api} — run \`relay serve\` here`);
217
+ process.exitCode = 1;
218
+ return;
219
+ }
220
+
221
+ try {
222
+ const g = await fetch(`${api}/api/graph/health`).then((r) => r.json());
223
+ if (!g.running) info("memory graph off — `relay graph up && relay graph enable` to turn it on");
224
+ else if (g.breaker?.state === "closed") ok(`memory graph connected (${g.url})`);
225
+ else bad(`memory graph cannot reach FalkorDB at ${g.url}${g.breaker?.lastError ? ` (${g.breaker.lastError})` : ""} — \`relay graph status\``);
226
+ } catch {
227
+ info("memory graph status unavailable (older relay serve?)");
228
+ }
229
+
230
+ const room = health.room;
231
+ if (!room?.url) {
232
+ info("no room joined on this machine — the board shows only local agents.");
233
+ info("Host: Team tab -> Invite next to a teammate. Guest: Join on the Team tab.");
234
+ return;
235
+ }
236
+ ok(`room joined as ${room.role} -> ${room.url}`);
237
+ if (room.role === "host") {
238
+ info(`sharing ${room.hostProjectName || "?"} (${room.hostWorkspacePath || "?"})`);
239
+ info("A host reads its own lock table, so the board is local by definition.");
240
+ }
241
+
242
+ if (room.role === "guest") {
243
+ if (!room.hostProjectId) {
244
+ bad("room.json has no hostProjectId — the lock mirror never starts, so the");
245
+ info("board stays empty even while your locks work. Re-join with the invite link.");
246
+ } else {
247
+ ok(`mirroring host project ${room.hostProjectId}`);
248
+ }
249
+ const token = require("../backend/lib/room").loadRoom()?.memberToken;
250
+ const hit = async (p) => {
251
+ const r = await fetch(`${String(room.url).replace(/\/$/, "")}${p}`, {
252
+ headers: {
253
+ "ngrok-skip-browser-warning": "relay",
254
+ ...(token ? { "x-relay-room-token": token } : {}),
255
+ },
256
+ signal: AbortSignal.timeout(6000),
257
+ });
258
+ if (!r.ok) throw new Error(`${r.status}`);
259
+ return r.json();
260
+ };
261
+ try {
262
+ await hit("/api/health");
263
+ ok("host answers through the tunnel");
264
+ } catch (err) {
265
+ bad(`cannot reach the host (${err.message}) — board falls back to local locks`);
266
+ info("Host is unreachable — Relay will look up their current tunnel and reconnect.");
267
+ }
268
+ try {
269
+ const remote = await hit(`/api/locks?projectId=${encodeURIComponent(room.hostProjectId || "")}`);
270
+ hostLockCount = (remote.locks || []).length;
271
+ ok(`host reports ${hostLockCount} lock(s) for the shared project`);
272
+ } catch (err) {
273
+ bad(`host refused /api/locks (${err.message})` + (token ? "" : " — no member token stored"));
274
+ }
275
+ }
276
+
277
+ // The mirror polls on its own clock, so a lock claimed a moment ago has not
278
+ // necessarily landed yet. Give it a few beats before calling anything broken.
279
+ let mine = await fetch(`${api}/api/locks`).then((r) => r.json());
280
+ for (let i = 0; i < 8 && hostLockCount > 0 && !(mine.locks || []).length; i += 1) {
281
+ await new Promise((r) => setTimeout(r, 500));
282
+ mine = await fetch(`${api}/api/locks`).then((r) => r.json());
283
+ }
284
+ if (hostLockCount > 0 && !(mine.locks || []).length) {
285
+ bad("the host holds locks but this machine mirrors none of them.");
286
+ info("Locks still arbitrate correctly (hooks call the host directly); it is only");
287
+ info("the board that is fed by the mirror. Restart `relay serve` here — and if it");
288
+ info("comes back, report it: the mirror is not committing its polls.");
289
+ }
290
+
291
+ const localKeys = new Set(Object.values(mine.raw?.locks || {}).map((l) => `${l.filePath}::${l.agentId}`));
292
+ const fromHost = (mine.locks || []).filter((l) => !localKeys.has(`${l.filePath}::${l.agentId}`));
293
+ console.log("");
294
+ console.log(` board would render ${(mine.locks || []).length} lock(s):`);
295
+ info(`${fromHost.length} mirrored from the room, ${localKeys.size} claimed on this machine`);
296
+ for (const l of mine.locks || []) {
297
+ info(`${l.filePath} ${l.holder?.login ? "@" + l.holder.login : l.agentId} ${l.mode || "write"}`);
298
+ }
299
+ if (!(mine.locks || []).length) {
300
+ console.log("");
301
+ info("Nothing is locked anywhere right now — an empty board here is correct.");
302
+ info("Have someone start an agent edit, then run this again.");
303
+ }
304
+ }
305
+
306
+ async function main() {
307
+ if (cmd === "help" || cmd === "-h" || cmd === "--help") {
308
+ help();
309
+ return;
310
+ }
311
+ if (cmd === "doctor") {
312
+ await doctor();
313
+ return;
314
+ }
315
+ if (cmd === "graph") {
316
+ await require("./graph").graph(rest);
317
+ return;
318
+ }
319
+ if (cmd === "serve") {
320
+ await serve(rest);
321
+ return;
322
+ }
323
+ if (cmd === "push" || cmd === "pull") {
324
+ try {
325
+ const r = await fetch(`http://127.0.0.1:3001/api/${cmd}`, {
326
+ method: "POST",
327
+ headers: { "Content-Type": "application/json" },
328
+ body: "{}",
329
+ });
330
+ const body = await r.json();
331
+ if (!r.ok) {
332
+ console.error(body.hint || body.error || `${cmd} failed`);
333
+ process.exitCode = 1;
334
+ return;
335
+ }
336
+ const files = body.files || [];
337
+ console.log(files.length ? `${cmd} ${files.length} file(s)` : `${cmd}: nothing to ${cmd}`);
338
+ for (const file of files) console.log(` ${file}`);
339
+ } catch {
340
+ console.error("offline — run relay serve");
341
+ process.exitCode = 1;
342
+ }
343
+ return;
344
+ }
345
+
346
+ if (cmd === "status") {
347
+ try {
348
+ const r = await fetch("http://127.0.0.1:3001/api/health");
349
+ console.log(JSON.stringify(await r.json(), null, 2));
350
+ } catch {
351
+ console.error("offline — run relay serve");
352
+ process.exitCode = 1;
353
+ }
354
+ return;
355
+ }
356
+
357
+ const gh = require("../backend/lib/githubLogin");
358
+ const clone = require("../backend/lib/cloneRepo");
359
+
360
+ if (cmd === "whoami") {
361
+ const { user, session } = gh.whoami();
362
+ if (!user) {
363
+ console.log("not signed in — run relay login");
364
+ process.exitCode = 1;
365
+ return;
366
+ }
367
+ console.log(`${user.name} (@${user.login})`);
368
+ if (session.projectId) console.log(`project ${session.projectId}`);
369
+ return;
370
+ }
371
+
372
+ if (cmd === "logout") {
373
+ gh.logout();
374
+ console.log("signed out");
375
+ return;
376
+ }
377
+
378
+ if (cmd === "login") {
379
+ try {
380
+ const user = await gh.loginFromGh();
381
+ console.log(`signed in as ${user.name} (@${user.login}) via GitHub CLI`);
382
+ return;
383
+ } catch (err) {
384
+ if (process.env.GITHUB_CLIENT_ID) {
385
+ const device = await gh.loginDeviceFlow();
386
+ console.log(`Open ${device.verification_uri} and enter code: ${device.user_code}`);
387
+ const user = await gh.pollDevice(device);
388
+ console.log(`signed in as ${user.name} (@${user.login})`);
389
+ return;
390
+ }
391
+ console.error(err.message || err);
392
+ console.error("Install GitHub CLI and run: gh auth login");
393
+ console.error("Or set GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET and use Mission Control → Continue with GitHub.");
394
+ process.exitCode = 1;
395
+ }
396
+ return;
397
+ }
398
+
399
+ if (cmd === "clone") {
400
+ const url = rest[0];
401
+ const dir = rest[1];
402
+ const { project, path: dest } = await clone.cloneRepo(url, dir);
403
+ console.log(`cloned ${url}`);
404
+ console.log(`workspace ${dest}`);
405
+ console.log(`registered ${project.name} (${project.id})`);
406
+ return;
407
+ }
408
+
409
+ if (cmd === "add") {
410
+ const project = clone.addLocal(rest[0], rest[1]);
411
+ console.log(`added ${project.path} as ${project.name}`);
412
+ return;
413
+ }
414
+
415
+ if (cmd === "init") {
416
+ const target = path.resolve(rest[0] || process.cwd());
417
+ if (!fs.existsSync(target)) {
418
+ console.error(`path not found: ${target}`);
419
+ process.exitCode = 1;
420
+ return;
421
+ }
422
+ const { installProjectHooks, installGlobalHooks } = require("../backend/lib/installHooks");
423
+ try {
424
+ installProjectHooks(target);
425
+ installGlobalHooks();
426
+ const existing = clone.addLocal(target, path.basename(target));
427
+ console.log(`Relay initialized in ${existing.path}`);
428
+ console.log("Hooks + MCP wired for Cursor, Claude Code, Codex, Copilot CLI, Antigravity.");
429
+ console.log("Run `relay serve`, then say `/relay ask` in any agent — it calls MCP relay_room_brief.");
430
+ } catch (err) {
431
+ console.error(err.message || err);
432
+ process.exitCode = 1;
433
+ }
434
+ return;
435
+ }
436
+
437
+ if (cmd === "mcp-url") {
438
+ const room = require("../backend/lib/room").loadRoom();
439
+ if (!room?.url) {
440
+ console.log("Not in a room. `relay serve`, then Share in Mission Control, or join with an invite link.");
441
+ console.log("Locally your agents already reach relay over stdio — nothing to configure.");
442
+ process.exitCode = 1;
443
+ return;
444
+ }
445
+ const config = {
446
+ mcpServers: {
447
+ "relay-room": {
448
+ type: "http",
449
+ url: `${String(room.url).replace(/\/$/, "")}/mcp`,
450
+ headers: {
451
+ "ngrok-skip-browser-warning": "relay",
452
+ ...(room.memberToken ? { "x-relay-room-token": room.memberToken } : {}),
453
+ },
454
+ },
455
+ },
456
+ };
457
+ console.log(`Room MCP endpoint (${room.role}) — read-only for anyone off this machine.\n`);
458
+ console.log(JSON.stringify(config, null, 2));
459
+ console.log(`\nPaste into the agent's MCP config. It exposes the room's context (chat history,
460
+ recent edits, locks, conflicts) to any MCP client — including one on a machine
461
+ with no relay installed. Locks and other writes stay on each member's own relay.`);
462
+ if (!room.memberToken && room.role === "guest") {
463
+ console.log("\nNo member token on file — if the host made the room invite-only, re-join with the invite link.");
464
+ }
465
+ return;
466
+ }
467
+
468
+ help();
469
+ process.exitCode = 1;
470
+ }
471
+
472
+ main().catch((err) => {
473
+ console.error(err.message || err);
474
+ process.exit(1);
475
+ });
@@ -0,0 +1,17 @@
1
+ services:
2
+ falkordb:
3
+ image: falkordb/falkordb:latest
4
+ container_name: relay-falkordb
5
+ ports:
6
+ - "6379:6379"
7
+ - "3010:3000"
8
+ environment:
9
+ # RDB snapshots, not AOF: replaying FalkorDB's AOF effect stream can segfault
10
+ # on restart (see #21). Relay re-sends anything a lost snapshot missed.
11
+ REDIS_ARGS: "--save 30 1 --save 300 10 --appendonly no"
12
+ volumes:
13
+ # The image writes here; /data only holds a symlink, so mounting /data persisted nothing.
14
+ - falkordb_data:/var/lib/falkordb/data
15
+ restart: unless-stopped
16
+ volumes:
17
+ falkordb_data: {}