agentmash 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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);