agentmash 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/agentmash.mjs +1798 -0
- package/clients/cursor/after_file_edit.mjs +115 -0
- package/clients/git/post_commit.mjs +205 -0
- package/hooks/lib.mjs +1957 -0
- package/hooks/mcp_launcher.mjs +386 -0
- package/hooks/post_tool_use.mjs +78 -0
- package/hooks/pre_tool_use.mjs +191 -0
- package/hooks/session_end.mjs +76 -0
- package/hooks/stop.mjs +88 -0
- package/hooks/user_prompt_submit.mjs +65 -0
- package/mcp/coordination.mjs +126 -0
- package/mcp/render.mjs +358 -0
- package/mcp/server.mjs +452 -0
- package/package.json +52 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// AgentMash client for Cursor's agent hooks.
|
|
3
|
+
//
|
|
4
|
+
// Cursor is the one non-Claude-Code tool whose extension point is a near-exact
|
|
5
|
+
// analogue of the one AgentMash already uses: `afterFileEdit` hands a spawned
|
|
6
|
+
// process the same JSON-on-stdin shape, with `file_path` and an `edits` array of
|
|
7
|
+
// {old_string, new_string}. So this adapter is the Claude Code PostToolUse hook
|
|
8
|
+
// with a different envelope, and it reports into the same room, with the same
|
|
9
|
+
// identity, under the same reliability contract.
|
|
10
|
+
//
|
|
11
|
+
// Verified against https://cursor.com/docs/hooks — see docs/ADAPTERS.md.
|
|
12
|
+
//
|
|
13
|
+
// One script handles three events, dispatched on `hook_event_name`:
|
|
14
|
+
// beforeSubmitPrompt → cache the prompt as this conversation's task hint
|
|
15
|
+
// afterFileEdit → report the edit
|
|
16
|
+
// sessionEnd → close the session out
|
|
17
|
+
//
|
|
18
|
+
// What this does NOT do: warn a Cursor agent before it edits a file a teammate
|
|
19
|
+
// just touched. Cursor has `preToolUse`, but its payload for edit tools and its
|
|
20
|
+
// blocking-response schema are not documented well enough to write against
|
|
21
|
+
// without guessing, and guessing here means a wrong advisory in someone's
|
|
22
|
+
// editor. Reporting is one-directional for now, and that is still the half that
|
|
23
|
+
// makes the room honest.
|
|
24
|
+
|
|
25
|
+
// Installed next to lib.mjs (see docs/ADAPTERS.md); in this checkout lib.mjs
|
|
26
|
+
// lives in hooks/. Try both rather than keeping a second copy in sync.
|
|
27
|
+
const lib = await import(new URL('./lib.mjs', import.meta.url).href).catch(() =>
|
|
28
|
+
import(new URL('../../hooks/lib.mjs', import.meta.url).href)
|
|
29
|
+
);
|
|
30
|
+
const {
|
|
31
|
+
armExitGuard,
|
|
32
|
+
finish,
|
|
33
|
+
getDeveloper,
|
|
34
|
+
getGitBranch,
|
|
35
|
+
httpJson,
|
|
36
|
+
loadConfig,
|
|
37
|
+
parseJson,
|
|
38
|
+
readSessionCache,
|
|
39
|
+
readStdin,
|
|
40
|
+
summarizeChange,
|
|
41
|
+
toRepoRelative,
|
|
42
|
+
truncate,
|
|
43
|
+
writeSessionCache,
|
|
44
|
+
} = lib;
|
|
45
|
+
|
|
46
|
+
armExitGuard();
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
const input = parseJson(await readStdin());
|
|
50
|
+
if (!input) return;
|
|
51
|
+
|
|
52
|
+
// Cursor can have several roots open at once. The first is the one an edit is
|
|
53
|
+
// reported against; a file outside it keeps its absolute path, same as always.
|
|
54
|
+
const projectDir = input.workspace_roots?.[0] || process.cwd();
|
|
55
|
+
const config = loadConfig(projectDir);
|
|
56
|
+
if (config.disabled || !config.room) return;
|
|
57
|
+
|
|
58
|
+
// `conversation_id` is Cursor's stable per-thread id — the same role Claude
|
|
59
|
+
// Code's session_id plays, so it becomes one row on the dashboard. It is
|
|
60
|
+
// documented as a base field on every hook payload, but `sessionEnd` is
|
|
61
|
+
// documented with a `session_id` of its own: if that is what a given Cursor
|
|
62
|
+
// build sends, falling back keeps the end on the session it belongs to
|
|
63
|
+
// instead of ending a phantom `unknown` one and leaving the real one active.
|
|
64
|
+
const sessionId = input.conversation_id || input.session_id || 'unknown';
|
|
65
|
+
const cache = readSessionCache(sessionId);
|
|
66
|
+
const event = input.hook_event_name;
|
|
67
|
+
|
|
68
|
+
// The payload carries `user_email` (the Cursor account), but identity has to
|
|
69
|
+
// match whatever the same person's Claude Code hooks and git commits send, or
|
|
70
|
+
// one teammate shows up as two. git config user.email is that common value.
|
|
71
|
+
const developer = getDeveloper(config, projectDir);
|
|
72
|
+
|
|
73
|
+
const body = {
|
|
74
|
+
developer,
|
|
75
|
+
session_id: sessionId,
|
|
76
|
+
git_branch: getGitBranch(projectDir),
|
|
77
|
+
timestamp: new Date().toISOString(),
|
|
78
|
+
};
|
|
79
|
+
const post = (extra) =>
|
|
80
|
+
httpJson('POST', `${config.url}/events`, {
|
|
81
|
+
room: config.room,
|
|
82
|
+
timeoutMs: config.reportTimeoutMs,
|
|
83
|
+
body: { ...body, ...extra },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (event === 'beforeSubmitPrompt') {
|
|
87
|
+
const hint = truncate(input.prompt || '', 200);
|
|
88
|
+
if (!hint) return;
|
|
89
|
+
cache.task_hint = hint;
|
|
90
|
+
writeSessionCache(sessionId, cache);
|
|
91
|
+
await post({ event_type: 'prompt', tool: 'beforeSubmitPrompt', task_hint: hint });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (event === 'sessionEnd') {
|
|
96
|
+
await post({ event_type: 'session_end', tool: 'sessionEnd', task_hint: cache.task_hint || null });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (event !== 'afterFileEdit' || !input.file_path) return;
|
|
101
|
+
|
|
102
|
+
await post({
|
|
103
|
+
event_type: 'edit',
|
|
104
|
+
tool: 'afterFileEdit',
|
|
105
|
+
file_path: toRepoRelative(projectDir, input.file_path),
|
|
106
|
+
task_hint: cache.task_hint || null,
|
|
107
|
+
// Cursor's `edits` is shaped exactly like Claude Code's MultiEdit input, so
|
|
108
|
+
// the same summarizer produces the same "+12/-3 lines, touching X".
|
|
109
|
+
change_summary: summarizeChange('MultiEdit', { edits: input.edits || [] }),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
main()
|
|
114
|
+
.catch(() => {})
|
|
115
|
+
.finally(finish);
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// AgentMash git client — reports the files a commit touched.
|
|
3
|
+
//
|
|
4
|
+
// This is the half of the team Claude Code hooks cannot see: someone on Cursor,
|
|
5
|
+
// someone in vim, someone who never opens an agent at all. It cannot watch work
|
|
6
|
+
// in progress the way an editor hook can (see docs/ADAPTERS.md for exactly what
|
|
7
|
+
// that costs) — it reports where a person landed, not where they are.
|
|
8
|
+
//
|
|
9
|
+
// Invoked in the background by .git/hooks/post-commit, which has already
|
|
10
|
+
// returned control to git. Same contract as every other client: hard timeouts,
|
|
11
|
+
// no retries, exit 0 whatever happens, never a word on stdout.
|
|
12
|
+
//
|
|
13
|
+
// node git_post_commit.mjs [repo-root] [commit-ish]
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
// Installed at .claude/agentmash/git_post_commit.mjs, next to lib.mjs; in this
|
|
20
|
+
// checkout it sits in clients/git/ instead. Try both rather than duplicating
|
|
21
|
+
// the config, identity and timeout rules that lib.mjs already owns.
|
|
22
|
+
const lib = await import(new URL('./lib.mjs', import.meta.url).href).catch(() =>
|
|
23
|
+
import(new URL('../../hooks/lib.mjs', import.meta.url).href)
|
|
24
|
+
);
|
|
25
|
+
const { armExitGuard, finish, getDeveloper, getGitBranch, httpJson, loadConfig, truncate } = lib;
|
|
26
|
+
|
|
27
|
+
// A commit is many events, so this guard is looser than a hook's — but it still
|
|
28
|
+
// bounds a worker that finds a server which accepts connections and never answers.
|
|
29
|
+
armExitGuard(20_000);
|
|
30
|
+
|
|
31
|
+
// One commit that renames a directory can touch hundreds of files. Reporting all
|
|
32
|
+
// of them buries the room in noise for no extra signal, so take the first slice
|
|
33
|
+
// and say so.
|
|
34
|
+
const MAX_FILES = 50;
|
|
35
|
+
// Whole-run budget. Past this we stop mid-commit rather than keep a background
|
|
36
|
+
// process alive against a wedged server.
|
|
37
|
+
const TOTAL_BUDGET_MS = 10_000;
|
|
38
|
+
// Reserved out of that budget for the session_end, which is the one event the
|
|
39
|
+
// room cannot do without: skip it and `commit-<sha>` sits in the dashboard as an
|
|
40
|
+
// active session for the next hour, which is exactly the lie PROTOCOL.md §4.1
|
|
41
|
+
// forbids. It is one request, and it is the cheapest one in the batch.
|
|
42
|
+
const SESSION_END_RESERVE_MS = 2_500;
|
|
43
|
+
// One connection per event, closed after each (see lib.mjs), so a 300 ms server
|
|
44
|
+
// costs 300 ms per file when posted serially — 50 files does not fit in the
|
|
45
|
+
// budget on any ordinary network. A small pool does, and five in flight from a
|
|
46
|
+
// background process is nothing for a server that already accepts a whole team.
|
|
47
|
+
const EDIT_CONCURRENCY = 5;
|
|
48
|
+
// Bounded memory of what we have already reported. Enough to cover a long
|
|
49
|
+
// afternoon of rebasing; an amend is caught by the tree hash, not by this list.
|
|
50
|
+
const REMEMBERED_COMMITS = 100;
|
|
51
|
+
|
|
52
|
+
function git(repo, args) {
|
|
53
|
+
return execFileSync('git', args, {
|
|
54
|
+
cwd: repo,
|
|
55
|
+
timeout: 3000,
|
|
56
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
57
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
58
|
+
}).toString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where to keep the "already reported" list. The git directory, because it is
|
|
63
|
+
* per-repository, survives reboots, and is never committed — this is local
|
|
64
|
+
* bookkeeping, not something a teammate should receive.
|
|
65
|
+
*/
|
|
66
|
+
function reportedFile(repo) {
|
|
67
|
+
let dir = '.git';
|
|
68
|
+
try {
|
|
69
|
+
dir = git(repo, ['rev-parse', '--git-dir']).trim() || '.git';
|
|
70
|
+
} catch {
|
|
71
|
+
// not a repo, or git is missing — the caller already bailed if so
|
|
72
|
+
}
|
|
73
|
+
return path.resolve(repo, dir, 'agentmash-reported.json');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readReported(file) {
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
79
|
+
if (!Array.isArray(parsed)) return [];
|
|
80
|
+
// Entries written before this client remembered trees are bare sha strings.
|
|
81
|
+
return parsed.map((entry) => (typeof entry === 'string' ? { sha: entry } : entry)).filter(Boolean);
|
|
82
|
+
} catch {
|
|
83
|
+
return []; // first commit, or a corrupt file we are about to replace
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The protocol promises no deduplication, so being idempotent over our own
|
|
89
|
+
* re-runs is our job. post-commit fires again for a cherry-pick, and a
|
|
90
|
+
* reset-then-recommit can reproduce a sha exactly.
|
|
91
|
+
*
|
|
92
|
+
* The tree hash is what catches an amend: `git commit --amend -m ...` mints a
|
|
93
|
+
* new sha for work already in the room, so a sha-keyed list would report it
|
|
94
|
+
* twice. Same tree, same files, same work — the cost of this is that a commit
|
|
95
|
+
* which restores an earlier tree exactly (a revert of a revert) goes unreported,
|
|
96
|
+
* which is a far rarer event than an amend.
|
|
97
|
+
*/
|
|
98
|
+
function alreadyReported(file, sha, tree) {
|
|
99
|
+
return readReported(file).some((e) => e.sha === sha || (tree && e.tree === tree));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function rememberReported(file, sha, tree) {
|
|
103
|
+
try {
|
|
104
|
+
const list = readReported(file);
|
|
105
|
+
list.push({ sha, tree });
|
|
106
|
+
fs.writeFileSync(file, JSON.stringify(list.slice(-REMEMBERED_COMMITS)));
|
|
107
|
+
} catch {
|
|
108
|
+
// a read-only .git must not break the commit that just happened
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `added removed path` per file. Binary files report "-" for both counts. */
|
|
113
|
+
function changedFiles(repo, sha) {
|
|
114
|
+
const out = git(repo, ['show', '--numstat', '--format=', '--no-renames', sha]);
|
|
115
|
+
const files = [];
|
|
116
|
+
for (const line of out.split('\n')) {
|
|
117
|
+
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
|
118
|
+
if (!m) continue;
|
|
119
|
+
files.push({ added: m[1], removed: m[2], file: m[3].replace(/\\/g, '/') });
|
|
120
|
+
}
|
|
121
|
+
return files;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function main() {
|
|
125
|
+
const repo = path.resolve(process.argv[2] || process.cwd());
|
|
126
|
+
const config = loadConfig(repo);
|
|
127
|
+
if (config.disabled || !config.room) return;
|
|
128
|
+
|
|
129
|
+
let head;
|
|
130
|
+
try {
|
|
131
|
+
// sha \n parents \n tree \n committer date \n subject
|
|
132
|
+
head = git(repo, ['log', '-1', '--format=%H%n%P%n%T%n%cI%n%s', process.argv[3] || 'HEAD']).split('\n');
|
|
133
|
+
} catch {
|
|
134
|
+
return; // no commits, no git, shallow weirdness — none of it is our business
|
|
135
|
+
}
|
|
136
|
+
const [sha, parents, tree, when, ...subjectParts] = head;
|
|
137
|
+
if (!sha) return;
|
|
138
|
+
|
|
139
|
+
// A merge commit's diff is its whole branch. Reporting it would claim the
|
|
140
|
+
// merger touched every file on it, which is false and drowns the room.
|
|
141
|
+
if (parents.trim().split(/\s+/).filter(Boolean).length > 1) return;
|
|
142
|
+
|
|
143
|
+
const seen = reportedFile(repo);
|
|
144
|
+
if (alreadyReported(seen, sha, tree)) return;
|
|
145
|
+
|
|
146
|
+
let files;
|
|
147
|
+
try {
|
|
148
|
+
files = changedFiles(repo, sha);
|
|
149
|
+
} catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (files.length === 0) return;
|
|
153
|
+
|
|
154
|
+
const short = sha.slice(0, 7);
|
|
155
|
+
const truncated = files.length > MAX_FILES;
|
|
156
|
+
const reporting = files.slice(0, MAX_FILES);
|
|
157
|
+
|
|
158
|
+
const developer = getDeveloper(config, repo);
|
|
159
|
+
const branch = getGitBranch(repo);
|
|
160
|
+
const timestamp = when || new Date().toISOString();
|
|
161
|
+
// The commit is finished the moment we can see it, so it gets its own session
|
|
162
|
+
// id and is closed out below — leaving it "active" would be a lie the
|
|
163
|
+
// dashboard repeats for an hour.
|
|
164
|
+
const sessionId = `commit-${short}`;
|
|
165
|
+
const subject = truncate(subjectParts.join('\n') || `commit ${short}`, 200);
|
|
166
|
+
|
|
167
|
+
const post = (body) =>
|
|
168
|
+
httpJson('POST', `${config.url}/events`, {
|
|
169
|
+
room: config.room,
|
|
170
|
+
timeoutMs: config.reportTimeoutMs,
|
|
171
|
+
body: { developer, session_id: sessionId, git_branch: branch, task_hint: subject, timestamp, ...body },
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const editDeadline = Date.now() + TOTAL_BUDGET_MS - SESSION_END_RESERVE_MS;
|
|
175
|
+
const queue = reporting.slice();
|
|
176
|
+
const note = truncated ? `, ${files.length} files in this commit` : '';
|
|
177
|
+
const sendOne = async () => {
|
|
178
|
+
while (queue.length > 0 && Date.now() < editDeadline) {
|
|
179
|
+
const f = queue.shift();
|
|
180
|
+
const scale =
|
|
181
|
+
f.added === '-' || f.removed === '-' ? 'binary file' : `+${f.added}/-${f.removed} lines`;
|
|
182
|
+
await post({
|
|
183
|
+
event_type: 'edit',
|
|
184
|
+
tool: 'git-commit',
|
|
185
|
+
file_path: f.file,
|
|
186
|
+
change_summary: `commit ${short} · ${scale}${note}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await Promise.all(
|
|
193
|
+
Array.from({ length: Math.min(EDIT_CONCURRENCY, queue.length) }, sendOne)
|
|
194
|
+
);
|
|
195
|
+
} finally {
|
|
196
|
+
// Always, even when the edits ran out of budget or threw: a half-reported
|
|
197
|
+
// commit is a small inaccuracy, an unclosed session is a standing one.
|
|
198
|
+
await post({ event_type: 'session_end', tool: 'git-commit' });
|
|
199
|
+
rememberReported(seen, sha, tree);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
main()
|
|
204
|
+
.catch(() => {})
|
|
205
|
+
.finally(finish);
|