relaybrain 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -3
  3. package/backend/coordinator/client.js +364 -0
  4. package/backend/coordinator/lockTable.js +649 -0
  5. package/backend/coordinator/server.js +179 -0
  6. package/backend/lib/centralAuth.js +62 -0
  7. package/backend/lib/centralStore.js +112 -0
  8. package/backend/lib/centralSync.js +88 -0
  9. package/backend/lib/cloneRepo.js +108 -0
  10. package/backend/lib/collisionMetrics.js +231 -0
  11. package/backend/lib/conflicts.js +35 -0
  12. package/backend/lib/dashboard.js +53 -0
  13. package/backend/lib/depGraph.js +393 -0
  14. package/backend/lib/deps.js +46 -0
  15. package/backend/lib/dirtyFiles.js +33 -0
  16. package/backend/lib/editMatchers.js +55 -0
  17. package/backend/lib/falkor/analytics.js +228 -0
  18. package/backend/lib/falkor/bus.js +16 -0
  19. package/backend/lib/falkor/central.js +281 -0
  20. package/backend/lib/falkor/client.js +165 -0
  21. package/backend/lib/falkor/config.js +73 -0
  22. package/backend/lib/falkor/coord.js +299 -0
  23. package/backend/lib/falkor/cursor.js +58 -0
  24. package/backend/lib/falkor/demo.js +221 -0
  25. package/backend/lib/falkor/fixtures.js +85 -0
  26. package/backend/lib/falkor/index.js +146 -0
  27. package/backend/lib/falkor/ingest.js +233 -0
  28. package/backend/lib/falkor/intents.js +32 -0
  29. package/backend/lib/falkor/keys.js +102 -0
  30. package/backend/lib/falkor/migrations.js +56 -0
  31. package/backend/lib/falkor/project.js +307 -0
  32. package/backend/lib/falkor/query.js +344 -0
  33. package/backend/lib/falkor/redact.js +30 -0
  34. package/backend/lib/falkor/routes.js +125 -0
  35. package/backend/lib/falkor/schema.js +108 -0
  36. package/backend/lib/falkor/write.js +281 -0
  37. package/backend/lib/githubLogin.js +108 -0
  38. package/backend/lib/githubOAuth.js +42 -0
  39. package/backend/lib/githubRepo.js +117 -0
  40. package/backend/lib/harvest.js +85 -0
  41. package/backend/lib/httpServer.js +27 -0
  42. package/backend/lib/installAgentRelay.js +235 -0
  43. package/backend/lib/installHooks.js +309 -0
  44. package/backend/lib/lamport.js +14 -0
  45. package/backend/lib/localBrowse.js +109 -0
  46. package/backend/lib/memorySync.js +63 -0
  47. package/backend/lib/notices.js +150 -0
  48. package/backend/lib/ot.js +74 -0
  49. package/backend/lib/patches.js +192 -0
  50. package/backend/lib/paths.js +85 -0
  51. package/backend/lib/peerClash.js +64 -0
  52. package/backend/lib/presence.js +35 -0
  53. package/backend/lib/relayOsBlock.js +84 -0
  54. package/backend/lib/repoSnapshot.js +125 -0
  55. package/backend/lib/room.js +638 -0
  56. package/backend/lib/roomAsk.js +148 -0
  57. package/backend/lib/roomAuth.js +215 -0
  58. package/backend/lib/roomControl.js +226 -0
  59. package/backend/lib/roomLocks.js +201 -0
  60. package/backend/lib/roomSignal.js +265 -0
  61. package/backend/lib/roomSync.js +194 -0
  62. package/backend/lib/seed.js +107 -0
  63. package/backend/lib/sseHub.js +40 -0
  64. package/backend/lib/store.js +79 -0
  65. package/backend/lib/supergraph.js +46 -0
  66. package/backend/lib/transcripts/discover.js +437 -0
  67. package/backend/lib/transcripts/parse.js +558 -0
  68. package/backend/lib/transcripts/sessionId.js +36 -0
  69. package/backend/lib/transcripts/sync.js +291 -0
  70. package/backend/lib/transcripts/util.js +299 -0
  71. package/backend/lib/treeSitterImports.js +219 -0
  72. package/backend/lib/watchWorkspace.js +42 -0
  73. package/backend/mcp/README.md +193 -0
  74. package/backend/mcp/server.js +42 -0
  75. package/backend/mcp/tools.js +484 -0
  76. package/backend/server.js +3894 -0
  77. package/cli/graph.js +286 -0
  78. package/cli/relay.js +475 -0
  79. package/docker-compose.falkor.yml +17 -0
  80. package/docs/MEMORY-GRAPH.md +314 -0
  81. package/hooks/relay-antigravity-post-tool.js +2 -0
  82. package/hooks/relay-antigravity-pre-read.js +2 -0
  83. package/hooks/relay-antigravity-pre-tool.js +2 -0
  84. package/hooks/relay-antigravity-stop.js +2 -0
  85. package/hooks/relay-claude-post-tool.js +2 -0
  86. package/hooks/relay-claude-pre-read.js +2 -0
  87. package/hooks/relay-claude-pre-tool.js +2 -0
  88. package/hooks/relay-claude-stop.js +2 -0
  89. package/hooks/relay-codex-post-tool.js +2 -0
  90. package/hooks/relay-codex-pre-read.js +2 -0
  91. package/hooks/relay-codex-pre-tool.js +2 -0
  92. package/hooks/relay-codex-stop.js +2 -0
  93. package/hooks/relay-copilot-post-tool.js +2 -0
  94. package/hooks/relay-copilot-pre-read.js +2 -0
  95. package/hooks/relay-copilot-pre-tool.js +2 -0
  96. package/hooks/relay-copilot-stop.js +2 -0
  97. package/hooks/relay-cursor-post-tool.js +2 -0
  98. package/hooks/relay-cursor-pre-read.js +2 -0
  99. package/hooks/relay-cursor-pre-tool.js +2 -0
  100. package/hooks/relay-cursor-stop.js +2 -0
  101. package/hooks/relay-hook-lib.js +621 -0
  102. package/mission-control/out/404.html +1 -0
  103. package/mission-control/out/__next.__PAGE__.txt +26 -0
  104. package/mission-control/out/__next._full.txt +21 -0
  105. package/mission-control/out/__next._tree.txt +4 -0
  106. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_buildManifest.js +11 -0
  107. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_clientMiddlewareManifest.js +1 -0
  108. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_ssgManifest.js +1 -0
  109. package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
  110. package/mission-control/out/_next/static/chunks/064ntycm-fx5v.js +1 -0
  111. package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  112. package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
  113. package/mission-control/out/_next/static/chunks/1-dy52ls2ntb_.css +4 -0
  114. package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
  115. package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
  116. package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
  117. package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
  118. package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
  119. package/mission-control/out/_next/static/chunks/31qgr-p-oulpb.js +1 -0
  120. package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
  121. package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
  122. package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
  123. package/mission-control/out/_not-found/__next._full.txt +18 -0
  124. package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
  125. package/mission-control/out/_not-found/__next._tree.txt +3 -0
  126. package/mission-control/out/_not-found.html +1 -0
  127. package/mission-control/out/_not-found.txt +18 -0
  128. package/mission-control/out/agents/antigravity.png +0 -0
  129. package/mission-control/out/agents/claude.png +0 -0
  130. package/mission-control/out/agents/codex.png +0 -0
  131. package/mission-control/out/agents/copilot.png +0 -0
  132. package/mission-control/out/agents/cursor.png +0 -0
  133. package/mission-control/out/icon.svg +4 -0
  134. package/mission-control/out/index.html +1 -0
  135. package/mission-control/out/index.txt +21 -0
  136. package/mission-control/out/logo.png +0 -0
  137. package/mission-control/out/logo_transparent.png +0 -0
  138. package/package.json +76 -6
  139. package/scripts/falkor-rebuild.js +38 -0
  140. package/scripts/falkor-seed-demo.js +29 -0
