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/agentmash.mjs ADDED
@@ -0,0 +1,1798 @@
1
+ #!/usr/bin/env node
2
+ // AgentMash CLI — Node built-ins only. (The package it ships in is not
3
+ // dependency-free: `agentmash mcp` runs the real MCP server, which needs
4
+ // @modelcontextprotocol/sdk. Everything this file installs into a repo is.)
5
+ //
6
+ // npx agentmash init create a room and wire this repo up
7
+ // npx agentmash join ID point this repo at an existing room
8
+ // npx agentmash doctor explain exactly what is and isn't working
9
+ // npx agentmash status who is working on what, right now
10
+ // npx agentmash git-hook install or remove the post-commit client
11
+ // npx agentmash uninstall remove the hooks from this repo
12
+ // npx agentmash mcp serve the MCP tools on stdio (what .mcp.json runs)
13
+
14
+ import fs from 'node:fs';
15
+ import net from 'node:net';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { execFileSync, spawn } from 'node:child_process';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
22
+ const DEFAULT_SERVER = (process.env.AGENTMASH_SERVER || 'https://agentmash.dev').replace(/\/+$/, '');
23
+ const HOOK_FILES = [
24
+ 'lib.mjs',
25
+ 'pre_tool_use.mjs',
26
+ 'post_tool_use.mjs',
27
+ 'user_prompt_submit.mjs',
28
+ 'session_end.mjs',
29
+ 'stop.mjs',
30
+ ];
31
+ // Not a hook, but it installs like one: dependency-free, copied into the repo,
32
+ // committed, and picked up by a teammate's `git pull`.
33
+ const MCP_LAUNCHER = 'mcp_launcher.mjs';
34
+ const INSTALLED_FILES = [...HOOK_FILES, MCP_LAUNCHER];
35
+ const MCP_SERVER_NAME = 'agentmash';
36
+ // Non-Claude-Code clients of the same protocol, copied in beside the hooks so
37
+ // they travel with the repo too. [source path under clients/, installed name]
38
+ const CLIENT_FILES = [['git/post_commit.mjs', 'git_post_commit.mjs']];
39
+ const REQUEST_TIMEOUT_MS = 10_000;
40
+
41
+ const c = {
42
+ bold: (s) => `${s}`,
43
+ dim: (s) => `${s}`,
44
+ green: (s) => `${s}`,
45
+ red: (s) => `${s}`,
46
+ yellow: (s) => `${s}`,
47
+ cyan: (s) => `${s}`,
48
+ };
49
+ const ok = (s) => console.log(` ${c.green('✓')} ${s}`);
50
+ const bad = (s) => console.log(` ${c.red('✗')} ${s}`);
51
+ const warn = (s) => console.log(` ${c.yellow('!')} ${s}`);
52
+ const info = (s) => console.log(` ${c.dim('·')} ${s}`);
53
+
54
+ function die(message) {
55
+ console.error(`\n${c.red('error')} ${message}\n`);
56
+ process.exit(1);
57
+ }
58
+
59
+ // ── locations ──────────────────────────────────────────────────────────────
60
+
61
+ function findRepoRoot(start = process.cwd()) {
62
+ let dir = start;
63
+ for (let i = 0; i < 40; i++) {
64
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
65
+ const parent = path.dirname(dir);
66
+ if (parent === dir) break;
67
+ dir = parent;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Hook sources: the repo checkout in dev, or the copy bundled with the
74
+ * published package. The checkout comes first because `sync-hooks.mjs` leaves a
75
+ * vendored copy in cli/hooks/ that goes stale the moment anyone edits hooks/,
76
+ * and installing yesterday's hooks is worse than not finding them.
77
+ */
78
+ function hookSourceDir() {
79
+ for (const candidate of [path.join(HERE, '..', 'hooks'), path.join(HERE, 'hooks')]) {
80
+ if (INSTALLED_FILES.every((f) => fs.existsSync(path.join(candidate, f)))) return candidate;
81
+ }
82
+ die('could not locate the hook scripts to install (broken package?)');
83
+ }
84
+
85
+ function clientSourceDir() {
86
+ for (const candidate of [path.join(HERE, 'clients'), path.join(HERE, '..', 'clients')]) {
87
+ if (CLIENT_FILES.every(([src]) => fs.existsSync(path.join(candidate, src)))) return candidate;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ const meshDir = (repo) => path.join(repo, '.claude', 'agentmash');
93
+ const configPath = (repo) => path.join(meshDir(repo), 'config.json');
94
+ const settingsPath = (repo) => path.join(repo, '.claude', 'settings.json');
95
+ const mcpJsonPath = (repo) => path.join(repo, '.mcp.json');
96
+
97
+ function readConfig(repo) {
98
+ try {
99
+ return JSON.parse(fs.readFileSync(configPath(repo), 'utf8'));
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ // ── http ───────────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Node races the addresses a hostname resolves to and gives each attempt 250 ms
109
+ * by default. A TCP connect to a server a continent away takes longer than that,
110
+ * so every attempt is abandoned, fetch rejects with ETIMEDOUT in ~300 ms, and the
111
+ * caller sees a dead server that curl reaches fine. Give each attempt half the
112
+ * request's own budget instead: a slow-but-working first address gets to finish,
113
+ * and a blackholed one still leaves the other half for the next family.
114
+ */
115
+ function widenConnectAttempts(timeoutMs) {
116
+ try {
117
+ net.setDefaultAutoSelectFamilyAttemptTimeout(Math.max(250, Math.floor(timeoutMs / 2)));
118
+ } catch {
119
+ // Node older than 18.18 has no such knob, and no such 250 ms default to undo
120
+ }
121
+ }
122
+
123
+ async function api(method, url, { room, token, poll, body } = {}) {
124
+ const started = Date.now();
125
+ try {
126
+ widenConnectAttempts(REQUEST_TIMEOUT_MS);
127
+ const res = await fetch(url, {
128
+ method,
129
+ // Short-lived CLI: don't leave keep-alive sockets open for exit to race.
130
+ // Credentials go in headers, never in the URL — query strings end up in
131
+ // access logs, shell history and browser referrers.
132
+ headers: {
133
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
134
+ 'x-agentmash-room': room || '',
135
+ ...(token ? { 'x-agentmash-token': token } : {}),
136
+ ...(poll ? { 'x-agentmash-poll': poll } : {}),
137
+ connection: 'close',
138
+ },
139
+ body: body === undefined ? undefined : JSON.stringify(body),
140
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
141
+ });
142
+ const text = await res.text();
143
+ let json = null;
144
+ try {
145
+ json = JSON.parse(text);
146
+ } catch {
147
+ /* non-JSON response */
148
+ }
149
+ return { ok: res.ok, status: res.status, json, text, ms: Date.now() - started };
150
+ } catch (err) {
151
+ return { ok: false, status: 0, json: null, text: String(err?.message || err), ms: Date.now() - started };
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Ask the server about the room this repo points at, with the key in a header.
157
+ * `GET /rooms/:id` puts a working credential in the URL, so it lands verbatim
158
+ * in the server's log, in every proxy in between, and in shell history — and
159
+ * `doctor` runs it constantly. Servers older than /rooms/self answer that path
160
+ * from their `:id` route with a 404 that carries no `code`, which is the signal
161
+ * to fall back so a new CLI still works against an old server.
162
+ */
163
+ async function lookupRoom(url, room, token) {
164
+ const res = await api('GET', `${url}/rooms/self`, { room, token });
165
+ if (res.status === 404 && !res.json?.code) return api('GET', `${url}/rooms/${room}`, { room, token });
166
+ return res;
167
+ }
168
+
169
+ // ── per-machine credentials ────────────────────────────────────────────────
170
+ // Developer tokens are personal: they live in the user's home directory, mode
171
+ // 0600, and never in the repo. Keyed by server + the room's non-secret ref, so
172
+ // rotating the room key leaves them intact.
173
+
174
+ function credentialsPath() {
175
+ const home = process.env.AGENTMASH_HOME || os.homedir();
176
+ return path.join(home, '.agentmash', 'credentials.json');
177
+ }
178
+
179
+ function readCredentials() {
180
+ try {
181
+ const parsed = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
182
+ return parsed && typeof parsed === 'object' ? parsed : { version: 1, tokens: {} };
183
+ } catch {
184
+ return { version: 1, tokens: {} };
185
+ }
186
+ }
187
+
188
+ function credentialKey(url, roomRef) {
189
+ return `${url.replace(/\/+$/, '')}|${roomRef}`;
190
+ }
191
+
192
+ function readToken(url, roomRef) {
193
+ if (!roomRef) return '';
194
+ const entry = readCredentials().tokens?.[credentialKey(url, roomRef)];
195
+ return typeof entry?.token === 'string' ? entry.token : '';
196
+ }
197
+
198
+ function writeToken(url, roomRef, entry) {
199
+ const file = credentialsPath();
200
+ const store = readCredentials();
201
+ store.version = 1;
202
+ store.tokens = store.tokens ?? {};
203
+ store.tokens[credentialKey(url, roomRef)] = entry;
204
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
205
+ fs.writeFileSync(file, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
206
+ try {
207
+ fs.chmodSync(file, 0o600);
208
+ } catch {
209
+ /* best effort on platforms without POSIX modes */
210
+ }
211
+ }
212
+
213
+ /** The room this repo is wired to, plus whatever credentials we hold for it. */
214
+ function connection(repo) {
215
+ const config = readConfig(repo);
216
+ const url = (process.env.AGENTMASH_URL || config?.url || '').replace(/\/+$/, '');
217
+ const room = process.env.AGENTMASH_ROOM || config?.room || '';
218
+ const roomRef = process.env.AGENTMASH_ROOM_REF || config?.room_ref || '';
219
+ return { config, url, room, roomRef, token: process.env.AGENTMASH_TOKEN || readToken(url, roomRef) };
220
+ }
221
+
222
+ // ── install ────────────────────────────────────────────────────────────────
223
+
224
+ const HOOK_DIR_TOKEN = '${CLAUDE_PROJECT_DIR}/.claude/agentmash';
225
+ const hookCommand = (script, extra = {}) => ({
226
+ type: 'command',
227
+ command: 'node',
228
+ args: [`${HOOK_DIR_TOKEN}/${script}`],
229
+ timeout: 5,
230
+ ...extra,
231
+ });
232
+
233
+ // Reporting hooks run with async: true — Claude Code doesn't wait for them, so
234
+ // a distant server costs the agent nothing. Only the advisory check is
235
+ // synchronous, because its whole purpose is to speak before an edit happens.
236
+ const HOOK_ENTRIES = {
237
+ UserPromptSubmit: { hooks: [hookCommand('user_prompt_submit.mjs', { async: true })] },
238
+ PreToolUse: { matcher: 'Write|Edit|MultiEdit', hooks: [hookCommand('pre_tool_use.mjs')] },
239
+ PostToolUse: { matcher: 'Write|Edit|MultiEdit', hooks: [hookCommand('post_tool_use.mjs', { async: true })] },
240
+ // SessionEnd hooks share a short budget, and the process is going away, so
241
+ // this one stays synchronous with a little more room than the default.
242
+ SessionEnd: { hooks: [hookCommand('session_end.mjs', { timeout: 4 })] },
243
+ // Synchronous on purpose: its whole job is to speak before the turn ends.
244
+ Stop: { hooks: [hookCommand('stop.mjs')] },
245
+ };
246
+
247
+ // Claude Code reads project-scoped MCP servers from .mcp.json at the repo root
248
+ // and expands ${VAR} and ${VAR:-default} in `command`, `args`, `env`, `url` and
249
+ // `headers`. CLAUDE_PROJECT_DIR is set in the *spawned server's* environment,
250
+ // not in Claude Code's own, so at expansion time it is unset — and an unset
251
+ // variable with no default is left in the path verbatim, registering a server
252
+ // that never starts. The docs name `${CLAUDE_PROJECT_DIR:-.}` as the form to
253
+ // use for exactly this reason; `.` is the directory Claude Code launches
254
+ // project servers in. `doctor` spawns this same expansion rather than the
255
+ // launcher's resolved path, so a wrong belief here fails out loud.
256
+ const MCP_SERVER_ENTRY = {
257
+ command: 'node',
258
+ args: ['${CLAUDE_PROJECT_DIR:-.}/.claude/agentmash/' + MCP_LAUNCHER],
259
+ };
260
+
261
+ /** Merge our server into whatever .mcp.json the repo already has. Null when nothing changes. */
262
+ function mergeMcpJson(existing) {
263
+ let config = {};
264
+ if (existing && existing.trim()) {
265
+ try {
266
+ config = JSON.parse(existing);
267
+ } catch {
268
+ throw new Error('is not valid JSON');
269
+ }
270
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
271
+ throw new Error('must contain a JSON object at the top level');
272
+ }
273
+ }
274
+ config.mcpServers = config.mcpServers ?? {};
275
+ const current = config.mcpServers[MCP_SERVER_NAME];
276
+ if (JSON.stringify(current) === JSON.stringify(MCP_SERVER_ENTRY)) return null;
277
+ config.mcpServers[MCP_SERVER_NAME] = MCP_SERVER_ENTRY;
278
+ return JSON.stringify(config, null, 2) + '\n';
279
+ }
280
+
281
+ function copyHooks(repo) {
282
+ const source = hookSourceDir();
283
+ fs.mkdirSync(meshDir(repo), { recursive: true });
284
+ for (const file of INSTALLED_FILES) {
285
+ fs.copyFileSync(path.join(source, file), path.join(meshDir(repo), file));
286
+ }
287
+ const clients = clientSourceDir();
288
+ if (!clients) return;
289
+ for (const [src, dest] of CLIENT_FILES) {
290
+ fs.copyFileSync(path.join(clients, src), path.join(meshDir(repo), dest));
291
+ }
292
+ }
293
+
294
+ // ── git client ─────────────────────────────────────────────────────────────
295
+ // The post-commit hook is the only way a room ever sees a teammate who edits by
296
+ // hand, or in an editor with no extension point. It lives in .git/hooks, which
297
+ // is NOT committed — so unlike the Claude Code hooks it does not arrive with a
298
+ // `git pull`, and every developer installs it on their own machine.
299
+
300
+ const GIT_HOOK_MARKER = 'agentmash-git-hook';
301
+
302
+ /**
303
+ * Never blocks, never fails, never speaks. It backgrounds the worker and returns
304
+ * immediately, which is the git equivalent of the reporting hooks' `async: true`
305
+ * — the developer's `git commit` finishes at exactly the speed it did before.
306
+ */
307
+ const GIT_HOOK_SCRIPT = `#!/bin/sh
308
+ # ${GIT_HOOK_MARKER} — installed by \`agentmash git-hook install\`.
309
+ #
310
+ # Reports which files this commit touched to your team's AgentMash room, in the
311
+ # background. It cannot slow down or fail your commit: it starts a detached
312
+ # process and exits 0 immediately.
313
+ #
314
+ # Any post-commit hook that was already here was moved to post-commit.local and
315
+ # still runs first, unchanged. Removing this file (or \`agentmash git-hook
316
+ # uninstall\`) restores it.
317
+
318
+ hook_dir=$(dirname "$0")
319
+ if [ -x "$hook_dir/post-commit.local" ]; then
320
+ "$hook_dir/post-commit.local" "$@" </dev/null
321
+ fi
322
+
323
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || root=""
324
+ [ -n "$root" ] || root="$PWD"
325
+ worker="$root/.claude/agentmash/git_post_commit.mjs"
326
+
327
+ # Hooks run under a non-login shell that never reads your profile, so a node
328
+ # installed by nvm/volta/fnm is not on PATH — that is every commit made from a
329
+ # GUI client. Look where those put it before giving up. Globs and [ -x ] are
330
+ # shell builtins: no subprocess, no measurable cost.
331
+ node_bin=""
332
+ if command -v node >/dev/null 2>&1; then
333
+ node_bin=node
334
+ else
335
+ for candidate in "$HOME"/.volta/bin/node /opt/homebrew/bin/node /usr/local/bin/node \
336
+ "$HOME"/.nvm/versions/node/*/bin/node; do
337
+ if [ -x "$candidate" ]; then node_bin="$candidate"; break; fi
338
+ done
339
+ fi
340
+
341
+ if [ -f "$worker" ] && [ -n "$node_bin" ]; then
342
+ "$node_bin" "$worker" "$root" </dev/null >/dev/null 2>&1 &
343
+ fi
344
+
345
+ exit 0
346
+ `;
347
+
348
+ const contains = (parent, child) => child === parent || child.startsWith(parent + path.sep);
349
+
350
+ /**
351
+ * Where git will actually look for hooks, and what kind of place that is.
352
+ *
353
+ * core.hooksPath must be honoured — git runs hooks from there and nowhere else,
354
+ * so writing to .git/hooks when it is set produces a hook that is never run.
355
+ * But *where* it points changes what installing means: a `.githooks/` or
356
+ * `.husky/_` directory is inside the working tree, and a file written there is
357
+ * a tracked file modified plus an untracked sibling created — see installGitHook.
358
+ * Linked worktrees are the other case: their hooks live in the common git dir.
359
+ */
360
+ function resolveHooksDir(repo) {
361
+ const ask = (args) => {
362
+ try {
363
+ return execFileSync('git', args, { cwd: repo, timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] })
364
+ .toString()
365
+ .trim();
366
+ } catch {
367
+ return '';
368
+ }
369
+ };
370
+ const gitDir = path.resolve(repo, ask(['rev-parse', '--git-common-dir']) || '.git');
371
+ const workTree = path.resolve(ask(['rev-parse', '--show-toplevel']) || repo);
372
+ const configured = ask(['config', '--get', 'core.hooksPath']);
373
+ if (!configured) {
374
+ return { dir: path.join(gitDir, 'hooks'), configured: null, insideWorkTree: false, outsideRepo: false };
375
+ }
376
+ // git resolves a relative core.hooksPath against the top of the working tree.
377
+ const dir = path.resolve(workTree, configured);
378
+ const inGitDir = contains(gitDir, dir);
379
+ return {
380
+ dir,
381
+ configured,
382
+ insideWorkTree: !inGitDir && contains(workTree, dir),
383
+ outsideRepo: !inGitDir && !contains(workTree, dir),
384
+ };
385
+ }
386
+
387
+ const gitHooksDir = (repo) => resolveHooksDir(repo).dir;
388
+ const gitHookPath = (repo) => path.join(gitHooksDir(repo), 'post-commit');
389
+ const chainedHookPath = (repo) => path.join(gitHooksDir(repo), 'post-commit.local');
390
+
391
+ /**
392
+ * What is at a path, *without* following symlinks — the whole point.
393
+ * fs.existsSync() answers false for a dangling symlink, and writing to that
394
+ * path then creates the file at the link's target, wherever that is.
395
+ */
396
+ function pathKind(file) {
397
+ let stat;
398
+ try {
399
+ stat = fs.lstatSync(file, { throwIfNoEntry: false });
400
+ } catch {
401
+ return 'unreadable'; // no permission on the directory, EIO, a path with a non-directory in it
402
+ }
403
+ if (!stat) return 'missing';
404
+ if (stat.isSymbolicLink()) return 'symlink';
405
+ if (stat.isDirectory()) return 'directory';
406
+ if (stat.isFile()) return 'file';
407
+ return 'something else'; // fifo, socket, device
408
+ }
409
+
410
+ // O_NOFOLLOW closes the gap between the lstat above and the open below: if the
411
+ // path became a symlink in between, the open fails instead of following it.
412
+ // Windows has neither the flag nor the symlink-in-.git/hooks case; 0 there.
413
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
414
+
415
+ /** Write the shim to a path we have just established is a plain file (or nothing). */
416
+ function writeHookFile(file, { replacing }) {
417
+ const { O_WRONLY, O_CREAT, O_TRUNC, O_EXCL } = fs.constants;
418
+ const flags = replacing ? O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW : O_WRONLY | O_CREAT | O_EXCL;
419
+ const fd = fs.openSync(file, flags, 0o755);
420
+ try {
421
+ fs.writeFileSync(fd, GIT_HOOK_SCRIPT);
422
+ // O_CREAT's mode is masked by umask, and git ignores a non-executable hook.
423
+ fs.fchmodSync(fd, 0o755);
424
+ } finally {
425
+ fs.closeSync(fd);
426
+ }
427
+ }
428
+
429
+ function isOurGitHook(file) {
430
+ if (pathKind(file) !== 'file') return false;
431
+ try {
432
+ return fs.readFileSync(file, 'utf8').includes(GIT_HOOK_MARKER);
433
+ } catch {
434
+ return false;
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Returns { ok, message }. Never throws: a checkout where the hooks directory
440
+ * cannot be written still gets a working install of everything else.
441
+ *
442
+ * Two refusals matter more than the install:
443
+ *
444
+ * 1. Anything at post-commit that is not a plain file we may replace. A dangling
445
+ * symlink is the sharp case — `existsSync` says false, the write then lands
446
+ * at the link's target, and the chmod makes it executable. That is an
447
+ * arbitrary executable file anywhere on the filesystem, from a command we ask
448
+ * every developer on a team to run.
449
+ * 2. A hooks directory inside the working tree (core.hooksPath = .githooks,
450
+ * .husky/_, lefthook). Writing there modifies a tracked file, and the next
451
+ * `git add -A` commits AgentMash to everybody. The whole argument for
452
+ * installing by default is that .git/hooks is never committed; where that is
453
+ * not true, the argument is not either, so we stop and ask.
454
+ */
455
+ function installGitHook(repo, { forceHooksPath = false } = {}) {
456
+ const hooks = resolveHooksDir(repo);
457
+ const file = path.join(hooks.dir, 'post-commit');
458
+ const chained = path.join(hooks.dir, 'post-commit.local');
459
+ const shown = path.relative(repo, hooks.dir) || hooks.dir;
460
+
461
+ if (hooks.insideWorkTree && !forceHooksPath) {
462
+ return {
463
+ ok: false,
464
+ message:
465
+ `this repo runs hooks from ${shown}/ (core.hooksPath), which is inside the working tree —\n` +
466
+ ` installing there would modify a tracked file and commit AgentMash to your whole team.\n` +
467
+ ` Re-run with --force-hooks-path if that is what you want.`,
468
+ };
469
+ }
470
+
471
+ try {
472
+ fs.mkdirSync(hooks.dir, { recursive: true });
473
+
474
+ const kind = pathKind(file);
475
+ if (kind === 'symlink') {
476
+ return {
477
+ ok: false,
478
+ message:
479
+ `${file} is a symlink — leaving it alone.\n` +
480
+ ` Writing through it would create an executable file wherever it points.`,
481
+ };
482
+ }
483
+ if (kind === 'unreadable') return { ok: false, message: `cannot inspect ${file} — leaving it alone` };
484
+ if (kind !== 'missing' && kind !== 'file') {
485
+ return { ok: false, message: `${file} is not a regular file (${kind}) — leaving it alone` };
486
+ }
487
+
488
+ if (kind === 'file' && !isOurGitHook(file)) {
489
+ // Somebody's existing hook. Step aside politely: move it to
490
+ // post-commit.local, which our script runs first, so their behaviour is
491
+ // byte-for-byte what it was.
492
+ const chainedKind = pathKind(chained);
493
+ if (chainedKind !== 'missing') {
494
+ return { ok: false, message: `post-commit.local already exists (${chainedKind}) — leaving ${file} alone` };
495
+ }
496
+ // rename, never chmod: making a non-executable hook executable would
497
+ // start running something git was ignoring
498
+ fs.renameSync(file, chained);
499
+ writeHookFile(file, { replacing: false });
500
+ return { ok: true, message: 'git post-commit hook installed alongside the one already there' };
501
+ }
502
+
503
+ writeHookFile(file, { replacing: kind === 'file' });
504
+ const where = hooks.outsideRepo
505
+ ? ` in ${hooks.dir} (core.hooksPath — shared with every repository that uses it)`
506
+ : hooks.insideWorkTree
507
+ ? ` in ${shown}/ (core.hooksPath, inside the working tree — do not commit it)`
508
+ : '';
509
+ return {
510
+ ok: true,
511
+ message: (kind === 'file' ? 'git post-commit hook updated' : 'git post-commit hook installed') + where,
512
+ };
513
+ } catch (err) {
514
+ return { ok: false, message: `could not write ${file} — ${err.message}` };
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Whether the installed shim will actually do anything. The shim needs two
520
+ * things: the worker beside it, and a `node` that a non-login `/bin/sh` can
521
+ * find. The second is the one that surprises people — `git commit` from
522
+ * SourceTree, VS Code or JetBrains runs with a PATH that never saw nvm.
523
+ */
524
+ function workerCanRun(repo) {
525
+ if (!fs.existsSync(path.join(meshDir(repo), 'git_post_commit.mjs'))) {
526
+ return { ok: false, message: 'the hook is installed but the worker is not — run: agentmash git-hook install' };
527
+ }
528
+ let found = '';
529
+ try {
530
+ found = execFileSync('/bin/sh', ['-c', 'command -v node'], { timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] })
531
+ .toString()
532
+ .trim();
533
+ } catch {
534
+ // no node on PATH at all, or no /bin/sh (Windows) — say nothing rather than
535
+ // guess; the shim itself is inert-and-silent in that case by design
536
+ return { ok: true };
537
+ }
538
+ if (/[/\\](\.nvm|\.volta|\.fnm|\.asdf|fnm_multishells|\.nodenv)[/\\]/.test(found)) {
539
+ return {
540
+ ok: false,
541
+ message:
542
+ `node comes from a version manager (${found}), which a GUI git client's PATH will not have.\n` +
543
+ ` The hook looks in the usual version-manager locations too, but commits made outside a\n` +
544
+ ` terminal are the case to check if your commits never show up.`,
545
+ };
546
+ }
547
+ return { ok: true };
548
+ }
549
+
550
+ function uninstallGitHook(repo) {
551
+ const file = gitHookPath(repo);
552
+ const chained = chainedHookPath(repo);
553
+ try {
554
+ const kind = pathKind(file);
555
+ if (kind === 'missing') return { ok: true, message: 'no git post-commit hook to remove' };
556
+ // A symlink is never ours even if what it points at contains our marker:
557
+ // somebody else made that link, and removing it is their decision.
558
+ if (kind === 'symlink') return { ok: false, message: `${file} is a symlink, not ours — leaving it alone` };
559
+ if (!isOurGitHook(file)) return { ok: false, message: `${file} is not ours — leaving it alone` };
560
+ fs.rmSync(file, { force: true });
561
+ if (pathKind(chained) === 'file') {
562
+ fs.renameSync(chained, file);
563
+ return { ok: true, message: 'git post-commit hook removed, the one it replaced is back in place' };
564
+ }
565
+ return { ok: true, message: 'git post-commit hook removed' };
566
+ } catch (err) {
567
+ return { ok: false, message: `could not remove ${file} — ${err.message}` };
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Check we'll be able to write the files we merge into before doing anything
573
+ * remote — otherwise a broken settings file leaves an orphan room on the server.
574
+ */
575
+ function assertSettingsUsable(repo) {
576
+ for (const file of [settingsPath(repo), mcpJsonPath(repo)]) {
577
+ if (!fs.existsSync(file)) continue;
578
+ let parsed;
579
+ try {
580
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
581
+ } catch {
582
+ die(`${file} is not valid JSON — fix or move it, then re-run.`);
583
+ }
584
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
585
+ die(`${file} must contain a JSON object at the top level.`);
586
+ }
587
+ }
588
+ }
589
+
590
+ /** Merge our entries into settings.json without disturbing anything already there. */
591
+ function mergeSettings(repo) {
592
+ const file = settingsPath(repo);
593
+ let settings = {};
594
+ if (fs.existsSync(file)) {
595
+ try {
596
+ settings = JSON.parse(fs.readFileSync(file, 'utf8'));
597
+ } catch {
598
+ die(`${file} is not valid JSON — fix or move it, then re-run.`);
599
+ }
600
+ if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) {
601
+ die(`${file} must contain a JSON object at the top level.`);
602
+ }
603
+ }
604
+ settings.hooks = settings.hooks ?? {};
605
+ let added = 0;
606
+ for (const [event, group] of Object.entries(HOOK_ENTRIES)) {
607
+ const groups = (settings.hooks[event] = settings.hooks[event] ?? []);
608
+ if (JSON.stringify(groups).includes('.claude/agentmash/')) continue;
609
+ groups.push(group);
610
+ added += 1;
611
+ }
612
+ fs.mkdirSync(path.dirname(file), { recursive: true });
613
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
614
+ return added;
615
+ }
616
+
617
+ /** Register the MCP server in .mcp.json, keeping any servers the team added. */
618
+ function mergeMcpFile(repo) {
619
+ const file = mcpJsonPath(repo);
620
+ let existing = null;
621
+ if (fs.existsSync(file)) existing = fs.readFileSync(file, 'utf8');
622
+ // A server already called "agentmash" that is not ours gets overwritten by
623
+ // the merge. Narrow, but silently replacing someone's server is not ours to
624
+ // do quietly, so say what was there.
625
+ let replaced = null;
626
+ try {
627
+ const before = existing && existing.trim() ? JSON.parse(existing) : {};
628
+ const current = before?.mcpServers?.[MCP_SERVER_NAME];
629
+ if (current && JSON.stringify(current) !== JSON.stringify(MCP_SERVER_ENTRY)) replaced = current;
630
+ } catch {
631
+ /* mergeMcpJson reports an unparseable file */
632
+ }
633
+ let merged;
634
+ try {
635
+ merged = mergeMcpJson(existing);
636
+ } catch (err) {
637
+ die(`${file} ${err.message} — fix or move it, then re-run.`);
638
+ }
639
+ if (merged === null) return { changed: false, replaced: null };
640
+ fs.writeFileSync(file, merged);
641
+ return { changed: true, replaced };
642
+ }
643
+
644
+ function writeConfig(repo, config) {
645
+ fs.mkdirSync(meshDir(repo), { recursive: true });
646
+ fs.writeFileSync(configPath(repo), JSON.stringify(config, null, 2) + '\n');
647
+ }
648
+
649
+ function wire(repo, config, { gitHook = true, forceHooksPath = false } = {}) {
650
+ copyHooks(repo);
651
+ const added = mergeSettings(repo);
652
+ const mcp = mergeMcpFile(repo);
653
+ writeConfig(repo, config);
654
+ // Installed by default, and argued for in docs/ADAPTERS.md: it is local-only
655
+ // (nothing is committed, so it cannot surprise a teammate), it cannot affect a
656
+ // commit, and `uninstall` puts everything back. An opt-in flag nobody
657
+ // discovers would leave exactly the people this is for invisible.
658
+ return {
659
+ added,
660
+ mcp: mcp.changed,
661
+ replaced: mcp.replaced,
662
+ git: gitHook ? installGitHook(repo, { forceHooksPath }) : null,
663
+ };
664
+ }
665
+
666
+ function printJoined(repo, config, { added, mcp, replaced, git }) {
667
+ const dashboard = `${config.url}/r/${config.room}`;
668
+ console.log(`\n${c.bold('AgentMash is wired up.')}\n`);
669
+ ok(`hooks installed ${c.dim(path.relative(repo, meshDir(repo)))}`);
670
+ ok(added > 0 ? `settings.json updated (${added} hook events)` : 'settings.json already had the hooks');
671
+ ok(mcp ? 'MCP server registered .mcp.json' : 'MCP server already registered in .mcp.json');
672
+ if (replaced) {
673
+ warn(
674
+ '.mcp.json already had a server called "agentmash" and it has been replaced:\n' +
675
+ ` ${c.dim(JSON.stringify(replaced))}\n` +
676
+ ' Put it back under another name if you still need it.'
677
+ );
678
+ }
679
+ if (git?.ok) ok(git.message);
680
+ else if (git) warn(git.message);
681
+ ok(`room ${c.cyan(config.room)}`);
682
+ console.log(`\n ${c.bold('Dashboard')} ${c.cyan(dashboard)}\n`);
683
+ console.log(` ${c.bold('To bring in your team — commit and push:')}`);
684
+ console.log(c.dim(' git add .claude .mcp.json && git commit -m "add agentmash" && git push\n'));
685
+ console.log(' Teammates just pull. The hooks and room travel with the repo,');
686
+ console.log(' so their next Claude Code session joins automatically.\n');
687
+ console.log(` ${c.bold('Each teammate approves the MCP server once.')}`);
688
+ console.log(' Claude Code asks before it runs a server a repo brought with it, and');
689
+ console.log(' AgentMash does not answer that on anyone else\'s behalf. The hooks work');
690
+ console.log(` either way; ${c.bold('npx agentmash doctor')} says what a machine is missing.\n`);
691
+ console.log(' The git post-commit client is local to this checkout — .git/hooks is not');
692
+ console.log(` committed — so each teammate runs ${c.bold('npx agentmash git-hook install')} once.\n`);
693
+ console.log(c.dim(' (Already have Claude Code open? It picks up new hooks on its own,\n') +
694
+ c.dim(' but restarting the session is the sure thing.)\n'));
695
+ }
696
+
697
+ /**
698
+ * Open a link in the person's browser, when there is a person: stdout is a
699
+ * terminal, this is not CI, and they did not say --no-open. The platform's
700
+ * own opener, detached, so a browser that takes its time never holds the
701
+ * command open. Returns whether anything was launched.
702
+ */
703
+ function openInBrowser(url, args = {}) {
704
+ if (args.noOpen || !process.stdout.isTTY || process.env.CI) return false;
705
+ const [cmd, cmdArgs] =
706
+ process.platform === 'win32'
707
+ ? ['cmd', ['/c', 'start', '', url.replace(/&/g, '^&')]]
708
+ : process.platform === 'darwin'
709
+ ? ['open', [url]]
710
+ : ['xdg-open', [url]];
711
+ try {
712
+ const child = spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore' });
713
+ child.on('error', () => {});
714
+ child.unref();
715
+ return true;
716
+ } catch {
717
+ return false;
718
+ }
719
+ }
720
+
721
+ /** Two lines, for someone who typed `npx agentmash` in a repo that is not wired up. */
722
+ function nudge() {
723
+ console.log(`\n This repo is not connected yet. ${c.bold('npx agentmash init')} wires it up in one command.`);
724
+ console.log(` Want to see it first? ${c.bold('npx agentmash demo')} opens a made-up team. ${c.dim('(--help for everything else)')}\n`);
725
+ }
726
+
727
+ // ── commands ───────────────────────────────────────────────────────────────
728
+
729
+ async function cmdInit(args) {
730
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot();
731
+ if (!repo) die('not inside a git repository — run this in your team\'s repo, or pass --repo <path>');
732
+ assertSettingsUsable(repo);
733
+ const server = (args.server || DEFAULT_SERVER).replace(/\/+$/, '');
734
+
735
+ const existing = readConfig(repo);
736
+ if (existing?.room && !args.force) {
737
+ console.log(`\n${c.yellow('This repo is already connected.')}`);
738
+ info(`room ${c.cyan(existing.room)} on ${existing.url}`);
739
+ info(`dashboard ${c.cyan(`${existing.url}/r/${existing.room}`)}`);
740
+ console.log(`\n Re-run with ${c.bold('--force')} to create a new room, or use ${c.bold('agentmash join <id>')}.\n`);
741
+ return;
742
+ }
743
+
744
+ const label = args.label || path.basename(repo);
745
+ console.log(`\ncreating a room on ${c.dim(server)} …`);
746
+ const res = await api('POST', `${server}/rooms`, { body: { label } });
747
+ if (!res.ok) {
748
+ if (res.status === 0) {
749
+ die(
750
+ `could not reach ${server}\n ${c.dim(res.text)}\n\n` +
751
+ ` Is the server deployed? Point somewhere else with --server <url>,\n` +
752
+ ` or run one locally: npm run dev:server (then --server http://localhost:8787)`
753
+ );
754
+ }
755
+ die(`server refused to create a room (HTTP ${res.status}) ${res.json?.error ?? ''}`);
756
+ }
757
+
758
+ const config = { url: res.json.server_url || server, room: res.json.room, label };
759
+ // Older servers don't have refs; the field is simply absent then.
760
+ if (res.json.room_ref) config.room_ref = res.json.room_ref;
761
+ const budget = await measuredBudget(config.url);
762
+ if (budget) config.timeout_ms = budget;
763
+ const result = wire(repo, config, { gitHook: !args.noGitHook, forceHooksPath: args.forceHooksPath });
764
+ printJoined(repo, config, result);
765
+ if (budget) {
766
+ info(`this server is ${budget}ms away, so the advisory budget was set to match`);
767
+ }
768
+ if (openInBrowser(`${config.url}/r/${config.room}`, args)) info('opened the dashboard in your browser (--no-open to skip)\n');
769
+ }
770
+
771
+ async function cmdDemo(args) {
772
+ const server = (args.server || DEFAULT_SERVER).replace(/\/+$/, '');
773
+ const url = `${server}/demo`;
774
+ console.log(`\n ${c.bold('Demo')} ${c.cyan(url)}\n`);
775
+ console.log(' A made-up team of four on a made-up repo, so you can see the room move');
776
+ console.log(` before there is anyone in yours. Nothing you do there leaves the page.\n`);
777
+ if (openInBrowser(url, args)) info('opened it in your browser (--no-open to skip)\n');
778
+ }
779
+
780
+ /**
781
+ * The derivation lives in hooks/lib.mjs next to the constants it is derived
782
+ * from, so that changing one cannot quietly leave the other behind. Loaded from
783
+ * whichever copy of the hooks this invocation would install.
784
+ *
785
+ * Returns null rather than dying if the hooks cannot be loaded: `doctor` is the
786
+ * command you run when the install is broken, and it has better things to say
787
+ * about that than a stack trace about a timeout formula.
788
+ */
789
+ async function budgetMath() {
790
+ try {
791
+ return await import(pathToFileURL(path.join(hookSourceDir(), 'lib.mjs')).href);
792
+ } catch {
793
+ return null;
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Size the advisory timeout to the network actually in front of us. Each hook
799
+ * is a fresh process paying TCP + TLS setup, so a distant server needs a
800
+ * bigger budget than a local one or its warnings never arrive.
801
+ */
802
+ async function measuredBudget(url) {
803
+ await api('GET', `${url}/healthz`); // warm: ignore idle wake-up cost
804
+ const probe = await api('GET', `${url}/healthz`);
805
+ if (!probe.ok) return null;
806
+ const math = await budgetMath();
807
+ if (!math) return null;
808
+ const budget = math.budgetForRoundTrip(probe.ms);
809
+ return budget > 1500 ? budget : null; // the default is already comfortable
810
+ }
811
+
812
+ async function cmdJoin(args) {
813
+ const target = args._[0];
814
+ if (!target) die('usage: agentmash join <room-id | dashboard-url>');
815
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot();
816
+ if (!repo) die('not inside a git repository — run this in your team\'s repo, or pass --repo <path>');
817
+ assertSettingsUsable(repo);
818
+
819
+ // Accept a bare room id or a full dashboard URL.
820
+ let room = target;
821
+ let server = args.server || DEFAULT_SERVER;
822
+ const asUrl = target.match(/^(https?:\/\/[^/]+)\/r\/(mash_[A-Za-z0-9_-]+)/);
823
+ if (asUrl) {
824
+ server = asUrl[1];
825
+ room = asUrl[2];
826
+ }
827
+ server = server.replace(/\/+$/, '');
828
+ if (!/^mash_[A-Za-z0-9_-]{16,64}$/.test(room)) die(`"${target}" does not look like a room id`);
829
+
830
+ const res = await lookupRoom(server, room);
831
+ if (res.status === 404) {
832
+ die(
833
+ res.json?.deleted
834
+ ? `that room was deleted on ${server}`
835
+ : res.json?.rotated
836
+ ? `that room key has been rotated or revoked on ${server} — ask for the current one`
837
+ : `room ${room} does not exist on ${server}`
838
+ );
839
+ }
840
+ if (!res.ok && res.status === 0) die(`could not reach ${server}\n ${c.dim(res.text)}`);
841
+
842
+ const config = { url: server, room, label: res.json?.label ?? null };
843
+ if (res.json?.room_ref) config.room_ref = res.json.room_ref;
844
+ printJoined(
845
+ repo,
846
+ config,
847
+ wire(repo, config, { gitHook: !args.noGitHook, forceHooksPath: args.forceHooksPath })
848
+ );
849
+ if (openInBrowser(`${config.url}/r/${config.room}`, args)) info('opened the dashboard in your browser (--no-open to skip)\n');
850
+ }
851
+
852
+ function identity(repo) {
853
+ if (process.env.AGENTMASH_DEV) return { who: process.env.AGENTMASH_DEV, from: 'AGENTMASH_DEV' };
854
+ try {
855
+ const email = execFileSync('git', ['config', 'user.email'], {
856
+ cwd: repo,
857
+ timeout: 1500,
858
+ stdio: ['ignore', 'pipe', 'ignore'],
859
+ })
860
+ .toString()
861
+ .trim();
862
+ if (email) return { who: email, from: 'git config user.email' };
863
+ } catch {
864
+ /* fall through */
865
+ }
866
+ return { who: os.userInfo?.().username ?? 'unknown', from: 'OS username (fallback)' };
867
+ }
868
+
869
+ /**
870
+ * Expand `${VAR}` and `${VAR:-default}` the way Claude Code expands them in
871
+ * .mcp.json. An unset variable with no default is left in the string verbatim —
872
+ * that is the documented behaviour, and it is how a registration turns into a
873
+ * server that never starts.
874
+ */
875
+ function expandMcpValue(value, env) {
876
+ return String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (whole, name, fallback) => {
877
+ const set = env[name];
878
+ // Shell semantics, which is what the syntax borrows: `${VAR}` expands to
879
+ // whatever VAR holds and is left verbatim when it is unset, while `:-`
880
+ // falls back for unset *and* empty.
881
+ if (fallback === undefined) return set === undefined ? whole : set;
882
+ return set === undefined || set === '' ? fallback : set;
883
+ });
884
+ }
885
+
886
+ /**
887
+ * The command Claude Code will actually run for our server, read out of
888
+ * .mcp.json and expanded the way Claude Code expands it.
889
+ *
890
+ * CLAUDE_PROJECT_DIR is deliberately removed before expanding: Claude Code sets
891
+ * it in the spawned server's environment, not in its own, so at expansion time
892
+ * it is unset and `${CLAUDE_PROJECT_DIR:-.}` resolves through its default. A
893
+ * doctor that expanded with the variable set would prove a path no teammate
894
+ * takes. Returns null when nothing is registered under our name.
895
+ */
896
+ function mcpLaunchCommand(repo) {
897
+ let entry;
898
+ try {
899
+ entry = JSON.parse(fs.readFileSync(mcpJsonPath(repo), 'utf8'))?.mcpServers?.[MCP_SERVER_NAME];
900
+ } catch {
901
+ return null;
902
+ }
903
+ if (!entry || typeof entry.command !== 'string') return null;
904
+ const env = { ...process.env };
905
+ delete env.CLAUDE_PROJECT_DIR;
906
+ return {
907
+ command: expandMcpValue(entry.command, env),
908
+ args: (Array.isArray(entry.args) ? entry.args : []).map((arg) => expandMcpValue(arg, env)),
909
+ };
910
+ }
911
+
912
+ /**
913
+ * Start the MCP server exactly as Claude Code starts it — the command and args
914
+ * out of .mcp.json, expanded, spawned from the repo root with
915
+ * CLAUDE_PROJECT_DIR in the child's environment — and ask it for its tools.
916
+ *
917
+ * Hand-rolled rather than using the MCP SDK, because the SDK is precisely what
918
+ * may be missing on the machine doctor is being run on. The protocol here is
919
+ * three messages of newline-delimited JSON.
920
+ */
921
+ function probeMcpServer(repo, launch, timeoutMs = 10_000) {
922
+ return new Promise((resolve) => {
923
+ const started = Date.now();
924
+ const child = spawn(launch.command, launch.args, {
925
+ cwd: repo,
926
+ env: { ...process.env, CLAUDE_PROJECT_DIR: repo },
927
+ stdio: ['pipe', 'pipe', 'pipe'],
928
+ });
929
+ let buffer = '';
930
+ let stderr = '';
931
+ let serverInfo = null;
932
+ let done = false;
933
+
934
+ const finish = (result) => {
935
+ if (done) return;
936
+ done = true;
937
+ clearTimeout(timer);
938
+ child.kill();
939
+ resolve({ ...result, ms: Date.now() - started, stderr: stderr.trim() });
940
+ };
941
+ const timer = setTimeout(
942
+ () => finish({ ok: false, error: `no answer within ${timeoutMs}ms` }),
943
+ timeoutMs
944
+ );
945
+ const send = (payload) => {
946
+ try {
947
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...payload }) + '\n');
948
+ } catch {
949
+ /* the exit handler reports it */
950
+ }
951
+ };
952
+
953
+ child.on('error', (err) => finish({ ok: false, error: String(err?.message || err) }));
954
+ child.on('exit', (code) => finish({ ok: false, error: `exited ${code} without answering` }));
955
+ child.stderr.on('data', (chunk) => (stderr += chunk));
956
+ child.stdout.on('data', (chunk) => {
957
+ buffer += chunk;
958
+ let index;
959
+ while ((index = buffer.indexOf('\n')) !== -1) {
960
+ const line = buffer.slice(0, index).trim();
961
+ buffer = buffer.slice(index + 1);
962
+ if (!line) continue;
963
+ let message;
964
+ try {
965
+ message = JSON.parse(line);
966
+ } catch {
967
+ continue;
968
+ }
969
+ if (message.id === 1 && message.result) {
970
+ serverInfo = message.result.serverInfo ?? {};
971
+ send({ method: 'notifications/initialized' });
972
+ send({ id: 2, method: 'tools/list', params: {} });
973
+ } else if (message.id === 2 && message.result) {
974
+ finish({
975
+ ok: true,
976
+ // The launcher's fallback server answers, but only to say it cannot.
977
+ degraded: String(serverInfo?.version ?? '').endsWith('-unavailable'),
978
+ tools: message.result.tools ?? [],
979
+ serverInfo,
980
+ });
981
+ } else if (message.error) {
982
+ finish({ ok: false, error: message.error.message ?? 'JSON-RPC error' });
983
+ }
984
+ }
985
+ });
986
+
987
+ send({
988
+ id: 1,
989
+ method: 'initialize',
990
+ params: {
991
+ protocolVersion: '2025-06-18',
992
+ capabilities: {},
993
+ clientInfo: { name: 'agentmash-doctor', version: '0.2.0' },
994
+ },
995
+ });
996
+ });
997
+ }
998
+
999
+ /** Run the MCP server on stdio. This is what .mcp.json reaches through npx. */
1000
+ async function cmdMcp() {
1001
+ for (const candidate of [path.join(HERE, 'mcp', 'server.mjs'), path.join(HERE, '..', 'mcp', 'server.mjs')]) {
1002
+ if (!fs.existsSync(candidate)) continue;
1003
+ const module = await import(pathToFileURL(candidate).href);
1004
+ await module.start();
1005
+ return;
1006
+ }
1007
+ die('the MCP server is missing from this package');
1008
+ }
1009
+
1010
+ async function cmdDoctor(args) {
1011
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1012
+ console.log(`\n${c.bold('AgentMash doctor')} ${c.dim(repo)}\n`);
1013
+ let problems = 0;
1014
+
1015
+ const major = Number(process.versions.node.split('.')[0]);
1016
+ if (major >= 20) ok(`node ${process.versions.node}`);
1017
+ else if (major >= 18) warn(`node ${process.versions.node} — works, but 20+ recommended`);
1018
+ else {
1019
+ bad(`node ${process.versions.node} — too old; hooks need 18+ for built-in fetch`);
1020
+ problems++;
1021
+ }
1022
+
1023
+ if (findRepoRoot(repo)) ok('inside a git repository');
1024
+ else {
1025
+ warn('not a git repository — identity falls back to your OS username');
1026
+ }
1027
+
1028
+ const installed = HOOK_FILES.filter((f) => fs.existsSync(path.join(meshDir(repo), f)));
1029
+ if (installed.length === HOOK_FILES.length) ok(`hook scripts present (${installed.length})`);
1030
+ else {
1031
+ bad(`hook scripts missing from .claude/agentmash (${installed.length}/${HOOK_FILES.length}) — run: agentmash init`);
1032
+ problems++;
1033
+ }
1034
+
1035
+ let registered = 0;
1036
+ try {
1037
+ const settings = JSON.parse(fs.readFileSync(settingsPath(repo), 'utf8'));
1038
+ for (const event of Object.keys(HOOK_ENTRIES)) {
1039
+ if (JSON.stringify(settings?.hooks?.[event] ?? []).includes('.claude/agentmash/')) registered++;
1040
+ }
1041
+ } catch {
1042
+ /* handled below */
1043
+ }
1044
+ const eventCount = Object.keys(HOOK_ENTRIES).length;
1045
+ if (registered === eventCount) ok(`hooks registered in .claude/settings.json (${eventCount} events)`);
1046
+ else {
1047
+ bad(`only ${registered}/${eventCount} hook events registered in settings.json — run: agentmash init`);
1048
+ problems++;
1049
+ }
1050
+
1051
+ const launcher = path.join(meshDir(repo), MCP_LAUNCHER);
1052
+ if (fs.existsSync(launcher)) ok(`MCP launcher present ${c.dim(`.claude/agentmash/${MCP_LAUNCHER}`)}`);
1053
+ else {
1054
+ bad(`${MCP_LAUNCHER} missing from .claude/agentmash — run: agentmash init`);
1055
+ problems++;
1056
+ }
1057
+
1058
+ let mcpRegistered = false;
1059
+ try {
1060
+ const mcpJson = JSON.parse(fs.readFileSync(mcpJsonPath(repo), 'utf8'));
1061
+ mcpRegistered =
1062
+ JSON.stringify(mcpJson?.mcpServers?.[MCP_SERVER_NAME]) === JSON.stringify(MCP_SERVER_ENTRY);
1063
+ } catch {
1064
+ /* reported below */
1065
+ }
1066
+ if (mcpRegistered) ok('MCP server registered in .mcp.json');
1067
+ else {
1068
+ bad('.mcp.json does not register the agentmash MCP server — run: agentmash init');
1069
+ problems++;
1070
+ }
1071
+
1072
+ // Approval is a statement about this machine, not a fault in the install:
1073
+ // AgentMash no longer writes it, because whether to run a server a repo
1074
+ // brought with it belongs to whoever owns the machine.
1075
+ let approved = false;
1076
+ try {
1077
+ const settings = JSON.parse(fs.readFileSync(settingsPath(repo), 'utf8'));
1078
+ approved =
1079
+ settings?.enableAllProjectMcpServers === true ||
1080
+ (Array.isArray(settings?.enabledMcpjsonServers) &&
1081
+ settings.enabledMcpjsonServers.includes(MCP_SERVER_NAME));
1082
+ } catch {
1083
+ /* the file may not exist yet; the hook checks above already said so */
1084
+ }
1085
+ if (approved)
1086
+ info(
1087
+ `this repo's settings already approve the MCP server ${c.dim('enabledMcpjsonServers')}\n` +
1088
+ ' Claude Code honours that only once you have trusted this folder yourself.'
1089
+ );
1090
+ else
1091
+ info(
1092
+ 'Claude Code will ask you once to approve the agentmash MCP server in .mcp.json.\n' +
1093
+ ' Until you do, its tools are absent from your sessions — the hooks are unaffected.'
1094
+ );
1095
+
1096
+ const launch = mcpLaunchCommand(repo);
1097
+ if (launch) {
1098
+ const shown = [launch.command, ...launch.args].join(' ');
1099
+ // A cold `npx` fetch is the only thing here that can take tens of seconds,
1100
+ // and only a machine that opted into it pays that.
1101
+ const probe = await probeMcpServer(repo, launch, process.env.AGENTMASH_MCP_NPX === '1' ? 30_000 : 10_000);
1102
+ if (probe.ok && !probe.degraded) {
1103
+ ok(`the command in .mcp.json starts and answers tools/list ${c.dim(`${probe.tools.length} tools · ${probe.ms}ms`)}`);
1104
+ info(c.dim(shown));
1105
+ if (probe.ms > 5000) info('a first run downloads the package; later starts are fast');
1106
+ } else if (probe.ok) {
1107
+ bad(
1108
+ 'MCP server started in degraded mode — its tools answer "unavailable" instead of reporting\n' +
1109
+ ` who is working. ${c.dim(probe.stderr.split('\n').pop() || '')}\n` +
1110
+ ' Fix: install agentmash in this repo (npm install -D agentmash), set\n' +
1111
+ ' AGENTMASH_MCP_ENTRY to an AgentMash checkout, or set AGENTMASH_MCP_NPX=1\n' +
1112
+ ' to let this machine fetch the package from npm itself.'
1113
+ );
1114
+ problems++;
1115
+ } else {
1116
+ bad(
1117
+ `the command in .mcp.json did not start ${c.dim(probe.error)}\n` +
1118
+ ` ${c.dim(shown)}\n` +
1119
+ ' That is the command Claude Code runs, expanded the way Claude Code expands it.\n' +
1120
+ ' While it fails, every session has no AgentMash tools at all — run: agentmash init'
1121
+ );
1122
+ problems++;
1123
+ }
1124
+ }
1125
+
1126
+ const conn = connection(repo);
1127
+ const { config, url, room } = conn;
1128
+ if (config) ok(`config.json found ${c.dim(`${config.url} · ${config.room}`)}`);
1129
+ else {
1130
+ bad('no .claude/agentmash/config.json — run: agentmash init (or join <id>)');
1131
+ problems++;
1132
+ }
1133
+ if (process.env.AGENTMASH_URL) info(`AGENTMASH_URL env overrides config → ${process.env.AGENTMASH_URL}`);
1134
+ if (process.env.AGENTMASH_ROOM) info(`AGENTMASH_ROOM env overrides config → ${process.env.AGENTMASH_ROOM}`);
1135
+ if (process.env.AGENTMASH_DISABLE === '1') warn('AGENTMASH_DISABLE=1 — hooks are no-ops right now');
1136
+ if (process.env.AGENTMASH_STRICT === '1') info('AGENTMASH_STRICT=1 — conflicting edits will be blocked, not advised');
1137
+
1138
+ // A warning, never a problem: the git client is optional, and .git/hooks is
1139
+ // not committed, so a teammate who only pulled will always be missing it.
1140
+ const hooksInfo = resolveHooksDir(repo);
1141
+ const gitHookFile = path.join(hooksInfo.dir, 'post-commit');
1142
+ const hookKind = pathKind(gitHookFile);
1143
+ if (isOurGitHook(gitHookFile)) {
1144
+ const chained = pathKind(chainedHookPath(repo)) === 'file' ? ' (chained after post-commit.local)' : '';
1145
+ ok(`git post-commit client installed${chained} ${c.dim(path.relative(repo, gitHookFile))}`);
1146
+ // Installed is not the same as working: hooks run under a non-login shell
1147
+ // that never reads a profile, so a node from a version manager is not on
1148
+ // PATH when git is driven from a GUI client.
1149
+ const nodeCheck = workerCanRun(repo);
1150
+ if (!nodeCheck.ok) warn(nodeCheck.message);
1151
+ } else if (hookKind !== 'missing' && hookKind !== 'file') {
1152
+ warn(`${gitHookFile} is a ${hookKind}, not a hook — the git client is not installed here`);
1153
+ } else if (hookKind === 'file') {
1154
+ warn('a post-commit hook is installed but is not ours — run: agentmash git-hook install');
1155
+ } else if (hooksInfo.insideWorkTree) {
1156
+ warn(`no git post-commit client: this repo runs hooks from ${path.relative(repo, hooksInfo.dir)}/ (core.hooksPath),`);
1157
+ info('which is inside the working tree — installing there would commit AgentMash to the team.');
1158
+ info(`run ${c.bold('agentmash git-hook install --force-hooks-path')} only if that is what you want`);
1159
+ } else {
1160
+ warn('no git post-commit client — teammates not using Claude Code stay invisible');
1161
+ info(`install it with ${c.bold('agentmash git-hook install')} (local to this checkout)`);
1162
+ }
1163
+
1164
+ const me = identity(repo);
1165
+ ok(`you are ${c.cyan(me.who)} ${c.dim(`(${me.from})`)}`);
1166
+
1167
+ if (!url || !room) {
1168
+ console.log(`\n${c.red('Not connected')} — fix the items above, then re-run doctor.\n`);
1169
+ // exitCode, not exit(): tearing the process down while a socket is still
1170
+ // closing crashes Node on Windows (0xC0000409), which reads as a CLI bug.
1171
+ process.exitCode = 1;
1172
+ return;
1173
+ }
1174
+
1175
+ // Measure a second time: the first call to an idle server pays wake-up cost,
1176
+ // and reporting what a steady-state hook will see is more useful.
1177
+ const first = await api('GET', `${url}/healthz`);
1178
+ const health = first.ok ? await api('GET', `${url}/healthz`) : first;
1179
+ if (health.ok) ok(`server reachable ${c.dim(`${url} · ${health.ms}ms`)}`);
1180
+ else {
1181
+ bad(`cannot reach ${url} ${c.dim(health.text || `HTTP ${health.status}`)}`);
1182
+ problems++;
1183
+ }
1184
+
1185
+ const budget = Number(process.env.AGENTMASH_TIMEOUT_MS || config?.timeout_ms) || 1500;
1186
+ // What a hook pays is not this round trip. `agentmash doctor` runs in one warm
1187
+ // process; every hook is a cold one that pays TCP, TLS and the request before
1188
+ // an answer can arrive. Comparing the raw round trip against the budget is how
1189
+ // a config that drops every advisory passes a health check — so compare
1190
+ // against what a cold process actually needs instead.
1191
+ const math = await budgetMath();
1192
+ const cold = health.ok && math ? math.coldCheckCostMs(health.ms) : 0;
1193
+ if (cold > budget) {
1194
+ bad(
1195
+ `round trip ${health.ms}ms costs a cold hook about ${Math.round(cold)}ms, over the ${budget}ms advisory\n` +
1196
+ ` budget — conflict warnings will be silently skipped. Raise it in\n` +
1197
+ ` .claude/agentmash/config.json: "timeout_ms": ${math.budgetForRoundTrip(health.ms)}`
1198
+ );
1199
+ problems++;
1200
+ } else if (cold > budget * 0.7) {
1201
+ warn(
1202
+ `round trip ${health.ms}ms costs a cold hook about ${Math.round(cold)}ms, close to the ${budget}ms ` +
1203
+ `advisory budget — raise "timeout_ms" in config.json if warnings go missing`
1204
+ );
1205
+ }
1206
+
1207
+ if (health.ok) {
1208
+ const roomRes = await lookupRoom(url, room, conn.token);
1209
+ if (roomRes.ok) {
1210
+ ok(`room valid ${c.cyan(room)}${roomRes.json?.label ? c.dim(` · ${roomRes.json.label}`) : ''}`);
1211
+ if (roomRes.json?.key_expires_at) {
1212
+ warn(
1213
+ `this room key expires ${roomRes.json.key_expires_at} — it has been rotated. ` +
1214
+ `Pull the repo to pick up the new one.`
1215
+ );
1216
+ }
1217
+ const reconcile = roomRes.json?.reconcile_mode ?? 'approve';
1218
+ info(
1219
+ `reconciliation ${reconcile}: ${
1220
+ reconcile === 'approve'
1221
+ ? 'agents open a PR for collisions they settle'
1222
+ : reconcile === 'auto'
1223
+ ? 'agents commit reconciled collisions directly'
1224
+ : 'agents are never stopped to reconcile'
1225
+ }`
1226
+ );
1227
+ info(
1228
+ roomRes.json?.stream_diffs === true
1229
+ ? 'diff streaming on: edits carry their changed text to the live view (memory only on the server)'
1230
+ : 'diff streaming off: the live view shows presence and summaries, never code — `agentmash diffs on` to change'
1231
+ );
1232
+ const mode = roomRes.json?.token_mode ?? 'off';
1233
+ if (mode === 'off') info('this room accepts any writer holding the room key (token_mode off)');
1234
+ else if (conn.token) ok(`developer token present ${c.dim(`(token_mode ${mode})`)}`);
1235
+ else {
1236
+ bad(`this room requires a developer token (token_mode ${mode}) — run: agentmash login`);
1237
+ problems++;
1238
+ }
1239
+ } else if (roomRes.status === 404) {
1240
+ bad(
1241
+ roomRes.json?.rotated
1242
+ ? `this room key has been rotated or revoked — pull the repo for the new one`
1243
+ : roomRes.json?.deleted
1244
+ ? `this room was deleted — its audit trail is all that is left: npx agentmash audit`
1245
+ : `this room does not exist on this server — it may have been reset`
1246
+ );
1247
+ problems++;
1248
+ } else {
1249
+ bad(`room check failed (HTTP ${roomRes.status})`);
1250
+ problems++;
1251
+ }
1252
+
1253
+ const activity = await api('GET', `${url}/activity`, { room, token: conn.token });
1254
+ if (activity.ok) {
1255
+ const sessions = activity.json?.sessions ?? [];
1256
+ const others = sessions.filter((s) => s.developer !== me.who);
1257
+ ok(`activity readable ${c.dim(`${sessions.length} session(s) in the last hour`)}`);
1258
+ if (others.length === 0) info('no teammates seen yet in this room');
1259
+ else for (const s of others) info(`teammate: ${c.cyan(s.developer)} ${c.dim(s.last_touched)}`);
1260
+ }
1261
+ }
1262
+
1263
+ console.log(
1264
+ problems === 0
1265
+ ? `\n${c.green('All good.')} Your agent's edits are being shared with this room.\n`
1266
+ : `\n${c.red(`${problems} problem(s) found.`)} Fix the ✗ lines above.\n`
1267
+ );
1268
+ if (problems > 0) process.exitCode = 1;
1269
+ }
1270
+
1271
+ const PRESENCE_TAG = {
1272
+ active: 'working',
1273
+ idle: 'idle',
1274
+ stale: 'no signal',
1275
+ ended: 'session ended',
1276
+ };
1277
+
1278
+ async function cmdStatus(args) {
1279
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1280
+ const conn = connection(repo);
1281
+ const { url, room } = conn;
1282
+ if (!url || !room) die('this repo is not connected — run: agentmash init');
1283
+
1284
+ const res = await api('GET', `${url}/activity`, { room, token: conn.token });
1285
+ if (!res.ok) die(`could not read activity from ${url} ${c.dim(res.text || `HTTP ${res.status}`)}`);
1286
+
1287
+ const { sessions = [], hot_files = [] } = res.json ?? {};
1288
+ console.log(`\n${c.bold('AgentMash')} ${c.dim(`· room ${room}`)}\n`);
1289
+ if (sessions.length === 0) console.log(c.dim(' nobody active in the last hour\n'));
1290
+ for (const s of sessions) {
1291
+ // Presence, when the server is new enough to send it: a name with no state
1292
+ // beside it reads as "here", which is the thing this board must stop implying.
1293
+ const mins = Math.round((Date.now() - (s.last_seen_ts || Date.parse(s.last_touched))) / 60000);
1294
+ const state = PRESENCE_TAG[s.presence] ?? '';
1295
+ console.log(
1296
+ ` ${c.cyan(c.bold(s.developer))} ${c.dim(`· ${state ? `${state} · ` : ''}${mins}m ago${s.git_branch ? ` · ${s.git_branch}` : ''}`)}`
1297
+ );
1298
+ if (s.task_hint) console.log(` ${s.task_hint}`);
1299
+ for (const f of s.files.slice(0, 5)) console.log(c.dim(` ${f.file_path}`));
1300
+ console.log('');
1301
+ }
1302
+ if (hot_files.length) {
1303
+ console.log(` ${c.bold(c.red('hot files'))} ${c.dim('· touched by 2+ people')}`);
1304
+ for (const h of hot_files) console.log(` ${c.red(h.file_path)} ${c.dim(h.developers.join(', '))}`);
1305
+ console.log('');
1306
+ }
1307
+ console.log(c.dim(` dashboard: ${url}/r/${room}\n`));
1308
+ }
1309
+
1310
+ function cmdUninstall(args) {
1311
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1312
+ const file = settingsPath(repo);
1313
+ if (fs.existsSync(file)) {
1314
+ try {
1315
+ const settings = JSON.parse(fs.readFileSync(file, 'utf8'));
1316
+ for (const event of Object.keys(HOOK_ENTRIES)) {
1317
+ if (!Array.isArray(settings?.hooks?.[event])) continue;
1318
+ settings.hooks[event] = settings.hooks[event].filter(
1319
+ (g) => !JSON.stringify(g).includes('.claude/agentmash/')
1320
+ );
1321
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
1322
+ }
1323
+ // `init` no longer writes this, but an install from an earlier version
1324
+ // did, and leaving an approval behind for a server that is being removed
1325
+ // would outlive the thing it approves.
1326
+ if (Array.isArray(settings.enabledMcpjsonServers)) {
1327
+ settings.enabledMcpjsonServers = settings.enabledMcpjsonServers.filter(
1328
+ (name) => name !== MCP_SERVER_NAME
1329
+ );
1330
+ if (settings.enabledMcpjsonServers.length === 0) delete settings.enabledMcpjsonServers;
1331
+ }
1332
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
1333
+ } catch {
1334
+ warn('could not clean settings.json — remove the agentmash entries by hand');
1335
+ }
1336
+ }
1337
+ removeMcpEntry(repo);
1338
+ const git = uninstallGitHook(repo);
1339
+ fs.rmSync(meshDir(repo), { recursive: true, force: true });
1340
+ console.log(`\n${c.bold('AgentMash removed')} from ${repo}\n`);
1341
+ if (git.ok) info(git.message);
1342
+ else warn(git.message);
1343
+ info('your teammates still have it until they pull this change\n');
1344
+ }
1345
+
1346
+ /**
1347
+ * Take our server out of .mcp.json and nothing else. The file is deleted only
1348
+ * when it is left holding nothing at all — a team that registered its own MCP
1349
+ * servers there keeps them, and keeps the file.
1350
+ */
1351
+ function removeMcpEntry(repo) {
1352
+ const file = mcpJsonPath(repo);
1353
+ if (!fs.existsSync(file)) return;
1354
+ let config;
1355
+ try {
1356
+ config = JSON.parse(fs.readFileSync(file, 'utf8'));
1357
+ } catch {
1358
+ warn('could not clean .mcp.json — remove the agentmash entry by hand');
1359
+ return;
1360
+ }
1361
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) return;
1362
+ if (config.mcpServers && typeof config.mcpServers === 'object') {
1363
+ delete config.mcpServers[MCP_SERVER_NAME];
1364
+ if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
1365
+ }
1366
+ if (Object.keys(config).length === 0) fs.rmSync(file, { force: true });
1367
+ else fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
1368
+ }
1369
+
1370
+ function cmdGitHook(args) {
1371
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot();
1372
+ if (!repo) die("not inside a git repository — run this in your team's repo, or pass --repo <path>");
1373
+ const action = args._[0] || 'install';
1374
+ if (!['install', 'uninstall', 'status'].includes(action)) {
1375
+ die('usage: agentmash git-hook [install|uninstall|status]');
1376
+ }
1377
+
1378
+ if (action === 'status') {
1379
+ const file = gitHookPath(repo);
1380
+ if (isOurGitHook(file)) ok(`installed ${c.dim(file)}`);
1381
+ else if (fs.existsSync(file)) warn(`a post-commit hook is installed but is not ours ${c.dim(file)}`);
1382
+ else info(`not installed ${c.dim(file)}`);
1383
+ return;
1384
+ }
1385
+
1386
+ if (action === 'uninstall') {
1387
+ const result = uninstallGitHook(repo);
1388
+ (result.ok ? ok : warn)(result.message);
1389
+ return;
1390
+ }
1391
+
1392
+ const worker = path.join(meshDir(repo), 'git_post_commit.mjs');
1393
+ if (!fs.existsSync(worker)) {
1394
+ // The shim is inert without the worker beside it, so put it there first.
1395
+ copyHooks(repo);
1396
+ }
1397
+ if (!fs.existsSync(worker)) {
1398
+ // Only reachable from a broken install of the CLI itself, and worth saying:
1399
+ // an inert hook that reports nothing is exactly what nobody notices.
1400
+ warn('the worker script is missing from this agentmash install — the hook would report nothing');
1401
+ return;
1402
+ }
1403
+ const result = installGitHook(repo, { forceHooksPath: args.forceHooksPath });
1404
+ (result.ok ? ok : warn)(result.message);
1405
+ if (result.ok && !readConfig(repo)) {
1406
+ info('this repo has no room yet — run: agentmash init (or join <id>)');
1407
+ }
1408
+ }
1409
+
1410
+ // ── security commands ──────────────────────────────────────────────────────
1411
+
1412
+ function requireConnection(repo) {
1413
+ const conn = connection(repo);
1414
+ if (!conn.url || !conn.room) die('this repo is not connected — run: agentmash init');
1415
+ return conn;
1416
+ }
1417
+
1418
+ /** Every one of these needs a server that knows the endpoint; say so plainly. */
1419
+ function assertSupported(res, what) {
1420
+ // These routes never 404 for a live room, so a 404 means the route is absent.
1421
+ if (res.status === 404) {
1422
+ die(`this server is too old to ${what} — upgrade the coordination server first`);
1423
+ }
1424
+ if (res.status === 0) die(`could not reach the server\n ${c.dim(res.text)}`);
1425
+ }
1426
+
1427
+ async function cmdRotate(args) {
1428
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1429
+ const conn = requireConnection(repo);
1430
+ const grace = args['grace-minutes'] !== undefined ? Number(args['grace-minutes']) : undefined;
1431
+
1432
+ const res = await api('POST', `${conn.url}/rooms/rotate`, {
1433
+ room: conn.room,
1434
+ token: conn.token,
1435
+ body: grace === undefined ? {} : { grace_minutes: grace },
1436
+ });
1437
+ assertSupported(res, 'rotate room keys');
1438
+ if (!res.ok) die(`rotation refused (HTTP ${res.status}) ${res.json?.error ?? ''}`);
1439
+
1440
+ const config = { ...(conn.config ?? {}), url: conn.url, room: res.json.room };
1441
+ if (res.json.room_ref) config.room_ref = res.json.room_ref;
1442
+ writeConfig(repo, config);
1443
+
1444
+ console.log(`\n${c.bold('Room key rotated.')}\n`);
1445
+ ok(`new room key written to ${c.dim('.claude/agentmash/config.json')}`);
1446
+ ok(`the old key keeps working until ${c.cyan(res.json.previous_expires_at)} ${c.dim(`(${res.json.grace_minutes} min grace)`)}`);
1447
+ console.log(`\n ${c.bold('Commit and push — that is what hands the new key to your team:')}`);
1448
+ console.log(c.dim(' git add .claude && git commit -m "Rotate the agentmash room key" && git push\n'));
1449
+ console.log(' Teammates just pull, as usual. Anyone who has not pulled by the end of');
1450
+ console.log(' the grace period stops reporting — silently, as hooks always do — until');
1451
+ console.log(` they do. Cut the grace short with ${c.bold('agentmash revoke')}.\n`);
1452
+ }
1453
+
1454
+ async function cmdRevoke(args) {
1455
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1456
+ const conn = requireConnection(repo);
1457
+ const res = await api('POST', `${conn.url}/rooms/revoke`, { room: conn.room, token: conn.token });
1458
+ assertSupported(res, 'revoke room keys');
1459
+ if (!res.ok) die(`revocation refused (HTTP ${res.status}) ${res.json?.error ?? ''}`);
1460
+ console.log(`\n${c.bold('Revoked.')} ${res.json.revoked} old key(s) stopped working just now.`);
1461
+ console.log(` Anyone still on an old key is cut off until they pull.\n`);
1462
+ }
1463
+
1464
+ async function cmdLogin(args) {
1465
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1466
+ const conn = requireConnection(repo);
1467
+ const me = identity(repo);
1468
+
1469
+ let roomRef = conn.roomRef;
1470
+ if (!roomRef) {
1471
+ const lookup = await lookupRoom(conn.url, conn.room, conn.token);
1472
+ roomRef = lookup.json?.room_ref;
1473
+ if (!roomRef) die('this server is too old to issue developer tokens — upgrade it first');
1474
+ const config = { ...(conn.config ?? {}), room_ref: roomRef };
1475
+ writeConfig(repo, config);
1476
+ }
1477
+
1478
+ // The GitHub path proves who you are; the fallback only proves you were
1479
+ // first. Prefer the former wherever the server can do it.
1480
+ if (!args.local) {
1481
+ const started = await api('POST', `${conn.url}/auth/cli/start`, {
1482
+ room: conn.room,
1483
+ body: { alias: me.who },
1484
+ });
1485
+ assertSupported(started, 'sign in');
1486
+ if (started.ok) {
1487
+ console.log(`\n${c.bold('Sign in with GitHub')}\n`);
1488
+ info(`open ${c.cyan(started.json.verify_url)}`);
1489
+ info(`this terminal is waiting (code ${c.bold(started.json.code)})`);
1490
+ const deadline = Date.now() + 10 * 60_000;
1491
+ while (Date.now() < deadline) {
1492
+ await new Promise((r) => setTimeout(r, 2000));
1493
+ const polled = await api('POST', `${conn.url}/auth/cli/token`, { poll: started.json.poll_token });
1494
+ if (polled.status === 202) continue;
1495
+ if (!polled.ok) die(`sign-in failed (HTTP ${polled.status}) ${polled.json?.error ?? ''}`);
1496
+ writeToken(conn.url, roomRef, {
1497
+ token: polled.json.token,
1498
+ developer: polled.json.developer,
1499
+ alias: me.who,
1500
+ source: 'github',
1501
+ created_at: new Date().toISOString(),
1502
+ });
1503
+ console.log(`\n${c.bold('Signed in.')}`);
1504
+ ok(`events from this machine are now proven to be ${c.cyan(polled.json.developer)}`);
1505
+ ok(`token stored in ${c.dim(credentialsPath())} ${c.dim('(this machine only, never committed)')}\n`);
1506
+ return;
1507
+ }
1508
+ die('sign-in timed out — run agentmash login again');
1509
+ }
1510
+ if (started.status !== 503) die(`sign-in refused (HTTP ${started.status}) ${started.json?.error ?? ''}`);
1511
+ warn('this server has no GitHub app configured — falling back to a first-use claim');
1512
+ }
1513
+
1514
+ const developer = args.developer || me.who;
1515
+ const res = await api('POST', `${conn.url}/rooms/tokens`, {
1516
+ room: conn.room,
1517
+ body: { developer, alias: me.who },
1518
+ });
1519
+ assertSupported(res, 'issue developer tokens');
1520
+ if (res.status === 409) die(res.json?.error ?? 'this name is already enrolled from another machine');
1521
+ if (!res.ok) die(`could not issue a token (HTTP ${res.status}) ${res.json?.error ?? ''}`);
1522
+ writeToken(conn.url, roomRef, {
1523
+ token: res.json.token,
1524
+ developer,
1525
+ alias: me.who,
1526
+ source: 'room-key',
1527
+ created_at: new Date().toISOString(),
1528
+ });
1529
+ console.log(`\n${c.bold('Enrolled.')}`);
1530
+ ok(`${c.cyan(developer)} is now bound to this machine for this room`);
1531
+ ok(`token stored in ${c.dim(credentialsPath())} ${c.dim('(this machine only, never committed)')}`);
1532
+ info('this proves first use, not identity — a server with GitHub sign-in proves more\n');
1533
+ }
1534
+
1535
+ const RECONCILE_EXPLAINED = {
1536
+ approve:
1537
+ 'when an agent finishes having changed a file a teammate also changed, it reconciles and opens a pull request for a human to approve',
1538
+ auto: 'agents reconcile and commit the result directly — nobody reviews it; switch to this once the team trusts it',
1539
+ off: 'agents are never stopped to reconcile; collisions show on the board and nowhere else',
1540
+ };
1541
+
1542
+ async function cmdReconcile(args) {
1543
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1544
+ const conn = requireConnection(repo);
1545
+ const mode = args._[0];
1546
+ if (!mode) {
1547
+ const current = await api('GET', `${conn.url}/rooms/self`, { room: conn.room, token: conn.token });
1548
+ assertSupported(current, 'report room settings');
1549
+ if (!current.ok) die(`could not read room settings (HTTP ${current.status})`);
1550
+ const now = current.json.reconcile_mode ?? 'approve';
1551
+ console.log(`\n${c.bold('Reconciliation')} ${c.cyan(now)}\n`);
1552
+ info(`${RECONCILE_EXPLAINED[now]}\n`);
1553
+ info(`change it with: agentmash reconcile <approve|auto|off>\n`);
1554
+ return;
1555
+ }
1556
+ if (!Object.hasOwn(RECONCILE_EXPLAINED, mode)) die('usage: agentmash reconcile <approve|auto|off>');
1557
+ const res = await api('POST', `${conn.url}/rooms/settings`, {
1558
+ room: conn.room,
1559
+ token: conn.token,
1560
+ body: { reconcile_mode: mode },
1561
+ });
1562
+ assertSupported(res, 'change room settings');
1563
+ if (!res.ok) die(`could not change settings (HTTP ${res.status}) ${res.json?.error ?? ''}`);
1564
+ console.log(`\n${c.bold('Updated.')} reconciliation ${c.cyan(res.json.reconcile_mode)}\n`);
1565
+ info(`${RECONCILE_EXPLAINED[res.json.reconcile_mode]}\n`);
1566
+ }
1567
+
1568
+ async function cmdDiffs(args) {
1569
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1570
+ const conn = requireConnection(repo);
1571
+ const want = args._[0];
1572
+ if (want !== undefined && want !== 'on' && want !== 'off') die('usage: agentmash diffs <on|off>');
1573
+ const explain = (on) =>
1574
+ on
1575
+ ? 'edits carry the changed text to the live view; it is shown live and kept in memory only, never written to disk on the server'
1576
+ : 'the live view shows who is where and what changed in a sentence; the changed text itself stays on each machine';
1577
+ if (want === undefined) {
1578
+ const current = await api('GET', `${conn.url}/rooms/self`, { room: conn.room, token: conn.token });
1579
+ assertSupported(current, 'report room settings');
1580
+ if (!current.ok) die(`could not read room settings (HTTP ${current.status})`);
1581
+ const on = current.json.stream_diffs === true;
1582
+ console.log(`\n${c.bold('Diff streaming')} ${c.cyan(on ? 'on' : 'off')}\n`);
1583
+ info(`${explain(on)}\n`);
1584
+ info(`change it with: agentmash diffs <on|off> — any developer can still keep theirs home with AGENTMASH_DIFFS=0\n`);
1585
+ return;
1586
+ }
1587
+ const res = await api('POST', `${conn.url}/rooms/settings`, {
1588
+ room: conn.room,
1589
+ token: conn.token,
1590
+ body: { stream_diffs: want === 'on' },
1591
+ });
1592
+ assertSupported(res, 'change room settings');
1593
+ if (!res.ok) die(`could not change settings (HTTP ${res.status}) ${res.json?.error ?? ''}`);
1594
+ console.log(`\n${c.bold('Updated.')} diff streaming ${c.cyan(res.json.stream_diffs ? 'on' : 'off')}\n`);
1595
+ info(`${explain(res.json.stream_diffs === true)}\n`);
1596
+ if (res.json.stream_diffs) info('it takes effect for each agent at its next edit.\n');
1597
+ }
1598
+
1599
+ async function cmdSecure(args) {
1600
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1601
+ const conn = requireConnection(repo);
1602
+ const mode = args._[0];
1603
+ const body = {};
1604
+ if (mode) {
1605
+ if (!['off', 'any', 'github'].includes(mode)) die('usage: agentmash secure <off|any|github> [--retention-days N]');
1606
+ body.token_mode = mode;
1607
+ }
1608
+ if (args['retention-days'] !== undefined) {
1609
+ body.retention_days = args['retention-days'] === 'default' ? null : Number(args['retention-days']);
1610
+ }
1611
+ if (Object.keys(body).length === 0) {
1612
+ const current = await api('GET', `${conn.url}/rooms/tokens`, { room: conn.room, token: conn.token });
1613
+ assertSupported(current, 'report room settings');
1614
+ if (!current.ok) die(`could not read room settings (HTTP ${current.status})`);
1615
+ console.log(`\n${c.bold('Room security')} ${c.dim(`token_mode ${current.json.token_mode}`)}\n`);
1616
+ for (const t of current.json.tokens) {
1617
+ const state = t.revoked ? c.dim('revoked') : c.green('live');
1618
+ console.log(` ${c.cyan(t.developer)} ${c.dim(`${t.source} · ${state} · last used ${t.last_used ?? 'never'}`)}`);
1619
+ }
1620
+ if (current.json.tokens.length === 0) info('no developer tokens issued yet');
1621
+ console.log('');
1622
+ return;
1623
+ }
1624
+ const res = await api('POST', `${conn.url}/rooms/settings`, { room: conn.room, token: conn.token, body });
1625
+ assertSupported(res, 'change room settings');
1626
+ if (!res.ok) die(`could not change settings (HTTP ${res.status}) ${res.json?.error ?? ''}`);
1627
+ console.log(`\n${c.bold('Updated.')} token_mode ${c.cyan(res.json.token_mode)}, retention ${c.cyan(res.json.retention_days ?? 'server default')} days\n`);
1628
+ if (res.json.token_mode !== 'off') {
1629
+ info('every teammate now needs to run: npx agentmash login');
1630
+ info('until they do, their edits stop being reported — silently, as always\n');
1631
+ }
1632
+ }
1633
+
1634
+ async function cmdAudit(args) {
1635
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1636
+ const conn = requireConnection(repo);
1637
+ const res = await api('GET', `${conn.url}/audit`, { room: conn.room, token: conn.token });
1638
+ assertSupported(res, 'report an audit trail');
1639
+ if (!res.ok) die(`could not read the audit trail (HTTP ${res.status})`);
1640
+ console.log(`\n${c.bold('AgentMash audit')} ${c.dim(`· ${res.json.room_ref}`)}\n`);
1641
+ if (res.json.deleted_at) {
1642
+ warn(`this room was deleted ${res.json.deleted_at} — what follows is all that is left of it`);
1643
+ }
1644
+ for (const entry of res.json.entries) {
1645
+ console.log(` ${c.dim(entry.ts)} ${c.bold(entry.action)} ${c.cyan(entry.actor ?? '—')}`);
1646
+ if (entry.detail) console.log(` ${c.dim(entry.detail)}`);
1647
+ }
1648
+ if (res.json.entries.length === 0) info('nothing recorded yet');
1649
+ console.log('');
1650
+ }
1651
+
1652
+ async function cmdPurge(args) {
1653
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1654
+ const conn = requireConnection(repo);
1655
+ const scope = args._[0] === 'events' ? 'events' : 'prompts';
1656
+ if (!args.yes) die(`this deletes ${scope} for the whole room and cannot be undone — re-run with --yes`);
1657
+ const res = await api('POST', `${conn.url}/rooms/purge`, { room: conn.room, token: conn.token, body: { scope } });
1658
+ assertSupported(res, 'purge room data');
1659
+ if (!res.ok) die(`purge refused (HTTP ${res.status}) ${res.json?.error ?? ''}`);
1660
+ console.log(`\n${c.bold('Purged.')} ${res.json.affected} row(s) of ${scope} removed. The room keeps working.\n`);
1661
+ }
1662
+
1663
+ async function cmdDelete(args) {
1664
+ const repo = args.repo ? path.resolve(args.repo) : findRepoRoot() || process.cwd();
1665
+ const conn = requireConnection(repo);
1666
+ if (!args.yes) die('this deletes the room, its events and every developer token — re-run with --yes');
1667
+ const res = await api('DELETE', `${conn.url}/rooms`, { room: conn.room, token: conn.token });
1668
+ assertSupported(res, 'delete rooms');
1669
+ if (!res.ok) die(`deletion refused (HTTP ${res.status}) ${res.json?.error ?? ''}`);
1670
+ console.log(`\n${c.bold('Room deleted.')} ${res.json.events_removed} event(s) removed.\n`);
1671
+ if (res.json.audit_readable) {
1672
+ info(
1673
+ `the record of what was done to this room survives — ${c.bold('agentmash audit')} still reads it` +
1674
+ (res.json.audit_retention_days ? c.dim(` (for ${res.json.audit_retention_days} more days)`) : '')
1675
+ );
1676
+ }
1677
+ info('the hooks are still installed and now do nothing — run: agentmash init, or uninstall\n');
1678
+ }
1679
+
1680
+ function usage() {
1681
+ console.log(`
1682
+ ${c.bold('agentmash')} — let your team see each other's work on one repository
1683
+
1684
+ ${c.bold('npx agentmash demo')} see it move first: a made-up team, nothing to set up
1685
+ ${c.bold('npx agentmash init')} create a room and wire up this repo
1686
+ ${c.bold('npx agentmash join')} <id|url> point this repo at an existing room
1687
+ ${c.bold('npx agentmash doctor')} check what is and isn't working
1688
+ ${c.bold('npx agentmash status')} who is working on what, right now
1689
+ ${c.bold('npx agentmash git-hook')} [install|uninstall|status]
1690
+ the post-commit client, for teammates
1691
+ who are not using Claude Code
1692
+ ${c.bold('npx agentmash uninstall')} remove the hooks from this repo
1693
+ ${c.bold('npx agentmash-digest')} the week in review, to a channel or your terminal
1694
+ ${c.dim('npx agentmash mcp')} serve the MCP tools on stdio ${c.dim('(what .mcp.json runs)')}
1695
+
1696
+ ${c.bold('npx agentmash rotate')} issue a new room key, old one on a grace period
1697
+ ${c.bold('npx agentmash revoke')} end that grace period now
1698
+ ${c.bold('npx agentmash login')} prove this machine is you (optional)
1699
+ ${c.bold('npx agentmash secure')} <off|any|github> require proven identities to write
1700
+ ${c.bold('npx agentmash reconcile')} <approve|auto|off> what agents do when they finish having collided
1701
+ ${c.bold('npx agentmash diffs')} <on|off> stream the changed text to the live view (off by default)
1702
+ ${c.bold('npx agentmash audit')} what was read and changed, by whom
1703
+ ${c.bold('npx agentmash purge')} <prompts|events> delete what the room collected
1704
+ ${c.bold('npx agentmash delete')} delete the room outright
1705
+
1706
+ ${c.dim('--server <url>')} coordination server ${c.dim(`(default ${DEFAULT_SERVER})`)}
1707
+ ${c.dim('--repo <path>')} target repository ${c.dim('(default: enclosing git repo)')}
1708
+ ${c.dim('--label <text>')} name for the room ${c.dim('(default: repo folder name)')}
1709
+ ${c.dim('--force')} with init: replace an existing room
1710
+ ${c.dim('--no-git-hook')} with init/join: skip the post-commit client
1711
+ ${c.dim('--no-open')} with init/join/demo: do not open the browser
1712
+ ${c.dim('--force-hooks-path')} install into a core.hooksPath inside the working tree
1713
+ ${c.dim('--yes')} with purge/delete: confirm, because it cannot be undone
1714
+ ${c.dim('--grace-minutes')} with rotate: how long the old key keeps working
1715
+
1716
+ ${c.dim('--version')} print the installed version
1717
+ `);
1718
+ }
1719
+
1720
+ function parseArgs(argv) {
1721
+ const args = { _: [] };
1722
+ for (let i = 0; i < argv.length; i++) {
1723
+ const token = argv[i];
1724
+ if (token === '--force') args.force = true;
1725
+ else if (token === '--no-git-hook') args.noGitHook = true;
1726
+ else if (token === '--no-open') args.noOpen = true;
1727
+ else if (token === '--force-hooks-path') args.forceHooksPath = true;
1728
+ else if (token === '--yes') args.yes = true;
1729
+ else if (token === '--local') args.local = true;
1730
+ else if (token === '--help' || token === '-h') args.help = true;
1731
+ else if (token.startsWith('--')) args[token.slice(2)] = argv[++i];
1732
+ else args._.push(token);
1733
+ }
1734
+ return args;
1735
+ }
1736
+
1737
+ /** Read from the package we were installed as, so `--version` is the published one. */
1738
+ function version() {
1739
+ try {
1740
+ return JSON.parse(fs.readFileSync(path.join(HERE, 'package.json'), 'utf8')).version ?? 'unknown';
1741
+ } catch {
1742
+ return 'unknown';
1743
+ }
1744
+ }
1745
+
1746
+ const [, , command, ...rest] = process.argv;
1747
+ const args = parseArgs(rest);
1748
+ if (command === '--version' || command === '-v' || command === 'version') {
1749
+ console.log(version());
1750
+ process.exit(0);
1751
+ }
1752
+ if (!command && !args.help) {
1753
+ // Bare `npx agentmash`: the person is standing in a repo wondering what to
1754
+ // do. Tell them that, not the whole manual — which --help still is.
1755
+ const repo = findRepoRoot();
1756
+ const config = repo ? readConfig(repo) : null;
1757
+ if (repo && !config?.room) {
1758
+ nudge();
1759
+ process.exit(0);
1760
+ }
1761
+ if (repo && config?.room) {
1762
+ console.log(`\n This repo is connected. ${c.bold('Dashboard')} ${c.cyan(`${config.url}/r/${config.room}`)}`);
1763
+ console.log(` ${c.bold('npx agentmash status')} says who is here; ${c.dim('--help')} lists the rest.\n`);
1764
+ process.exit(0);
1765
+ }
1766
+ }
1767
+ if (args.help || !command || command === 'help' || command === '--help' || command === '-h') {
1768
+ usage();
1769
+ process.exit(0);
1770
+ }
1771
+
1772
+ const commands = {
1773
+ init: cmdInit,
1774
+ join: cmdJoin,
1775
+ demo: cmdDemo,
1776
+ doctor: cmdDoctor,
1777
+ status: cmdStatus,
1778
+ 'git-hook': async (a) => cmdGitHook(a),
1779
+ rotate: cmdRotate,
1780
+ revoke: cmdRevoke,
1781
+ login: cmdLogin,
1782
+ secure: cmdSecure,
1783
+ reconcile: cmdReconcile,
1784
+ diffs: cmdDiffs,
1785
+ audit: cmdAudit,
1786
+ purge: cmdPurge,
1787
+ delete: cmdDelete,
1788
+ uninstall: async (a) => cmdUninstall(a),
1789
+ mcp: cmdMcp,
1790
+ };
1791
+
1792
+ const handler = commands[command];
1793
+ if (!handler) {
1794
+ console.error(`unknown command: ${command}`);
1795
+ usage();
1796
+ process.exit(1);
1797
+ }
1798
+ handler(args).catch((err) => die(err?.stack || String(err)));