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,621 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared hook library. Fail-open.
4
+ * Pre-tool claims (15s TTL). Post-tool flushes the finished file + releases it.
5
+ * Stop releases leftovers + transcript ingest.
6
+ */
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ // Loaded on demand. Read hooks fire on nearly every tool call an agent makes and
11
+ // only need an HTTP POST; making them pay to load the coordinator client and the
12
+ // dependency graph parser would put that cost in front of every file an agent
13
+ // looks at.
14
+ let clientMod = null;
15
+ function client() {
16
+ if (!clientMod) clientMod = require("../backend/coordinator/client");
17
+ return clientMod;
18
+ }
19
+
20
+ function readStdin() {
21
+ return new Promise((resolve) => {
22
+ let data = "";
23
+ process.stdin.setEncoding("utf8");
24
+ process.stdin.on("data", (c) => (data += c));
25
+ process.stdin.on("end", () => resolve(data.replace(/^\uFEFF/, "")));
26
+ setTimeout(() => resolve(data.replace(/^\uFEFF/, "")), 400);
27
+ });
28
+ }
29
+
30
+ function normalizeWorkspaceRoot(root) {
31
+ if (!root || typeof root !== "string") return null;
32
+ let trimmed = root.trim();
33
+ if (!trimmed) return null;
34
+ trimmed = trimmed.replace(/^file:\/+/i, "/");
35
+ const drive = trimmed.match(/^\/?([a-zA-Z]):([\\/].*)$/);
36
+ if (drive) trimmed = `${drive[1]}:${drive[2]}`;
37
+ const doubled = trimmed.match(/^([a-zA-Z]):[\\/]+\1:[\\/]+(.*)$/i);
38
+ if (doubled) trimmed = `${doubled[1]}:\\${doubled[2]}`;
39
+ return path.resolve(trimmed);
40
+ }
41
+
42
+ // Antigravity quotes every tool argument, so TargetFile arrives as the literal
43
+ // string `"c:\\Users\\me\\app.ts"` — surrounding quotes included. A quoted path
44
+ // is not path.isAbsolute, so it was resolved against the workspace and dropped
45
+ // as foreign: claims never reached the board while Activity still showed the
46
+ // transcript. Same unwrap as backend/lib/transcripts/parse.js antigravityArg.
47
+ function unquoteArg(value) {
48
+ if (typeof value !== "string") return null;
49
+ const trimmed = value.trim().replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1");
50
+ return trimmed || null;
51
+ }
52
+
53
+ function normalizeHookPath(raw) {
54
+ const unquoted = unquoteArg(raw);
55
+ if (!unquoted) return null;
56
+ let s = unquoted;
57
+ if (/^file:/i.test(s)) {
58
+ try {
59
+ const rest = decodeURIComponent(s.replace(/^file:\/+/i, ""));
60
+ s = /^[a-zA-Z]:/.test(rest) || rest.startsWith("/") ? rest : `/${rest}`;
61
+ } catch {
62
+ /* keep the unquoted form */
63
+ }
64
+ }
65
+ return s || null;
66
+ }
67
+
68
+ function hydratePayload(payload) {
69
+ const p = payload && typeof payload === "object" ? { ...payload } : {};
70
+ // Copilot documents toolArgs as a JSON string, not an object. Leave it as a
71
+ // string and the path scan sees none of view/grep/glob's fields.
72
+ for (const key of ["toolArgs", "tool_args", "tool_input"]) {
73
+ if (typeof p[key] === "string" && p[key].trim().startsWith("{")) {
74
+ try {
75
+ const parsed = JSON.parse(p[key]);
76
+ if (parsed && typeof parsed === "object") p[key] = parsed;
77
+ } catch {
78
+ /* leave as string */
79
+ }
80
+ }
81
+ }
82
+ return p;
83
+ }
84
+
85
+ function toolNameFromPayload(payload) {
86
+ const p = hydratePayload(payload);
87
+ return p.tool_name || p.toolName || p.tool_input?.tool || p.name || null;
88
+ }
89
+
90
+ function extractFilePath(payload) {
91
+ const p = hydratePayload(payload);
92
+ const args = p.toolCall?.args || p.toolCall?.arguments || {};
93
+ return (
94
+ // Cursor's beforeReadFile / beforeTabFileRead put the path at the top level
95
+ // rather than under tool_input. postToolUse uses tool_name + tool_input.
96
+ p.file_path ||
97
+ p.tool_input?.file_path ||
98
+ p.tool_input?.target_file ||
99
+ p.tool_input?.path ||
100
+ p.tool_input?.relative_path ||
101
+ p.tool_input?.relative_workspace_path ||
102
+ p.tool_input?.file ||
103
+ p.TargetFile ||
104
+ p.AbsolutePath ||
105
+ args.TargetFile ||
106
+ args.AbsolutePath ||
107
+ args.DirectoryPath ||
108
+ args.SearchPath ||
109
+ args.SearchDirectory ||
110
+ args.file_path ||
111
+ p.toolInput?.file_path ||
112
+ p.toolArgs?.file_path ||
113
+ p.toolArgs?.target_file ||
114
+ p.toolArgs?.TargetFile ||
115
+ p.toolArgs?.path ||
116
+ p.tool_args?.file_path ||
117
+ p.tool_args?.target_file ||
118
+ p.tool_args?.path ||
119
+ null
120
+ );
121
+ }
122
+
123
+ // Every agent names this field differently, and renames it between versions.
124
+ // A hook that does not recognise the shape claims nothing and stays silent, so
125
+ // the failure looks like "Relay isn't locking" rather than "Relay didn't find
126
+ // the path" — which is why the explicit list above is backed by a scan.
127
+ const PATH_KEY =
128
+ /^(file_?path|target_?file|absolute_?path|directory_?path|search_?path|search_?directory|filename|file|path|uri|notebook_?path)$/i;
129
+
130
+ function scanForPaths(value, depth = 0, out = new Set()) {
131
+ if (depth > 6 || out.size > 32) return out;
132
+ if (Array.isArray(value)) {
133
+ for (const item of value) scanForPaths(item, depth + 1, out);
134
+ return out;
135
+ }
136
+ if (!value || typeof value !== "object") return out;
137
+ for (const [key, item] of Object.entries(value)) {
138
+ if (typeof item === "string") {
139
+ if (PATH_KEY.test(key) && item.trim() && !item.includes("\n") && item.length < 1024) {
140
+ out.add(item);
141
+ }
142
+ } else {
143
+ scanForPaths(item, depth + 1, out);
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ function extractFilePaths(payload) {
150
+ const p = hydratePayload(payload);
151
+ const one = extractFilePath(p);
152
+ const extra = []
153
+ .concat(p.tool_input?.files || [])
154
+ .concat(p.files || [])
155
+ .concat(p.tool_input?.target_files || [])
156
+ .concat((p.attachments || []).map((a) => a && a.file_path));
157
+ const named = [one, ...extra].filter((v) => typeof v === "string" && v.trim());
158
+ // Always scan: multi_replace_file_content can name several TargetFiles, and a
159
+ // single top-level hit used to hide the rest.
160
+ return [...new Set([...named, ...scanForPaths(p), ...patchTargets(p)])];
161
+ }
162
+
163
+ // apply_patch (Codex, Copilot) names its files only inside the patch text, under
164
+ // tool_input.command / toolArgs.input. Without this an apply_patch edit claims nothing.
165
+ const PATCH_HEADER = /^\*\*\* (?:Add|Update|Delete) File: (.+?)\s*$/gm;
166
+ function patchTargets(p) {
167
+ const out = [];
168
+ const texts = [p.tool_input, p.toolArgs, p.toolInput, p.tool_args]
169
+ .filter((v) => v && typeof v === "object")
170
+ .flatMap((v) => [v.input, v.command, v.patch])
171
+ .concat(typeof p.tool_input === "string" ? [p.tool_input] : []);
172
+ for (const text of texts) {
173
+ const s = Array.isArray(text) ? text.join("\n") : text;
174
+ if (typeof s !== "string" || !s.includes("*** ")) continue;
175
+ PATCH_HEADER.lastIndex = 0;
176
+ let m;
177
+ while ((m = PATCH_HEADER.exec(s))) out.push(m[1]);
178
+ }
179
+ return out;
180
+ }
181
+
182
+ // An agent can edit a path outside the workspace it was started in (a temp file,
183
+ // a sibling repo, a dotfile in $HOME). Relativizing those produces a "../../.."
184
+ // path that gets locked and flushed against whichever workspace happens to be
185
+ // current — foreign edits landing in this workspace's activity feed. Anything
186
+ // that escapes the root is simply not this workspace's business.
187
+ function workspaceRelativeFiles(workspace, filePaths) {
188
+ const out = [];
189
+ for (const raw of filePaths) {
190
+ if (typeof raw !== "string" || !raw.trim()) continue;
191
+ // A relative path in the payload is relative to the WORKSPACE, not to
192
+ // wherever the agent happened to spawn this hook process. Resolving it
193
+ // against process.cwd() produced paths that escaped the root and were then
194
+ // dropped as foreign — a silent no-claim.
195
+ const cleaned = normalizeHookPath(raw);
196
+ if (!cleaned) continue;
197
+ const abs = path.isAbsolute(cleaned) ? cleaned : path.resolve(workspace, cleaned);
198
+ const rel = path.relative(workspace, abs).replace(/\\/g, "/");
199
+ if (!rel || rel.startsWith("../") || rel === ".." || path.isAbsolute(rel)) continue;
200
+ out.push(rel);
201
+ }
202
+ return [...new Set(out)];
203
+ }
204
+
205
+ // Opt-in, off by default: RELAY_HOOK_DEBUG=1 appends what each hook saw to
206
+ // ~/.relay/hook-debug.log. A hook that fails open leaves no other trace, so
207
+ // without this "nothing showed up" is unfalsifiable.
208
+ //
209
+ // Post-tool phases: post:enter, post:no-files, post:flush, post:release,
210
+ // post:flush-fail, post:error. Stop: stop:flush-owned, stop:release-all.
211
+ function debugHook(mode, phase, detail) {
212
+ if (!process.env.RELAY_HOOK_DEBUG) return;
213
+ try {
214
+ const fs = require("fs");
215
+ const file = path.join(os.homedir(), ".relay", "hook-debug.log");
216
+ fs.appendFileSync(file, `${new Date().toISOString()} ${mode} ${phase} ${JSON.stringify(detail)}\n`);
217
+ } catch {
218
+ /* debugging must never break the hook */
219
+ }
220
+ }
221
+
222
+ function modeLabel(mode) {
223
+ const map = {
224
+ claude: "Claude Code",
225
+ cursor: "Cursor",
226
+ codex: "Codex",
227
+ copilot: "Copilot",
228
+ antigravity: "Antigravity",
229
+ };
230
+ return map[mode] || mode;
231
+ }
232
+
233
+ /**
234
+ * Which product is actually running this hook?
235
+ *
236
+ * The script's own name can't answer that. `.claude/settings.json` is read by
237
+ * Claude Code, by Cursor (which maps the Claude-style config onto its own
238
+ * events) and by Copilot (which documents reading `.claude/settings.json` from
239
+ * the repo). So `relay-claude-pre-tool.js` runs under at least three products,
240
+ * and trusting the filename is what put "Claude Code" on Cursor's edits and
241
+ * made one editor claim the same file twice under two names.
242
+ *
243
+ * Each product leaves a distinct fingerprint in its payload, so identify by
244
+ * evidence and fall back to the declared mode only when there is none.
245
+ */
246
+ function detectProduct(payload, declared) {
247
+ const p = payload || {};
248
+
249
+ // Cursor stamps every hook payload with its version.
250
+ if (typeof p.cursor_version === "string") return "cursor";
251
+
252
+ // Antigravity is the only one with workspacePaths/artifactDirectoryPath, and
253
+ // its transcripts live under ~/.gemini/antigravity*.
254
+ if (Array.isArray(p.workspacePaths) || typeof p.artifactDirectoryPath === "string") return "antigravity";
255
+ if (typeof p.transcriptPath === "string" && /[\\/]\.gemini[\\/]antigravity/i.test(p.transcriptPath)) {
256
+ return "antigravity";
257
+ }
258
+ if (p.toolCall && typeof p.toolCall === "object") return "antigravity";
259
+
260
+ // turn_id is documented as a Codex-specific extension to the payload.
261
+ if (typeof p.turn_id === "string") return "codex";
262
+
263
+ // Copilot is the only one that puts a timestamp in the payload, and its
264
+ // camelCase event names produce camelCase tool fields.
265
+ if (p.toolName !== undefined || p.toolArgs !== undefined || p.timestamp !== undefined) return "copilot";
266
+ if (process.env.COPILOT_AGENT_PROMPT || process.env.GITHUB_COPILOT_API_TOKEN) return "copilot";
267
+
268
+ // Claude Code sets this on subprocesses it spawns itself. CLAUDECODE is not
269
+ // usable here because IDE extensions set it too, which is exactly the
270
+ // confusion we are trying to resolve.
271
+ if (process.env.CLAUDE_CODE_CHILD_SESSION === "1") return "claude";
272
+
273
+ return declared;
274
+ }
275
+
276
+ // In a shared room the lock UI has to answer "who is holding this?" across
277
+ // machines, and an agentId alone only says "Claude Code on some hostname".
278
+ function currentLogin() {
279
+ try {
280
+ return require("../backend/lib/store").loadSession().login || os.userInfo().username || null;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ function lockAgentIdFor(mode, payload) {
287
+ // Derive the session from the transcript the payload points at, exactly as the
288
+ // transcript parser does, so a lock and its transcript always name one session.
289
+ // Cursor's conversation_id and Codex's rollout filename are not documented to match.
290
+ const transcript = payload.transcript_path || payload.transcriptPath;
291
+ const fromTranscript = transcript
292
+ ? require("../backend/lib/transcripts/sessionId").sessionIdFromTranscript(modeLabel(mode), transcript)
293
+ : null;
294
+ const session =
295
+ fromTranscript ||
296
+ payload.session_id ||
297
+ payload.conversation_id ||
298
+ // Antigravity and Copilot both name this in camelCase. Missing them meant
299
+ // every turn from those two collapsed onto the session id "local", so the
300
+ // room could not tell two concurrent Antigravity turns apart.
301
+ payload.conversationId ||
302
+ payload.sessionId ||
303
+ process.env.SESSION_ID ||
304
+ "local";
305
+ return `${modeLabel(mode)}:${os.hostname()}:${session}`;
306
+ }
307
+
308
+ function resolveWorkspacePath(payload) {
309
+ const candidates = [
310
+ process.env.RELAY_WORKSPACE_PATH,
311
+ payload.cwd,
312
+ payload.workspace_root,
313
+ ...(Array.isArray(payload.workspace_roots) ? payload.workspace_roots : []),
314
+ // Antigravity's only root field. Without it every Antigravity hook fell
315
+ // through to process.cwd(), which is wherever the IDE happened to spawn
316
+ // the hook — so claims and edits were filed against the wrong workspace,
317
+ // or dropped as outside it.
318
+ ...(Array.isArray(payload.workspacePaths) ? payload.workspacePaths : []),
319
+ process.cwd(),
320
+ ];
321
+ for (const raw of candidates) {
322
+ const resolved = normalizeWorkspaceRoot(raw);
323
+ if (resolved) return resolved;
324
+ }
325
+ return process.cwd();
326
+ }
327
+
328
+ /**
329
+ * Answer in the dialect the running product actually speaks.
330
+ *
331
+ * These are four genuinely different contracts, and getting one wrong fails
332
+ * silently in whichever direction the product happens to default:
333
+ * - Cursor : {permission} — fail-open
334
+ * - Claude : hookSpecificOutput.permissionDecision, top-level decision/reason
335
+ * being deprecated for PreToolUse
336
+ * - Codex : the same envelope as Claude. `continue`/`stopReason` parse but
337
+ * are unsupported here, and returning them marks the hook failed
338
+ * and lets the tool run anyway, so a deny written that way is
339
+ * worse than no hook at all
340
+ * - Copilot : a flat {permissionDecision}, no envelope. Fail-CLOSED: a crash
341
+ * or any non-zero exit denies the call, so allow must exit 0
342
+ * - Antigrav: a top-level {decision}. It has no permissionDecision and no
343
+ * hookSpecificOutput, so the envelope we used to send parsed as
344
+ * "no decision" and every Antigravity lock silently allowed
345
+ */
346
+ function respond(mode, allowed, reason) {
347
+ if (mode === "cursor") {
348
+ process.stdout.write(
349
+ JSON.stringify(allowed ? { permission: "allow" } : { permission: "deny", user_message: reason })
350
+ );
351
+ return;
352
+ }
353
+ if (mode === "antigravity") {
354
+ process.stdout.write(
355
+ JSON.stringify(allowed ? { decision: "allow" } : { decision: "deny", reason })
356
+ );
357
+ return;
358
+ }
359
+ if (mode === "copilot") {
360
+ process.stdout.write(
361
+ JSON.stringify(
362
+ allowed
363
+ ? { permissionDecision: "allow" }
364
+ : { permissionDecision: "deny", permissionDecisionReason: reason }
365
+ )
366
+ );
367
+ return;
368
+ }
369
+ // Claude Code and Codex share the hookSpecificOutput envelope.
370
+ process.stdout.write(
371
+ JSON.stringify({
372
+ hookSpecificOutput: {
373
+ hookEventName: "PreToolUse",
374
+ permissionDecision: allowed ? "allow" : "deny",
375
+ ...(allowed ? {} : { permissionDecisionReason: reason }),
376
+ },
377
+ })
378
+ );
379
+ if (!allowed) {
380
+ process.stderr.write(reason + "\n");
381
+ process.exitCode = 2;
382
+ }
383
+ }
384
+
385
+ async function runPreTool(declaredMode) {
386
+ let mode = declaredMode;
387
+ try {
388
+ const raw = await readStdin();
389
+ const payload = raw ? JSON.parse(raw) : {};
390
+ mode = detectProduct(payload, declaredMode);
391
+ const workspace = resolveWorkspacePath(payload);
392
+ fetch(`http://127.0.0.1:${process.env.RELAY_PORT || 3001}/api/ensure-workspace`, {
393
+ method: "POST",
394
+ headers: { "Content-Type": "application/json" },
395
+ body: JSON.stringify({ workspacePath: workspace }),
396
+ }).catch(() => undefined);
397
+ const files = workspaceRelativeFiles(workspace, extractFilePaths(payload));
398
+ if (!files.length) {
399
+ debugHook(mode, "pre:no-files", { workspace, keys: Object.keys(payload) });
400
+ respond(mode, true);
401
+ return;
402
+ }
403
+ const agentId = lockAgentIdFor(mode, payload);
404
+ const { dependsOn } = require("../backend/lib/deps");
405
+ const deps = files.flatMap((rel) => dependsOn(workspace, rel));
406
+ const result = await client().claimFile(workspace, agentId, files[0], 15000, {
407
+ dependsOn: deps,
408
+ files: files.length > 1 ? files : undefined,
409
+ mode: payload.mode === "read" ? "read" : "write",
410
+ holder: { label: modeLabel(mode), host: os.hostname(), login: currentLogin() },
411
+ });
412
+ debugHook(mode, "pre:claim", { workspace, files, agentId, via: result.via || null, allowed: result.allowed });
413
+ if (result.allowed === false) {
414
+ respond(mode, false, result.reason || `Locked by ${result.holder}. Pick a different file.`);
415
+ return;
416
+ }
417
+ client().heartbeat(workspace, agentId, files[0]).catch(() => undefined);
418
+ respond(mode, true);
419
+ } catch {
420
+ respond(mode, true);
421
+ }
422
+ }
423
+
424
+ /**
425
+ * A read tool fired. Report it and get out of the way.
426
+ *
427
+ * This never claims, never blocks and never denies: reading a file is not a
428
+ * claim on it. It is reported so the room can see what every agent is actually
429
+ * working through, instead of the dashboard only lighting up for the few files
430
+ * being written.
431
+ */
432
+ async function runPreRead(declaredMode) {
433
+ let mode = declaredMode;
434
+ try {
435
+ const raw = await readStdin();
436
+ const payload = raw ? JSON.parse(raw) : {};
437
+ mode = detectProduct(payload, declaredMode);
438
+ const workspace = resolveWorkspacePath(payload);
439
+ const files = workspaceRelativeFiles(workspace, extractFilePaths(payload));
440
+ if (files.length) {
441
+ const port = process.env.RELAY_PORT || 3001;
442
+ // Deliberately not awaited past a short deadline: an agent must never wait
443
+ // on telemetry to read a file.
444
+ await Promise.race([
445
+ fetch(`http://127.0.0.1:${port}/api/coord/read`, {
446
+ method: "POST",
447
+ headers: { "Content-Type": "application/json" },
448
+ body: JSON.stringify({
449
+ workspacePath: workspace,
450
+ workspaceId: workspace,
451
+ agentId: lockAgentIdFor(mode, payload),
452
+ files,
453
+ mode: "read",
454
+ holder: { label: modeLabel(mode), host: os.hostname(), login: currentLogin() },
455
+ }),
456
+ }).catch(() => undefined),
457
+ new Promise((r) => setTimeout(r, 500)),
458
+ ]);
459
+ debugHook(mode, "read", { workspace, files });
460
+ }
461
+ respond(mode, true);
462
+ } catch {
463
+ respond(mode, true);
464
+ }
465
+ }
466
+
467
+ // PostToolUse and Stop don't need a verdict from us — we only observe there.
468
+ // Every one of the five treats an empty object as "no decision, carry on", and
469
+ // a PreToolUse-shaped envelope on a Stop event is at best ignored.
470
+ function respondPost() {
471
+ process.stdout.write("{}");
472
+ }
473
+
474
+ async function flushFiles(workspace, agentId, files) {
475
+ const port = process.env.RELAY_PORT || 3001;
476
+ try {
477
+ const res = await fetch(`http://127.0.0.1:${port}/api/flush-file`, {
478
+ method: "POST",
479
+ headers: { "Content-Type": "application/json" },
480
+ body: JSON.stringify({ workspacePath: workspace, agentId, files }),
481
+ });
482
+ let body = {};
483
+ try {
484
+ body = await res.json();
485
+ } catch {
486
+ body = {};
487
+ }
488
+ return { ok: res.ok, status: res.status, body };
489
+ } catch (err) {
490
+ return { ok: false, error: err.message || String(err) };
491
+ }
492
+ }
493
+
494
+ async function flushOwned(workspace, agentId) {
495
+ const port = process.env.RELAY_PORT || 3001;
496
+ try {
497
+ await fetch(`http://127.0.0.1:${port}/api/flush-owned`, {
498
+ method: "POST",
499
+ headers: { "Content-Type": "application/json" },
500
+ body: JSON.stringify({ workspacePath: workspace, agentId }),
501
+ });
502
+ } catch {
503
+ /* fail-open */
504
+ }
505
+ }
506
+
507
+ async function runPostTool(declaredMode) {
508
+ let mode = declaredMode;
509
+ try {
510
+ const raw = await readStdin();
511
+ const payload = raw ? JSON.parse(raw) : {};
512
+ mode = detectProduct(payload, declaredMode);
513
+ const workspace = resolveWorkspacePath(payload);
514
+ const rawPaths = extractFilePaths(payload);
515
+ const files = workspaceRelativeFiles(workspace, rawPaths);
516
+ const agentId = lockAgentIdFor(mode, payload);
517
+ const toolName = toolNameFromPayload(payload);
518
+ debugHook(mode, "post:enter", {
519
+ workspace,
520
+ toolName,
521
+ agentId,
522
+ files,
523
+ rawPaths: rawPaths.slice(0, 8),
524
+ keys: Object.keys(payload),
525
+ });
526
+ if (!files.length) {
527
+ debugHook(mode, "post:no-files", {
528
+ workspace,
529
+ toolName,
530
+ rawPaths: rawPaths.slice(0, 8),
531
+ keys: Object.keys(payload),
532
+ });
533
+ respondPost();
534
+ return;
535
+ }
536
+ const flushed = await flushFiles(workspace, agentId, files);
537
+ debugHook(mode, "post:flush", {
538
+ workspace,
539
+ files,
540
+ agentId,
541
+ ok: flushed.ok,
542
+ status: flushed.status,
543
+ flushed: flushed.body?.flushed,
544
+ error: flushed.error,
545
+ });
546
+ if (!flushed.ok) {
547
+ debugHook(mode, "post:flush-fail", { workspace, files, agentId, ...flushed });
548
+ }
549
+ for (const file of files) {
550
+ const released = await client().releaseFile(workspace, agentId, file);
551
+ debugHook(mode, "post:release", {
552
+ workspace,
553
+ file,
554
+ agentId,
555
+ ok: released?.ok,
556
+ released: released?.released,
557
+ reason: released?.reason,
558
+ });
559
+ }
560
+ respondPost();
561
+ } catch (err) {
562
+ debugHook(mode, "post:error", { message: err?.message || String(err) });
563
+ respondPost();
564
+ }
565
+ }
566
+
567
+ async function ingestStop(workspace, agentId, payload, mode) {
568
+ try {
569
+ await fetch(`http://127.0.0.1:${process.env.RELAY_PORT || 3001}/api/ingest-stop`, {
570
+ method: "POST",
571
+ headers: { "Content-Type": "application/json" },
572
+ body: JSON.stringify({
573
+ workspacePath: workspace,
574
+ agentId,
575
+ agent: modeLabel(mode),
576
+ sessionId:
577
+ payload.session_id || payload.conversation_id || payload.conversationId || payload.sessionId,
578
+ transcript_path: payload.transcript_path || payload.transcriptPath,
579
+ messages: payload.messages || [],
580
+ }),
581
+ });
582
+ } catch {
583
+ /* fail-open */
584
+ }
585
+ }
586
+
587
+ async function runStop(declaredMode) {
588
+ let mode = declaredMode;
589
+ try {
590
+ const raw = await readStdin();
591
+ const payload = raw ? JSON.parse(raw) : {};
592
+ mode = detectProduct(payload, declaredMode);
593
+ const workspace = resolveWorkspacePath(payload);
594
+ const agentId = lockAgentIdFor(mode, payload);
595
+ debugHook(mode, "stop:enter", { workspace, agentId });
596
+ await flushOwned(workspace, agentId);
597
+ debugHook(mode, "stop:flush-owned", { workspace, agentId });
598
+ const released = await client().releaseAll(workspace, agentId);
599
+ debugHook(mode, "stop:release-all", { workspace, agentId, released: released?.released });
600
+ await ingestStop(workspace, agentId, payload, mode);
601
+ respondPost();
602
+ } catch (err) {
603
+ debugHook(mode, "stop:error", { message: err?.message || String(err) });
604
+ respondPost();
605
+ }
606
+ }
607
+
608
+ module.exports = {
609
+ runPreTool,
610
+ runPreRead,
611
+ runPostTool,
612
+ runStop,
613
+ extractFilePath,
614
+ extractFilePaths,
615
+ workspaceRelativeFiles,
616
+ lockAgentIdFor,
617
+ resolveWorkspacePath,
618
+ detectProduct,
619
+ toolNameFromPayload,
620
+ respond,
621
+ };
@@ -0,0 +1 @@
1
+ <!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/chunks/2xar70jzx8f1y.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/31cgqj_-l15yt.js"/><script src="/_next/static/chunks/0-3l04fnym95-.js" async=""></script><script src="/_next/static/chunks/2n66mlnb34vjq.js" async=""></script><script src="/_next/static/chunks/turbopack-3hsllzpnzz12p.js" async=""></script><script src="/_next/static/chunks/1ya1u0izct4f6.js" async="" crossorigin=""></script><link rel="preload" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&amp;family=IBM+Plex+Sans:wght@400;500;600;700&amp;display=swap" as="style"/><meta name="robots" content="noindex"/><title>./relay</title><meta name="description" content="GitHub Desktop for AI agents"/><link rel="shortcut icon" href="/logo_transparent.png"/><link rel="icon" href="/logo_transparent.png"/><link rel="apple-touch-icon" href="/logo.png"/><link rel="preconnect" href="https://fonts.googleapis.com"/><title>404: This page could not be found.</title><link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&amp;family=IBM+Plex+Sans:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/><script src="/_next/static/chunks/0cz1d0mv5g_q7.js" noModule=""></script></head><body><div hidden=""><!--$--><!--/$--></div><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><!--$--><!--/$--><script src="/_next/static/chunks/31cgqj_-l15yt.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[38070,[\"/_next/static/chunks/1ya1u0izct4f6.js\"],\"RelayProvider\"]\n3:I[39756,[\"/_next/static/chunks/1ya1u0izct4f6.js\"],\"default\"]\n4:I[37457,[\"/_next/static/chunks/1ya1u0izct4f6.js\"],\"default\"]\n5:I[97367,[\"/_next/static/chunks/1ya1u0izct4f6.js\"],\"OutletBoundary\"]\n6:\"$Sreact.suspense\"\n9:I[97367,[\"/_next/static/chunks/1ya1u0izct4f6.js\"],\"ViewportBoundary\"]\nb:I[97367,[\"/_next/static/chunks/1ya1u0izct4f6.js\"],\"MetadataBoundary\"]\nd:I[68027,[\"/_next/static/chunks/1ya1u0izct4f6.js\"],\"default\",1]\n:HL[\"/_next/static/chunks/2xar70jzx8f1y.css\",\"style\"]\n:HL[\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500\u0026family=IBM+Plex+Sans:wght@400;500;600;700\u0026display=swap\",\"style\"]\n8:X\n0:{\"P\":null,\"c\":[\"\",\"_not-found\"],\"q\":\"\",\"i\":false,\"f\":[[[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{},\"$undefined\",\"$undefined\",4608]},\"$undefined\",\"$undefined\",4608]},\"$undefined\",\"$undefined\",4624],[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/chunks/2xar70jzx8f1y.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}],[\"$\",\"script\",\"script-0\",{\"src\":\"/_next/static/chunks/1ya1u0izct4f6.js\",\"async\":true,\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[[\"$\",\"head\",null,{\"children\":[[\"$\",\"link\",null,{\"rel\":\"preconnect\",\"href\":\"https://fonts.googleapis.com\"}],[\"$\",\"link\",null,{\"href\":\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500\u0026family=IBM+Plex+Sans:wght@400;500;600;700\u0026display=swap\",\"rel\":\"stylesheet\"}]]}],[\"$\",\"body\",null,{\"children\":[\"$\",\"$L2\",null,{\"children\":[\"$\",\"$L3\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L4\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]]}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[null,[\"$\",\"$L3\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L4\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":\"$0:f:0:1:0:props:children:1:props:children:1:props:children:props:children:props:notFound:0:1:props:style\",\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":\"$0:f:0:1:0:props:children:1:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style\",\"children\":404}],[\"$\",\"div\",null,{\"style\":\"$0:f:0:1:0:props:children:1:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style\",\"children\":[\"$\",\"h2\",null,{\"style\":\"$0:f:0:1:0:props:children:1:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style\",\"children\":\"This page could not be found.\"}]}]]}]}]],null,[\"$\",\"$L5\",null,{\"children\":[\"$\",\"$6\",null,{\"name\":\"Next.MetadataOutlet\",\"children\":\"$@7\"}]}]]}],{},null,false,null]},null,false,\"$8\"]},null,false,null],[\"$\",\"$1\",\"h\",{\"children\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$L9\",null,{\"children\":\"$La\"}],[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$Lb\",null,{\"children\":[\"$\",\"$6\",null,{\"name\":\"Next.Metadata\",\"children\":\"$Lc\"}]}]}],null]}],false]],\"m\":\"$undefined\",\"G\":[\"$d\",[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/chunks/2xar70jzx8f1y.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]]],\"S\":true,\"h\":null,\"r\":\"$undefined\",\"s\":\"$undefined\",\"a\":\"$undefined\",\"l\":\"$undefined\",\"p\":\"$undefined\",\"d\":\"$undefined\",\"b\":\"dglM39XRu0cmneClUkGYd\"}\n8:C\na:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\ne:I[27201,[\"/_next/static/chunks/1ya1u0izct4f6.js\"],\"IconMark\"]\n7:null\nc:[[\"$\",\"title\",\"0\",{\"children\":\"./relay\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"GitHub Desktop for AI agents\"}],[\"$\",\"link\",\"2\",{\"rel\":\"shortcut icon\",\"href\":\"/logo_transparent.png\"}],[\"$\",\"link\",\"3\",{\"rel\":\"icon\",\"href\":\"/logo_transparent.png\"}],[\"$\",\"link\",\"4\",{\"rel\":\"apple-touch-icon\",\"href\":\"/logo.png\"}],[\"$\",\"$Le\",\"5\",{}]]\n"])</script></body></html>