relaybrain 0.0.0-stage → 0.2.0

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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -2
  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/fixtures.js +85 -0
  25. package/backend/lib/falkor/index.js +146 -0
  26. package/backend/lib/falkor/ingest.js +233 -0
  27. package/backend/lib/falkor/intents.js +32 -0
  28. package/backend/lib/falkor/keys.js +102 -0
  29. package/backend/lib/falkor/migrations.js +56 -0
  30. package/backend/lib/falkor/project.js +307 -0
  31. package/backend/lib/falkor/query.js +344 -0
  32. package/backend/lib/falkor/redact.js +30 -0
  33. package/backend/lib/falkor/routes.js +81 -0
  34. package/backend/lib/falkor/schema.js +108 -0
  35. package/backend/lib/falkor/write.js +281 -0
  36. package/backend/lib/githubLogin.js +108 -0
  37. package/backend/lib/githubOAuth.js +42 -0
  38. package/backend/lib/githubRepo.js +117 -0
  39. package/backend/lib/harvest.js +85 -0
  40. package/backend/lib/httpServer.js +27 -0
  41. package/backend/lib/installAgentRelay.js +235 -0
  42. package/backend/lib/installHooks.js +309 -0
  43. package/backend/lib/lamport.js +14 -0
  44. package/backend/lib/localBrowse.js +109 -0
  45. package/backend/lib/memorySync.js +63 -0
  46. package/backend/lib/notices.js +143 -0
  47. package/backend/lib/ot.js +74 -0
  48. package/backend/lib/patches.js +192 -0
  49. package/backend/lib/paths.js +85 -0
  50. package/backend/lib/peerClash.js +64 -0
  51. package/backend/lib/presence.js +35 -0
  52. package/backend/lib/relayOsBlock.js +84 -0
  53. package/backend/lib/repoSnapshot.js +125 -0
  54. package/backend/lib/room.js +498 -0
  55. package/backend/lib/roomAsk.js +148 -0
  56. package/backend/lib/roomAuth.js +215 -0
  57. package/backend/lib/roomControl.js +226 -0
  58. package/backend/lib/roomLocks.js +201 -0
  59. package/backend/lib/roomSignal.js +265 -0
  60. package/backend/lib/roomSync.js +194 -0
  61. package/backend/lib/seed.js +107 -0
  62. package/backend/lib/sseHub.js +40 -0
  63. package/backend/lib/store.js +79 -0
  64. package/backend/lib/supergraph.js +46 -0
  65. package/backend/lib/transcripts/discover.js +338 -0
  66. package/backend/lib/transcripts/parse.js +558 -0
  67. package/backend/lib/transcripts/sessionId.js +36 -0
  68. package/backend/lib/transcripts/sync.js +291 -0
  69. package/backend/lib/transcripts/util.js +299 -0
  70. package/backend/lib/treeSitterImports.js +219 -0
  71. package/backend/lib/watchWorkspace.js +42 -0
  72. package/backend/mcp/README.md +193 -0
  73. package/backend/mcp/server.js +42 -0
  74. package/backend/mcp/tools.js +484 -0
  75. package/backend/server.js +3839 -0
  76. package/cli/graph.js +278 -0
  77. package/cli/relay.js +419 -0
  78. package/docker-compose.falkor.yml +17 -0
  79. package/docs/MEMORY-GRAPH.md +269 -0
  80. package/hooks/relay-antigravity-post-tool.js +2 -0
  81. package/hooks/relay-antigravity-pre-read.js +2 -0
  82. package/hooks/relay-antigravity-pre-tool.js +2 -0
  83. package/hooks/relay-antigravity-stop.js +2 -0
  84. package/hooks/relay-claude-post-tool.js +2 -0
  85. package/hooks/relay-claude-pre-read.js +2 -0
  86. package/hooks/relay-claude-pre-tool.js +2 -0
  87. package/hooks/relay-claude-stop.js +2 -0
  88. package/hooks/relay-codex-post-tool.js +2 -0
  89. package/hooks/relay-codex-pre-read.js +2 -0
  90. package/hooks/relay-codex-pre-tool.js +2 -0
  91. package/hooks/relay-codex-stop.js +2 -0
  92. package/hooks/relay-copilot-post-tool.js +2 -0
  93. package/hooks/relay-copilot-pre-read.js +2 -0
  94. package/hooks/relay-copilot-pre-tool.js +2 -0
  95. package/hooks/relay-copilot-stop.js +2 -0
  96. package/hooks/relay-cursor-post-tool.js +2 -0
  97. package/hooks/relay-cursor-pre-read.js +2 -0
  98. package/hooks/relay-cursor-pre-tool.js +2 -0
  99. package/hooks/relay-cursor-stop.js +2 -0
  100. package/hooks/relay-hook-lib.js +621 -0
  101. package/mission-control/out/404.html +1 -0
  102. package/mission-control/out/__next.__PAGE__.txt +26 -0
  103. package/mission-control/out/__next._full.txt +21 -0
  104. package/mission-control/out/__next._tree.txt +4 -0
  105. package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
  106. package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  107. package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
  108. package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
  109. package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
  110. package/mission-control/out/_next/static/chunks/25rvg4bnew99f.css +4 -0
  111. package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
  112. package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
  113. package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
  114. package/mission-control/out/_next/static/chunks/3_5x4qn1_b4al.js +1 -0
  115. package/mission-control/out/_next/static/chunks/3o1tiwncjss_k.js +1 -0
  116. package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
  117. package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
  118. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_buildManifest.js +11 -0
  119. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_clientMiddlewareManifest.js +1 -0
  120. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_ssgManifest.js +1 -0
  121. package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
  122. package/mission-control/out/_not-found/__next._full.txt +18 -0
  123. package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
  124. package/mission-control/out/_not-found/__next._tree.txt +3 -0
  125. package/mission-control/out/_not-found.html +1 -0
  126. package/mission-control/out/_not-found.txt +18 -0
  127. package/mission-control/out/agents/antigravity.png +0 -0
  128. package/mission-control/out/agents/claude.png +0 -0
  129. package/mission-control/out/agents/codex.png +0 -0
  130. package/mission-control/out/agents/copilot.png +0 -0
  131. package/mission-control/out/agents/cursor.png +0 -0
  132. package/mission-control/out/icon.svg +4 -0
  133. package/mission-control/out/index.html +1 -0
  134. package/mission-control/out/index.txt +21 -0
  135. package/mission-control/out/logo.png +0 -0
  136. package/mission-control/out/logo_transparent.png +0 -0
  137. package/package.json +74 -4
  138. package/scripts/falkor-rebuild.js +57 -0
  139. package/scripts/falkor-seed-demo.js +157 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Relay contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,291 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ![Relay](./logo.png)
