agentmash 0.3.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.
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env node
2
+ // The MCP server as a teammate receives it.
3
+ //
4
+ // It lives beside the hooks because it travels the same way they do: both
5
+ // installers copy it into .claude/agentmash/, the team commits it, and a
6
+ // teammate gets it by `git pull`. Unlike the hooks it cannot be the whole
7
+ // story — the real MCP server needs @modelcontextprotocol/sdk, and a
8
+ // teammate's checkout has no reason to contain it. So this file is a shim
9
+ // with three layers, tried in order:
10
+ //
11
+ // 1. the real server, imported in-process, when its dependencies already
12
+ // resolve here — a checkout of AgentMash itself, or a repo that
13
+ // installed the `agentmash` package;
14
+ // 2. the published package by way of `npx`, off unless this machine sets
15
+ // AGENTMASH_MCP_NPX=1, because it downloads and executes code from the
16
+ // registry and that is not a decision the repo gets to make for you;
17
+ // 3. a degraded server, implemented below in plain JSON-RPC, that starts,
18
+ // answers tools/list, and makes every call say that AgentMash could not
19
+ // start and the answer is UNKNOWN.
20
+ //
21
+ // Layer 3 is why this file exists at all. An MCP server that fails to start is
22
+ // invisible to the agent: the tools are simply absent, so it stops asking and
23
+ // carries on as though nobody else were working in the repo — the one wrong
24
+ // answer this project exists to prevent. Registering a server that says "I do
25
+ // not know" is worse than one that works and far better than one that is
26
+ // missing, because only the agent can act on the difference.
27
+ //
28
+ // Nothing but JSON-RPC is ever written to stdout: that channel is the
29
+ // protocol. Diagnostics go to stderr, where Claude Code logs them.
30
+
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { spawn } from 'node:child_process';
34
+ import { fileURLToPath, pathToFileURL } from 'node:url';
35
+
36
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
37
+
38
+ // The version is stamped in by cli/sync-hooks.mjs at pack time, so the copy of
39
+ // this file that ships inside a package always names the version it shipped
40
+ // with. Change the marker comment and the stamp stops happening silently — the
41
+ // packer throws instead. A hand-edited constant is how this file came to pin a
42
+ // version that was never published. The checked-in value is the one the web
43
+ // installer commits — it has no packer to stamp it — so it has to be moved with
44
+ // the version bump; cli/test/package.test.mjs diffs the two installers and fails
45
+ // if it drifts.
46
+ const PINNED_VERSION = '0.3.0'; // agentmash:pin
47
+ const PACKAGE_SPEC = process.env.AGENTMASH_MCP_PACKAGE || `agentmash@${PINNED_VERSION}`;
48
+
49
+ /**
50
+ * Layer 2 is opt-in. It fetches a package from the network and runs it at
51
+ * session start, which is a decision for the person whose machine it is, not
52
+ * for whoever ran `agentmash init` and committed the result. Off, the worst
53
+ * case is layer 3: tools that are present and say they cannot answer.
54
+ */
55
+ const NPX_ENABLED = process.env.AGENTMASH_MCP_NPX === '1';
56
+
57
+ /** Long enough for a cold `npx` download, short enough to still answer a client. */
58
+ const NPX_BUDGET_MS = Number(process.env.AGENTMASH_MCP_NPX_TIMEOUT_MS) || 25_000;
59
+
60
+ /** The version of MCP this shim implements — not whatever the client asks for. */
61
+ const PROTOCOL_VERSION = '2025-06-18';
62
+ /** The `-unavailable` suffix is how `agentmash doctor` recognises layer 3. */
63
+ const DEGRADED_VERSION = '0.1.0-unavailable';
64
+
65
+ const log = (message) => process.stderr.write(`[agentmash-mcp] ${message}\n`);
66
+
67
+ /** The repo this was installed into: .claude/agentmash/<this file> is two levels down. */
68
+ function projectDir() {
69
+ return process.env.CLAUDE_PROJECT_DIR || path.resolve(HERE, '..', '..');
70
+ }
71
+
72
+ /** Candidate copies of the real server, nearest first. */
73
+ function entryCandidates(repo) {
74
+ const candidates = [];
75
+ if (process.env.AGENTMASH_MCP_ENTRY) candidates.push(process.env.AGENTMASH_MCP_ENTRY);
76
+ // A checkout of AgentMash itself, where `npm install` has already run.
77
+ candidates.push(path.join(repo, 'mcp', 'server.mjs'));
78
+ // A repo that installed the package, here or in a parent (monorepo roots).
79
+ let dir = repo;
80
+ for (let i = 0; i < 6; i++) {
81
+ candidates.push(path.join(dir, 'node_modules', 'agentmash', 'mcp', 'server.mjs'));
82
+ const parent = path.dirname(dir);
83
+ if (parent === dir) break;
84
+ dir = parent;
85
+ }
86
+ return candidates.filter((file) => fs.existsSync(file));
87
+ }
88
+
89
+ /** Layer 1. Returns false when the file is there but its dependencies are not. */
90
+ async function serveInProcess(repo) {
91
+ for (const entry of entryCandidates(repo)) {
92
+ try {
93
+ const module = await import(pathToFileURL(entry).href);
94
+ await module.start();
95
+ return true;
96
+ } catch (err) {
97
+ log(`cannot run ${entry}: ${err?.message || err}`);
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /** Quote one argument for a Windows shell, which is what `.cmd` needs to run. */
104
+ const quoteForShell = (arg) => (/^[\w@.:=/\\-]+$/.test(arg) ? arg : `"${String(arg).replace(/"/g, '""')}"`);
105
+
106
+ /**
107
+ * How layer 2 is spawned, per platform. Exported so a test can check the
108
+ * Windows form on a machine that is not Windows: since the BatBadBut fix
109
+ * (Node 18.20.2 / 20.12.2 / 21.7.3) `spawn` refuses a `.cmd` without
110
+ * `shell: true` and throws EINVAL, which silently cost Windows layer 2
111
+ * altogether — and a shell in turn means the arguments have to be quoted.
112
+ */
113
+ export function npxInvocation(platform, spec) {
114
+ // `--package <spec> -- agentmash mcp` rather than `npx <spec> mcp`: it names
115
+ // the binary explicitly, so a spec that is a path or a tarball is still
116
+ // installed rather than executed.
117
+ const args = ['--yes', '--package', spec, '--', 'agentmash', 'mcp'];
118
+ if (platform !== 'win32') return { command: 'npx', args, shell: false };
119
+ return { command: 'npx.cmd', args: args.map(quoteForShell), shell: true };
120
+ }
121
+
122
+ /**
123
+ * True for a line that is a JSON-RPC message and not, say, an npm update
124
+ * notice. Forwarding anything else would corrupt the protocol stream, and
125
+ * treating it as proof the child is alive would strand the client on a server
126
+ * that never answers.
127
+ */
128
+ export function isJsonRpcLine(line) {
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(line);
132
+ } catch {
133
+ return false;
134
+ }
135
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.jsonrpc === '2.0';
136
+ }
137
+
138
+ /**
139
+ * Layer 2. Hand stdio to `npx agentmash mcp` and get out of the way.
140
+ *
141
+ * The handover is reversible on purpose: everything the client sends is kept
142
+ * until the child has answered something, so if `npx` cannot reach the
143
+ * registry — no network, package not published yet — we can still fall through
144
+ * to layer 3 and replay the requests to it, instead of leaving the client
145
+ * talking to a corpse.
146
+ *
147
+ * @returns {Promise<{served: boolean, detail?: string, replay?: Buffer[]}>}
148
+ */
149
+ function serveViaNpx(onStdin) {
150
+ return new Promise((resolve) => {
151
+ const { command, args, shell } = npxInvocation(process.platform, PACKAGE_SPEC);
152
+ let child;
153
+ try {
154
+ child = spawn(command, args, {
155
+ stdio: ['pipe', 'pipe', 'inherit'],
156
+ shell,
157
+ env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir() },
158
+ });
159
+ } catch (err) {
160
+ return resolve({ served: false, detail: `could not run npx: ${err?.message || err}` });
161
+ }
162
+
163
+ const buffered = [];
164
+ let answered = false;
165
+ let settled = false;
166
+ let out = '';
167
+
168
+ const timer = setTimeout(() => {
169
+ if (answered || settled) return;
170
+ child.kill();
171
+ give(`npx ${PACKAGE_SPEC} did not answer within ${NPX_BUDGET_MS}ms`);
172
+ }, NPX_BUDGET_MS);
173
+ timer.unref?.();
174
+
175
+ function give(detail) {
176
+ if (settled) return;
177
+ settled = true;
178
+ clearTimeout(timer);
179
+ onStdin(null);
180
+ resolve({ served: false, detail, replay: buffered });
181
+ }
182
+
183
+ /** Forward whole lines, and only the ones that are protocol. */
184
+ function drain(final) {
185
+ let index;
186
+ while ((index = out.indexOf('\n')) !== -1) {
187
+ const line = out.slice(0, index);
188
+ out = out.slice(index + 1);
189
+ take(line);
190
+ }
191
+ if (final && out.trim()) {
192
+ take(out);
193
+ out = '';
194
+ }
195
+ }
196
+
197
+ function take(line) {
198
+ if (!line.trim()) return;
199
+ if (!isJsonRpcLine(line)) {
200
+ log(`ignored non-protocol output from npx: ${line.trim().slice(0, 200)}`);
201
+ return;
202
+ }
203
+ answered = true;
204
+ clearTimeout(timer);
205
+ process.stdout.write(line + '\n');
206
+ }
207
+
208
+ onStdin((chunk) => {
209
+ if (settled) return;
210
+ buffered.push(chunk);
211
+ try {
212
+ child.stdin.write(chunk);
213
+ } catch {
214
+ /* the child is gone; the exit handler decides what happens next */
215
+ }
216
+ });
217
+
218
+ child.stdin.on('error', () => {
219
+ /* EPIPE when npx fails before reading — handled on exit */
220
+ });
221
+ child.stdout.on('data', (chunk) => {
222
+ out += chunk.toString('utf8');
223
+ drain(false);
224
+ });
225
+ child.on('error', (err) => give(`could not run npx: ${err?.message || err}`));
226
+ child.on('exit', (code, signal) => {
227
+ drain(true);
228
+ if (answered) {
229
+ // It spoke, so the session had a working server; its death is the
230
+ // client's problem to report, not something to paper over here.
231
+ settled = true;
232
+ clearTimeout(timer);
233
+ resolve({ served: true });
234
+ process.exit(code ?? 0);
235
+ }
236
+ give(`npx ${PACKAGE_SPEC} exited ${signal || code} before answering`);
237
+ });
238
+ });
239
+ }
240
+
241
+ // ── layer 3: the degraded server ───────────────────────────────────────────
242
+
243
+ const TOOLS = [
244
+ {
245
+ name: 'who_is_working_on_what',
246
+ title: 'Who is working on what',
247
+ description:
248
+ 'Reports who else is working in this repository. UNAVAILABLE on this machine: AgentMash could not start its MCP server, so this cannot answer and must not be read as "nobody is working on anything".',
249
+ },
250
+ {
251
+ name: 'has_anyone_touched',
252
+ title: 'Has anyone touched this path',
253
+ description:
254
+ 'Reports whether a teammate recently edited a path. UNAVAILABLE on this machine: AgentMash could not start its MCP server, so a quiet answer here would be a guess, not a fact.',
255
+ },
256
+ {
257
+ name: 'list_contested_files',
258
+ title: 'List contested files',
259
+ description:
260
+ 'Lists files more than one person has edited recently. UNAVAILABLE on this machine: AgentMash could not start its MCP server.',
261
+ },
262
+ ];
263
+
264
+ function unavailableText(detail) {
265
+ return `AgentMash could not start its MCP server on this machine, so it cannot say who is working in this repository.
266
+
267
+ Treat the answer as UNKNOWN. This is not the same as "nobody is working on anything" — AgentMash never got to ask.
268
+
269
+ Reason: ${detail}
270
+
271
+ The hooks are unaffected: if this repo is connected, teammates' edits are still reported and you will still be warned before you edit a file someone just changed. Only these query tools are missing.
272
+
273
+ To fix it, on this machine:
274
+ npx agentmash doctor says exactly what is missing
275
+ npm install -D agentmash puts the real server in this repo, for everyone
276
+ AGENTMASH_MCP_ENTRY=<path> point at mcp/server.mjs in an AgentMash checkout
277
+ AGENTMASH_MCP_NPX=1 let this machine fetch the package from npm itself
278
+
279
+ Each of those needs network access to npm once; without it these tools stay unavailable.`;
280
+ }
281
+
282
+ /**
283
+ * MCP over stdio is newline-delimited JSON-RPC, which is little enough
284
+ * protocol to implement here — and implementing it here is the point: the
285
+ * degraded server has to run on a machine that has no dependencies at all.
286
+ */
287
+ function createDegradedServer(detail) {
288
+ const message = unavailableText(detail);
289
+ let buffer = '';
290
+
291
+ const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n');
292
+ const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
293
+ const fail = (id, code, msg) => send({ jsonrpc: '2.0', id, error: { code, message: msg } });
294
+
295
+ function handle(request) {
296
+ const { id, method } = request;
297
+ if (id === undefined || id === null) return; // a notification: nothing to answer
298
+ switch (method) {
299
+ case 'initialize':
300
+ return reply(id, {
301
+ // The version this shim implements. Echoing the client's back would
302
+ // claim support for whatever it happened to be handed.
303
+ protocolVersion: PROTOCOL_VERSION,
304
+ capabilities: { tools: {} },
305
+ serverInfo: { name: 'agentmash', version: DEGRADED_VERSION },
306
+ instructions:
307
+ 'AgentMash is registered here but could not start. Its tools report themselves unavailable; never read that as "nobody is working on anything".',
308
+ });
309
+ case 'ping':
310
+ return reply(id, {});
311
+ case 'tools/list':
312
+ return reply(id, {
313
+ tools: TOOLS.map((tool) => ({
314
+ ...tool,
315
+ inputSchema: { type: 'object', properties: {}, additionalProperties: true },
316
+ annotations: { readOnlyHint: true, openWorldHint: true },
317
+ })),
318
+ });
319
+ case 'tools/call':
320
+ return reply(id, { content: [{ type: 'text', text: message }], isError: true });
321
+ default:
322
+ return fail(id, -32601, `method not found: ${method}`);
323
+ }
324
+ }
325
+
326
+ return {
327
+ feed(chunk) {
328
+ buffer += chunk.toString('utf8');
329
+ let index;
330
+ while ((index = buffer.indexOf('\n')) !== -1) {
331
+ const line = buffer.slice(0, index).trim();
332
+ buffer = buffer.slice(index + 1);
333
+ if (!line) continue;
334
+ let request;
335
+ try {
336
+ request = JSON.parse(line);
337
+ } catch {
338
+ send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
339
+ continue;
340
+ }
341
+ handle(request);
342
+ }
343
+ },
344
+ };
345
+ }
346
+
347
+ // ── wiring ─────────────────────────────────────────────────────────────────
348
+
349
+ async function main() {
350
+ const repo = projectDir();
351
+ if (await serveInProcess(repo)) return;
352
+
353
+ // One stdin reader for the whole process: layer 2 borrows it and hands it
354
+ // back, so no request is lost when the handover fails.
355
+ let sink = null;
356
+ const pending = [];
357
+ process.stdin.on('data', (chunk) => (sink ? sink(chunk) : pending.push(chunk)));
358
+ const setSink = (next) => {
359
+ sink = next;
360
+ if (!next) return;
361
+ while (pending.length) next(pending.shift());
362
+ };
363
+
364
+ let detail = 'the MCP server and its dependencies are not installed here';
365
+ if (NPX_ENABLED) {
366
+ const attempt = await serveViaNpx(setSink);
367
+ if (attempt.served) return;
368
+ detail = attempt.detail || detail;
369
+ pending.unshift(...(attempt.replay || []));
370
+ }
371
+
372
+ log(`degraded: ${detail}`);
373
+ const degraded = createDegradedServer(detail);
374
+ setSink((chunk) => degraded.feed(chunk));
375
+ }
376
+
377
+ // Set only by the tests, which import this file to check the pieces that cannot
378
+ // be run on the platform the tests run on. Unset — which is everywhere else —
379
+ // this file always starts a server, because a launcher that quietly declines to
380
+ // start is the failure it exists to prevent.
381
+ if (process.env.AGENTMASH_MCP_IMPORT_ONLY !== '1') {
382
+ main().catch((err) => {
383
+ log(`fatal: ${err?.stack || err}`);
384
+ process.exit(1);
385
+ });
386
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // AgentMash PostToolUse hook (Write|Edit|MultiEdit): report the edit to the
3
+ // coordination server. Fire-and-forget — always exits 0, always silent.
4
+
5
+ import {
6
+ armExitGuard,
7
+ composeSummary,
8
+ finish,
9
+ getDeveloper,
10
+ getGitBranch,
11
+ httpJson,
12
+ loadConfig,
13
+ parseJson,
14
+ readSessionCache,
15
+ readStdin,
16
+ summarizeChange,
17
+ takeOverrideNote,
18
+ toRepoRelative,
19
+ writeSessionCache,
20
+ } from './lib.mjs';
21
+ // Namespace import for anything added after v0: hooks and lib.mjs are vendored
22
+ // into the repo and can drift apart in a partial merge. A missing *named*
23
+ // import is a module-link error — a non-zero exit and a stack trace in the
24
+ // agent's transcript, which is exactly what these hooks promise never to do.
25
+ import * as lib from './lib.mjs';
26
+
27
+ armExitGuard();
28
+
29
+ async function main() {
30
+ const input = parseJson(await readStdin());
31
+ const filePath = input?.tool_input?.file_path;
32
+ if (!filePath) return;
33
+
34
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || input?.cwd || process.cwd();
35
+ const config = loadConfig(projectDir);
36
+ if (config.disabled || !config.room) return;
37
+
38
+ const sessionId = input?.session_id || 'unknown';
39
+ const cache = readSessionCache(sessionId);
40
+ const file = toRepoRelative(projectDir, filePath);
41
+
42
+ // If strict mode was overridden for this file, the reason rides out here, on
43
+ // the existing event stream — so the teammate who was overridden sees it in
44
+ // their own advisory, and the room sees it on the dashboard. No new call, no
45
+ // new field, and this hook is async, so it costs the edit nothing.
46
+ const override = takeOverrideNote(cache, file);
47
+ if (override) writeSessionCache(sessionId, cache);
48
+ const summary = composeSummary(summarizeChange(input?.tool_name, input?.tool_input), override);
49
+
50
+ await httpJson('POST', `${config.url}/events`, {
51
+ room: config.room,
52
+ token: config.token,
53
+ timeoutMs: config.reportTimeoutMs,
54
+ body: {
55
+ developer: getDeveloper(config, input?.cwd),
56
+ session_id: sessionId,
57
+ event_type: 'edit',
58
+ tool: input?.tool_name || null,
59
+ file_path: file,
60
+ git_branch: getGitBranch(input?.cwd || projectDir),
61
+ task_hint: config.shareHints ? cache.task_hint || null : null,
62
+ change_summary: summary || null,
63
+ symbols: lib.symbolsForEdit?.(filePath, input?.tool_name, input?.tool_input) ?? null,
64
+ // The changed text, only when this developer allows it and the room
65
+ // asked for it (PreToolUse cached the room's answer). Absent otherwise,
66
+ // and an older vendored lib.mjs cannot build it at all.
67
+ diff:
68
+ config.streamDiffs && cache.stream_diffs === true
69
+ ? (lib.diffForEdit?.(input?.tool_name, input?.tool_input) ?? null)
70
+ : undefined,
71
+ timestamp: new Date().toISOString(),
72
+ },
73
+ });
74
+ }
75
+
76
+ main()
77
+ .catch(() => {})
78
+ .finally(finish);
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ // AgentMash PreToolUse hook (Write|Edit|MultiEdit): ask the coordination
3
+ // server whether a teammate's agent recently touched this file, and if so
4
+ // inject a message shaped to change what this agent does next.
5
+ //
6
+ // Advisory mode (default): exit 0 with hookSpecificOutput.additionalContext —
7
+ // the agent sees the heads-up but the edit proceeds through the normal
8
+ // permission flow. Strict mode (AGENTMASH_STRICT=1): permissionDecision
9
+ // "deny", plus an acknowledge protocol the agent can complete on its own.
10
+ //
11
+ // The wording, the severity rule and the acknowledge protocol all live in
12
+ // lib.mjs; this file is the plumbing around them.
13
+ //
14
+ // Anything going wrong — server down, slow, misconfigured — is a silent
15
+ // exit 0 within the advisory budget (AGENTMASH_TIMEOUT_MS, default 1500 ms).
16
+
17
+ import {
18
+ ACK_TTL_MS,
19
+ ADVISORY_TIMEOUT_MS,
20
+ ackFile,
21
+ armExitGuard,
22
+ buildCollisionAdvice,
23
+ buildStrictBlock,
24
+ emitAndExit,
25
+ finish,
26
+ getDeveloper,
27
+ getGitBranch,
28
+ httpJson,
29
+ loadConfig,
30
+ parseJson,
31
+ readAcks,
32
+ readSessionCache,
33
+ readStdin,
34
+ resolveAck,
35
+ scratchDir,
36
+ toRepoRelative,
37
+ writeSessionCache,
38
+ } from './lib.mjs';
39
+ // See the note in post_tool_use.mjs: guarded access for post-v0 helpers.
40
+ import * as lib from './lib.mjs';
41
+
42
+ // A server that is *unreachable* rather than down — a firewall, a hung
43
+ // instance — accepts nothing and refuses nothing, so the AbortSignal fires on
44
+ // schedule but the connecting socket keeps the event loop alive and this guard
45
+ // is the only thing that ends the process. Sized from the advisory budget
46
+ // instead of a flat 4 s, because strict mode deliberately has no cooldown (see
47
+ // below) and so every single edit pays this in full.
48
+ const EXIT_GUARD_SLACK_MS = 250;
49
+ const exitGuard = armExitGuard(ADVISORY_TIMEOUT_MS + EXIT_GUARD_SLACK_MS);
50
+
51
+ // Don't re-inject the same advisory for the same file for a while; the agent
52
+ // already knows, and repeated context is noise.
53
+ //
54
+ // Strict mode gets no such cooldown, and that is the point: a cooldown there
55
+ // would be a ten-minute pass earned by one block, which is the rubber stamp the
56
+ // acknowledge protocol exists to prevent. The acknowledgement *is* strict
57
+ // mode's cooldown — scoped to one collision, and paid for with a reason.
58
+ const ADVISORY_COOLDOWN_MS = 10 * 60_000;
59
+
60
+ function advisory(context) {
61
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: context } };
62
+ }
63
+
64
+ /**
65
+ * Strict mode, second half. An acknowledgement buys passage, not silence: the
66
+ * full advisory still goes into the agent's context, and the reason is parked
67
+ * for the reporting hook to publish. Note what is deliberately *not* returned —
68
+ * permissionDecision "allow" — because getting past AgentMash must not also get
69
+ * past the user's own permission rules.
70
+ */
71
+ function strictOutcome(advice, sessionId, cache, file) {
72
+ // The reason is read against the collision the agent was just shown and
73
+ // against what the agent itself is working on — see resolveAck.
74
+ const verdict = resolveAck(readAcks(sessionId), advice.token, {
75
+ grounding: advice.grounding,
76
+ taskHint: cache.task_hint,
77
+ });
78
+ const firstUsed = cache.acks?.[advice.token];
79
+
80
+ if (verdict.ok && (!firstUsed || Date.now() - firstUsed < ACK_TTL_MS)) {
81
+ cache.acks = { ...(cache.acks || {}), [advice.token]: firstUsed || Date.now() };
82
+ cache.overrides = {
83
+ ...(cache.overrides || {}),
84
+ [file]: { developer: advice.developer, reason: verdict.reason, at: Date.now() },
85
+ };
86
+ writeSessionCache(sessionId, cache);
87
+ return advisory(
88
+ `${advice.text}\n\nYou acknowledged this collision: "${verdict.reason}". Proceeding — ` +
89
+ `that acknowledgement is recorded and rides out with your next reported edit.`
90
+ );
91
+ }
92
+
93
+ // An expired acknowledgement is a rejection with a specific cause, so say so
94
+ // rather than letting the agent re-run a command that already worked once.
95
+ const rejected = verdict.ok ? 'expired' : verdict.rejected;
96
+ return {
97
+ hookSpecificOutput: {
98
+ hookEventName: 'PreToolUse',
99
+ permissionDecision: 'deny',
100
+ permissionDecisionReason: buildStrictBlock(advice, {
101
+ ackDir: scratchDir(),
102
+ ackPath: ackFile(sessionId),
103
+ rejected,
104
+ }),
105
+ },
106
+ };
107
+ }
108
+
109
+ async function main() {
110
+ const input = parseJson(await readStdin());
111
+ const filePath = input?.tool_input?.file_path;
112
+ if (!filePath) return null;
113
+
114
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || input?.cwd || process.cwd();
115
+ const config = loadConfig(projectDir);
116
+ if (config.disabled || !config.room) return null;
117
+
118
+ // A distant server may be given a longer budget; the guard has to outlive it.
119
+ if (config.advisoryTimeoutMs > ADVISORY_TIMEOUT_MS) {
120
+ clearTimeout(exitGuard);
121
+ armExitGuard(config.advisoryTimeoutMs + EXIT_GUARD_SLACK_MS);
122
+ }
123
+
124
+ const sessionId = input?.session_id || 'unknown';
125
+ const file = toRepoRelative(projectDir, filePath);
126
+ const cache = readSessionCache(sessionId);
127
+
128
+ if (!config.strict) {
129
+ const advisedAt = cache.advised?.[file] || 0;
130
+ if (Date.now() - advisedAt < ADVISORY_COOLDOWN_MS) return null;
131
+ }
132
+
133
+ const developer = getDeveloper(config, input?.cwd);
134
+ // What this file is bound to — its imports and its own exports. The server
135
+ // matches them against what teammates recently changed, so a signature change
136
+ // in another file still reaches us. Old servers ignore the parameter.
137
+ const symbols = lib.symbolsForFile?.(filePath) ?? null;
138
+ const url =
139
+ `${config.url}/check?file_path=${encodeURIComponent(file)}` +
140
+ `&developer=${encodeURIComponent(developer)}` +
141
+ (symbols?.length ? `&symbols=${encodeURIComponent(symbols.join(','))}` : '');
142
+ const result = await httpJson('GET', url, {
143
+ room: config.room,
144
+ token: config.token,
145
+ timeoutMs: config.advisoryTimeoutMs,
146
+ });
147
+ const holders = Array.isArray(result?.holders) ? result.holders : [];
148
+ // The room says whether edits here may carry their text to the live view.
149
+ // PostToolUse is fire-and-forget with no answer to read, so the answer is
150
+ // kept for it here, per session. A missing field (older server) means no.
151
+ if (result && (result.stream_diffs === true) !== (cache.stream_diffs === true)) {
152
+ cache.stream_diffs = result.stream_diffs === true;
153
+ writeSessionCache(sessionId, cache);
154
+ }
155
+ // Claims are teammates' stated intent for this file — see buildClaimAdvice.
156
+ // An older server never sends them, and the guarded access keeps an older
157
+ // vendored lib.mjs from throwing on a newer server.
158
+ const claims = Array.isArray(result?.claims) ? result.claims : [];
159
+ if (holders.length === 0 && claims.length === 0) return null;
160
+
161
+ const advice = holders.length
162
+ ? buildCollisionAdvice({
163
+ file,
164
+ holders,
165
+ branch: getGitBranch(input?.cwd || projectDir),
166
+ showHints: config.shareHints,
167
+ })
168
+ : null;
169
+ const claimAdvice = claims.length
170
+ ? (lib.buildClaimAdvice?.({ file, claims, showHints: config.shareHints }) ?? null)
171
+ : null;
172
+ if (!advice && !claimAdvice) return null;
173
+
174
+ // A collision and a claim on the same file are one message, not two.
175
+ if (advice && claimAdvice) advice.text += `\n\n${claimAdvice.text}`;
176
+
177
+ // Strict mode blocks on writes that happened; a claim alone is intent, and
178
+ // intent gets the advisory path in every mode.
179
+ if (advice && config.strict) return strictOutcome(advice, sessionId, cache, file);
180
+
181
+ cache.advised = { ...(cache.advised || {}), [file]: Date.now() };
182
+ writeSessionCache(sessionId, cache);
183
+ return advisory(advice ? advice.text : claimAdvice.text);
184
+ }
185
+
186
+ main()
187
+ .then((payload) => {
188
+ if (payload) emitAndExit(payload);
189
+ else finish();
190
+ })
191
+ .catch(finish);