ai-agentmanager 0.0.0-stage → 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +767 -2
  4. package/SECURITY.md +44 -0
  5. package/bin/agentmgr-hub.js +16 -0
  6. package/bin/agentmgr.js +4 -0
  7. package/docs/architecture.md +67 -0
  8. package/docs/distributed.md +147 -0
  9. package/docs/engines.md +177 -0
  10. package/docs/images/agents.jpg +0 -0
  11. package/docs/images/conflict-radar.jpg +0 -0
  12. package/docs/plugins.md +81 -0
  13. package/package.json +56 -4
  14. package/public/app.js +3083 -0
  15. package/public/hub/hub.css +230 -0
  16. package/public/hub/hub.js +766 -0
  17. package/public/hub/index.html +146 -0
  18. package/public/hub/term.js +147 -0
  19. package/public/index.html +342 -0
  20. package/public/share.html +34 -0
  21. package/public/share.js +41 -0
  22. package/public/style.css +541 -0
  23. package/public/touchscroll.js +73 -0
  24. package/public/transport.js +78 -0
  25. package/scripts/build-release.sh +58 -0
  26. package/scripts/fix-pty.js +15 -0
  27. package/src/backlog.js +413 -0
  28. package/src/brief.js +512 -0
  29. package/src/cli.js +1447 -0
  30. package/src/client.js +49 -0
  31. package/src/config.js +109 -0
  32. package/src/conflicts.js +105 -0
  33. package/src/engines/antigravity.js +296 -0
  34. package/src/engines/claude.js +98 -0
  35. package/src/engines/codex.js +331 -0
  36. package/src/engines/index.js +37 -0
  37. package/src/fleet.js +176 -0
  38. package/src/git.js +340 -0
  39. package/src/hub.js +857 -0
  40. package/src/hubactions.js +107 -0
  41. package/src/hubbacklog.js +373 -0
  42. package/src/hublink.js +437 -0
  43. package/src/inbox.js +94 -0
  44. package/src/judge.js +278 -0
  45. package/src/livebrowser.js +637 -0
  46. package/src/manager.js +2554 -0
  47. package/src/notify.js +284 -0
  48. package/src/plugins.js +119 -0
  49. package/src/preview.js +334 -0
  50. package/src/races.js +192 -0
  51. package/src/remote.js +284 -0
  52. package/src/server.js +759 -0
  53. package/src/service.js +150 -0
  54. package/src/sessions.js +160 -0
  55. package/src/store.js +104 -0
  56. package/src/stuck.js +160 -0
  57. package/src/tracker.js +311 -0
  58. package/src/trust.js +44 -0
