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/mcp/server.mjs ADDED
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env node
2
+ // AgentMash MCP server — "who is working on what?", answerable mid-plan.
3
+ //
4
+ // The hooks tell an agent about a collision at the moment it edits a file,
5
+ // which is the last moment the answer can still be useful. This exposes the
6
+ // same room over MCP so an agent can ask while it is still deciding what to
7
+ // do.
8
+ //
9
+ // It is a stdio server that reuses the coordination server's existing HTTP
10
+ // API and the hooks' config resolution, so there is no new endpoint, no new
11
+ // credential, and nothing to keep in sync but the room id that already lives
12
+ // in the repo.
13
+ //
14
+ // Nothing is ever written to stdout except JSON-RPC: that channel is the
15
+ // protocol. Diagnostics go to stderr.
16
+
17
+ import { pathToFileURL } from 'node:url';
18
+
19
+ import { z } from 'zod';
20
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
21
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
22
+
23
+ import crypto from 'node:crypto';
24
+
25
+ import { buildReconciliationBrief, toRepoRelative } from '../hooks/lib.mjs';
26
+ import { fetchJson, normalizePath, postJson, resolveCoordination } from './coordination.mjs';
27
+ import {
28
+ renderClaimResult,
29
+ renderClaims,
30
+ renderCollisions,
31
+ renderContestedFiles,
32
+ renderPathActivity,
33
+ renderRelease,
34
+ renderResolved,
35
+ renderUnavailable,
36
+ renderWhoIsWorking,
37
+ } from './render.mjs';
38
+
39
+ /**
40
+ * Claims are held by a session, and this process is one. Claude Code does not
41
+ * hand its own session id to MCP servers, so the process mints its own: the
42
+ * hooks' session_end cannot release these, which is why the transport closing
43
+ * does (see start) and why every claim also carries a TTL.
44
+ */
45
+ const SESSION_ID = `mcp-${crypto.randomBytes(8).toString('base64url')}`;
46
+ /** Only a process that actually claimed something has anything to release on exit. */
47
+ let claimedSomething = false;
48
+
49
+ const NAME = 'agentmash';
50
+ const VERSION = '0.1.0';
51
+
52
+ /** One hour of context by default; a working day when asking about a path. */
53
+ const DEFAULT_WINDOW_MIN = 60;
54
+ const DEFAULT_PATH_WINDOW_MIN = 480;
55
+ const MAX_WINDOW_MIN = 7 * 24 * 60; // the server prunes events after 7 days
56
+
57
+ const INSTRUCTIONS = `AgentMash coordinates the coding agents of everyone working in this same repository. It
58
+ reports who is active right now, what they said they are working on, which branch they are on, and which files
59
+ they have edited — and it lets you claim the scope you are about to work on so their agents route around you.
60
+
61
+ Use it while you are still planning, not after you have started editing. The point is to change what you do,
62
+ not to explain a collision afterwards.
63
+
64
+ The one habit that matters: before starting any task that touches more than a file or two, call claim_work with
65
+ the paths you expect to change and a one-sentence description. If it reports an overlap, split the work — take
66
+ what they have not claimed, or build on top of what they will land — instead of editing the same lines. When you
67
+ finish, call release_claim. Claims expire on their own and end with your session, so forgetting is safe; claiming
68
+ too broadly ("src") is not, because it tells your teammates nothing.
69
+
70
+ When a collision has already happened — you and a teammate both changed the same file — AgentMash will stop you
71
+ before you finish and hand you a reconciliation brief. You can also ask for it any time with get_collisions. Do
72
+ the merge yourself, on this machine, preserving both intents; then call mark_resolved so the room knows it is
73
+ settled and stops asking.
74
+
75
+ who_is_working_on_what, has_anyone_touched, list_claims, list_contested_files and get_collisions only read. When
76
+ the coordination server cannot be reached, every tool says so in as many words — never read a failure as "nobody
77
+ is working on anything".`;
78
+
79
+ const minutesField = (fallback, what) =>
80
+ z
81
+ .number()
82
+ .int()
83
+ .min(1)
84
+ .max(MAX_WINDOW_MIN)
85
+ .default(fallback)
86
+ .describe(
87
+ `How far back to look, in minutes (default ${fallback}${
88
+ fallback === 480 ? ', about a working day' : ''
89
+ }). ${what} Events older than 7 days are pruned by the server, so larger values stop helping.`
90
+ );
91
+
92
+ const includeSelfField = z
93
+ .boolean()
94
+ .default(false)
95
+ .describe(
96
+ "Include this agent's own developer identity in the answer. Off by default, because you rarely need to be told what you yourself have been editing."
97
+ );
98
+
99
+ const text = (body) => ({ content: [{ type: 'text', text: body }] });
100
+
101
+ /**
102
+ * Resolved per call rather than once at startup: `agentmash init` may connect
103
+ * the repo while this process is already running, and a long-lived server that
104
+ * cached "not connected" would keep saying so for the rest of the session.
105
+ */
106
+ function coordinationNow() {
107
+ const index = process.argv.indexOf('--repo');
108
+ const fromFlag = index !== -1 ? process.argv[index + 1] : null;
109
+ return resolveCoordination(process.env.CLAUDE_PROJECT_DIR || fromFlag || process.cwd());
110
+ }
111
+
112
+ /** Ask /activity once and hand the payload to a renderer, or explain why we could not. */
113
+ async function activity(coordination, minutes, subject, render) {
114
+ const answer = await fetchJson(coordination, `/activity?minutes=${encodeURIComponent(minutes)}`);
115
+ if (!answer.ok) return text(renderUnavailable(coordination, answer, subject));
116
+ return text(render(answer.data, coordination));
117
+ }
118
+
119
+ export function createServer() {
120
+ const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS });
121
+ const annotations = { readOnlyHint: true, openWorldHint: true };
122
+
123
+ server.registerTool(
124
+ 'who_is_working_on_what',
125
+ {
126
+ title: 'Who is working on what',
127
+ description: `Ask this repository's AgentMash room who else is working here right now: their name, what
128
+ they told their agent they are doing, the branch they are on, and the files they have touched. Call it while
129
+ you are still planning — before you decide which files to change, and especially when the request is broad
130
+ enough to overlap with someone else ("refactor the auth layer", "add a migration", "clean up the API"). The
131
+ answer is meant to change your plan: if a teammate is already reshaping a module, read their changes first or
132
+ pick work that does not collide. If the coordination server cannot be reached, this says so explicitly; an
133
+ empty room and an unreachable server are worded differently, so do not treat one as the other.`,
134
+ inputSchema: {
135
+ minutes: minutesField(DEFAULT_WINDOW_MIN, 'Someone idle for longer than this will not appear.'),
136
+ include_self: includeSelfField,
137
+ },
138
+ annotations,
139
+ },
140
+ async ({ minutes, include_self }) => {
141
+ const coordination = coordinationNow();
142
+ return activity(coordination, minutes, 'who else is working in this repo', (data) =>
143
+ renderWhoIsWorking(data, coordination, { minutes, includeSelf: include_self })
144
+ );
145
+ }
146
+ );
147
+
148
+ server.registerTool(
149
+ 'has_anyone_touched',
150
+ {
151
+ title: 'Has anyone touched this path',
152
+ description: `Ask whether anyone on the team has recently edited a particular file, or anything inside a
153
+ particular directory. Use it the moment you know which part of the repo your task lives in and while you can
154
+ still change course — "has anyone touched this module today?" is exactly the question it answers. Takes a
155
+ repo-relative path, either a file ("server/src/app.ts") or a directory ("server/src", "dashboard"), and
156
+ reports who touched what, when, what their edit did, and the task they were on. A genuinely quiet path and an
157
+ unreachable coordination server are worded differently, so do not read one as the other.`,
158
+ inputSchema: {
159
+ path: z
160
+ .string()
161
+ .min(1)
162
+ .max(1024)
163
+ .describe(
164
+ 'Repo-relative path to a file or a directory, with forward slashes — for example "server/src/app.ts" or "server/src". A directory matches everything beneath it.'
165
+ ),
166
+ minutes: minutesField(
167
+ DEFAULT_PATH_WINDOW_MIN,
168
+ 'Edits older than this will not appear.'
169
+ ),
170
+ include_self: includeSelfField,
171
+ },
172
+ annotations,
173
+ },
174
+ async ({ path, minutes, include_self }) => {
175
+ const coordination = coordinationNow();
176
+ // Agents hand over absolute paths as readily as relative ones; the room
177
+ // only ever stores repo-relative ones.
178
+ const target = normalizePath(toRepoRelative(coordination.projectDir, path));
179
+ if (!target) {
180
+ return text('`path` must be a repo-relative file or directory path, for example "server/src/app.ts".');
181
+ }
182
+ return activity(coordination, minutes, `recent activity under ${target}`, (data) =>
183
+ renderPathActivity(data, coordination, { target, minutes, includeSelf: include_self })
184
+ );
185
+ }
186
+ );
187
+
188
+ server.registerTool(
189
+ 'list_contested_files',
190
+ {
191
+ title: 'List contested files',
192
+ description: `List the files in this repository that more than one person's agent has edited recently —
193
+ the places where a collision has already begun. Use it when you are choosing where to work, before a refactor
194
+ that spans several files, or whenever you want to know which parts of the codebase are currently crowded. An
195
+ empty list and an unreachable coordination server are worded differently.`,
196
+ inputSchema: {
197
+ minutes: minutesField(
198
+ DEFAULT_WINDOW_MIN,
199
+ 'A file last contested longer ago than this will not appear.'
200
+ ),
201
+ },
202
+ annotations,
203
+ },
204
+ async ({ minutes }) => {
205
+ const coordination = coordinationNow();
206
+ return activity(coordination, minutes, 'which files are contested', (data) =>
207
+ renderContestedFiles(data, coordination, { minutes })
208
+ );
209
+ }
210
+ );
211
+
212
+ // ── claims: say what you are taking, before you take it ──────────────────
213
+ const writeAnnotations = {
214
+ readOnlyHint: false,
215
+ destructiveHint: false,
216
+ idempotentHint: false,
217
+ openWorldHint: true,
218
+ };
219
+
220
+ server.registerTool(
221
+ 'claim_work',
222
+ {
223
+ title: 'Claim work',
224
+ description: `Tell the room what you are about to work on, so other agents can route around you — and find
225
+ out, before you write anything, whether someone has already claimed part of it. Call this at the start of any
226
+ task that will touch more than a file or two, with the repo-relative paths you expect to change and a
227
+ one-sentence description of the task. If the answer reports an OVERLAP, do not just carry on: split the work —
228
+ take the files and symbols they have not claimed, or build on what they will land rather than changing it. A
229
+ claim is stated intent, not a lock; it expires on its own, ends with your session, and is released early by
230
+ release_claim. Claim what you will actually touch: a claim on "src" tells your teammates nothing.`,
231
+ inputSchema: {
232
+ task: z
233
+ .string()
234
+ .min(3)
235
+ .max(300)
236
+ .describe(
237
+ 'What you are about to do, in one sentence, the way you would tell a teammate. Shown to every other agent that comes near this scope.'
238
+ ),
239
+ paths: z
240
+ .array(z.string().min(1).max(1024))
241
+ .min(1)
242
+ .max(50)
243
+ .describe(
244
+ 'Repo-relative files or directories you expect to change, forward slashes. A directory claims everything beneath it.'
245
+ ),
246
+ symbols: z
247
+ .array(z.string().min(1).max(200))
248
+ .max(100)
249
+ .optional()
250
+ .describe(
251
+ 'Exported names you expect to change or rename, if you know them. Lets a teammate three files away be warned when you reshape an interface they import.'
252
+ ),
253
+ ttl_minutes: z
254
+ .number()
255
+ .int()
256
+ .min(5)
257
+ .max(480)
258
+ .default(120)
259
+ .describe(
260
+ 'How long the claim stands if you never release it (default 120). It is released early by release_claim and when your session ends.'
261
+ ),
262
+ },
263
+ annotations: writeAnnotations,
264
+ },
265
+ async ({ task, paths, symbols, ttl_minutes }) => {
266
+ const coordination = coordinationNow();
267
+ const rel = paths.map((p) => normalizePath(toRepoRelative(coordination.projectDir, p))).filter(Boolean);
268
+ if (rel.length === 0) {
269
+ return text('`paths` must contain at least one repo-relative file or directory, for example "server/src/app.ts".');
270
+ }
271
+ const answer = await postJson(coordination, '/claims', {
272
+ developer: coordination.developer,
273
+ session_id: SESSION_ID,
274
+ task,
275
+ paths: rel,
276
+ symbols,
277
+ ttl_minutes,
278
+ });
279
+ if (!answer.ok) return text(renderUnavailable(coordination, answer, 'whether this scope is already claimed'));
280
+ claimedSomething = true;
281
+ return text(renderClaimResult(answer.data, coordination, { paths: rel, task }));
282
+ }
283
+ );
284
+
285
+ server.registerTool(
286
+ 'release_claim',
287
+ {
288
+ title: 'Release a claim',
289
+ description: `Release a claim you made with claim_work, once the work is done or you have decided not to do
290
+ it. Pass the claim id to release one, or nothing to release every claim this session holds. Not releasing is
291
+ safe — claims expire and end with your session — but releasing promptly lets a waiting teammate start sooner.`,
292
+ inputSchema: {
293
+ claim_id: z
294
+ .string()
295
+ .regex(/^claim_[A-Za-z0-9_-]+$/)
296
+ .optional()
297
+ .describe('The id claim_work returned. Omit to release every claim this session holds.'),
298
+ },
299
+ annotations: writeAnnotations,
300
+ },
301
+ async ({ claim_id }) => {
302
+ const coordination = coordinationNow();
303
+ const answer = await postJson(coordination, '/claims/release', {
304
+ developer: coordination.developer,
305
+ session_id: SESSION_ID,
306
+ ...(claim_id ? { claim_id } : {}),
307
+ });
308
+ if (!answer.ok) return text(renderUnavailable(coordination, answer, 'whether the claim was released'));
309
+ return text(renderRelease(answer.data, { claimId: claim_id }));
310
+ }
311
+ );
312
+
313
+ server.registerTool(
314
+ 'list_claims',
315
+ {
316
+ title: 'List claims',
317
+ description: `List what other people's agents have claimed in this repository right now — the scopes they
318
+ have said they are taking, and for what. Cheaper than reading their edits, because a claim exists before any
319
+ edit does. Use it when choosing where to work. Nothing claimed and an unreachable server are worded differently.`,
320
+ inputSchema: { include_self: includeSelfField },
321
+ annotations,
322
+ },
323
+ async ({ include_self }) => {
324
+ const coordination = coordinationNow();
325
+ const answer = await fetchJson(coordination, '/claims');
326
+ if (!answer.ok) return text(renderUnavailable(coordination, answer, 'what is claimed right now'));
327
+ return text(renderClaims(answer.data, coordination, { includeSelf: include_self }));
328
+ }
329
+ );
330
+
331
+ // ── collisions: settle what the claim did not prevent ────────────────────
332
+ server.registerTool(
333
+ 'get_collisions',
334
+ {
335
+ title: 'Get collisions',
336
+ description: `Which files you changed did a teammate also change? For each, this says who, when, on which
337
+ branch, what they were working on, what their edit did, and which symbols you both touched — then how to
338
+ reconcile: see their version, merge so both intents survive, and either open a pull request or commit
339
+ directly, depending on how this room is configured. Call it before you finish a task, or whenever a merge is
340
+ coming. The Stop hook asks the same question automatically as you finish; this lets you ask earlier. No
341
+ collisions and an unreachable server are worded differently.`,
342
+ inputSchema: {
343
+ minutes: minutesField(DEFAULT_WINDOW_MIN, 'Edits older than this are not considered collisions.'),
344
+ },
345
+ annotations,
346
+ },
347
+ async ({ minutes }) => {
348
+ const coordination = coordinationNow();
349
+ const answer = await fetchJson(
350
+ coordination,
351
+ `/collisions?developer=${encodeURIComponent(coordination.developer)}&minutes=${encodeURIComponent(minutes)}`
352
+ );
353
+ if (!answer.ok) return text(renderUnavailable(coordination, answer, 'whether you have collided with anyone'));
354
+ return text(renderCollisions(answer.data, coordination, { minutes, brief: buildReconciliationBrief }));
355
+ }
356
+ );
357
+
358
+ server.registerTool(
359
+ 'mark_resolved',
360
+ {
361
+ title: 'Mark a collision resolved',
362
+ description: `Tell the room a collision on a file is settled, so it stops prompting about it. Call it after
363
+ you have reconciled: outcome "pr" with the pull request URL when this room needs a human to approve, "merged"
364
+ when you committed the reconciled result directly, "no_conflict" when you checked and the two changes did not
365
+ actually overlap, or "dismissed" when the team decided to leave it. The room will prompt again only if someone
366
+ edits the file after this.`,
367
+ inputSchema: {
368
+ file_path: z
369
+ .string()
370
+ .min(1)
371
+ .max(1024)
372
+ .describe('Repo-relative path of the file whose collision you reconciled.'),
373
+ outcome: z
374
+ .enum(['pr', 'merged', 'no_conflict', 'dismissed'])
375
+ .describe('How it was settled. "pr" needs `url`.'),
376
+ url: z.string().url().optional().describe('The pull request URL, for outcome "pr".'),
377
+ note: z
378
+ .string()
379
+ .max(500)
380
+ .optional()
381
+ .describe('One sentence on what you kept from each side, for the people who will read the board.'),
382
+ },
383
+ annotations: writeAnnotations,
384
+ },
385
+ async ({ file_path, outcome, url, note }) => {
386
+ const coordination = coordinationNow();
387
+ const rel = normalizePath(toRepoRelative(coordination.projectDir, file_path));
388
+ if (!rel) return text('`file_path` must be a repo-relative path, for example "src/models/user.ts".');
389
+ const answer = await postJson(coordination, '/resolutions', {
390
+ developer: coordination.developer,
391
+ session_id: SESSION_ID,
392
+ file_path: rel,
393
+ outcome,
394
+ url,
395
+ note,
396
+ });
397
+ if (!answer.ok) return text(renderUnavailable(coordination, answer, 'whether the resolution was recorded'));
398
+ return text(renderResolved(answer.data, { filePath: rel, outcome, url }));
399
+ }
400
+ );
401
+
402
+ return server;
403
+ }
404
+
405
+ /**
406
+ * Best effort, bounded, and only if this process ever claimed anything: a
407
+ * session that is going away should not leave its name on a scope for the
408
+ * rest of a TTL.
409
+ */
410
+ async function releaseOwnClaims() {
411
+ if (!claimedSomething) return;
412
+ claimedSomething = false;
413
+ const coordination = { ...coordinationNow(), timeoutMs: 1500 };
414
+ await postJson(coordination, '/claims/release', {
415
+ developer: coordination.developer,
416
+ session_id: SESSION_ID,
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Serve on stdio. Exported because this process is not always the entry point:
422
+ * `agentmash mcp` and the committed launcher in .claude/agentmash/ both start
423
+ * the server by importing it, so that the way it is reached can change without
424
+ * the server itself knowing.
425
+ */
426
+ export async function start() {
427
+ const server = createServer();
428
+ // The host ending the session is our session_end, and it arrives in one of
429
+ // three shapes: the transport closing, stdin ending, or a signal. All three
430
+ // are best effort — a host that calls TerminateProcess (Windows) delivers
431
+ // none of them — which is why every claim also carries a TTL.
432
+ server.server.onclose = () => {
433
+ void releaseOwnClaims();
434
+ };
435
+ process.stdin.once('end', () => {
436
+ void releaseOwnClaims();
437
+ });
438
+ for (const signal of ['SIGINT', 'SIGTERM']) {
439
+ process.on(signal, () => {
440
+ releaseOwnClaims().finally(() => process.exit(0));
441
+ });
442
+ }
443
+ await server.connect(new StdioServerTransport());
444
+ }
445
+
446
+ // Only run when executed directly, so tests can import createServer().
447
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
448
+ start().catch((err) => {
449
+ console.error('[agentmash-mcp] fatal:', err);
450
+ process.exit(1);
451
+ });
452
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "agentmash",
3
+ "version": "0.3.0",
4
+ "description": "Let your team's Claude Code agents see each other's work",
5
+ "keywords": [
6
+ "claude-code",
7
+ "claude",
8
+ "hooks",
9
+ "coordination",
10
+ "agents",
11
+ "multi-agent",
12
+ "team"
13
+ ],
14
+ "homepage": "https://github.com/dauletbekalim/agentmash#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/dauletbekalim/agentmash/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/dauletbekalim/agentmash.git",
21
+ "directory": "cli"
22
+ },
23
+ "license": "MIT",
24
+ "type": "module",
25
+ "bin": {
26
+ "agentmash": "./agentmash.mjs"
27
+ },
28
+ "files": [
29
+ "agentmash.mjs",
30
+ "hooks/",
31
+ "mcp/",
32
+ "clients/"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "sync-hooks": "node sync-hooks.mjs",
42
+ "prepare": "node sync-hooks.mjs",
43
+ "test": "node --import tsx --test test/*.test.mjs"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "1.30.0",
47
+ "zod": "4.5.4"
48
+ },
49
+ "devDependencies": {
50
+ "tsx": "^4.19.2"
51
+ }
52
+ }