4
+
5
+ ### GitHub Desktop for AI agents.
6
+
7
+ *Git coordinates humans committing minutes apart. **Relay coordinates agents editing the same files right now** — file locks, dependency-aware locks, live patch sync, and one shared board.*
8
+
9
+ **Twelve agents on one repo, and none of them blind to the others.**
10
+
11
+ [**Watch the demo**](https://youtu.be/vUea5Qih-nM) · [**Read the Documentation**](https://relay-brain.vercel.app/docs.html) · [**Docs in this repo**](./docs/README.md)
12
+
13
+ <br/>
14
+
15
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A520-3c873a?style=for-the-badge&logo=node.js&logoColor=white" alt="Node 20+"></a>
16
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2563eb?style=for-the-badge" alt="MIT"></a>
17
+ <img src="https://img.shields.io/badge/memory-FalkorDB%20graph-dc2626?style=for-the-badge" alt="FalkorDB memory graph">
18
+ <img src="https://img.shields.io/badge/login-not%20required-16a34a?style=for-the-badge" alt="No login">
19
+
20
+ <br/>
21
+ <br/>
22
+
23
+ **Works with**
24
+
25
+ <p align="center">
26
+ <img src="./support_images/readme/cursor.png" alt="Cursor" height="46" />
27
+ <img src="./support_images/readme/claude.png" alt="Claude Code" height="46" />
28
+ <img src="./support_images/readme/copilot.png" alt="GitHub Copilot" height="46" />
29
+ <br />
30
+ <img src="./support_images/readme/codex.png" alt="Codex" height="46" />
31
+ <img src="./support_images/readme/antigravity.png" alt="Google Antigravity" height="46" />
32
+ </p>
33
+
34
+ </div>
35
+
36
+ ## The problem
37
+
38
+ Eighteen months ago you had **one** coding assistant. Today you probably have **three running at once** — Claude in one terminal, Cursor in another, Copilot in the editor. They all write to the same files, and not one of them knows the others exist.
39
+
40
+ Now put **four people** on that repo. That's **twelve agents**, editing the same code, blind to each other. You find out at the merge. Or you don't find out at all.
41
+
42
+ Git solved this for humans — but Git assumes people committing at human speed, minutes apart, one branch each. **Agents write at machine speed, in parallel, in the same directory.** There is no layer for that.
43
+
44
+ ## The fix
45
+
46
+ ```diff
47
+ - 12 agents · 12 blind writers · conflicts discovered at merge
48
+ + 12 agents · 1 lock table · conflicts prevented at the edit
49
+ ```
50
+
51
+ Relay sits under every agent as a **coordination layer**. Pre-tool hooks claim a lock before an agent writes; post-tool hooks release it. If one agent is modifying a component, another **cannot silently overwrite** those changes.
52
+
53
+ > **One host, no backend.** One person runs `relay serve` — that machine *is* the server. Teammates connect over a fast tunnel (Cloudflare Quick Tunnel, Tailscale Funnel, custom host URL, or ngrok), straight to that laptop. Nothing lives on infrastructure you do not own.
54
+
55
+ <div align="center">
56
+
57
+ ![Relay Demo](./screenRec.gif)
58
+
59
+ **[Watch the full demo on YouTube](https://youtu.be/vUea5Qih-nM)**
60
+
61
+ </div>
62
+
63
+ ## The four pillars
64
+
65
+ | | What it does |
66
+ |---|---|
67
+ | **File locking** | Every agent write goes through a claim/release cycle against a shared lock table (TTL-based, auto-expiring). Destructive overwrites are blocked, not detected after the fact. |
68
+ | **Dependency-graph locking** | Files do not exist independently — one change can affect dozens. Relay parses imports with tree-sitter and asks *"does this change affect files another agent is working on?"*, not just *"is this exact file locked?"* Agents stay parallel; the blast radius stays safe. |
69
+ | **Patch sync across machines** | Your teammate has their own filesystem, environment, and agents. Relay propagates **patches**, not whole-project copies — `relay push` / `relay pull` keep every clone on the same working state. |
70
+ | **Mission Control** | One board for every agent on every machine: live lock table, lock graph, code edits, unified agent chat history, conflicts, presence. |
71
+ | **Memory graph (FalkorDB)** | Every agent, session, prompt, edit, block and decision across machines, as one graph. Agents get it *before* they act (`relay_session_brief`), and *at the moment of conflict*: a denied lock says what the holder is working on. |
72
+
73
+ ## Memory graph: built for Graph Hacks (FalkorDB)
74
+
75
+ Locks stop two agents writing the same file at once. They don't tell the second agent **why** the file is taken, **what was decided** about it, or **which prompt** made it look the way it does. Before this, relay's memory was flat JSON, capped at 2,000 events, with nothing connected.
76
+
77
+ Now every agent turn, edit, lock claim, block, decision and teammate's activity flows into a **FalkorDB graph per project**:
78
+
79
+ ```
80
+ Person → Agent → Session → Turn
81
+ └─ MADE → Edit ─ MODIFIED → File ← ON ─ Contention ─ HELD_BY → Session (its intent)
82
+ └─ PROMPTED_BY → the user request behind the change
83
+ File ─ CO_EDITED → File (PageRank = hotspots, label propagation = work areas)
84
+ ```
85
+
86
+ - **Explainable by default.** Every edit links to the user request that caused it, inferred from transcripts. "continue" and "yes" are skipped in favor of the real request, so no agent has to report anything.
87
+ - **Context at the conflict.** *"File `src/auth.ts` is locked by Claude Code… They're working on: "switch the login flow to JWT tokens" (2 min ago)."*
88
+ - **Context before acting.** `relay_session_brief(paths)` hands any agent (Claude Code, Cursor, Codex, Copilot, Antigravity) who is on those files now and why, past blocks, decisions, the prompts behind recent changes, and who to ask.
89
+ - **Graph algorithms as agent tools.** PageRank and label propagation over co-edited files find coordination hotspots and work areas. `relay_graph_query` exposes 10 named Cypher queries. Every answer ships with its Cypher.
90
+ - **Shared across the room.** The host's graph is the room's memory. Guests query it over the existing tunnel, even without FalkorDB installed.
91
+ - **Safe to add.** Single writer, idempotent `MERGE`s, a circuit breaker, and a full fail-open path: with the graph off, relay behaves exactly as before.
92
+
93
+ ```bash
94
+ relay graph up # FalkorDB in Docker
95
+ relay graph enable # save the URL; switches a running relay on
96
+ relay serve # Mission Control → Memory Graph tab
97
+ ```
98
+
99
+ Full setup (Windows/macOS/Linux, hosted FalkorDB, troubleshooting): [docs/MEMORY-GRAPH.md → Setup](./docs/MEMORY-GRAPH.md#setup) · [Engineering Blog Post: From Flat JSON to a FalkorDB Memory Graph](./docs/BLOG-POST.md).
100
+
101
+ ## Quick start
102
+
103
+ **Install** (Node 20+):
104
+
105
+ ```bash
106
+ npm install -g relaybrain # installs the `relay` CLI with Mission Control prebuilt
107
+ relay graph up # optional: FalkorDB memory graph in Docker
108
+ relay graph enable
109
+ ```
110
+
111
+ **Host** — the machine everyone else connects to:
112
+
113
+ ```bash
114
+ relay login # GitHub identity (uses the gh CLI)
115
+ relay clone <repo-url> # clone + register workspace + install agent hooks
116
+ relay serve # npm install: everything on :3001 · source checkout: Mission Control on :3002
117
+ ```
118
+
119
+ Then open Mission Control → **Team → Share** to start a tunnel (Cloudflare Quick Tunnel / Tailscale Funnel / custom URL) and mint the invite link.
120
+
121
+ **Teammate:**
122
+
123
+ ```bash
124
+ relay serve # their own local relay
125
+ # join with the host's invite link on the Team tab
126
+ relay pull # take the host's current working state
127
+ ```
128
+
129
+ Now start agents as usual. Hooks claim and release locks automatically — nobody has to remember a command.
130
+
131
+ <details>
132
+ <summary><strong>Install options</strong></summary>
133
+
134
+ <br/>
135
+
136
+ | Method | Command |
137
+ |--------|---------|
138
+ | npm | `npm install -g relaybrain` |
139
+ | local dev | `npm i`, then `npm link` in this repo |
140
+ | attach an existing repo | `relay add /path/to/repo` |
141
+ | fresh clone | `relay clone <url> [dir]` |
142
+
143
+ Requires **Node.js 20+**. No hosted backend, no login server. The memory graph needs FalkorDB (`relay graph up`, Docker); without it, relay runs on files alone.
144
+
145
+ </details>
146
+
147
+ ## How it works
148
+
149
+ ```text
150
+ Cursor ──┐ pre-tool hook: claim
151
+ Claude ──┤ post-tool hook: release ┌──────────────┐
152
+ Copilot ─┼──► lock table ◄── dep graph ──│ relay serve │──► Mission Control
153
+ Codex ───┤ ▲ │ (your host) │ ▲
154
+ Antigravity ┘ │ └──────┬───────┘ │
155
+ │ ngrok tunnel │ │
156
+ teammate's relay ◄──── patches ─────┴──────────────────┘
157
+ ```
158
+
159
+ | Layer | Who runs it | What it guards |
160
+ |-------|-------------|----------------|
161
+ | **Claim / release** | pre- and post-tool hooks | exclusive write access to a file |
162
+ | **Dependency graph** | tree-sitter import scan | files *affected by* the edit, not just the edit |
163
+ | **Room** | `relay serve` + tunnel (Cloudflare / Tailscale / custom) | one shared lock table across machines |
164
+ | **Patches** | `relay push` / `relay pull` | working-tree state without full copies |
165
+ | **Board** | Mission Control (:3002) | who holds what, right now |
166
+
167
+ > Locks and the board are separate subsystems — hooks arbitrate straight against the host, while the board is fed by a mirror. `relay doctor` walks that chain and tells you which link is broken.
168
+
169
+ ## Commands
170
+
171
+ | Command | Description |
172
+ |---------|-------------|
173
+ | `relay login` / `logout` / `whoami` | GitHub identity (via `gh` CLI, or device flow with `GITHUB_CLIENT_ID`) |
174
+ | `relay clone <url> [dir]` | git clone + register workspace + install agent hooks |
175
+ | `relay add <path>` | Attach an existing local repo |
176
+ | `relay init [path]` | Wire hooks, Relay MCP, and `/relay ask` instructions for all agents |
177
+ | `relay serve` | API on `127.0.0.1:3001` + Mission Control on `:3002` |
178
+ | `relay serve --no-ui` | API + coordinator only |
179
+ | `relay push` | Send your dirty working-tree files to the shared room |
180
+ | `relay pull` | Apply the host's current dirty files onto this clone |
181
+ | `relay status` | Health check (room, role, host, ports) |
182
+ | `relay mcp-url` | Print the MCP config for this room's shared-context endpoint |
183
+ | `relay doctor` | Diagnose an empty Coordinator board |
184
+
185
+ ## Mission Control
186
+
187
+ Started by `relay serve` — runs on your own machine, no hosted account.
188
+
189
+ | | URL |
190
+ |---|-----|
191
+ | Dashboard | http://localhost:3002 |
192
+ | API | http://127.0.0.1:3001/api/health |
193
+
194
+ **Collisions prevented counter** (persisted to `~/.relay/data/projects/<id>/collisions.json` and mirrored to `<repo>/.relay/collisions.json`).
195
+
196
+ ## MCP — shared room context
197
+
198
+ Any MCP client can read the room's context, **even on a machine with no relay installed**:
199
+
200
+ ```bash
201
+ relay mcp-url # prints a ready-to-paste mcpServers block
202
+ ```
203
+
204
+ Coordination tools (`relay_claim_file`, `relay_release_file`, `relay_status`) run against each member's own local relay. The room endpoint is **read-only** for anyone off the host machine.
205
+
206
+ <details>
207
+ <summary><strong>Tools exposed</strong></summary>
208
+
209
+ <br/>
210
+
211
+ | Tool | Purpose |
212
+ |------|---------|
213
+ | `relay_claim_file` | Acquire an exclusive write lock before editing |
214
+ | `relay_release_file` | Release the lock after editing |
215
+ | `relay_status` | View the full lock table |
216
+ | `relay_get_conflicts` | Overlapping edits in the last 5 minutes |
217
+ | `relay_get_recent_changes` | Recent `code_edit` events |
218
+ | `relay_get_chat_history` | Unified agent chat across every room member |
219
+ | `relay_room_brief` | Teammate activity brief (for the `/relay ask` agent pseudo-command) |
220
+ | `relay_get_collision_stats` | Lifetime counters for collisions Relay prevented |
221
+ | `relay_session_brief` | Memory-graph brief for files you're about to touch: who's on them now and why, blocks, decisions, prompts behind changes |
222
+ | `relay_graph_query` | Named Cypher queries over the FalkorDB memory graph (`file_story`, `who_to_ask`, `contention_trace`, `search_memory`, `hotspots`, …) |
223
+ | `relay_report_change` | Push a code-change event |
224
+ | `relay_report_decision` / `relay_get_decisions` | Append / read decisions |
225
+ | `relay_update_task` / `relay_get_active_tasks` | Append / read tasks |
226
+ | `relay_get_project_context` | Full JSON project context |
227
+ | `relay_sync` | Re-read agent transcripts into unified history |
228
+
229
+ </details>
230
+
231
+ ## Hooks
232
+
233
+ `relay clone` / `relay add` / `relay init` install pre-tool (**claim**), post-tool (**release**), pre-read, stop hooks, **Relay MCP** (`relay_room_brief` for `/relay ask`), and the relay-os instruction block into every agent surface:
234
+
235
+ | Agent | Hooks | Instructions | Relay MCP (`relay_room_brief`) |
236
+ |-------|--------|--------------|--------------------------------|
237
+ | Claude Code | `.claude/settings.json` | `CLAUDE.md` | `.claude/settings.json` → `mcpServers.relay` |
238
+ | Cursor | `.cursor/hooks.json` | `.cursor/rules/relay.mdc`, `AGENTS.md` | `.cursor/mcp.json` |
239
+ | Codex | `.codex/hooks.json` | `AGENTS.md` | `.codex/config.toml` → `[mcp_servers.relay]` |
240
+ | Copilot CLI | `.github/hooks/relay-os.json` | `.github/copilot-instructions.md` | `.github/mcp.json` |
241
+ | Antigravity | `.agents/hooks.json` | `AGENTS.md` | `.agents/mcp_config.json` |
242
+
243
+ Languages parsed for the dependency graph: TypeScript/JavaScript, Python, Go, Rust, Java, C#, C/C++, PHP, Ruby.
244
+
245
+ ## Dependencies
246
+
247
+ | | |
248
+ |---|---|
249
+ | **Required** | Node.js 20+, npm |
250
+ | **Bundled** | `express`, `cors`, `ws`, `@vscode/tree-sitter-wasm` · `next`, `react` (Mission Control) |
251
+ | **Not needed** | MongoDB, Redis, Docker, a hosted backend, an account |
252
+ | **Optional** | `gh` CLI for `relay login` · `cloudflared` / Tailscale Funnel / ngrok for cross-machine rooms · Docker or FalkorDB Cloud for the memory graph |
253
+
254
+ **Env vars:** `RELAY_PORT` (3001) · `RELAY_UI_PORT` (3002) · `RELAY_UI_ORIGIN` · `RELAY_TUNNEL` (`cloudflared`\|`tailscale`\|`ngrok`) · `RELAY_PUBLIC_URL` · `RELAY_PUBLIC_READONLY`
255
+
256
+ ## Documentation
257
+
258
+ Full documentation lives in [`docs/`](./docs/README.md).
259
+
260
+ | | |
261
+ |---|---|
262
+ | [Quickstart](./docs/QUICKSTART.md) | Coordinating in about five minutes |
263
+ | [Installation](./docs/INSTALLATION.md) | Requirements, install methods, what gets written where |
264
+ | [Concepts](./docs/CONCEPTS.md) | Workspace, project, agent ID, lock, room, patch |
265
+ | [Rooms and teams](./docs/ROOMS.md) | Hosting, invites, member tokens, joining, leaving |
266
+ | [Architecture](./docs/ARCHITECTURE.md) | Process model, the two planes, one edit end to end |
267
+ | [Locking](./docs/LOCKING.md) | The lock table, TTLs, ownership, dependency-graph soft locks |
268
+ | [Sync and patches](./docs/SYNC.md) | Patch format, Lamport ordering, transform, transcripts |
269
+ | [CLI reference](./docs/CLI.md) | Every command, flag and exit code |
270
+ | [HTTP API reference](./docs/HTTP-API.md) | Every endpoint, with auth rules |
271
+ | [MCP reference](./docs/MCP.md) | The seventeen tools and the read-only room endpoint |
272
+ | [Hooks reference](./docs/HOOKS.md) | The hook contract per agent |
273
+ | [Configuration](./docs/CONFIGURATION.md) | Environment variables, ports, on-disk layout |
274
+ | [Troubleshooting](./docs/TROUBLESHOOTING.md) | `relay doctor` and the known failure modes |
275
+ | [Memory graph](./docs/MEMORY-GRAPH.md) | FalkorDB graph model, queries, algorithms, migrations & retention |
276
+ | [Blog post](./docs/BLOG-POST.md) | From flat JSON to a FalkorDB memory graph for AI coding agents |
277
+
278
+ ## Roadmap
279
+
280
+ - **Git worktree isolation** — every agent in its own workspace off the same repository, free to experiment and test independently, integrated back into main when the work is ready.
281
+ - Richer conflict resolution on top of the existing OT / patch layer.
282
+
283
+ <div align="center">
284
+
285
+ **We used Relay to build Relay.** The problem we were solving was the problem we were having.
286
+
287
+ The next time you build a hackathon project, do not just add more agents. **Just relay.it**
288
+
289
+ <sub>MIT licensed · Your machine is the server.</sub>
290
+
291
+ </div>
@@ -0,0 +1,364 @@
1
+ const fs = require("fs");
2
+ const http = require("http");
3
+ const path = require("path");
4
+ const { coordinatorStatePath } = require("../lib/paths");
5
+ const { loadRoom } = require("../lib/room");
6
+
7
+ const TOTAL_BUDGET_MS = 3000;
8
+ const HTTP_TIMEOUT_MS = 2000;
9
+
10
+ function readState(workspacePath) {
11
+ const files = [coordinatorStatePath(workspacePath), coordinatorStatePath(null)];
12
+ for (const file of files) {
13
+ try {
14
+ const state = JSON.parse(fs.readFileSync(file, "utf8"));
15
+ if (state.port || state.apiPort) return state;
16
+ } catch {
17
+ /* next */
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+
23
+ function readPort(workspacePath) {
24
+ const state = readState(workspacePath);
25
+ return state?.port ? Number(state.port) : null;
26
+ }
27
+
28
+ // The API server owns the same in-memory lock table the dashboard renders, and
29
+ // its port is fixed, so it is the one target that cannot go stale. The
30
+ // coordinator's port is ephemeral and its state file is trusted blindly by
31
+ // readPort — a `relay serve` that dies after binding the coordinator but before
32
+ // binding the API leaves a pointer to a dead port behind.
33
+ function apiPort(workspacePath) {
34
+ if (process.env.RELAY_PORT) return Number(process.env.RELAY_PORT);
35
+ const state = readState(workspacePath);
36
+ return Number(state?.apiPort) || 3001;
37
+ }
38
+
39
+ // A room whose host has gone away (closed laptop, expired tunnel) otherwise
40
+ // costs every hook a full HTTP timeout on each of claim/heartbeat/release, which
41
+ // reads to the user as "the agent froze".
42
+ const ROOM_COOLDOWN_MS = 30_000;
43
+ const roomFailedAt = new Map();
44
+
45
+ // Only a guest talks to the room. The host *is* the coordinator: routing its own
46
+ // hooks out through its ngrok tunnel and back adds a full internet round trip to
47
+ // every claim, and stalls the agent whenever the tunnel hiccups.
48
+ function roomUsable(room) {
49
+ if (!room?.url || room.role !== "guest") return false;
50
+ const failed = roomFailedAt.get(room.url);
51
+ return !failed || Date.now() - failed > ROOM_COOLDOWN_MS;
52
+ }
53
+
54
+ function markRoomDown(room) {
55
+ if (room?.url) roomFailedAt.set(room.url, Date.now());
56
+ }
57
+
58
+ // The lock table namespaces every lock by absolute workspace path, so a guest
59
+ // claiming `src/auth.ts` under `D:\work\repo` and a host holding it under
60
+ // `/home/ana/repo` land in two different namespaces and never collide — the
61
+ // coordinator looks shared while arbitrating nothing. Rewriting the workspace to
62
+ // the host's path is what puts both users on one mutex.
63
+ // `path.resolve` alone is not enough on Windows: the same folder reaches us as
64
+ // both `C:\Users\PRAKRI~1\...` and `C:\Users\Prakrititz Borah\...` depending on
65
+ // who spawned the agent, and a mismatch here silently sends the claim to the
66
+ // guest's own namespace instead of the shared one.
67
+ function canonicalPath(value) {
68
+ if (!value) return "";
69
+ try {
70
+ return fs.realpathSync.native(value).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
71
+ } catch {
72
+ return path.resolve(String(value)).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
73
+ }
74
+ }
75
+
76
+ function roomPayload(room, workspacePath, payload) {
77
+ if (room?.role !== "guest" || !room.hostWorkspacePath) return payload;
78
+ const local = canonicalPath(room.projectPath);
79
+ const here = canonicalPath(workspacePath);
80
+ // Antigravity (and some other agents) report a cwd that is the same repo on
81
+ // a different drive letter than the clone Relay joined. Still rewrite onto
82
+ // the host path so both machines share one lock namespace. Skip only when
83
+ // this is clearly a different folder with no room of its own.
84
+ if (local && here && local !== here && !here.startsWith(`${local}/`)) {
85
+ const hereRoom = loadRoom(workspacePath);
86
+ if (!hereRoom?.url || hereRoom.url !== room.url) return payload;
87
+ }
88
+ return { ...payload, workspaceId: room.hostWorkspacePath, peerWorkspacePath: workspacePath };
89
+ }
90
+
91
+ function postJson(port, pathname, body, timeoutMs) {
92
+ return new Promise((resolve, reject) => {
93
+ const data = JSON.stringify(body || {});
94
+ const req = http.request(
95
+ {
96
+ host: "127.0.0.1",
97
+ port,
98
+ path: pathname,
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/json",
102
+ "Content-Length": Buffer.byteLength(data),
103
+ },
104
+ timeout: timeoutMs,
105
+ },
106
+ (res) => {
107
+ let raw = "";
108
+ res.on("data", (c) => (raw += c));
109
+ res.on("end", () => {
110
+ try {
111
+ resolve({ status: res.statusCode, body: JSON.parse(raw || "{}") });
112
+ } catch (err) {
113
+ reject(err);
114
+ }
115
+ });
116
+ }
117
+ );
118
+ req.on("timeout", () => {
119
+ req.destroy(new Error("timeout"));
120
+ });
121
+ req.on("error", reject);
122
+ req.write(data);
123
+ req.end();
124
+ });
125
+ }
126
+
127
+ function getJson(port, pathname, timeoutMs) {
128
+ return new Promise((resolve, reject) => {
129
+ const req = http.request(
130
+ {
131
+ host: "127.0.0.1",
132
+ port,
133
+ path: pathname,
134
+ method: "GET",
135
+ timeout: timeoutMs,
136
+ },
137
+ (res) => {
138
+ let raw = "";
139
+ res.on("data", (c) => (raw += c));
140
+ res.on("end", () => {
141
+ try {
142
+ resolve(JSON.parse(raw || "{}"));
143
+ } catch (err) {
144
+ reject(err);
145
+ }
146
+ });
147
+ }
148
+ );
149
+ req.on("timeout", () => req.destroy(new Error("timeout")));
150
+ req.on("error", reject);
151
+ req.end();
152
+ });
153
+ }
154
+
155
+ function lockfilePath(workspacePath, file) {
156
+ const crypto = require("crypto");
157
+ const { locksDirFor } = require("../lib/paths");
158
+ const hash = crypto.createHash("sha256").update(file).digest("hex").slice(0, 16);
159
+ return path.join(locksDirFor(workspacePath), `${hash}.lock`);
160
+ }
161
+
162
+ function filesystemClaim(workspacePath, agentId, file, ttlMs) {
163
+ const lockPath = lockfilePath(workspacePath, file);
164
+ const now = Date.now();
165
+ try {
166
+ fs.writeFileSync(
167
+ lockPath,
168
+ JSON.stringify({ filePath: file, agentId, claimedAt: now, ttlMs, source: "filesystem", workspaceId: workspacePath }),
169
+ { flag: "wx" }
170
+ );
171
+ return { allowed: true, source: "filesystem" };
172
+ } catch {
173
+ try {
174
+ const existing = JSON.parse(fs.readFileSync(lockPath, "utf8"));
175
+ const { sameOwner } = require("./lockTable");
176
+ if (sameOwner(existing.agentId, agentId) || now >= existing.claimedAt + (existing.ttlMs || 15000)) {
177
+ fs.writeFileSync(
178
+ lockPath,
179
+ JSON.stringify({ filePath: file, agentId, claimedAt: now, ttlMs, source: "filesystem", workspaceId: workspacePath })
180
+ );
181
+ return { allowed: true, source: "filesystem" };
182
+ }
183
+ return { allowed: false, holder: existing.agentId, source: "filesystem" };
184
+ } catch {
185
+ return { allowed: true, source: "fail-open" };
186
+ }
187
+ }
188
+ }
189
+
190
+ async function postRemote(url, pathname, body, timeoutMs, token) {
191
+ const ctrl = new AbortController();
192
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
193
+ try {
194
+ const res = await fetch(`${url.replace(/\/$/, "")}${pathname}`, {
195
+ method: "POST",
196
+ headers: {
197
+ "Content-Type": "application/json",
198
+ "ngrok-skip-browser-warning": "relay",
199
+ // An invite-only host rejects unauthenticated lock traffic; without the
200
+ // token every claim would silently fall back to local-only locking.
201
+ ...(token ? { "x-relay-room-token": token } : {}),
202
+ },
203
+ body: JSON.stringify(body || {}),
204
+ signal: ctrl.signal,
205
+ });
206
+ return { status: res.status, body: await res.json().catch(() => ({})) };
207
+ } finally {
208
+ clearTimeout(timer);
209
+ }
210
+ }
211
+
212
+ async function claimFile(workspacePath, agentId, file, ttlMs = 15000, opts = {}) {
213
+ const started = Date.now();
214
+ const room = loadRoom(workspacePath);
215
+ const payload = {
216
+ agentId,
217
+ file,
218
+ ttl: ttlMs,
219
+ workspaceId: workspacePath,
220
+ dependsOn: opts.dependsOn,
221
+ files: opts.files,
222
+ mode: opts.mode,
223
+ holder: opts.holder || null,
224
+ };
225
+ // Local API owns the control-plane socket. Guests must not wait on ngrok HTTP
226
+ // while chat snapshots are in flight — claims hop loopback, then a tiny WS.
227
+ // A `degraded` answer means the daemon could not reach the host, so it is not
228
+ // an answer at all — it must not short-circuit the ladder the way a real
229
+ // verdict does. Before this, the daemon's local-table grant came back as a
230
+ // plain `allowed: true` and stopped the search here, shadowing the room HTTP
231
+ // rung below: a dead control socket with a perfectly healthy tunnel never
232
+ // fell through to the wire that was still up.
233
+ let degraded = null;
234
+ try {
235
+ const remaining = Math.max(80, 800 - (Date.now() - started));
236
+ const { body } = await postJson(apiPort(workspacePath), "/api/coord/claim", roomPayload(room, workspacePath, payload), remaining);
237
+ if (body?.degraded) degraded = { ...body, source: body.source || "local_fallback" };
238
+ else if (body && typeof body.allowed === "boolean") return { ...body, source: body.source || "control" };
239
+ } catch {
240
+ /* coordinator / remote fallback */
241
+ }
242
+ if (roomUsable(room)) {
243
+ try {
244
+ const { body } = await postRemote(room.url, "/api/coord/claim", roomPayload(room, workspacePath, payload), HTTP_TIMEOUT_MS, room.memberToken);
245
+ return { ...body, source: body.source || "room" };
246
+ } catch {
247
+ markRoomDown(room);
248
+ }
249
+ }
250
+ // Every rung below this point is local to *this* machine: the coordinator
251
+ // process, the lock directory on this disk, and finally fail-open. None of
252
+ // them can adjudicate a lock that belongs to the room, so once we know we are
253
+ // a guest whose host is unreachable, consulting them would only launder an
254
+ // unanswered question into a confident yes. Refuse with the reason instead.
255
+ if (degraded) {
256
+ console.warn("[relay] HOST_UNREACHABLE — claim refused rather than granted locally");
257
+ return degraded;
258
+ }
259
+ const port = readPort(workspacePath);
260
+ if (port) {
261
+ try {
262
+ const remaining = Math.max(50, HTTP_TIMEOUT_MS - (Date.now() - started));
263
+ const { body } = await postJson(port, "/claim", payload, remaining);
264
+ return { ...body, source: body.source || "coordinator" };
265
+ } catch {
266
+ /* fall through */
267
+ }
268
+ }
269
+ const remaining = TOTAL_BUDGET_MS - (Date.now() - started);
270
+ if (remaining > 0) {
271
+ try {
272
+ return filesystemClaim(workspacePath, agentId, file, ttlMs);
273
+ } catch {
274
+ /* fail open */
275
+ }
276
+ }
277
+ console.warn("[relay] COORDINATOR_UNAVAILABLE — fail-open");
278
+ return { allowed: true, source: "fail-open" };
279
+ }
280
+
281
+ async function releaseFile(workspacePath, agentId, file) {
282
+ const room = loadRoom(workspacePath);
283
+ const payload = { agentId, file, workspaceId: workspacePath };
284
+ try {
285
+ const { body } = await postJson(apiPort(workspacePath), "/api/coord/release", roomPayload(room, workspacePath, payload), 600);
286
+ // A degraded release is already queued for replay by the daemon, but the
287
+ // tunnel may still be up even when the control socket is not — try it, and
288
+ // let the replay be a harmless no-op if this succeeds.
289
+ if (body && !body.degraded) return body;
290
+ } catch {
291
+ /* fallback */
292
+ }
293
+ if (roomUsable(room)) {
294
+ try {
295
+ const { body } = await postRemote(room.url, "/api/coord/release", roomPayload(room, workspacePath, payload), HTTP_TIMEOUT_MS, room.memberToken);
296
+ return body;
297
+ } catch {
298
+ markRoomDown(room);
299
+ }
300
+ }
301
+ return { ok: true };
302
+ }
303
+
304
+ async function status(workspacePath) {
305
+ const port = readPort(workspacePath);
306
+ if (!port) return { locks: {} };
307
+ try {
308
+ return await getJson(port, "/status", HTTP_TIMEOUT_MS);
309
+ } catch {
310
+ return { locks: {} };
311
+ }
312
+ }
313
+
314
+ async function releaseAll(workspacePath, agentId) {
315
+ const room = loadRoom(workspacePath);
316
+ const payload = { agentId, workspaceId: workspacePath };
317
+ if (roomUsable(room)) {
318
+ try {
319
+ const { body } = await postRemote(room.url, "/api/coord/release-all", roomPayload(room, workspacePath, payload), HTTP_TIMEOUT_MS, room.memberToken);
320
+ return body;
321
+ } catch {
322
+ markRoomDown(room);
323
+ /* local fallback */
324
+ }
325
+ }
326
+ const port = readPort(workspacePath);
327
+ if (port) {
328
+ try {
329
+ const { body } = await postJson(port, "/release-all", payload, HTTP_TIMEOUT_MS);
330
+ return body;
331
+ } catch {
332
+ /* ignore */
333
+ }
334
+ }
335
+ try {
336
+ const { body } = await postJson(apiPort(workspacePath), "/api/coord/release-all", payload, HTTP_TIMEOUT_MS);
337
+ return body;
338
+ } catch {
339
+ /* ignore */
340
+ }
341
+ return { released: [] };
342
+ }
343
+
344
+ async function heartbeat(workspacePath, agentId, file) {
345
+ const room = loadRoom(workspacePath);
346
+ const payload = { agentId, file, workspaceId: workspacePath };
347
+ try {
348
+ const { body } = await postJson(apiPort(workspacePath), "/api/coord/heartbeat", roomPayload(room, workspacePath, payload), 400);
349
+ if (body) return body;
350
+ } catch {
351
+ /* fallback */
352
+ }
353
+ if (roomUsable(room)) {
354
+ try {
355
+ const { body } = await postRemote(room.url, "/api/coord/heartbeat", roomPayload(room, workspacePath, payload), HTTP_TIMEOUT_MS, room.memberToken);
356
+ return body;
357
+ } catch {
358
+ markRoomDown(room);
359
+ }
360
+ }
361
+ return { ok: false };
362
+ }
363
+
364
+ module.exports = { claimFile, releaseFile, releaseAll, status, readPort, heartbeat, apiPort, postJson };