package/src/preview.js ADDED
@@ -0,0 +1,334 @@
1
+ // Browser tabs: look at a page as this machine sees it (the agent's dev server on
2
+ // http://localhost:5173, a staging site on the VPN, any URL) from another device.
3
+ //
4
+ // On this computer the tab loads the URL directly. From another device each site gets its
5
+ // own HTTPS listener (same certificate and sign-in as remote access) that relays everything
6
+ // to it at the root path, so apps that use absolute paths, redirects and WebSockets (hot
7
+ // reload) keep working. This daemon's own ports are never relayed, and the Agent Manager
8
+ // session cookie is stripped before forwarding.
9
+ import http from 'node:http';
10
+ import https from 'node:https';
11
+
12
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0']);
13
+ const MAX_LISTENERS = 20;
14
+ const HOP = ['connection', 'keep-alive', 'proxy-connection', 'proxy-authorization', 'te', 'trailer', 'upgrade'];
15
+
16
+ export const isLoopback = (url) => LOOPBACK.has(url.hostname);
17
+
18
+ /**
19
+ * "5173", "localhost:5173/app", "10.0.0.7:8080" or "example.com/docs" -> a normalized URL.
20
+ * Without a scheme, local and numbered addresses and ones with a port get http, names https.
21
+ */
22
+ export function parseUrl(input) {
23
+ let s = String(input ?? '').trim();
24
+ if (/^\d{1,5}$/.test(s)) s = `localhost:${s}`;
25
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
26
+ const host = s.split(/[/?#]/)[0];
27
+ const plain = /^(localhost|0\.0\.0\.0|[\d.]+|\[[\da-f:]+\])(:\d+)?$/i.test(host) || /:\d+$/.test(host);
28
+ s = `${plain ? 'http' : 'https'}://${s}`;
29
+ }
30
+ let url;
31
+ try {
32
+ url = new URL(s);
33
+ } catch {
34
+ throw Object.assign(new Error('Enter an address, like localhost:5173, 3000 or example.com'), { status: 400 });
35
+ }
36
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) throw Object.assign(new Error('Only http and https addresses can be opened'), { status: 400 });
37
+ if (url.username || url.password) throw Object.assign(new Error("Addresses can't carry a user name or password"), { status: 400 });
38
+ if (url.hostname === '0.0.0.0') url.hostname = 'localhost';
39
+ return url;
40
+ }
41
+
42
+ const targetPort = (url) => Number(url.port || (url.protocol === 'https:' ? 443 : 80));
43
+
44
+ /** Local URLs mentioned in terminal output ("Local: http://localhost:5173/"), newest first. */
45
+ export function findLocalUrls(text) {
46
+ const plain = text.replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g, '');
47
+ const out = [];
48
+ for (const m of plain.matchAll(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):\d{2,5}(?:\/[^\s'"<>)\]]*)?/g)) {
49
+ try {
50
+ const href = parseUrl(m[0].replace(/[.,;:]+$/, '')).href;
51
+ const i = out.indexOf(href);
52
+ if (i >= 0) out.splice(i, 1);
53
+ out.push(href);
54
+ } catch {}
55
+ }
56
+ return out.reverse().slice(0, 8);
57
+ }
58
+
59
+ const LOOPBACK_NAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
60
+
61
+ const OTHER_ANSI = /\x1b\[[0-9;?<>=]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>78c]/g;
62
+
63
+ /**
64
+ * Terminal output as plain lines. Claude Code's interface places words with cursor moves
65
+ * (column jumps, not spaces) and ends lines with \r\r\n, so column jumps become padding.
66
+ */
67
+ function plainLines(text) {
68
+ const out = [];
69
+ for (const line of text.replace(/\x1b\[\d*(;\d*)?[Hf]/g, '\n').split(/\r*\n|\r/)) {
70
+ let cur = '';
71
+ for (const part of line.split(/(\x1b\[\d*[CG])/)) {
72
+ const m = part.match(/^\x1b\[(\d*)([CG])$/);
73
+ if (!m) cur += part.replace(OTHER_ANSI, '');
74
+ else if (m[2] === 'C') cur += ' '.repeat(Number(m[1] || 1));
75
+ else cur = cur.length < Number(m[1] || 1) - 1 ? cur.padEnd(Number(m[1] || 1) - 1) : `${cur} `;
76
+ }
77
+ out.push(cur.replace(/\s+$/, ''));
78
+ }
79
+ return out;
80
+ }
81
+
82
+ const URL_TAIL = /https?:\/\/\S*$/;
83
+ const URL_PIECE = /^[A-Za-z0-9\-._~%!$&'()*+,;=:@/?#[\]]+$/;
84
+
85
+ /**
86
+ * Sign-in links a CLI printed (`railway login`, `gh auth login --web`, `gcloud auth login`...):
87
+ * an http(s) address whose redirect / callback parameter points back at a port on this machine.
88
+ * `cols` is the terminal width, to rejoin links wrapped across lines (by the terminal or by
89
+ * Claude Code, which indents the continuation). Newest first: [{ url, port, callback }].
90
+ */
91
+ export function findSignInLinks(text, cols = 0) {
92
+ const lines = [];
93
+ let lastLen = 0; // visual length of the previous physical line
94
+ for (const line of plainLines(text)) {
95
+ const prev = lines.at(-1);
96
+ const piece = line.replace(/^[\s│┃|⎿]+/, '');
97
+ if (prev !== undefined && lastLen >= (cols > 20 ? cols - 12 : 70) && URL_TAIL.test(prev) && piece && URL_PIECE.test(piece)) lines[lines.length - 1] = prev + piece;
98
+ else lines.push(line);
99
+ lastLen = line.length;
100
+ }
101
+ const found = [];
102
+ for (const m of lines.join('\n').matchAll(/https?:\/\/[^\s'"<>`]+/g)) {
103
+ let u;
104
+ try {
105
+ u = new URL(m[0].replace(/[.,;:)\]]+$/, ''));
106
+ } catch {
107
+ continue;
108
+ }
109
+ if (LOOPBACK_NAMES.has(u.hostname)) continue;
110
+ for (const [k, v] of u.searchParams) {
111
+ if (!/redirect|callback|return/i.test(k)) continue;
112
+ let r;
113
+ try {
114
+ r = new URL(v);
115
+ } catch {
116
+ continue;
117
+ }
118
+ if (!/^https?:$/.test(r.protocol) || !LOOPBACK_NAMES.has(r.hostname)) continue;
119
+ const port = Number(r.port || (r.protocol === 'https:' ? 443 : 80));
120
+ const i = found.findIndex((f) => f.url === u.href);
121
+ if (i >= 0) found.splice(i, 1);
122
+ found.push({ url: u.href, port, callback: `${r.origin}${r.pathname}` });
123
+ break;
124
+ }
125
+ }
126
+ return found.reverse().slice(0, 4);
127
+ }
128
+
129
+ /** A loopback callback address (pasted after signing in), checked against the expected port. */
130
+ export function parseCallback(input, port) {
131
+ let u;
132
+ try {
133
+ u = new URL(String(input ?? '').trim());
134
+ } catch {
135
+ return null;
136
+ }
137
+ if (!/^https?:$/.test(u.protocol) || !LOOPBACK_NAMES.has(u.hostname) || u.username || u.password) return null;
138
+ if (Number(u.port || (u.protocol === 'https:' ? 443 : 80)) !== port) return null;
139
+ return u;
140
+ }
141
+
142
+ export class Previews {
143
+ /**
144
+ * @param {object} o
145
+ * @param {() => {key: Buffer, cert: Buffer}} o.tls
146
+ * @param {(port: number) => Set<string>} o.allowedHosts Host headers the listener answers to
147
+ * @param {(cookieHeader: string) => boolean} o.signedIn
148
+ * @param {string} o.cookieName stripped before forwarding
149
+ * @param {() => number[]} o.reserved this daemon's own ports, never relayed
150
+ * @param {() => Set<string>} o.ownHosts this machine's own names and addresses (besides loopback)
151
+ * @param {() => object} [o.savedPorts] site -> relay port from before; a device accepts the
152
+ * self-signed certificate per port, so a site keeps its port across restarts
153
+ * @param {(ports: object) => void} [o.savePorts]
154
+ */
155
+ constructor({ tls, allowedHosts, signedIn, cookieName, reserved, ownHosts = () => new Set(), savedPorts = () => ({}), savePorts = () => {} }) {
156
+ Object.assign(this, { tls, allowedHosts, signedIn, cookieName, reserved, ownHosts, savedPorts, savePorts });
157
+ this.listeners = new Map(); // "http://localhost:5173" -> { server, port, target }
158
+ this.okCookies = new Map(); // cookie header -> checked until (ms)
159
+ }
160
+
161
+ /** The address another device uses for `input`, e.g. https://mini:53122/app. */
162
+ async frameUrl(input, requestHost) {
163
+ const url = parseUrl(input);
164
+ const port = await this.listen(url);
165
+ const host = String(requestHost ?? '').replace(/:\d+$/, '');
166
+ return `https://${host}:${port}${url.pathname}${url.search}${url.hash}`;
167
+ }
168
+
169
+ async listen(url) {
170
+ const tport = targetPort(url);
171
+ const own = new Set([...this.reserved(), ...[...this.listeners.values()].map((l) => l.port)]);
172
+ const self = isLoopback(url) || this.ownHosts().has(url.hostname.replace(/^\[|\]$/g, '').toLowerCase());
173
+ if (self && own.has(tport)) throw Object.assign(new Error("That is Agent Manager's own port"), { status: 400 });
174
+ const key = `${url.protocol}//${url.hostname}:${tport}`;
175
+ const existing = this.listeners.get(key);
176
+ if (existing) return existing.port;
177
+ if (this.listeners.size >= MAX_LISTENERS) throw Object.assign(new Error(`At most ${MAX_LISTENERS} sites can be open at once`), { status: 400 });
178
+
179
+ const target = { protocol: url.protocol, host: url.hostname.replace(/^\[|\]$/g, ''), port: tport, label: url.host, loopback: isLoopback(url) };
180
+ const { key: k, cert } = this.tls();
181
+ const server = https.createServer({ key: k, cert });
182
+ const entry = { server, port: 0, target };
183
+ server.on('request', (req, res) => this.relay(entry, req, res));
184
+ server.on('upgrade', (req, socket, head) => this.relayUpgrade(entry, req, socket, head));
185
+ server.on('clientError', (_err, socket) => socket.destroy());
186
+ const listenOn = (p) =>
187
+ new Promise((resolve, reject) => {
188
+ server.once('error', reject);
189
+ server.listen(p, '0.0.0.0', () => {
190
+ server.off('error', reject);
191
+ resolve();
192
+ });
193
+ });
194
+ const saved = this.savedPorts();
195
+ const before = Number(saved[key]) || 0;
196
+ try {
197
+ if (!before || own.has(before)) throw new Error('no saved port');
198
+ await listenOn(before);
199
+ } catch {
200
+ await listenOn(0); // taken by something else now: a new port (and one more certificate prompt)
201
+ }
202
+ entry.port = server.address().port;
203
+ this.listeners.set(key, entry);
204
+ if (entry.port !== before) this.savePorts({ ...saved, [key]: entry.port });
205
+ return entry.port;
206
+ }
207
+
208
+ async stopAll() {
209
+ const all = [...this.listeners.values()];
210
+ this.listeners.clear();
211
+ await Promise.all(
212
+ all.map(({ server }) => {
213
+ server.closeAllConnections?.();
214
+ return new Promise((r) => server.close(() => r()));
215
+ }),
216
+ );
217
+ }
218
+
219
+ /** Why this request may not go through, or null. */
220
+ refuse(entry, req) {
221
+ const host = (req.headers.host ?? '').toLowerCase();
222
+ if (!this.allowedHosts(entry.port).has(host)) return [403, 'Unknown host'];
223
+ const cookies = req.headers.cookie ?? '';
224
+ const until = this.okCookies.get(cookies);
225
+ if (until && until > Date.now()) return null;
226
+ if (!this.signedIn(cookies)) return [401, 'Sign in to Agent Manager on this machine first, then open this tab again.'];
227
+ if (this.okCookies.size > 100) this.okCookies.clear();
228
+ this.okCookies.set(cookies, Date.now() + 10_000);
229
+ return null;
230
+ }
231
+
232
+ /** Request headers as the local server expects them: its own Host and Origin, no Agent Manager cookie. */
233
+ forwardHeaders(entry, req, upgrade) {
234
+ const { target } = entry;
235
+ const self = `${target.protocol}//${target.label}`;
236
+ const viewer = `https://${req.headers.host}`;
237
+ const headers = { ...req.headers };
238
+ for (const h of HOP) if (!(upgrade && (h === 'connection' || h === 'upgrade'))) delete headers[h];
239
+ headers.host = target.label;
240
+ if (headers.origin) headers.origin = self;
241
+ if (headers.referer?.startsWith(viewer)) headers.referer = self + headers.referer.slice(viewer.length);
242
+ const cookie = (headers.cookie ?? '')
243
+ .split(';')
244
+ .filter((c) => c.trim() && !c.trim().startsWith(`${this.cookieName}=`))
245
+ .join(';');
246
+ if (cookie) headers.cookie = cookie;
247
+ else delete headers.cookie;
248
+ return headers;
249
+ }
250
+
251
+ request(entry, req, upgrade) {
252
+ const { target } = entry;
253
+ const mod = target.protocol === 'https:' ? https : http;
254
+ return mod.request({
255
+ host: target.host,
256
+ port: target.port,
257
+ method: req.method,
258
+ path: req.url,
259
+ headers: this.forwardHeaders(entry, req, upgrade),
260
+ autoSelectFamily: true, // "localhost" may be ::1 or 127.0.0.1, whichever the dev server took
261
+ rejectUnauthorized: !target.loopback, // a local dev server may use its own self-signed certificate
262
+ });
263
+ }
264
+
265
+ relay(entry, req, res) {
266
+ const no = this.refuse(entry, req);
267
+ if (no) return page(res, no[0], no[1]);
268
+ const preq = this.request(entry, req, false);
269
+ preq.on('response', (pres) => {
270
+ res.writeHead(pres.statusCode, pres.statusMessage, this.responseHeaders(entry, req, pres.headers));
271
+ pres.pipe(res);
272
+ });
273
+ preq.on('error', (err) => {
274
+ if (res.headersSent) return res.destroy();
275
+ page(res, 502, err.code === 'ECONNREFUSED' ? `Nothing is answering on ${entry.target.label} yet. Start the server, then reload.` : `Could not reach ${entry.target.label}: ${err.message}`);
276
+ });
277
+ req.pipe(preq);
278
+ }
279
+
280
+ /** Let the page load inside the tab and keep its redirects and cookies on this address. */
281
+ responseHeaders(entry, req, headers) {
282
+ const out = { ...headers };
283
+ for (const h of HOP) delete out[h];
284
+ delete out['x-frame-options'];
285
+ if (out['content-security-policy']) {
286
+ out['content-security-policy'] = String(out['content-security-policy'])
287
+ .split(';')
288
+ .filter((d) => !/^\s*frame-ancestors\b/i.test(d))
289
+ .join(';');
290
+ }
291
+ const { target } = entry;
292
+ if (out.location) {
293
+ const selves = [`${target.protocol}//${target.label}`];
294
+ if (target.loopback) selves.push(`${target.protocol}//localhost:${target.port}`, `${target.protocol}//127.0.0.1:${target.port}`);
295
+ for (const self of selves) {
296
+ if (out.location.startsWith(self)) out.location = `https://${req.headers.host}${out.location.slice(self.length)}`;
297
+ }
298
+ }
299
+ if (out['set-cookie']) out['set-cookie'] = [].concat(out['set-cookie']).map((c) => c.replace(/;\s*domain=[^;]*/gi, ''));
300
+ return out;
301
+ }
302
+
303
+ relayUpgrade(entry, req, socket, head) {
304
+ socket.on('error', () => {});
305
+ const no = this.refuse(entry, req);
306
+ if (no) {
307
+ socket.end(`HTTP/1.1 ${no[0]} ${http.STATUS_CODES[no[0]]}\r\nConnection: close\r\n\r\n`);
308
+ return;
309
+ }
310
+ const preq = this.request(entry, req, true);
311
+ preq.on('upgrade', (pres, psock, phead) => {
312
+ psock.on('error', () => socket.destroy());
313
+ const lines = [`HTTP/1.1 ${pres.statusCode} ${pres.statusMessage}`];
314
+ for (let i = 0; i < pres.rawHeaders.length; i += 2) lines.push(`${pres.rawHeaders[i]}: ${pres.rawHeaders[i + 1]}`);
315
+ socket.write(lines.join('\r\n') + '\r\n\r\n');
316
+ if (phead?.length) socket.write(phead);
317
+ if (head?.length) psock.write(head);
318
+ psock.pipe(socket).pipe(psock);
319
+ socket.on('close', () => psock.destroy());
320
+ });
321
+ preq.on('response', (pres) => {
322
+ socket.end(`HTTP/1.1 ${pres.statusCode} ${pres.statusMessage}\r\nConnection: close\r\n\r\n`);
323
+ pres.resume();
324
+ });
325
+ preq.on('error', () => socket.destroy());
326
+ preq.end();
327
+ }
328
+ }
329
+
330
+ function page(res, status, message) {
331
+ const esc = message.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
332
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
333
+ res.end(`<!doctype html><meta name="viewport" content="width=device-width"><body style="font:15px system-ui;color:#c9d1d9;background:#0f1117;padding:24px"><p>${esc}</p></body>`);
334
+ }
package/src/races.js ADDED
@@ -0,0 +1,192 @@
1
+ // Agent races: one intent, N agents with different models or approaches, compared side by
2
+ // side (outcome, checks, size of change, cost, a test command run in each worktree) so the
3
+ // user can pick a winner. Races live in the state file next to the agents.
4
+ import crypto from 'node:crypto';
5
+ import { spawn } from 'node:child_process';
6
+ import { EventEmitter } from 'node:events';
7
+ import * as git from './git.js';
8
+
9
+ const err = (status, message) => Object.assign(new Error(message), { status });
10
+ const now = () => new Date().toISOString();
11
+
12
+ export class Races extends EventEmitter {
13
+ constructor({ manager, store }) {
14
+ super();
15
+ this.manager = manager;
16
+ this.store = store;
17
+ store.data.races ??= {};
18
+ this.running = new Map(); // `${raceId}:${agentId}` -> child process
19
+ // Interrupted test runs can't be finished after a restart.
20
+ for (const race of Object.values(store.data.races)) {
21
+ for (const t of Object.values(race.tests ?? {})) if (t.status === 'running') Object.assign(t, { status: 'error', output: 'Interrupted by a daemon restart' });
22
+ }
23
+ manager.on('change', (a) => a.raceId && this.emit('change', a.raceId));
24
+ manager.on('removed', (id) => {
25
+ for (const race of Object.values(store.data.races)) {
26
+ if (race.members.includes(id)) {
27
+ race.members = race.members.filter((m) => m !== id);
28
+ if (race.winner === id) race.winner = null;
29
+ this.save(race);
30
+ }
31
+ }
32
+ });
33
+ }
34
+
35
+ save(race) {
36
+ this.store.save();
37
+ if (race) this.emit('change', race.id);
38
+ }
39
+
40
+ /**
41
+ * variants: [{ label?, model?, instructions? }] (at least 2). Everything else is passed to
42
+ * agent creation (repo / dir / project / base / permissionMode ...). Each variant gets its
43
+ * own worktree, so a repo URL or a git folder is required.
44
+ */
45
+ create({ intent, variants, testCommand, ...opts }) {
46
+ intent = String(intent ?? '').trim();
47
+ if (!intent) throw err(400, 'Intent is required');
48
+ if (!Array.isArray(variants) || variants.length < 2) throw err(400, 'A race needs at least two variants');
49
+ if (variants.length > 6) throw err(400, 'At most six variants');
50
+ if (!opts.repo && !opts.dir && !opts.project) throw err(400, 'A race needs a repository URL, a git folder or a project');
51
+ const id = `r${crypto.randomBytes(3).toString('hex')}`;
52
+ const race = { id, intent, testCommand: testCommand?.trim() || null, createdAt: now(), members: [], tests: {}, winner: null };
53
+ this.store.data.races[id] = race;
54
+ variants.forEach((v, i) => {
55
+ const label = (v.label?.trim() || v.model?.trim() || `v${i + 1}`).replace(/[^\w.-]+/g, '-').slice(0, 20);
56
+ const a = this.manager.create({
57
+ ...opts,
58
+ intent: v.instructions?.trim() ? `${intent}\n\nApproach for this attempt: ${v.instructions.trim()}` : intent,
59
+ name: `${opts.name || intent.split(/\s+/).slice(0, 4).join('-').toLowerCase().replace(/[^\w-]+/g, '')}-${label}`,
60
+ model: v.model?.trim() || opts.model,
61
+ worktree: true,
62
+ race: { id, variant: { label, model: v.model?.trim() || opts.model || null, instructions: v.instructions?.trim() || null } },
63
+ });
64
+ race.members.push(a.id);
65
+ });
66
+ this.save(race);
67
+ return this.view(id);
68
+ }
69
+
70
+ get(id) {
71
+ const race = this.store.data.races[id];
72
+ if (!race) throw err(404, `No race ${id}`);
73
+ return race;
74
+ }
75
+
76
+ list() {
77
+ return Object.values(this.store.data.races).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map((r) => this.view(r.id));
78
+ }
79
+
80
+ /** Scoreboard: one row per attempt. */
81
+ view(id) {
82
+ const race = this.get(id);
83
+ const rows = race.members
84
+ .map((mid) => this.manager.get(mid))
85
+ .filter(Boolean)
86
+ .map((a) => {
87
+ const files = this.manager.changeSets.get(a.id)?.files ?? {};
88
+ const counted = Object.values(files);
89
+ const checks = a.brief?.checks ?? [];
90
+ const started = Date.parse(a.lastStartedAt ?? a.createdAt);
91
+ const last = Date.parse(a.runtime.lastOutputAt ?? a.sessions.at(-1)?.lastActivity ?? a.updatedAt);
92
+ return {
93
+ id: a.id,
94
+ name: a.name,
95
+ variant: a.variant ?? null,
96
+ status: a.status,
97
+ working: a.runtime.busy,
98
+ outcome: a.results?.outcome ?? null,
99
+ briefStatus: a.brief?.status ?? null,
100
+ checks: { passed: checks.filter((c) => c.state === 'passed').length, failed: checks.filter((c) => c.state === 'failed').length, total: checks.length },
101
+ files: counted.length,
102
+ added: counted.reduce((n, f) => n + (f.added ?? 0), 0),
103
+ removed: counted.reduce((n, f) => n + (f.removed ?? 0), 0),
104
+ costUsd: a.costUsd,
105
+ tokens: a.usage?.io ?? 0,
106
+ minutes: Number.isFinite(last - started) && last > started ? Math.round((last - started) / 60_000) : null,
107
+ prs: a.prs.map((p) => ({ url: p.url, number: p.number, state: p.state })),
108
+ tests: race.tests[a.id] ?? null,
109
+ };
110
+ });
111
+ return { ...race, rows };
112
+ }
113
+
114
+ /** Run the race's test command in every attempt's worktree (two at a time). */
115
+ async runTests(id, { command } = {}) {
116
+ const race = this.get(id);
117
+ if (command !== undefined) race.testCommand = command.trim() || null;
118
+ if (!race.testCommand) throw err(400, 'Set a test command first');
119
+ const queue = race.members.map((m) => this.manager.store.get(m)).filter((a) => a?.workdir);
120
+ for (const a of queue) race.tests[a.id] = { status: 'queued', at: now() };
121
+ this.save(race);
122
+ const worker = async () => {
123
+ for (let a = queue.shift(); a; a = queue.shift()) await this.runOne(race, a);
124
+ };
125
+ Promise.all([worker(), worker()]).catch(() => {});
126
+ return this.view(id);
127
+ }
128
+
129
+ runOne(race, agent) {
130
+ race.tests[agent.id] = { status: 'running', at: now() };
131
+ this.save(race);
132
+ return new Promise((resolve) => {
133
+ const child = spawn(process.env.SHELL || '/bin/sh', ['-lc', race.testCommand], { cwd: agent.workdir, env: this.manager.childEnv(agent), detached: true });
134
+ this.running.set(`${race.id}:${agent.id}`, child);
135
+ let out = '';
136
+ const keep = (d) => (out = (out + d).slice(-6000));
137
+ child.stdout.on('data', keep);
138
+ child.stderr.on('data', keep);
139
+ const started = Date.now();
140
+ const timer = setTimeout(() => {
141
+ try {
142
+ process.kill(-child.pid, 'SIGKILL');
143
+ } catch {}
144
+ }, (this.manager.config.raceTestTimeoutSec ?? 900) * 1000);
145
+ const finish = (code, error) => {
146
+ clearTimeout(timer);
147
+ this.running.delete(`${race.id}:${agent.id}`);
148
+ race.tests[agent.id] = {
149
+ status: error ? 'error' : code === 0 ? 'passed' : 'failed',
150
+ exitCode: code,
151
+ seconds: Math.round((Date.now() - started) / 1000),
152
+ output: (error ? `${error}\n` : '') + out.slice(-4000),
153
+ at: now(),
154
+ };
155
+ this.save(race);
156
+ resolve();
157
+ };
158
+ child.on('error', (e) => finish(null, e.message));
159
+ child.on('close', (code) => finish(code));
160
+ });
161
+ }
162
+
163
+ async diff(id, agentId) {
164
+ const race = this.get(id);
165
+ if (!race.members.includes(agentId)) throw err(404, 'Not part of this race');
166
+ const a = this.manager.store.get(agentId);
167
+ const baseSha = a?.baseRef?.split(' @ ')[1];
168
+ if (!a?.workdir || !baseSha) return { diff: '' };
169
+ return { diff: await git.fullDiff(a.workdir, baseSha) };
170
+ }
171
+
172
+ /** Pick the winner. With cleanup, the other attempts are closed and deleted with their worktrees. */
173
+ async pick(id, agentId, { cleanup = false } = {}) {
174
+ const race = this.get(id);
175
+ if (!race.members.includes(agentId)) throw err(404, 'Not part of this race');
176
+ race.winner = agentId;
177
+ race.decidedAt = now();
178
+ this.save(race);
179
+ if (cleanup) {
180
+ for (const m of race.members.filter((x) => x !== agentId)) await this.manager.remove(m, { deleteWorkspace: true }).catch(() => {});
181
+ }
182
+ return this.view(id);
183
+ }
184
+
185
+ remove(id) {
186
+ this.get(id);
187
+ delete this.store.data.races[id];
188
+ this.save();
189
+ this.emit('removed', id);
190
+ return { ok: true };
191
+ }
192
+ }