ai-agentmanager 1.1.0 → 1.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/CHANGELOG.md +25 -0
- package/README.md +131 -3
- package/docs/plugins.md +4 -1
- package/package.json +1 -1
- package/public/app.js +259 -15
- package/public/index.html +198 -0
- package/public/land.js +149 -0
- package/public/playbooks.js +132 -0
- package/public/review.js +368 -0
- package/public/setup.js +67 -0
- package/public/spend.js +82 -0
- package/public/style.css +116 -1
- package/public/triggers.js +85 -0
- package/src/assistant.js +279 -0
- package/src/budgets.js +166 -0
- package/src/cleanup.js +1 -0
- package/src/cli.js +389 -1
- package/src/client.js +2 -1
- package/src/engines/claude.js +6 -4
- package/src/git.js +4 -4
- package/src/landing.js +212 -0
- package/src/manager.js +168 -78
- package/src/memory.js +106 -0
- package/src/notify.js +3 -1
- package/src/playbooks.js +178 -0
- package/src/review.js +209 -0
- package/src/server.js +108 -5
- package/src/stats.js +107 -0
- package/src/tracker.js +61 -8
- package/src/triggers.js +258 -0
package/src/server.js
CHANGED
|
@@ -10,8 +10,17 @@ import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.
|
|
|
10
10
|
import { AgentManager, shellNumber } from './manager.js';
|
|
11
11
|
import { Backlog } from './backlog.js';
|
|
12
12
|
import { Races } from './races.js';
|
|
13
|
+
import { Review } from './review.js';
|
|
13
14
|
import { Teams } from './teams.js';
|
|
15
|
+
import { Assistant } from './assistant.js';
|
|
14
16
|
import { Cleanup } from './cleanup.js';
|
|
17
|
+
import { Landing } from './landing.js';
|
|
18
|
+
import { Budgets } from './budgets.js';
|
|
19
|
+
import { stats } from './stats.js';
|
|
20
|
+
import { Playbooks } from './playbooks.js';
|
|
21
|
+
import { Memory } from './memory.js';
|
|
22
|
+
import { doctor } from './doctor.js';
|
|
23
|
+
import { Triggers, tokenMatches } from './triggers.js';
|
|
15
24
|
import { listEngines, registerEngine } from './engines/index.js';
|
|
16
25
|
import { Plugins, pluginSpecs } from './plugins.js';
|
|
17
26
|
import { HubLink, linkUrls } from './hublink.js';
|
|
@@ -46,7 +55,9 @@ function isAllowedOrigin(origin, port) {
|
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
|
|
49
|
-
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|shutdown$)/;
|
|
58
|
+
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|shutdown$)/;
|
|
59
|
+
const HOOK_PATH = '/api/hooks/agents';
|
|
60
|
+
const NO_REPO_KINDS = new Set(['general', 'assistant']);
|
|
50
61
|
|
|
51
62
|
export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
52
63
|
ensureHome();
|
|
@@ -57,8 +68,11 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
57
68
|
const manager = new AgentManager({ store, config, plugins });
|
|
58
69
|
const backlog = new Backlog({ manager, store, config });
|
|
59
70
|
const races = new Races({ manager, store });
|
|
71
|
+
const review = new Review({ manager });
|
|
60
72
|
const teams = new Teams({ manager, store, config });
|
|
61
73
|
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
74
|
+
const landing = new Landing({ manager, store, config });
|
|
75
|
+
const assistant = new Assistant({ manager, store, config });
|
|
62
76
|
// Alerts link to the phone address when remote access is on, else the local UI.
|
|
63
77
|
const notifier = new Notifier({
|
|
64
78
|
manager,
|
|
@@ -69,6 +83,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
69
83
|
return `${phone ? phone.url : `http://127.0.0.1:${port}/`}#${id}/term`;
|
|
70
84
|
},
|
|
71
85
|
});
|
|
86
|
+
const triggers = new Triggers({ manager, store, config, notifier });
|
|
87
|
+
const budgets = new Budgets({ manager, store, config, notifier });
|
|
88
|
+
const playbooks = new Playbooks({ manager, store });
|
|
89
|
+
const memory = new Memory({ manager, config });
|
|
72
90
|
let remoteServer = null;
|
|
73
91
|
let remotePort = null;
|
|
74
92
|
// Live browser tabs: what a page does is saved on the agent's tab; popups become new tabs.
|
|
@@ -135,9 +153,13 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
135
153
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
136
154
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
137
155
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
156
|
+
// The webhook signs in with its token instead of a device session or the client header.
|
|
157
|
+
const bearer = /^Bearer (.+)$/i.exec(req.headers.authorization ?? '')?.[1];
|
|
158
|
+
const hook = req.method === 'POST' && req.url.split('?')[0] === HOOK_PATH && tokenMatches(req.headers['x-agentmgr-token'] ?? bearer, config.triggers?.webhook?.enabled ? config.triggers.webhook.token : null);
|
|
138
159
|
const why = denied(req);
|
|
139
|
-
if (why) return res.status(why === 'Sign in required' ? 401 : 403).json({ error: why });
|
|
140
|
-
if (
|
|
160
|
+
if (why && !(hook && why === 'Sign in required')) return res.status(why === 'Sign in required' ? 401 : 403).json({ error: why });
|
|
161
|
+
if (req.url.split('?')[0] === HOOK_PATH && !hook) return res.status(401).json({ error: 'Bad or missing token' });
|
|
162
|
+
if (!hook && !['GET', 'HEAD'].includes(req.method) && req.headers[CLIENT_HEADER] !== '1') {
|
|
141
163
|
return res.status(403).json({ error: `Missing ${CLIENT_HEADER} header` });
|
|
142
164
|
}
|
|
143
165
|
next();
|
|
@@ -177,8 +199,16 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
177
199
|
api.post(
|
|
178
200
|
'/agents',
|
|
179
201
|
wrap((req) => {
|
|
180
|
-
const { intents, intent, ...opts } = req.body ?? {};
|
|
181
|
-
|
|
202
|
+
const { intents, intent, startedBy, ...opts } = req.body ?? {};
|
|
203
|
+
if (opts.kind === 'assistant') throw fail(400, 'Start the assistant with POST /assistant/start');
|
|
204
|
+
const list = (Array.isArray(intents) ? intents : [intent]).map((i) => String(i ?? '').trim()).filter(Boolean);
|
|
205
|
+
// Agents the assistant's own session starts: its limits apply, and they are marked as its.
|
|
206
|
+
const extra = assistant.admit(req.headers['x-agentmgr-agent'], list, opts);
|
|
207
|
+
const created = manager.createMany(list, { ...opts, ...extra });
|
|
208
|
+
if (extra.startedBy) {
|
|
209
|
+
for (const a of created) notifier.send('assistant', a, `was started by the assistant: ${a.intent.replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
210
|
+
}
|
|
211
|
+
return created;
|
|
182
212
|
}),
|
|
183
213
|
);
|
|
184
214
|
api.post('/general', wrap((req) => manager.createGeneral(req.body ?? {})));
|
|
@@ -393,6 +423,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
393
423
|
await manager.checkpoint(id);
|
|
394
424
|
return manager.get(id);
|
|
395
425
|
}));
|
|
426
|
+
api.get('/agents/:id/changes', wrap((req) => review.get(req.params.id)));
|
|
427
|
+
api.post('/agents/:id/changes/comments', wrap((req) => review.comment(req.params.id, req.body ?? {})));
|
|
428
|
+
api.post('/agents/:id/changes/commit', wrap((req) => review.commit(req.params.id, req.body ?? {})));
|
|
429
|
+
api.post('/agents/:id/changes/pr', wrap((req) => review.pr(req.params.id, req.body ?? {})));
|
|
396
430
|
api.post('/agents/:id/fork', wrap((req) => manager.fork(req.params.id, req.body ?? {})));
|
|
397
431
|
|
|
398
432
|
api.get('/races', wrap(() => races.list()));
|
|
@@ -404,6 +438,65 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
404
438
|
api.delete('/races/:id', wrap((req) => races.remove(req.params.id)));
|
|
405
439
|
|
|
406
440
|
// Clean up old agents: the strategy and days (kept in config.json), what a run would do, run now.
|
|
441
|
+
api.get('/assistant', wrap(() => assistant.status()));
|
|
442
|
+
api.put('/assistant', wrap((req) => {
|
|
443
|
+
const next = assistant.update(req.body ?? {});
|
|
444
|
+
saveConfig({ assistant: next });
|
|
445
|
+
return assistant.status();
|
|
446
|
+
}));
|
|
447
|
+
api.post('/assistant/start', wrap(() => assistant.start()));
|
|
448
|
+
api.post('/assistant/pause', wrap(() => assistant.pause()));
|
|
449
|
+
api.post('/assistant/check', wrap(() => assistant.check()));
|
|
450
|
+
api.get('/triggers', wrap(() => triggers.view()));
|
|
451
|
+
api.put('/triggers', wrap((req) => {
|
|
452
|
+
const { saved, token } = triggers.update(req.body ?? {});
|
|
453
|
+
saveConfig({ triggers: saved });
|
|
454
|
+
return { ...triggers.view(), ...(token ? { token } : {}) };
|
|
455
|
+
}));
|
|
456
|
+
api.post('/triggers/poll', wrap(async () => ({ ...(await triggers.poll()), ...triggers.view() })));
|
|
457
|
+
api.post('/hooks/agents', wrap((req) => triggers.hook(req.body ?? {})));
|
|
458
|
+
api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
|
|
459
|
+
api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
|
|
460
|
+
api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
|
|
461
|
+
// The first-run checklist: what is set up, and what this machine is missing.
|
|
462
|
+
api.get('/setup', wrap(async () => {
|
|
463
|
+
const checks = (await doctor().catch(() => [])).filter((c) => c.status === 'fail' || c.status === 'warn').filter((c) => c.name !== 'Daemon');
|
|
464
|
+
const agentsNow = store.list();
|
|
465
|
+
return {
|
|
466
|
+
checks,
|
|
467
|
+
steps: {
|
|
468
|
+
agent: agentsNow.some((a) => !NO_REPO_KINDS.has(a.kind)),
|
|
469
|
+
alerts: Boolean(loadNotify().enabled),
|
|
470
|
+
assistant: agentsNow.some((a) => a.kind === 'assistant') || Boolean(config.triggers?.github?.enabled || config.triggers?.webhook?.enabled),
|
|
471
|
+
remote: Boolean(remote.loadSettings().enabled),
|
|
472
|
+
budget: Boolean(config.budgets?.perAgentUsd || config.budgets?.perDayUsd),
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}));
|
|
476
|
+
api.get('/playbooks', wrap(() => playbooks.list()));
|
|
477
|
+
api.post('/playbooks', wrap((req) => playbooks.create(req.body ?? {})));
|
|
478
|
+
api.put('/playbooks/:id', wrap((req) => playbooks.update(req.params.id, req.body ?? {})));
|
|
479
|
+
api.delete('/playbooks/:id', wrap((req) => playbooks.remove(req.params.id)));
|
|
480
|
+
api.post('/playbooks/:id/run', wrap((req) => playbooks.run(req.params.id, { vars: req.body?.vars ?? {} })));
|
|
481
|
+
api.get('/stats', wrap((req) => {
|
|
482
|
+
const days = req.query.days === undefined ? 30 : Math.max(0, Math.min(3650, Math.floor(Number(req.query.days)) || 0));
|
|
483
|
+
return stats(store.list(), { days, projectOf: (a) => manager.projectInfo(a)?.name ?? null });
|
|
484
|
+
}));
|
|
485
|
+
api.get('/budgets', wrap(() => budgets.status()));
|
|
486
|
+
api.put('/budgets', wrap((req) => {
|
|
487
|
+
const next = budgets.update(req.body ?? {});
|
|
488
|
+
saveConfig({ budgets: next });
|
|
489
|
+
return budgets.status();
|
|
490
|
+
}));
|
|
491
|
+
api.get('/landing', wrap(() => landing.list()));
|
|
492
|
+
api.put('/landing', wrap((req) => {
|
|
493
|
+
const next = landing.update(req.body ?? {});
|
|
494
|
+
saveConfig({ landing: next });
|
|
495
|
+
return landing.list();
|
|
496
|
+
}));
|
|
497
|
+
api.post('/agents/:id/land/update', wrap((req) => landing.updateBranch(req.params.id, req.body ?? {})));
|
|
498
|
+
api.post('/agents/:id/land/fix', wrap((req) => landing.fix(req.params.id, req.body ?? {})));
|
|
499
|
+
api.post('/agents/:id/land/merge', wrap((req) => landing.merge(req.params.id, req.body ?? {})));
|
|
407
500
|
api.get('/cleanup', wrap(() => cleanup.status()));
|
|
408
501
|
api.put('/cleanup', wrap(async (req) => {
|
|
409
502
|
const next = cleanup.update(req.body ?? {});
|
|
@@ -756,6 +849,11 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
756
849
|
if (closing) return;
|
|
757
850
|
closing = true;
|
|
758
851
|
cleanup.close();
|
|
852
|
+
landing.close();
|
|
853
|
+
triggers.close();
|
|
854
|
+
budgets.close();
|
|
855
|
+
playbooks.close();
|
|
856
|
+
assistant.close();
|
|
759
857
|
console.log(`[agentmgr] shutting down (${reason}); closing agents so their sessions are saved`);
|
|
760
858
|
await manager.stopAll({ final: true });
|
|
761
859
|
await manager.flush().catch((err) => console.error(`[agentmgr] final flush failed: ${err.message}`));
|
|
@@ -792,6 +890,11 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
792
890
|
manager,
|
|
793
891
|
teams,
|
|
794
892
|
cleanup,
|
|
893
|
+
landing,
|
|
894
|
+
triggers,
|
|
895
|
+
budgets,
|
|
896
|
+
playbooks,
|
|
897
|
+
assistant,
|
|
795
898
|
backlog,
|
|
796
899
|
live,
|
|
797
900
|
url: `http://${host}:${port}`,
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Results across agents for a period: how many started and from where, pull requests opened and
|
|
2
|
+
// merged, the merge rate, time to a first PR and to merge, cost per merged PR, and the same by
|
|
3
|
+
// engine and model and by project. Everything comes from what agentmgr already records.
|
|
4
|
+
import { costOf } from './budgets.js';
|
|
5
|
+
|
|
6
|
+
const HOUR = 3_600_000;
|
|
7
|
+
|
|
8
|
+
const median = (xs) => {
|
|
9
|
+
if (!xs.length) return null;
|
|
10
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
11
|
+
const m = Math.floor(s.length / 2);
|
|
12
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
13
|
+
};
|
|
14
|
+
const round = (n, d = 2) => (n == null ? null : Math.round(n * 10 ** d) / 10 ** d);
|
|
15
|
+
|
|
16
|
+
/** Where an agent came from. */
|
|
17
|
+
export function sourceOf(a) {
|
|
18
|
+
if (a.kind === 'assistant') return 'assistant session';
|
|
19
|
+
if (a.startedBy) return 'assistant';
|
|
20
|
+
if (a.trigger) return a.trigger.source === 'github' ? 'GitHub label' : 'webhook';
|
|
21
|
+
if (a.playbook) return 'playbook';
|
|
22
|
+
if (a.backlogItem) return 'backlog';
|
|
23
|
+
if (a.team) return 'team';
|
|
24
|
+
if (a.race) return 'race';
|
|
25
|
+
if (a.forkOf) return 'fork';
|
|
26
|
+
if (a.imported) return 'imported';
|
|
27
|
+
return 'you';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** PRs an agent opened or that were found on its branch (not ones it only mentioned). */
|
|
31
|
+
const ownPrs = (a) => Object.values(a.prs ?? {}).filter((p) => p.source !== 'mentioned');
|
|
32
|
+
|
|
33
|
+
function bucket() {
|
|
34
|
+
return { agents: 0, withPr: 0, merged: 0, closed: 0, open: 0, cost: 0, toPrH: [], toMergeH: [], fixes: 0 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function add(b, a) {
|
|
38
|
+
const prs = ownPrs(a);
|
|
39
|
+
const created = Date.parse(a.createdAt);
|
|
40
|
+
b.agents += 1;
|
|
41
|
+
b.cost += costOf(a);
|
|
42
|
+
if (prs.length) b.withPr += 1;
|
|
43
|
+
b.merged += prs.filter((p) => p.state === 'MERGED').length;
|
|
44
|
+
b.closed += prs.filter((p) => p.state === 'CLOSED').length;
|
|
45
|
+
b.open += prs.filter((p) => p.state === 'OPEN').length;
|
|
46
|
+
const opened = prs.map((p) => Date.parse(p.openedAt)).filter(Number.isFinite).sort((x, y) => x - y)[0];
|
|
47
|
+
if (opened && created && opened >= created) b.toPrH.push((opened - created) / HOUR);
|
|
48
|
+
for (const p of prs.filter((x) => x.state === 'MERGED' && x.mergedAt)) {
|
|
49
|
+
const m = Date.parse(p.mergedAt);
|
|
50
|
+
if (created && m >= created) b.toMergeH.push((m - created) / HOUR);
|
|
51
|
+
}
|
|
52
|
+
b.fixes += Object.keys(a.landing?.sent ?? {}).length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function finish(b) {
|
|
56
|
+
const decided = b.merged + b.closed;
|
|
57
|
+
return {
|
|
58
|
+
agents: b.agents,
|
|
59
|
+
withPr: b.withPr,
|
|
60
|
+
merged: b.merged,
|
|
61
|
+
closed: b.closed,
|
|
62
|
+
open: b.open,
|
|
63
|
+
mergeRate: decided ? round(b.merged / decided, 3) : null,
|
|
64
|
+
cost: round(b.cost),
|
|
65
|
+
costPerMerged: b.merged ? round(b.cost / b.merged) : null,
|
|
66
|
+
medianHoursToPr: round(median(b.toPrH), 1),
|
|
67
|
+
medianHoursToMerge: round(median(b.toMergeH), 1),
|
|
68
|
+
fixesSent: b.fixes,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Results for agents created in the last `days` days (0: all of them). */
|
|
73
|
+
export function stats(agents, { days = 30, now = Date.now(), projectOf = () => null } = {}) {
|
|
74
|
+
const since = days ? now - days * 24 * HOUR : 0;
|
|
75
|
+
const list = agents.filter((a) => a.kind !== 'general' && a.kind !== 'assistant' && Date.parse(a.createdAt) >= since);
|
|
76
|
+
const total = bucket();
|
|
77
|
+
const by = { source: new Map(), engine: new Map(), project: new Map() };
|
|
78
|
+
const put = (map, key, a) => {
|
|
79
|
+
if (!map.has(key)) map.set(key, bucket());
|
|
80
|
+
add(map.get(key), a);
|
|
81
|
+
};
|
|
82
|
+
for (const a of list) {
|
|
83
|
+
add(total, a);
|
|
84
|
+
put(by.source, sourceOf(a), a);
|
|
85
|
+
put(by.engine, `${a.engine ?? 'claude'}${a.model ? ` · ${a.model}` : ''}`, a);
|
|
86
|
+
put(by.project, projectOf(a) ?? 'none', a);
|
|
87
|
+
}
|
|
88
|
+
const rows = (map) =>
|
|
89
|
+
[...map.entries()]
|
|
90
|
+
.map(([key, b]) => ({ key, ...finish(b) }))
|
|
91
|
+
.sort((x, y) => y.agents - x.agents)
|
|
92
|
+
.slice(0, 20);
|
|
93
|
+
// Agents per day, for a small chart.
|
|
94
|
+
const perDay = {};
|
|
95
|
+
for (const a of list) {
|
|
96
|
+
const d = new Date(a.createdAt).toISOString().slice(0, 10);
|
|
97
|
+
perDay[d] = (perDay[d] ?? 0) + 1;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
days,
|
|
101
|
+
total: finish(total),
|
|
102
|
+
bySource: rows(by.source),
|
|
103
|
+
byEngine: rows(by.engine),
|
|
104
|
+
byProject: rows(by.project),
|
|
105
|
+
perDay: Object.entries(perDay).sort(([a], [b]) => (a < b ? -1 : 1)).map(([day, n]) => ({ day, agents: n })),
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/tracker.js
CHANGED
|
@@ -72,8 +72,9 @@ export function recentSessionFiles(cwd, sinceMs) {
|
|
|
72
72
|
const URL_TOKEN = /https?:\/\/[^\s"'<>`\\)\]]+/g;
|
|
73
73
|
const PR_PATH = /^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/;
|
|
74
74
|
const GH_ISSUE_PATH = /^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)(?:[/?#]|$)/;
|
|
75
|
-
//
|
|
76
|
-
|
|
75
|
+
// Issue keys in links: Jira /browse/KEY-1 and ?selectedIssue=KEY-1 (boards, Polaris ideas),
|
|
76
|
+
// /issues/KEY-1, and Linear /issue/KEY-1.
|
|
77
|
+
const JIRA_KEY = /(?:\/browse\/|[?&]selectedIssue=|\/issues?\/)([A-Z][A-Z0-9_]+-\d+)\b/;
|
|
77
78
|
|
|
78
79
|
const trimUrl = (url) => url.replace(/[.,;:!?'"]+$/, '');
|
|
79
80
|
|
|
@@ -95,7 +96,7 @@ export function bugFromRef(ref, source) {
|
|
|
95
96
|
return { key: ref, url: null, source };
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
/** The
|
|
99
|
+
/** The issue key when the text starts with a Jira or Linear link ("https://x.atlassian.net/browse/ABC-1 fix it" → "ABC-1"). */
|
|
99
100
|
export function leadingJiraKey(text) {
|
|
100
101
|
const first = String(text ?? '').trimStart().match(/^<?(https?:\/\/\S+)/);
|
|
101
102
|
return first?.[1].match(JIRA_KEY)?.[1] ?? null;
|
|
@@ -218,17 +219,69 @@ export function scanTranscript(file, cursor, bugPatterns = []) {
|
|
|
218
219
|
return result;
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/** The gh CLI to run (AGENTMGR_GH_BIN points the tests at a fake one). */
|
|
223
|
+
export const ghBin = () => process.env.AGENTMGR_GH_BIN || 'gh';
|
|
224
|
+
|
|
225
|
+
const PR_FIELDS = 'number,title,url,state,isDraft,headRefOid,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup,createdAt,mergedAt';
|
|
226
|
+
const FAILED = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
|
|
227
|
+
const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
228
|
+
|
|
229
|
+
/** gh's statusCheckRollup -> { state: passed|failed|running|none, failed: [names], running, total } */
|
|
230
|
+
export function checkSummary(rollup) {
|
|
231
|
+
const list = Array.isArray(rollup) ? rollup : [];
|
|
232
|
+
const failed = [];
|
|
233
|
+
let running = 0;
|
|
234
|
+
for (const c of list) {
|
|
235
|
+
// Check runs have status/conclusion; commit statuses have state.
|
|
236
|
+
const result = c.conclusion || (c.status && c.status !== 'COMPLETED' ? 'PENDING' : null) || c.state || 'PENDING';
|
|
237
|
+
if (FAILED.has(result)) failed.push(c.name || c.context || 'check');
|
|
238
|
+
else if (!PASSED.has(result)) running += 1;
|
|
239
|
+
}
|
|
240
|
+
const state = !list.length ? 'none' : failed.length ? 'failed' : running ? 'running' : 'passed';
|
|
241
|
+
return { state, failed: failed.slice(0, 10), running, total: list.length };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Where a pull request stands on its way in: merged, closed, draft, fix, behind, ready or waiting. */
|
|
245
|
+
export function landState(pr) {
|
|
246
|
+
if (!pr?.state) return null;
|
|
247
|
+
if (pr.state === 'MERGED') return 'merged';
|
|
248
|
+
if (pr.state === 'CLOSED') return 'closed';
|
|
249
|
+
if (pr.isDraft) return 'draft';
|
|
250
|
+
const checks = pr.checks?.state;
|
|
251
|
+
if (checks === 'failed' || pr.review === 'CHANGES_REQUESTED' || pr.mergeable === 'CONFLICTING' || pr.mergeState === 'DIRTY') return 'fix';
|
|
252
|
+
if (pr.mergeState === 'BEHIND') return 'behind';
|
|
253
|
+
if (checks === 'running') return 'waiting';
|
|
254
|
+
if (pr.mergeState === 'CLEAN' || pr.mergeState === 'HAS_HOOKS' || pr.mergeState === 'UNSTABLE') return 'ready';
|
|
255
|
+
if (pr.review === 'APPROVED' && pr.mergeable === 'MERGEABLE') return 'ready';
|
|
256
|
+
return 'waiting';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Keep what we use from gh's PR JSON, small enough to store per agent. */
|
|
260
|
+
export function compactPr(p) {
|
|
261
|
+
if (!p) return p;
|
|
262
|
+
const { statusCheckRollup, reviewDecision, mergeStateStatus, headRefOid, mergeable, createdAt, mergedAt, ...rest } = p;
|
|
263
|
+
const out = { ...rest };
|
|
264
|
+
if (createdAt) out.openedAt = createdAt;
|
|
265
|
+
if (mergedAt) out.mergedAt = mergedAt;
|
|
266
|
+
if (statusCheckRollup !== undefined) out.checks = checkSummary(statusCheckRollup);
|
|
267
|
+
if (reviewDecision !== undefined) out.review = reviewDecision || null;
|
|
268
|
+
if (mergeable !== undefined) out.mergeable = mergeable || null;
|
|
269
|
+
if (mergeStateStatus !== undefined) out.mergeState = mergeStateStatus || null;
|
|
270
|
+
if (headRefOid) out.head = headRefOid;
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
221
274
|
/** PRs whose head is `branch`, via the gh CLI. Returns [] when gh is missing or unauthenticated. */
|
|
222
275
|
export async function prsForBranch(cwd, branch) {
|
|
223
276
|
if (process.env.AGENTMGR_NO_GH) return [];
|
|
224
277
|
if (!branch || branch === 'HEAD' || ['main', 'master', 'develop', 'trunk'].includes(branch)) return [];
|
|
225
278
|
try {
|
|
226
279
|
const out = await run(
|
|
227
|
-
|
|
228
|
-
['pr', 'list', '--head', branch, '--state', 'all', '--limit', '20', '--json',
|
|
280
|
+
ghBin(),
|
|
281
|
+
['pr', 'list', '--head', branch, '--state', 'all', '--limit', '20', '--json', PR_FIELDS],
|
|
229
282
|
{ cwd, timeout: 30_000 },
|
|
230
283
|
);
|
|
231
|
-
return JSON.parse(out || '[]').map((p) => ({ ...prFromUrl(p.url, 'branch'), ...p }));
|
|
284
|
+
return JSON.parse(out || '[]').map((p) => ({ ...prFromUrl(p.url, 'branch'), ...compactPr(p) }));
|
|
232
285
|
} catch {
|
|
233
286
|
return [];
|
|
234
287
|
}
|
|
@@ -238,8 +291,8 @@ export async function prsForBranch(cwd, branch) {
|
|
|
238
291
|
export async function prDetails(url) {
|
|
239
292
|
if (process.env.AGENTMGR_NO_GH) return null;
|
|
240
293
|
try {
|
|
241
|
-
const out = await run(
|
|
242
|
-
return JSON.parse(out);
|
|
294
|
+
const out = await run(ghBin(), ['pr', 'view', url, '--json', PR_FIELDS], { timeout: 30_000 });
|
|
295
|
+
return compactPr(JSON.parse(out));
|
|
243
296
|
} catch {
|
|
244
297
|
return null;
|
|
245
298
|
}
|
package/src/triggers.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Triggers: start agents and pass on instructions from where the work lives, with no model
|
|
2
|
+
// watching (no tokens spent). Three sources:
|
|
3
|
+
// - GitHub label: open issues with a label (default "agentmgr") in the repos or owners you list
|
|
4
|
+
// each get an agent in that repo. Labeling needs triage access, so it is the go-ahead.
|
|
5
|
+
// - "/agent" comments on an agent's own pull request, by the repo's owners, members or
|
|
6
|
+
// collaborators, are typed into that agent.
|
|
7
|
+
// - Webhook: POST /api/hooks/agents with the secret token creates an agent (Jira automation,
|
|
8
|
+
// CI, scripts). Off until a token is set; reachable from other machines only with remote access.
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { run } from './git.js';
|
|
11
|
+
import { ghBin } from './tracker.js';
|
|
12
|
+
|
|
13
|
+
export const TRIGGER_DEFAULTS = {
|
|
14
|
+
github: { enabled: false, label: 'agentmgr', repos: [], owners: [], everyMin: 5, comment: false },
|
|
15
|
+
prCommands: { enabled: true, prefix: '/agent' },
|
|
16
|
+
webhook: { enabled: false, token: null, repos: [] },
|
|
17
|
+
maxPerDay: 20,
|
|
18
|
+
};
|
|
19
|
+
const TRUSTED = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
|
20
|
+
const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
21
|
+
|
|
22
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
23
|
+
const list = (v) => (Array.isArray(v) ? v : String(v ?? '').split(/[\s,]+/)).map((x) => String(x).trim()).filter(Boolean);
|
|
24
|
+
|
|
25
|
+
/** Settings from config.json's "triggers", with defaults; the webhook token is never returned by `view`. */
|
|
26
|
+
export function triggerSettings(config = {}) {
|
|
27
|
+
const t = config.triggers ?? {};
|
|
28
|
+
const gh = { ...TRIGGER_DEFAULTS.github, ...t.github };
|
|
29
|
+
const pc = { ...TRIGGER_DEFAULTS.prCommands, ...t.prCommands };
|
|
30
|
+
const wh = { ...TRIGGER_DEFAULTS.webhook, ...t.webhook };
|
|
31
|
+
const every = Number(gh.everyMin);
|
|
32
|
+
const max = Number(t.maxPerDay ?? TRIGGER_DEFAULTS.maxPerDay);
|
|
33
|
+
return {
|
|
34
|
+
github: {
|
|
35
|
+
enabled: Boolean(gh.enabled),
|
|
36
|
+
label: String(gh.label || 'agentmgr').trim(),
|
|
37
|
+
repos: list(gh.repos).filter((r) => REPO_RE.test(r)),
|
|
38
|
+
owners: list(gh.owners).filter((o) => /^[\w.-]+$/.test(o)),
|
|
39
|
+
everyMin: Number.isFinite(every) && every >= 1 ? Math.min(1440, Math.floor(every)) : 5,
|
|
40
|
+
comment: Boolean(gh.comment),
|
|
41
|
+
},
|
|
42
|
+
prCommands: { enabled: pc.enabled !== false, prefix: String(pc.prefix || '/agent').trim() },
|
|
43
|
+
webhook: { enabled: Boolean(wh.enabled && wh.token), token: wh.token || null, repos: list(wh.repos) },
|
|
44
|
+
maxPerDay: Number.isFinite(max) && max >= 0 ? Math.min(500, Math.floor(max)) : TRIGGER_DEFAULTS.maxPerDay,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The request for an agent started from a GitHub issue. */
|
|
49
|
+
export function issueIntent(issue) {
|
|
50
|
+
const body = String(issue.body ?? '').trim();
|
|
51
|
+
return `${issue.url}\n\n${issue.title}${body ? `\n\n${body.slice(0, 6000)}` : ''}\n\nWhen you are done, open a pull request that says "Fixes #${issue.number}".`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A PR comment for the agent, or null when it isn't a command from someone trusted. */
|
|
55
|
+
export function prCommand(comment, prefix = '/agent') {
|
|
56
|
+
const body = String(comment?.body ?? '').trim();
|
|
57
|
+
if (!body.toLowerCase().startsWith(prefix.toLowerCase())) return null;
|
|
58
|
+
if (!TRUSTED.has(comment.authorAssociation)) return null;
|
|
59
|
+
const text = body.slice(prefix.length).trim();
|
|
60
|
+
return text || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Same length and same bytes, without leaking where they differ. */
|
|
64
|
+
export function tokenMatches(given, expected) {
|
|
65
|
+
if (!given || !expected) return false;
|
|
66
|
+
const a = Buffer.from(String(given));
|
|
67
|
+
const b = Buffer.from(String(expected));
|
|
68
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class Triggers {
|
|
72
|
+
constructor({ manager, store, config = {}, notifier = null, log = (m) => console.log(`[agentmgr] ${m}`) }) {
|
|
73
|
+
this.manager = manager;
|
|
74
|
+
this.store = store;
|
|
75
|
+
this.config = config;
|
|
76
|
+
this.notifier = notifier;
|
|
77
|
+
this.log = log;
|
|
78
|
+
store.data.triggers ??= { issues: {}, comments: {}, started: [], lastPoll: null, lastError: null };
|
|
79
|
+
this.schedule();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get state() {
|
|
83
|
+
return this.store.data.triggers;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get settings() {
|
|
87
|
+
return triggerSettings(this.config);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
schedule() {
|
|
91
|
+
clearInterval(this.timer);
|
|
92
|
+
this.timer = setInterval(() => this.poll().catch((e) => this.fail(e)), this.settings.github.everyMin * 60_000);
|
|
93
|
+
this.timer.unref?.();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
close() {
|
|
97
|
+
clearInterval(this.timer);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fail(e) {
|
|
101
|
+
this.state.lastError = { at: new Date().toISOString(), message: e.message.split('\n')[0].slice(0, 300) };
|
|
102
|
+
this.log(`triggers: ${this.state.lastError.message}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Settings for the UI: the token is replaced by whether one is set. */
|
|
106
|
+
view() {
|
|
107
|
+
const s = this.settings;
|
|
108
|
+
const today = this.startedToday();
|
|
109
|
+
return {
|
|
110
|
+
...s,
|
|
111
|
+
webhook: { enabled: s.webhook.enabled, hasToken: Boolean(s.webhook.token), repos: s.webhook.repos },
|
|
112
|
+
startedToday: today.length,
|
|
113
|
+
recent: this.state.started.slice(-20).reverse(),
|
|
114
|
+
lastPoll: this.state.lastPoll,
|
|
115
|
+
lastError: this.state.lastError,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
update(patch = {}) {
|
|
120
|
+
const cur = this.config.triggers ?? {};
|
|
121
|
+
const next = {
|
|
122
|
+
github: { ...cur.github, ...patch.github },
|
|
123
|
+
prCommands: { ...cur.prCommands, ...patch.prCommands },
|
|
124
|
+
webhook: { ...cur.webhook, ...patch.webhook },
|
|
125
|
+
maxPerDay: patch.maxPerDay ?? cur.maxPerDay,
|
|
126
|
+
};
|
|
127
|
+
if (patch.webhook?.newToken) {
|
|
128
|
+
next.webhook.token = crypto.randomBytes(24).toString('base64url');
|
|
129
|
+
delete next.webhook.newToken;
|
|
130
|
+
}
|
|
131
|
+
if (next.github.enabled && !list(next.github.repos).length && !list(next.github.owners).length) {
|
|
132
|
+
throw err(400, 'List the repos (owner/name) or owners to watch, so issues elsewhere on GitHub never start agents');
|
|
133
|
+
}
|
|
134
|
+
const s = triggerSettings({ triggers: next });
|
|
135
|
+
this.config.triggers = { github: s.github, prCommands: s.prCommands, webhook: { enabled: s.webhook.enabled, token: s.webhook.token, repos: s.webhook.repos }, maxPerDay: s.maxPerDay };
|
|
136
|
+
this.schedule();
|
|
137
|
+
return { saved: this.config.triggers, token: patch.webhook?.newToken ? s.webhook.token : undefined };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
startedToday() {
|
|
141
|
+
const since = Date.now() - 86_400_000;
|
|
142
|
+
return this.state.started.filter((x) => Date.parse(x.at) > since);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Create one agent for a trigger, within the daily cap. */
|
|
146
|
+
start({ source, url, intent, repo, name, project, priority }) {
|
|
147
|
+
if (this.startedToday().length >= this.settings.maxPerDay) throw err(429, `Triggers started ${this.settings.maxPerDay} agents in the last day (triggers.maxPerDay)`);
|
|
148
|
+
const [agent] = this.manager.createMany([intent], {
|
|
149
|
+
repo,
|
|
150
|
+
name,
|
|
151
|
+
project,
|
|
152
|
+
priority,
|
|
153
|
+
trigger: { source, url: url ?? null, at: new Date().toISOString() },
|
|
154
|
+
});
|
|
155
|
+
this.state.started = [...this.state.started, { at: new Date().toISOString(), source, url: url ?? null, agent: agent.id, name: agent.name }].slice(-200);
|
|
156
|
+
this.store.save();
|
|
157
|
+
this.notifier?.send?.('assistant', agent, `was started from ${source === 'github' ? 'a GitHub label' : 'a webhook'}${url ? `: ${url}` : ''}`);
|
|
158
|
+
this.log(`triggers: started ${agent.name} from ${source}${url ? ` (${url})` : ''}`);
|
|
159
|
+
return agent;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async gh(args) {
|
|
163
|
+
return run(ghBin(), args, { timeout: 60_000 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One pass: labeled issues, then commands on agents' pull requests. */
|
|
167
|
+
async poll() {
|
|
168
|
+
if (process.env.AGENTMGR_NO_GH) return { issues: 0, commands: 0 };
|
|
169
|
+
const s = this.settings;
|
|
170
|
+
let issues = 0;
|
|
171
|
+
let commands = 0;
|
|
172
|
+
if (s.github.enabled && (s.github.repos.length || s.github.owners.length)) issues = await this.pollIssues(s.github);
|
|
173
|
+
if (s.prCommands.enabled) commands = await this.pollPrCommands(s.prCommands.prefix);
|
|
174
|
+
this.state.lastPoll = { at: new Date().toISOString(), issues, commands };
|
|
175
|
+
this.state.lastError = null;
|
|
176
|
+
this.store.save();
|
|
177
|
+
return { issues, commands };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async pollIssues(g) {
|
|
181
|
+
const scopes = [...g.repos.map((r) => ['--repo', r]), ...g.owners.map((o) => ['--owner', o])];
|
|
182
|
+
let started = 0;
|
|
183
|
+
for (const scope of scopes) {
|
|
184
|
+
const out = await this.gh(['search', 'issues', '--label', g.label, '--state', 'open', ...scope, '--limit', '30', '--json', 'number,title,body,url,repository']);
|
|
185
|
+
for (const issue of JSON.parse(out || '[]')) {
|
|
186
|
+
if (this.state.issues[issue.url]) continue;
|
|
187
|
+
const nwo = issue.repository?.nameWithOwner;
|
|
188
|
+
if (!nwo || !REPO_RE.test(nwo)) continue;
|
|
189
|
+
// Mark first: a failure must not start the same issue again on every pass.
|
|
190
|
+
this.state.issues[issue.url] = { at: new Date().toISOString() };
|
|
191
|
+
try {
|
|
192
|
+
// A project already on this repo reuses its clone and settings.
|
|
193
|
+
const project = this.projectFor(nwo);
|
|
194
|
+
const agent = this.start({ source: 'github', url: issue.url, intent: issueIntent(issue), ...(project ? { project: project.shortcut } : { repo: `https://github.com/${nwo}` }), name: `${issue.repository.name}#${issue.number}` });
|
|
195
|
+
this.state.issues[issue.url].agent = agent.id;
|
|
196
|
+
started += 1;
|
|
197
|
+
if (g.comment) {
|
|
198
|
+
await this.gh(['issue', 'comment', issue.url, '--body', `AI Agent Manager started an agent on this (\`${agent.name}\`). It will open a pull request.`]).catch(() => {});
|
|
199
|
+
}
|
|
200
|
+
} catch (e) {
|
|
201
|
+
this.state.issues[issue.url].error = e.message;
|
|
202
|
+
if (e.status === 429) break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return started;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
projectFor(nwo) {
|
|
210
|
+
const re = new RegExp(`github\\.com[:/]${nwo.replace(/[.]/g, '\\.')}(\\.git)?/?$`, 'i');
|
|
211
|
+
return Object.values(this.store.data.projects ?? {}).find((p) => p.repo && re.test(p.repo) && p.shortcut) ?? null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async pollPrCommands(prefix) {
|
|
215
|
+
let sent = 0;
|
|
216
|
+
for (const agent of this.store.list()) {
|
|
217
|
+
for (const pr of Object.values(agent.prs ?? {})) {
|
|
218
|
+
if (pr.state !== 'OPEN' || pr.source === 'mentioned') continue;
|
|
219
|
+
const out = await this.gh(['pr', 'view', pr.url, '--json', 'comments']).catch(() => null);
|
|
220
|
+
if (!out) continue;
|
|
221
|
+
const comments = JSON.parse(out).comments ?? [];
|
|
222
|
+
const seen = (this.state.comments[pr.url] ??= { ids: [], since: new Date().toISOString() });
|
|
223
|
+
for (const c of comments) {
|
|
224
|
+
const id = c.id ?? c.url ?? `${c.author?.login}:${c.createdAt}`;
|
|
225
|
+
if (seen.ids.includes(id)) continue;
|
|
226
|
+
seen.ids = [...seen.ids, id].slice(-200);
|
|
227
|
+
// Comments from before we started watching this PR are history, not commands.
|
|
228
|
+
if (Date.parse(c.createdAt) < Date.parse(seen.since)) continue;
|
|
229
|
+
const text = prCommand(c, prefix);
|
|
230
|
+
if (!text) continue;
|
|
231
|
+
try {
|
|
232
|
+
this.manager.tell(agent.id, `${c.author?.login ?? 'Someone'} asked on your pull request ${pr.url}:\n${text}\n\nDo it, push, and reply on the pull request when done.`, { source: 'github' });
|
|
233
|
+
sent += 1;
|
|
234
|
+
} catch (e) {
|
|
235
|
+
this.log(`triggers: couldn't pass a comment to ${agent.name}: ${e.message}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return sent;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The webhook: { intent, repo?, project?, name?, priority? } with the token. */
|
|
244
|
+
hook(body = {}) {
|
|
245
|
+
const s = this.settings;
|
|
246
|
+
if (!s.webhook.enabled) throw err(404, 'Not found');
|
|
247
|
+
const intent = String(body.intent ?? '').trim();
|
|
248
|
+
if (!intent) throw err(400, 'intent is required');
|
|
249
|
+
const repo = body.repo ? String(body.repo).trim() : undefined;
|
|
250
|
+
const project = body.project ? String(body.project).trim() : undefined;
|
|
251
|
+
if (!repo && !project) throw err(400, 'repo or project is required');
|
|
252
|
+
if (s.webhook.repos.length && repo && !s.webhook.repos.some((r) => repo === r || repo.endsWith(`/${r}`) || repo.endsWith(`/${r}.git`))) {
|
|
253
|
+
throw err(403, 'That repo is not in triggers.webhook.repos');
|
|
254
|
+
}
|
|
255
|
+
const agent = this.start({ source: 'webhook', url: body.url ? String(body.url) : null, intent, repo, project, name: body.name ? String(body.name) : undefined, priority: body.priority });
|
|
256
|
+
return { id: agent.id, name: agent.name };
|
|
257
|
+
}
|
|
258
|
+
}
|