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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/agentmash.mjs +1798 -0
- package/clients/cursor/after_file_edit.mjs +115 -0
- package/clients/git/post_commit.mjs +205 -0
- package/hooks/lib.mjs +1957 -0
- package/hooks/mcp_launcher.mjs +386 -0
- package/hooks/post_tool_use.mjs +78 -0
- package/hooks/pre_tool_use.mjs +191 -0
- package/hooks/session_end.mjs +76 -0
- package/hooks/stop.mjs +88 -0
- package/hooks/user_prompt_submit.mjs +65 -0
- package/mcp/coordination.mjs +126 -0
- package/mcp/render.mjs +358 -0
- package/mcp/server.mjs +452 -0
- package/package.json +52 -0
|
@@ -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);
|