ai-agentmanager 1.2.1 → 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 +15 -0
- package/README.md +94 -3
- package/docs/plugins.md +4 -1
- package/package.json +1 -1
- package/public/app.js +65 -3
- package/public/index.html +133 -1
- 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 +104 -0
- package/public/triggers.js +85 -0
- package/src/assistant.js +13 -3
- package/src/budgets.js +166 -0
- package/src/cli.js +292 -2
- package/src/engines/claude.js +6 -4
- package/src/git.js +4 -4
- package/src/landing.js +212 -0
- package/src/manager.js +98 -67
- package/src/memory.js +106 -0
- package/src/notify.js +3 -2
- package/src/playbooks.js +178 -0
- package/src/review.js +209 -0
- package/src/server.js +85 -3
- package/src/stats.js +107 -0
- package/src/tracker.js +61 -8
- package/src/triggers.js +258 -0
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
|
+
}
|