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
@@ -0,0 +1,215 @@
1
+ const crypto = require("crypto");
2
+
3
+ // A shared room is reachable by anyone who learns the tunnel URL, and it hands
4
+ // out the whole working tree, every agent transcript, and write access to the
5
+ // lock table. Membership therefore has to be proven, not assumed.
6
+ //
7
+ // Two credentials, with different lifetimes:
8
+ // invite code — issued by the host to one named person, single use, expires.
9
+ // member token — minted when an invite is redeemed, presented on every
10
+ // subsequent request for as long as the room lasts.
11
+ // Only hashes are ever written to room.json, so a leaked room file cannot be
12
+ // replayed to rejoin the room.
13
+
14
+ const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
15
+ const SECRET_BYTES = 24;
16
+
17
+ function hash(raw) {
18
+ return crypto.createHash("sha256").update(String(raw)).digest("hex");
19
+ }
20
+
21
+ function mint(prefix) {
22
+ return `${prefix}_${crypto.randomBytes(SECRET_BYTES).toString("hex")}`;
23
+ }
24
+
25
+ function sameSecret(a, b) {
26
+ const left = Buffer.from(String(a || ""), "utf8");
27
+ const right = Buffer.from(String(b || ""), "utf8");
28
+ if (left.length !== right.length || !left.length) return false;
29
+ return crypto.timingSafeEqual(left, right);
30
+ }
31
+
32
+ function normLogin(login) {
33
+ return String(login || "").trim().toLowerCase();
34
+ }
35
+
36
+ function invitesOf(room) {
37
+ return Array.isArray(room?.invites) ? room.invites : [];
38
+ }
39
+
40
+ function inviteState(invite, now = Date.now()) {
41
+ if (invite.revokedAt) return "revoked";
42
+ if (invite.acceptedAt) return "accepted";
43
+ if (invite.expiresAt && now > invite.expiresAt) return "expired";
44
+ return "pending";
45
+ }
46
+
47
+ function createInvite(room, { login, ttlMs, invitedBy } = {}) {
48
+ const target = normLogin(login);
49
+ if (!target) return { ok: false, error: "login_required" };
50
+ const code = mint("rli");
51
+ const now = Date.now();
52
+ const invite = {
53
+ id: `inv_${crypto.randomBytes(6).toString("hex")}`,
54
+ login: target,
55
+ codeHash: hash(code),
56
+ createdAt: now,
57
+ expiresAt: now + (Number(ttlMs) || INVITE_TTL_MS),
58
+ invitedBy: normLogin(invitedBy) || null,
59
+ acceptedAt: null,
60
+ revokedAt: null,
61
+ };
62
+ room.invites = [...invitesOf(room).filter((i) => inviteState(i) !== "expired"), invite];
63
+ return { ok: true, invite, code };
64
+ }
65
+
66
+ function revokeInvite(room, id) {
67
+ const invite = invitesOf(room).find((i) => i.id === id);
68
+ if (!invite) return { ok: false, error: "not_found" };
69
+ invite.revokedAt = Date.now();
70
+ return { ok: true, invite };
71
+ }
72
+
73
+ /** Safe to send to a client: never includes the code or its hash. */
74
+ function inviteView(room) {
75
+ return invitesOf(room).map((i) => ({
76
+ id: i.id,
77
+ login: i.login,
78
+ state: inviteState(i),
79
+ createdAt: i.createdAt,
80
+ expiresAt: i.expiresAt,
81
+ invitedBy: i.invitedBy,
82
+ acceptedAt: i.acceptedAt,
83
+ }));
84
+ }
85
+
86
+ function pendingInviteFor(room, login) {
87
+ const claimed = normLogin(login);
88
+ return invitesOf(room).find((i) => normLogin(i.login) === claimed && inviteState(i) === "pending") || null;
89
+ }
90
+
91
+ function acceptedInviteFor(room, login) {
92
+ const claimed = normLogin(login);
93
+ return invitesOf(room).find((i) => normLogin(i.login) === claimed && inviteState(i) === "accepted") || null;
94
+ }
95
+
96
+ /**
97
+ * Checks an invite code against the room. `login` is who is claiming it — an
98
+ * invite addressed to one person must not be usable by another, otherwise a
99
+ * forwarded link is as good as an open door. A code-less redeem is only safe
100
+ * after GitHub identity proof (see /api/room/redeem).
101
+ */
102
+ function redeemInvite(room, code, login) {
103
+ const claimed = normLogin(login);
104
+ if (!claimed) return { ok: false, reason: "login_required" };
105
+ if (code) {
106
+ const hashed = hash(code);
107
+ const invite = invitesOf(room).find((i) => sameSecret(i.codeHash, hashed));
108
+ if (!invite) return { ok: false, reason: "invite_invalid" };
109
+ const state = inviteState(invite);
110
+ if (state === "revoked") return { ok: false, reason: "invite_revoked" };
111
+ if (state === "expired") return { ok: false, reason: "invite_expired" };
112
+ if (state === "accepted") return { ok: false, reason: "invite_already_used" };
113
+ if (normLogin(invite.login) !== claimed) {
114
+ return { ok: false, reason: "invite_wrong_user", invitedLogin: invite.login };
115
+ }
116
+ return { ok: true, invite };
117
+ }
118
+ // Code-less redeem is only safe after GitHub identity proof (see /api/room/redeem).
119
+ const pending = pendingInviteFor(room, claimed);
120
+ if (pending) return { ok: true, invite: pending };
121
+ const accepted = acceptedInviteFor(room, claimed);
122
+ if (accepted) return { ok: true, invite: accepted, rejoin: true };
123
+ return { ok: false, reason: "invite_required" };
124
+ }
125
+
126
+ function markAccepted(invite, login) {
127
+ invite.acceptedAt = Date.now();
128
+ invite.acceptedBy = normLogin(login);
129
+ return invite;
130
+ }
131
+
132
+ /** Mints the long-lived credential a member presents on every later request. */
133
+ function issueMemberToken(room, login) {
134
+ const target = normLogin(login);
135
+ const token = mint("rlm");
136
+ room.memberTokens = (Array.isArray(room.memberTokens) ? room.memberTokens : []).filter(
137
+ (t) => normLogin(t.login) !== target
138
+ );
139
+ room.memberTokens.push({ login: target, tokenHash: hash(token), issuedAt: Date.now() });
140
+ return token;
141
+ }
142
+
143
+ function authorizeMember(room, token) {
144
+ if (!token) return null;
145
+ const hashed = hash(token);
146
+ const row = (Array.isArray(room?.memberTokens) ? room.memberTokens : []).find((t) =>
147
+ sameSecret(t.tokenHash, hashed)
148
+ );
149
+ return row ? { login: row.login, issuedAt: row.issuedAt } : null;
150
+ }
151
+
152
+ function revokeMember(room, login) {
153
+ const target = normLogin(login);
154
+ room.memberTokens = (Array.isArray(room.memberTokens) ? room.memberTokens : []).filter(
155
+ (t) => normLogin(t.login) !== target
156
+ );
157
+ room.invites = invitesOf(room).map((i) =>
158
+ normLogin(i.login) === target && !i.revokedAt ? { ...i, revokedAt: Date.now() } : i
159
+ );
160
+ return room;
161
+ }
162
+
163
+ /**
164
+ * The room as it is safe to hand to any client. /api/health and /api/room are
165
+ * both reachable through the tunnel without credentials, and the raw room
166
+ * carries invite and member token hashes.
167
+ */
168
+ function publicRoom(room) {
169
+ if (!room) return null;
170
+ const { invites, memberTokens, members, memberToken, nonce, ...rest } = room;
171
+ return { ...rest, memberCount: Array.isArray(members) ? members.length : 0 };
172
+ }
173
+
174
+ /** Reads the member credential off a request. */
175
+ function roomToken(req) {
176
+ const header = req.headers?.["x-relay-room-token"];
177
+ if (header) return String(header);
178
+ const auth = String(req.headers?.authorization || "");
179
+ if (/^Room\s+/i.test(auth)) return auth.replace(/^Room\s+/i, "").trim();
180
+ return req.query?.token ? String(req.query.token) : null;
181
+ }
182
+
183
+ /**
184
+ * True when the request arrived from somewhere other than this machine. ngrok
185
+ * (and any reverse proxy) stamps X-Forwarded-For, which a remote caller cannot
186
+ * strip. Local Mission Control and local hooks stay unauthenticated so adding
187
+ * room security never breaks solo use.
188
+ */
189
+ function isRemoteRequest(req) {
190
+ if (req.headers?.["x-forwarded-for"] || req.headers?.["x-original-forwarded-for"]) return true;
191
+ const addr = String(req.socket?.remoteAddress || req.connection?.remoteAddress || "");
192
+ if (!addr) return false;
193
+ const bare = addr.replace(/^::ffff:/, "");
194
+ return bare !== "127.0.0.1" && bare !== "::1" && bare !== "localhost";
195
+ }
196
+
197
+ module.exports = {
198
+ INVITE_TTL_MS,
199
+ hash,
200
+ normLogin,
201
+ createInvite,
202
+ revokeInvite,
203
+ inviteView,
204
+ inviteState,
205
+ invitesOf,
206
+ redeemInvite,
207
+ pendingInviteFor,
208
+ markAccepted,
209
+ issueMemberToken,
210
+ authorizeMember,
211
+ revokeMember,
212
+ publicRoom,
213
+ roomToken,
214
+ isRemoteRequest,
215
+ };
@@ -0,0 +1,226 @@
1
+ const { WebSocket } = require("ws");
2
+
3
+ // A guest that releases a lock while the tunnel is down must still be able to
4
+ // tell the host about it, or the file stays locked room-wide until the TTL runs
5
+ // out (up to MAX_TTL_MS, five minutes). The queue is bounded because an outage
6
+ // has no upper bound: past this many pending mutations the oldest are dropped,
7
+ // since a release that stale has been overtaken by TTL expiry anyway.
8
+ const MAX_QUEUED = 200;
9
+
10
+ /**
11
+ * Guest-side RPC over the dedicated control socket.
12
+ * Locks are game inputs: tiny, ordered, never queued behind chat snapshots.
13
+ */
14
+ class RoomControl {
15
+ constructor() {
16
+ this.socket = null;
17
+ this.pending = new Map();
18
+ this.seq = 0;
19
+ this.onLocks = null;
20
+ // Mutations issued while `open` was false, replayed in order on reconnect.
21
+ this.queue = [];
22
+ this.queueSeq = 0;
23
+ this.onReplay = null;
24
+ this.flushing = null;
25
+ }
26
+
27
+ attach(socket) {
28
+ this.socket = socket;
29
+ }
30
+
31
+ get open() {
32
+ return Boolean(this.socket && this.socket.readyState === WebSocket.OPEN);
33
+ }
34
+
35
+ handle(msg) {
36
+ if (!msg || typeof msg !== "object") return false;
37
+ if (msg.type === "locks" && this.onLocks) {
38
+ this.onLocks(msg);
39
+ return true;
40
+ }
41
+ if ((msg.type === "rpc-ok" || msg.type === "rpc-err") && this.pending.has(msg.id)) {
42
+ const done = this.pending.get(msg.id);
43
+ this.pending.delete(msg.id);
44
+ let error = null;
45
+ if (msg.type === "rpc-err") {
46
+ error = new Error(msg.error || "rpc_failed");
47
+ // The host answered, and said no. Everything else that can reject an
48
+ // rpc — a dead socket, a timeout, a send that throws — is the tunnel
49
+ // failing rather than a verdict, and replay treats the two oppositely.
50
+ error.fromHost = true;
51
+ }
52
+ done(error, msg.result);
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ rpc(op, body, timeoutMs = 700) {
59
+ return new Promise((resolve, reject) => {
60
+ if (!this.open) {
61
+ reject(new Error("control_offline"));
62
+ return;
63
+ }
64
+ const id = String(++this.seq);
65
+ const timer = setTimeout(() => {
66
+ this.pending.delete(id);
67
+ reject(new Error("control_timeout"));
68
+ }, timeoutMs);
69
+ this.pending.set(id, (err, result) => {
70
+ clearTimeout(timer);
71
+ if (err) reject(err);
72
+ else resolve(result);
73
+ });
74
+ try {
75
+ this.socket.send(JSON.stringify({ type: "rpc", op, id, body }));
76
+ } catch (err) {
77
+ clearTimeout(timer);
78
+ this.pending.delete(id);
79
+ reject(err);
80
+ }
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Park a mutation that could not be sent, for replay on reconnect.
86
+ *
87
+ * Deliberately not used for `claim`: a claim is a question ("may I?"), and
88
+ * replaying it minutes later would answer a question nobody is still asking —
89
+ * the agent has long since been told no and moved on. Only the mutations that
90
+ * are statements of fact ("I am done with this file") are worth replaying.
91
+ *
92
+ * Deliberately not used for `heartbeat` either: a heartbeat is a liveness
93
+ * claim about *now*. Replaying a stale one would resurrect a lock the host has
94
+ * already expired, which is the opposite of what the sender meant.
95
+ */
96
+ enqueue(op, body) {
97
+ if (op !== "release" && op !== "release-all") return false;
98
+ const key = dedupeKey(op, body);
99
+ const existing = this.queue.findIndex((item) => item.key === key);
100
+ if (existing !== -1) this.queue.splice(existing, 1);
101
+ this.queue.push({ key, op, body, seq: ++this.queueSeq, at: Date.now() });
102
+ while (this.queue.length > MAX_QUEUED) this.queue.shift();
103
+ return true;
104
+ }
105
+
106
+ get queued() {
107
+ return this.queue.length;
108
+ }
109
+
110
+ /**
111
+ * Replay the parked mutations, oldest first, and hand back what the host made
112
+ * of them.
113
+ *
114
+ * A host *verdict* (an `rpc-err`, tagged `fromHost`) is final and the entry is
115
+ * dropped, so a release the host keeps refusing is not retried forever. Any
116
+ * other rejection — dead socket, timeout, a send that throws — is the tunnel
117
+ * failing rather than an answer, so those entries go back on the queue for the
118
+ * next reconnect rather than being silently lost.
119
+ */
120
+ flush() {
121
+ // Concurrent callers share one pass: a second flush would race the first for
122
+ // the same entries and could replay one twice. This is checked before the
123
+ // empty-queue guard because #flush drains the queue up front — by the time a
124
+ // second caller arrives the queue is already empty, and returning a fresh
125
+ // resolved promise there would report "done" while the replay is still in
126
+ // flight, which is exactly what settled() must not do.
127
+ if (this.flushing) return this.flushing;
128
+ if (!this.queue.length) return Promise.resolve({ replayed: [], rejected: [], requeued: [] });
129
+ this.flushing = this.#flush().finally(() => {
130
+ this.flushing = null;
131
+ });
132
+ return this.flushing;
133
+ }
134
+
135
+ /**
136
+ * Resolves once any in-flight replay has finished.
137
+ *
138
+ * Anything that claims a lock must wait on this. A queued release and a fresh
139
+ * claim for the same file both travel this socket, and `release` on the host
140
+ * only checks `sameOwner` — so if a re-claim lands first, the replayed release
141
+ * that follows deletes the lock the agent has just been granted and still
142
+ * believes it holds.
143
+ */
144
+ settled() {
145
+ return this.flushing || Promise.resolve(null);
146
+ }
147
+
148
+ async #flush() {
149
+ const batch = this.queue.slice().sort((a, b) => a.seq - b.seq);
150
+ this.queue = [];
151
+ const replayed = [];
152
+ const rejected = [];
153
+ const requeued = [];
154
+ for (const item of batch) {
155
+ if (!this.open) {
156
+ this.#requeue(item);
157
+ requeued.push({ ...item, error: "control_offline" });
158
+ continue;
159
+ }
160
+ try {
161
+ const result = await this.rpc(item.op, item.body);
162
+ replayed.push({ ...item, result });
163
+ } catch (err) {
164
+ const error = err?.message || String(err);
165
+ if (err?.fromHost) rejected.push({ ...item, error });
166
+ else {
167
+ this.#requeue(item);
168
+ requeued.push({ ...item, error });
169
+ }
170
+ }
171
+ }
172
+ const outcome = { replayed, rejected, requeued };
173
+ if (this.onReplay) {
174
+ try {
175
+ this.onReplay(outcome);
176
+ } catch {
177
+ /* a reporting failure must not break reconnection */
178
+ }
179
+ }
180
+ return outcome;
181
+ }
182
+
183
+ // Keeps the original seq, so a re-queued entry still replays ahead of
184
+ // mutations issued after it.
185
+ #requeue(item) {
186
+ if (this.queue.some((q) => q.key === item.key)) return;
187
+ this.queue.push(item);
188
+ while (this.queue.length > MAX_QUEUED) this.queue.shift();
189
+ }
190
+
191
+ send(obj) {
192
+ if (!this.open) return false;
193
+ try {
194
+ this.socket.send(JSON.stringify(obj));
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+ }
201
+
202
+ // release-all is per agent, not per file, so it collapses to one entry per
203
+ // agent+workspace; a per-file release collapses to one entry per file.
204
+ function dedupeKey(op, body = {}) {
205
+ const ws = String(body.workspaceId || "");
206
+ if (op === "release-all") return `release-all::${ws}::${String(body.agentId || "")}`;
207
+ return `${op}::${ws}::${String(body.file || "")}::${String(body.agentId || "")}`;
208
+ }
209
+
210
+ function sendSocket(socket, obj) {
211
+ if (!socket || socket.readyState !== 1) return false;
212
+ try {
213
+ socket.send(typeof obj === "string" ? obj : JSON.stringify(obj));
214
+ return true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ function broadcast(wss, obj) {
221
+ if (!wss) return;
222
+ const msg = JSON.stringify(obj);
223
+ for (const client of wss.clients) sendSocket(client, msg);
224
+ }
225
+
226
+ module.exports = { RoomControl, sendSocket, broadcast };
@@ -0,0 +1,201 @@
1
+ const { EventEmitter } = require("events");
2
+
3
+ const POLL_MS = 4000;
4
+ // How long a lock may sit past its own expiry before the mirror drops it. Locks
5
+ // carry a TTL of a few seconds and are renewed by heartbeat, so this is only
6
+ // what keeps a lock from flickering out between two polls.
7
+ const EXPIRY_GRACE_MS = 4000;
8
+ // Consecutive failed polls before the host counts as unreachable. One dropped
9
+ // tunnel request is normal; three in a row (~6s) is not.
10
+ const MAX_MISSES = 3;
11
+ // How long a push keeps the poller quiet. Long enough that a live socket does
12
+ // all the work, short enough that a socket which dies without closing cleanly
13
+ // (the normal way a tunnel disappears) is covered within one interval.
14
+ const PUSH_TRUST_MS = 8000;
15
+
16
+ /**
17
+ * A guest's own lock table is empty: its hooks claim against the host's
18
+ * coordinator (see coordinator/client.js), which is the whole point of a shared
19
+ * room. Without a mirror, every local read of the lock table — /api/locks, the
20
+ * SSE stream the Coordinator tab subscribes to, the dashboard — reports "no
21
+ * locks" and overwrites the host snapshot the dashboard just fetched.
22
+ *
23
+ * Two ways in, one authority. The host's lock table is the only writer; every
24
+ * change it makes is pushed down the room WebSocket and lands here via accept().
25
+ * That makes granting a lock and drawing it the SAME event rather than two
26
+ * subsystems that can disagree — which is exactly how a guest ended up with
27
+ * working locks and a permanently empty board. The poll below is no longer the
28
+ * delivery path, only reconciliation for whenever the socket is down.
29
+ *
30
+ * Only runs while a room is joined as a guest. Solo and host installs keep
31
+ * reading their own table exactly as before.
32
+ */
33
+ class RoomLockMirror extends EventEmitter {
34
+ constructor({ fetchLocks }) {
35
+ super();
36
+ this.fetchLocks = fetchLocks;
37
+ this.locks = [];
38
+ this.reads = [];
39
+ this.updatedAt = 0;
40
+ this.timer = null;
41
+ this.room = null;
42
+ this.failures = 0;
43
+ this.lastPushAt = 0;
44
+ }
45
+
46
+ /**
47
+ * Whether the host is answering. The board is otherwise indistinguishable
48
+ * between "nobody is editing" and "the tunnel is down", which is exactly the
49
+ * confusion a guest hits when their Coordinator screen stays empty.
50
+ */
51
+ get health() {
52
+ if (!this.active) return { joined: false, reachable: true, lastContactAt: null };
53
+ return {
54
+ joined: true,
55
+ reachable: this.updatedAt > 0 && this.failures < MAX_MISSES,
56
+ lastContactAt: this.updatedAt || null,
57
+ };
58
+ }
59
+
60
+ get active() {
61
+ return Boolean(this.room?.url && this.room.role === "guest" && this.room.hostProjectId);
62
+ }
63
+
64
+ /** Has the host ever answered? Until it has, there is nothing to prefer. */
65
+ get fresh() {
66
+ return this.active && this.updatedAt > 0;
67
+ }
68
+
69
+ start(room) {
70
+ const sameRoom =
71
+ this.room?.url === room?.url &&
72
+ this.room?.hostProjectId === room?.hostProjectId &&
73
+ this.room?.memberToken === room?.memberToken;
74
+ // Nothing below may touch this.room while it is still the same room.
75
+ //
76
+ // loadRoom() re-parses room.json and hands back a NEW object every call, and
77
+ // the dashboard calls syncRoomLocks() on every poll — so this used to swap
78
+ // this.room's identity every few seconds. poll() drops any response whose
79
+ // room is no longer current, which meant a poll still in flight at that
80
+ // moment was thrown away. Over loopback a poll finishes in milliseconds and
81
+ // nothing is lost; over an ngrok tunnel it takes long enough that every poll
82
+ // could land inside that window, leaving updatedAt at 0 forever — a guest
83
+ // whose locks worked fine but whose Coordinator board never filled in.
84
+ if (sameRoom && this.timer) return undefined;
85
+ const sameHost =
86
+ this.room?.role === "guest" &&
87
+ room?.role === "guest" &&
88
+ this.room?.hostProjectId &&
89
+ this.room.hostProjectId === room.hostProjectId;
90
+ if (sameHost && this.timer) {
91
+ // Tunnel URL changed (ngrok recycle). Keep the last snapshot so the
92
+ // Coordinator board does not flash empty while we retarget.
93
+ this.room = room;
94
+ this.failures = 0;
95
+ this.poll();
96
+ return undefined;
97
+ }
98
+ this.room = room || null;
99
+ if (!this.active) {
100
+ this.stop({ clear: true });
101
+ return undefined;
102
+ }
103
+ this.stop();
104
+ this.room = room;
105
+ this.timer = setInterval(() => this.poll(), POLL_MS);
106
+ if (this.timer.unref) this.timer.unref();
107
+ this.poll();
108
+ return undefined;
109
+ }
110
+
111
+ stop({ clear } = {}) {
112
+ if (this.timer) clearInterval(this.timer);
113
+ this.timer = null;
114
+ this.failures = 0;
115
+ this.lastPushAt = 0;
116
+ if (clear && (this.locks.length || this.reads.length)) {
117
+ this.locks = [];
118
+ this.reads = [];
119
+ this.updatedAt = 0;
120
+ this.emit("change", { locks: [], reads: [] });
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /**
126
+ * A lock change pushed straight from the host's table over the room socket.
127
+ * Same commit path as a successful poll, so both routes leave the mirror in
128
+ * exactly one state.
129
+ */
130
+ accept(locks, extras = {}) {
131
+ if (!this.active || !Array.isArray(locks)) return false;
132
+ this.lastPushAt = Date.now();
133
+ const readsChanged =
134
+ Array.isArray(extras.reads) && JSON.stringify(extras.reads) !== JSON.stringify(this.reads);
135
+ if (Array.isArray(extras.reads)) this.reads = extras.reads;
136
+ return this.commit(locks, readsChanged);
137
+ }
138
+
139
+ commit(locks, readsChanged = false) {
140
+ const changed = JSON.stringify(locks) !== JSON.stringify(this.locks) || readsChanged;
141
+ const wasDown = this.failures >= MAX_MISSES;
142
+ this.locks = locks;
143
+ this.updatedAt = Date.now();
144
+ this.failures = 0;
145
+ if (changed || wasDown) this.emit("change", { locks, reads: this.reads || [] });
146
+ return changed;
147
+ }
148
+
149
+ async poll() {
150
+ if (!this.active) return;
151
+ // The socket is delivering; a poll would only re-fetch what we already have.
152
+ if (Date.now() - this.lastPushAt < PUSH_TRUST_MS) return;
153
+ const room = this.room;
154
+ try {
155
+ const snap = await this.fetchLocks(room);
156
+ if (!snap || this.room !== room) return;
157
+ if (Array.isArray(snap)) {
158
+ this.commit(snap);
159
+ return;
160
+ }
161
+ if (!Array.isArray(snap.locks)) return;
162
+ const readsChanged =
163
+ Array.isArray(snap.reads) && JSON.stringify(snap.reads) !== JSON.stringify(this.reads);
164
+ if (Array.isArray(snap.reads)) this.reads = snap.reads;
165
+ this.commit(snap.locks, readsChanged);
166
+ } catch {
167
+ // Host unreachable: keep the last snapshot and let each lock age out on
168
+ // its own TTL, rather than flashing "all locks released" on every dropped
169
+ // tunnel request.
170
+ this.failures += 1;
171
+ if (this.failures === MAX_MISSES) this.emit("change", { locks: this.list() || [] });
172
+ }
173
+ }
174
+
175
+ /**
176
+ * The host's locks, minus anything that has since run out of TTL.
177
+ *
178
+ * A dropped poll used to blank this after 15s, which took the guest's whole
179
+ * Coordinator board down with it — including locks the host was still
180
+ * heartbeating. Ageing entries out by their own expiry instead means an
181
+ * unreachable host decays the board file by file, exactly as a host that went
182
+ * quiet would, and one slow tunnel request changes nothing.
183
+ */
184
+ list() {
185
+ if (!this.fresh) return null;
186
+ const cutoff = Date.now() - EXPIRY_GRACE_MS;
187
+ return this.locks.filter((l) => !l.expiresAt || l.expiresAt > cutoff);
188
+ }
189
+
190
+ /** Host reads, minus anything that has aged out of its own TTL. */
191
+ listReads() {
192
+ if (!this.fresh) return null;
193
+ const now = Date.now();
194
+ return (this.reads || []).filter((r) => {
195
+ const exp = r.expiresAt || (r.at || 0) + 30000;
196
+ return exp > now;
197
+ });
198
+ }
199
+ }
200
+
201
+ module.exports = { RoomLockMirror };