@@ -0,0 +1,219 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { Parser, Language, Query } = require("@vscode/tree-sitter-wasm");
4
+
5
+ const WASM_DIR = path.join(
6
+ path.dirname(require.resolve("@vscode/tree-sitter-wasm/package.json")),
7
+ "wasm"
8
+ );
9
+
10
+ const EXT_LANG = {
11
+ ".js": "javascript",
12
+ ".jsx": "javascript",
13
+ ".mjs": "javascript",
14
+ ".cjs": "javascript",
15
+ ".ts": "typescript",
16
+ ".tsx": "tsx",
17
+ ".py": "python",
18
+ ".go": "go",
19
+ ".rs": "rust",
20
+ ".java": "java",
21
+ ".cs": "c-sharp",
22
+ ".cpp": "cpp",
23
+ ".cc": "cpp",
24
+ ".cxx": "cpp",
25
+ ".hpp": "cpp",
26
+ ".hh": "cpp",
27
+ ".h": "cpp",
28
+ ".php": "php",
29
+ ".rb": "ruby",
30
+ };
31
+
32
+ const QUERIES = {
33
+ javascript: `
34
+ (import_statement source: (string) @path)
35
+ (export_statement source: (string) @path)
36
+ (call_expression
37
+ function: (identifier) @fn
38
+ arguments: (arguments . (string) @path)
39
+ (#eq? @fn "require"))
40
+ (call_expression
41
+ function: (import)
42
+ arguments: (arguments . (string) @path))
43
+ (call_expression
44
+ function: (import)
45
+ arguments: (arguments . (_) @dynamic))
46
+ `,
47
+ typescript: `
48
+ (import_statement source: (string) @path)
49
+ (export_statement source: (string) @path)
50
+ (call_expression
51
+ function: (identifier) @fn
52
+ arguments: (arguments . (string) @path)
53
+ (#eq? @fn "require"))
54
+ (call_expression
55
+ function: (import)
56
+ arguments: (arguments . (string) @path))
57
+ (call_expression
58
+ function: (import)
59
+ arguments: (arguments . (_) @dynamic))
60
+ `,
61
+ tsx: `
62
+ (import_statement source: (string) @path)
63
+ (export_statement source: (string) @path)
64
+ (call_expression
65
+ function: (identifier) @fn
66
+ arguments: (arguments . (string) @path)
67
+ (#eq? @fn "require"))
68
+ (call_expression
69
+ function: (import)
70
+ arguments: (arguments . (string) @path))
71
+ (call_expression
72
+ function: (import)
73
+ arguments: (arguments . (_) @dynamic))
74
+ `,
75
+ python: `
76
+ (import_from_statement module_name: (dotted_name) @name)
77
+ (import_from_statement module_name: (relative_import) @name)
78
+ (import_statement name: (dotted_name) @name)
79
+ (import_statement name: (aliased_import name: (dotted_name) @name))
80
+ (call
81
+ function: (attribute attribute: (identifier) @fn)
82
+ (#match? @fn "^(import_module|__import__)$")
83
+ arguments: (argument_list . (_) @dynamic))
84
+ `,
85
+ go: `
86
+ (import_spec path: (interpreted_string_literal) @path)
87
+ (import_spec path: (raw_string_literal) @path)
88
+ `,
89
+ rust: `
90
+ (mod_item name: (identifier) @mod)
91
+ (use_declaration argument: (scoped_identifier) @use)
92
+ (use_declaration argument: (identifier) @use)
93
+ (use_declaration argument: (use_wildcard (scoped_identifier) @use))
94
+ (use_declaration argument: (use_as_clause path: (_) @use))
95
+ (macro_invocation
96
+ macro: (identifier) @macro
97
+ (#eq? @macro "include")
98
+ (token_tree (string_literal) @path))
99
+ `,
100
+ java: `
101
+ (import_declaration (scoped_identifier) @name)
102
+ (import_declaration (identifier) @name)
103
+ `,
104
+ "c-sharp": `
105
+ (using_directive (qualified_name) @name)
106
+ (using_directive (identifier) @name)
107
+ `,
108
+ cpp: `
109
+ (preproc_include path: (string_literal) @path)
110
+ (preproc_include path: (system_lib_string) @system)
111
+ `,
112
+ php: `
113
+ (include_expression (string) @path)
114
+ (require_expression (string) @path)
115
+ (include_once_expression (string) @path)
116
+ (require_once_expression (string) @path)
117
+ `,
118
+ ruby: `
119
+ (call
120
+ method: (identifier) @fn
121
+ (#match? @fn "^(require|require_relative|load)$")
122
+ arguments: (argument_list (string) @path))
123
+ `,
124
+ };
125
+
126
+ let ready = null;
127
+ const languages = new Map();
128
+ const queries = new Map();
129
+
130
+ function wasmFile(lang) {
131
+ return path.join(WASM_DIR, `tree-sitter-${lang}.wasm`);
132
+ }
133
+
134
+ async function initTreeSitter() {
135
+ if (ready) return ready;
136
+ ready = (async () => {
137
+ await Parser.init({
138
+ locateFile: (file) => path.join(WASM_DIR, path.basename(file)),
139
+ });
140
+ const langs = [...new Set(Object.values(EXT_LANG))];
141
+ for (const lang of langs) {
142
+ const file = wasmFile(lang);
143
+ if (!fs.existsSync(file)) continue;
144
+ try {
145
+ const language = await Language.load(file);
146
+ languages.set(lang, language);
147
+ const src = QUERIES[lang];
148
+ if (src) {
149
+ try {
150
+ queries.set(lang, new Query(language, src));
151
+ } catch (err) {
152
+ console.warn(`[relay] tree-sitter query failed for ${lang}:`, err.message);
153
+ }
154
+ }
155
+ } catch (err) {
156
+ console.warn(`[relay] tree-sitter language ${lang} failed:`, err.message);
157
+ }
158
+ }
159
+ return true;
160
+ })();
161
+ return ready;
162
+ }
163
+
164
+ function unquote(text) {
165
+ const t = String(text || "").trim();
166
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")) || (t.startsWith("`") && t.endsWith("`"))) {
167
+ return t.slice(1, -1);
168
+ }
169
+ if (t.startsWith("<") && t.endsWith(">")) return t.slice(1, -1);
170
+ return t;
171
+ }
172
+
173
+ function extractWithQuery(lang, source) {
174
+ const language = languages.get(lang);
175
+ const query = queries.get(lang);
176
+ if (!language) return { staticSpecs: [], dynamic: [], engine: "none" };
177
+ const parser = new Parser();
178
+ parser.setLanguage(language);
179
+ const tree = parser.parse(source);
180
+ if (!tree) return { staticSpecs: [], dynamic: [], engine: "tree-sitter" };
181
+ const staticSpecs = [];
182
+ const dynamic = [];
183
+ if (query) {
184
+ for (const cap of query.captures(tree.rootNode)) {
185
+ const name = cap.name;
186
+ const text = unquote(cap.node.text);
187
+ if (!text) continue;
188
+ if (name === "dynamic") {
189
+ if (/^['"]/.test(cap.node.text) || text.startsWith(".")) staticSpecs.push(text);
190
+ else dynamic.push(text);
191
+ } else if (name === "system") dynamic.push(text);
192
+ else if (name === "fn" || name === "macro") continue;
193
+ else staticSpecs.push(text);
194
+ }
195
+ }
196
+ tree.delete();
197
+ parser.delete();
198
+ return {
199
+ staticSpecs: [...new Set(staticSpecs)],
200
+ dynamic: [...new Set(dynamic)],
201
+ engine: "tree-sitter",
202
+ };
203
+ }
204
+
205
+ function languageForExt(ext) {
206
+ return EXT_LANG[ext] || null;
207
+ }
208
+
209
+ function supportedLanguages() {
210
+ return [...languages.keys()];
211
+ }
212
+
213
+ module.exports = {
214
+ initTreeSitter,
215
+ extractWithQuery,
216
+ languageForExt,
217
+ supportedLanguages,
218
+ EXT_LANG,
219
+ };
@@ -0,0 +1,42 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { EventEmitter } = require("events");
4
+
5
+ const SKIP = new Set(["node_modules", ".git", ".next", ".relay", "out"]);
6
+
7
+ class WorkspaceWatcher extends EventEmitter {
8
+ constructor() {
9
+ super();
10
+ this.watchers = new Map();
11
+ }
12
+
13
+ watch(workspacePath, workspaceId) {
14
+ if (!workspacePath || this.watchers.has(workspacePath) || !fs.existsSync(workspacePath)) return;
15
+ try {
16
+ const watcher = fs.watch(workspacePath, { recursive: true }, (event, filename) => {
17
+ if (!filename) return;
18
+ const rel = String(filename).replace(/\\/g, "/");
19
+ if ([...SKIP].some((s) => rel.split("/").includes(s))) return;
20
+ this.emit("change", { workspacePath, workspaceId, file: rel, event });
21
+ });
22
+ watcher.on("error", () => this.unwatch(workspacePath));
23
+ this.watchers.set(workspacePath, watcher);
24
+ } catch {
25
+ /* fs.watch recursive unsupported — skip, Stop hook still fires */
26
+ }
27
+ }
28
+
29
+ unwatch(workspacePath) {
30
+ const w = this.watchers.get(workspacePath);
31
+ if (w) {
32
+ try {
33
+ w.close();
34
+ } catch {
35
+ /* ignore */
36
+ }
37
+ this.watchers.delete(workspacePath);
38
+ }
39
+ }
40
+ }
41
+
42
+ module.exports = { WorkspaceWatcher };
@@ -0,0 +1,193 @@
1
+ # Querying the room
2
+
3
+ Relay collects every member's agent transcripts, edits, locks and presence into
4
+ one place. This is how an agent — or a shell script, or you — asks that store a
5
+ question.
6
+
7
+ Relay does not answer questions. It scopes and returns facts; whoever asked does
8
+ the interpreting. That is deliberate: the caller is already a language model, so
9
+ a second one in the middle would only add cost, latency and a way to be wrong.
10
+
11
+ ## The one thing to know
12
+
13
+ **Reads are room-wide. Writes are local.**
14
+
15
+ Anything that reads — chat history, edits, locks, conflicts — answers for every
16
+ member of the room. Anything that writes — claiming a lock, reporting a change —
17
+ only ever runs against the relay on your own machine, over the transport that
18
+ still works when the tunnel does not.
19
+
20
+ ## Two transports, one tool surface
21
+
22
+ Both are served by [`tools.js`](./tools.js). They expose the same seventeen tools.
23
+
24
+ ### stdio — what your agents already use
25
+
26
+ `backend/mcp/server.js`, started by the agent itself. Nothing to configure; if
27
+ relay is installed, this already works, and after this change its reads span the
28
+ room instead of just this machine.
29
+
30
+ ### HTTP — through the room tunnel
31
+
32
+ `POST /mcp` on the host. For an agent on another machine, or one on a box with
33
+ no relay installed at all. Print the config with:
34
+
35
+ ```
36
+ relay mcp-url
37
+ ```
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "relay-room": {
43
+ "type": "http",
44
+ "url": "https://<tunnel>.ngrok-free.app/mcp",
45
+ "headers": {
46
+ "ngrok-skip-browser-warning": "relay",
47
+ "x-relay-room-token": "<your member token>"
48
+ }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ Stateless — no session id is issued, so there is nothing to expire. `GET /mcp`
55
+ and `DELETE /mcp` answer `405`; a client that asks for the optional event stream
56
+ just stops asking.
57
+
58
+ **Anyone arriving from off-machine gets the eleven read tools only.** Claiming a
59
+ lock as the host would file it under the host's workspace, the host's user, and
60
+ an agent id the host cannot verify — and would put a second writer on a lock
61
+ table that has exactly one on purpose. `tools/list` hides the other six, so a
62
+ remote agent never sees a tool it cannot call.
63
+
64
+ ## What you can ask
65
+
66
+ | Tool | Answers |
67
+ |---|---|
68
+ | `relay_session_brief` | memory-graph brief before editing files: who's on them now and why, blocks, decisions, prompts behind changes |
69
+ | `relay_graph_query` | named Cypher queries over the FalkorDB memory graph (`file_story`, `who_to_ask`, `contention_trace`, `search_memory`, `hotspots`, …) |
70
+ | `relay_get_chat_history` | what everyone told their agents to do, and what the agents said back |
71
+ | `relay_room_brief` | teammate activity brief — **`/relay ask`** in Cursor, Claude, Codex, Copilot, Antigravity |
72
+ | `relay_get_recent_changes` | which files changed, by whom, with diffs |
73
+ | `relay_status` | who holds which lock right now |
74
+ | `relay_get_conflicts` | who edited the same file as someone else in the last 5 minutes |
75
+ | `relay_get_collision_stats` | lifetime counters for collisions Relay prevented |
76
+ | `relay_get_project_context` | Central's view: changes, decisions, tasks, agents |
77
+ | `relay_get_decisions` / `relay_get_active_tasks` | the decision and task logs |
78
+
79
+ Writers (local only): `relay_claim_file`, `relay_release_file`,
80
+ `relay_report_change`, `relay_report_decision`, `relay_update_task`,
81
+ `relay_sync`.
82
+
83
+ ### Scoping a question
84
+
85
+ `relay_get_chat_history` takes `agent`, `ownerLogin` and `limit`. Those three
86
+ are the whole query language — "who, which agent, how much".
87
+
88
+ ```jsonc
89
+ // What is ana's Antigravity working on?
90
+ { "ownerLogin": "ana", "agent": "Antigravity", "limit": 20 }
91
+
92
+ // Everything Claude Code has done in this room, whoever is driving it
93
+ { "agent": "Claude Code" }
94
+
95
+ // The last 5 events from anyone
96
+ { "limit": 5 }
97
+ ```
98
+
99
+ `limit` defaults to 50 and is capped at 200 — memory.json is much larger than
100
+ any context window, and a remote read has to cross a tunnel to get here.
101
+
102
+ ### What comes back
103
+
104
+ ```jsonc
105
+ {
106
+ "history": [ /* oldest first, so the last entry is the newest */
107
+ {
108
+ "ts": 1786996380114,
109
+ "tsIso": "2026-08-17T19:53:00.114Z",
110
+ "agent": "Claude Code",
111
+ "ownerLogin": "prakrititz",
112
+ "sessionId": "03e3a558-…",
113
+ "role": "tool",
114
+ "kind": "code_edit",
115
+ "text": "Edit lockgraph.module.css",
116
+ "file": "lockgraph.module.css",
117
+ "diff": "--- a/lockgraph.module.css\n+++ b/…"
118
+ }
119
+ ],
120
+ "chats": [ { "id": "chat_claude-code_03e3…", "agent": "Claude Code",
121
+ "ownerLogin": "prakrititz", "mine": true,
122
+ "updatedAt": 1786996380114, "messages": [ /* … */ ] } ],
123
+ "timeline": [ /* … */ ],
124
+ "room": { "source": "host", "peers": ["ana"], "peer_snapshot_age_ms": 0 }
125
+ }
126
+ ```
127
+
128
+ `relay_get_recent_changes` returns `edits`, each with `file`, `agent`,
129
+ `ownerLogin`, `ts` and `diff`. `relay_get_conflicts` returns `conflicts`. Both
130
+ carry the same `room` block.
131
+
132
+ ## Reading the `room` block
133
+
134
+ Every read says where its answer came from and how old it is, because the
135
+ freshness is genuinely uneven: locks move room-wide in ~150ms, but transcripts
136
+ only travel on the 8s dashboard poll, and only when they changed. "What file are
137
+ they in" is live; "what did they ask it to do" can be a few seconds behind. That
138
+ is a judgment call about whether it's fresh enough for what you're about to do,
139
+ so the caller gets to make it.
140
+
141
+ | `source` | Means |
142
+ |---|---|
143
+ | `host` | asked the host just now; `peer_snapshot_age_ms` is under a second |
144
+ | `local_snapshot` | the host did not answer — this is the last sync, `peer_snapshot_age_ms` says how old |
145
+ | `solo` | not in a room; there are no peers to see |
146
+
147
+ **A guest whose host is offline still gets an answer.** roomSync parks every
148
+ peer's snapshot in your own `memory.json`, so the fallback is stale rather than
149
+ empty. The tunnel being down costs one short timeout and never an error.
150
+
151
+ Four reads in a row make one trip through the tunnel — the host response is
152
+ cached for a second, which is short enough that nothing is meaningfully staler
153
+ than a single request would have been.
154
+
155
+ ## Worked example
156
+
157
+ An agent about to edit a locked file, asking who is in its way:
158
+
159
+ ```jsonc
160
+ // relay_status — keyed "<workspaceId>::<filePath>"
161
+ { "locks": {
162
+ "C:\\repos\\relay.it::src/auth.ts": {
163
+ "filePath": "src/auth.ts", "holder": "ana", "agentId": "antigravity:ana",
164
+ "mode": "write", "claimedAt": 1786996352000, "ttlMs": 40000,
165
+ "expiresAt": 1786996392000, "readers": [], "escalated": false
166
+ } },
167
+ "uptime": 812.4 }
168
+
169
+ // relay_get_chat_history { "ownerLogin": "ana", "agent": "Antigravity", "limit": 5 }
170
+ // → "make the session cookie httpOnly"
171
+
172
+ // relay_get_recent_changes → ana has touched auth.ts, session.ts, auth.test.ts
173
+ ```
174
+
175
+ Three calls, no model in the middle, and the agent can decide for itself: work
176
+ on the other module, or wait deliberately. The lock stopped being a wall and
177
+ started being an explanation.
178
+
179
+ ## Troubleshooting
180
+
181
+ **`"source": "solo"` but you are in a room.** `loadRoom()` found no room for
182
+ this workspace. Check `.relay/room.json` in the repo, or `~/.relay/room.json`.
183
+
184
+ **`401 not_a_member` on the HTTP endpoint.** The host made the room
185
+ invite-only and your member token is missing or revoked. Re-join with the invite
186
+ link, then `relay mcp-url` again.
187
+
188
+ **A remote call returns "not available to remote callers".** Working as
189
+ intended — that tool writes. Call it against your own relay over stdio.
190
+
191
+ **Peers visible in Mission Control but not from MCP.** Both read the same merge
192
+ now, so this should not happen; if it does, `relay doctor` on the machine that
193
+ sees nothing is the place to start.
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP stdio — newline JSON-RPC. Chat history + locks. No markdown compile.
4
+ *
5
+ * The tools themselves live in ./tools.js so the room host can serve the same
6
+ * surface over HTTP; this file is only the stdio framing.
7
+ */
8
+ const { handleRpc, defaultCtx } = require("./tools");
9
+
10
+ function sendResult(id, result) {
11
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
12
+ }
13
+
14
+ function sendErr(id, message) {
15
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message } }) + "\n");
16
+ }
17
+
18
+ let buf = "";
19
+ process.stdin.setEncoding("utf8");
20
+ process.stdin.on("data", async (chunk) => {
21
+ buf += chunk;
22
+ const lines = buf.split("\n");
23
+ buf = lines.pop() || "";
24
+ for (const line of lines) {
25
+ const trimmed = line.trim();
26
+ if (!trimmed) continue;
27
+ let msg;
28
+ try {
29
+ msg = JSON.parse(trimmed);
30
+ } catch {
31
+ continue;
32
+ }
33
+ try {
34
+ // Rebuilt per message so a project registered after this process started
35
+ // still resolves, exactly as the inline lookup used to.
36
+ const reply = await handleRpc(msg, defaultCtx());
37
+ if (reply) sendResult(reply.id, reply.result);
38
+ } catch (err) {
39
+ sendErr(msg?.id, err.message);
40
+ }
41
+ }
42
+ });