ai-agentmanager 1.2.1 → 1.3.1
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 +20 -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 +323 -14
- package/src/client.js +46 -6
- package/src/engines/claude.js +6 -4
- package/src/git.js +4 -4
- package/src/landing.js +212 -0
- package/src/manager.js +100 -68
- 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 +87 -4
- package/src/stats.js +107 -0
- package/src/tracker.js +61 -8
- package/src/triggers.js +258 -0
package/src/playbooks.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Playbooks: saved requests you run again, by hand or on a schedule. One playbook is a request
|
|
2
|
+
// (with {{placeholders}} filled in at run time), the projects to run it in (one agent each), and
|
|
3
|
+
// optionally a model, permission mode and schedule: "upgrade dependency X in every repo",
|
|
4
|
+
// "every weekday at 9:00, triage the flaky tests".
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
8
|
+
export const EVERY = ['hour', 'day', 'weekday', 'week'];
|
|
9
|
+
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
10
|
+
|
|
11
|
+
/** "{{name}}" placeholders in a request, in order, once each. */
|
|
12
|
+
export function placeholders(text) {
|
|
13
|
+
return [...new Set([...String(text ?? '').matchAll(/\{\{\s*([\w-]+)\s*\}\}/g)].map((m) => m[1]))];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function render(text, vars = {}) {
|
|
17
|
+
return String(text).replace(/\{\{\s*([\w-]+)\s*\}\}/g, (m, k) => (vars[k] != null && String(vars[k]).trim() ? String(vars[k]).trim() : m));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A schedule, checked: { every: hour|day|weekday|week, at: "HH:MM", day: "mon".."sun" } or null. */
|
|
21
|
+
export function cleanSchedule(s) {
|
|
22
|
+
if (!s || !s.every) return null;
|
|
23
|
+
if (!EVERY.includes(s.every)) throw err(400, `schedule.every must be one of ${EVERY.join(', ')}`);
|
|
24
|
+
const at = String(s.at ?? '09:00').trim();
|
|
25
|
+
if (!/^([01]?\d|2[0-3]):[0-5]\d$/.test(at)) throw err(400, 'schedule.at must be HH:MM');
|
|
26
|
+
const day = s.every === 'week' ? String(s.day ?? 'mon').toLowerCase().slice(0, 3) : undefined;
|
|
27
|
+
if (day !== undefined && !DAYS.includes(day)) throw err(400, 'schedule.day must be mon..sun');
|
|
28
|
+
const [h, m] = at.split(':').map(Number);
|
|
29
|
+
return { every: s.every, at: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`, ...(day ? { day } : {}) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The first time the schedule fires strictly after `after` (local time). */
|
|
33
|
+
export function nextRun(schedule, after = new Date()) {
|
|
34
|
+
if (!schedule) return null;
|
|
35
|
+
const [h, m] = schedule.at.split(':').map(Number);
|
|
36
|
+
const t = new Date(after);
|
|
37
|
+
if (schedule.every === 'hour') {
|
|
38
|
+
t.setSeconds(0, 0);
|
|
39
|
+
t.setMinutes(m);
|
|
40
|
+
if (t <= after) t.setHours(t.getHours() + 1);
|
|
41
|
+
return t;
|
|
42
|
+
}
|
|
43
|
+
t.setHours(h, m, 0, 0);
|
|
44
|
+
for (let i = 0; i < 9; i += 1) {
|
|
45
|
+
const ok =
|
|
46
|
+
t > after &&
|
|
47
|
+
(schedule.every === 'day' ||
|
|
48
|
+
(schedule.every === 'weekday' && t.getDay() >= 1 && t.getDay() <= 5) ||
|
|
49
|
+
(schedule.every === 'week' && DAYS[t.getDay()] === schedule.day));
|
|
50
|
+
if (ok) return t;
|
|
51
|
+
t.setDate(t.getDate() + 1);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class Playbooks {
|
|
57
|
+
constructor({ manager, store, log = (m) => console.log(`[agentmgr] ${m}`) }) {
|
|
58
|
+
this.manager = manager;
|
|
59
|
+
this.store = store;
|
|
60
|
+
this.log = log;
|
|
61
|
+
store.data.playbooks ??= {};
|
|
62
|
+
this.timer = setInterval(() => this.tick().catch((e) => this.log(`playbooks: ${e.message}`)), 60_000);
|
|
63
|
+
this.timer.unref?.();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
close() {
|
|
67
|
+
clearInterval(this.timer);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get all() {
|
|
71
|
+
return this.store.data.playbooks;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
view(p) {
|
|
75
|
+
return { ...p, placeholders: placeholders(p.intent), nextRunAt: p.enabled && p.schedule ? nextRun(p.schedule, new Date(Math.max(Date.now(), Date.parse(p.lastRunAt ?? 0) || 0)))?.toISOString() ?? null : null };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
list() {
|
|
79
|
+
return Object.values(this.all)
|
|
80
|
+
.map((p) => this.view(p))
|
|
81
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get(id) {
|
|
85
|
+
const p = this.all[id] ?? Object.values(this.all).find((x) => x.name.toLowerCase() === String(id).toLowerCase());
|
|
86
|
+
if (!p) throw err(404, `No playbook "${id}"`);
|
|
87
|
+
return p;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
clean(input, prev = {}) {
|
|
91
|
+
const name = String(input.name ?? prev.name ?? '').trim().slice(0, 80);
|
|
92
|
+
if (!name) throw err(400, 'A name is required');
|
|
93
|
+
const intent = String(input.intent ?? prev.intent ?? '').trim();
|
|
94
|
+
if (!intent) throw err(400, 'The request is required');
|
|
95
|
+
const projects = (Array.isArray(input.projects) ? input.projects : String(input.projects ?? '').split(/[\s,]+/)).map((x) => String(x).trim().replace(/^@/, '')).filter(Boolean);
|
|
96
|
+
const list = input.projects === undefined ? prev.projects ?? [] : projects;
|
|
97
|
+
for (const p of list) if (!this.manager.resolveProject(p)) throw err(400, `No project "${p}" (agentmgr project ls)`);
|
|
98
|
+
if (!list.length) throw err(400, 'Pick at least one project to run it in');
|
|
99
|
+
const schedule = input.schedule === undefined ? prev.schedule ?? null : cleanSchedule(input.schedule);
|
|
100
|
+
if (schedule && placeholders(intent).some((k) => !(input.defaults ?? prev.defaults ?? {})[k])) {
|
|
101
|
+
throw err(400, 'A scheduled playbook needs a default value for each {{placeholder}}');
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
name,
|
|
105
|
+
intent,
|
|
106
|
+
projects: list,
|
|
107
|
+
model: (input.model ?? prev.model ?? '') || null,
|
|
108
|
+
permissionMode: (input.permissionMode ?? prev.permissionMode ?? '') || null,
|
|
109
|
+
priority: input.priority ?? prev.priority ?? 2,
|
|
110
|
+
defaults: input.defaults ?? prev.defaults ?? {},
|
|
111
|
+
schedule,
|
|
112
|
+
enabled: input.enabled ?? prev.enabled ?? true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
create(input = {}) {
|
|
117
|
+
const p = { id: crypto.randomBytes(3).toString('hex'), ...this.clean(input), createdAt: new Date().toISOString(), runs: [] };
|
|
118
|
+
if (Object.values(this.all).some((x) => x.name.toLowerCase() === p.name.toLowerCase())) throw err(409, `A playbook named "${p.name}" exists`);
|
|
119
|
+
this.all[p.id] = p;
|
|
120
|
+
this.store.save();
|
|
121
|
+
return this.view(p);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
update(id, input = {}) {
|
|
125
|
+
const p = this.get(id);
|
|
126
|
+
Object.assign(p, this.clean(input, p));
|
|
127
|
+
this.store.save();
|
|
128
|
+
return this.view(p);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
remove(id) {
|
|
132
|
+
const p = this.get(id);
|
|
133
|
+
delete this.all[p.id];
|
|
134
|
+
this.store.save();
|
|
135
|
+
return { ok: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One agent per project, with the placeholders filled in. */
|
|
139
|
+
run(id, { vars = {}, by = 'person' } = {}) {
|
|
140
|
+
const p = this.get(id);
|
|
141
|
+
const values = { ...p.defaults, ...vars };
|
|
142
|
+
const intent = render(p.intent, values);
|
|
143
|
+
const missing = placeholders(intent);
|
|
144
|
+
if (missing.length) throw err(400, `Fill in ${missing.map((k) => `{{${k}}}`).join(', ')}`);
|
|
145
|
+
const run = { at: new Date().toISOString(), by, agents: [], failed: [] };
|
|
146
|
+
for (const project of p.projects) {
|
|
147
|
+
try {
|
|
148
|
+
const [a] = this.manager.createMany([intent], { project, model: p.model ?? undefined, permissionMode: p.permissionMode ?? undefined, priority: p.priority });
|
|
149
|
+
this.manager.patch(a.id, { playbook: { id: p.id, name: p.name, at: run.at } });
|
|
150
|
+
run.agents.push({ id: a.id, name: a.name, project });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
run.failed.push({ project, reason: e.message });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
p.lastRunAt = run.at;
|
|
156
|
+
p.runs = [...(p.runs ?? []), run].slice(-20);
|
|
157
|
+
this.store.save();
|
|
158
|
+
this.log(`playbooks: ran "${p.name}" (${by}): ${run.agents.length} agent(s)${run.failed.length ? `, ${run.failed.length} failed` : ''}`);
|
|
159
|
+
return run;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Run what is due. A run missed while the daemon was down runs once when it is back. */
|
|
163
|
+
async tick(now = new Date()) {
|
|
164
|
+
for (const p of Object.values(this.all)) {
|
|
165
|
+
if (!p.enabled || !p.schedule) continue;
|
|
166
|
+
const since = new Date(Date.parse(p.lastRunAt ?? p.createdAt) || now.getTime());
|
|
167
|
+
const due = nextRun(p.schedule, since);
|
|
168
|
+
if (!due || due > now) continue;
|
|
169
|
+
try {
|
|
170
|
+
this.run(p.id, { by: 'schedule' });
|
|
171
|
+
} catch (e) {
|
|
172
|
+
p.lastRunAt = now.toISOString();
|
|
173
|
+
p.runs = [...(p.runs ?? []), { at: p.lastRunAt, by: 'schedule', agents: [], failed: [{ project: '*', reason: e.message }] }].slice(-20);
|
|
174
|
+
this.store.save();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/review.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Review an agent's changes: the diff against where its branch started, line comments sent
|
|
2
|
+
// back to the agent as one message, and commit / push / open a pull request from the UI.
|
|
3
|
+
import * as git from './git.js';
|
|
4
|
+
import { NO_REPO } from './manager.js';
|
|
5
|
+
import { ghBin } from './tracker.js';
|
|
6
|
+
|
|
7
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
8
|
+
const MAX_COMMENTS = 100;
|
|
9
|
+
|
|
10
|
+
/** "origin/main @ 1a2b3c4d5" -> { ref: 'origin/main', sha: '1a2b3c4d5' } */
|
|
11
|
+
export function parseBaseRef(baseRef) {
|
|
12
|
+
const m = /^(.*) @ ([0-9a-f]{4,40})$/.exec(baseRef ?? '');
|
|
13
|
+
return m ? { ref: m[1], sha: m[2] } : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The branch a pull request should target, from the ref the agent branched from. */
|
|
17
|
+
export function prBase(ref) {
|
|
18
|
+
if (!ref || ref === 'HEAD' || /^[0-9a-f]{7,40}$/.test(ref)) return null;
|
|
19
|
+
return ref.replace(/^origin\//, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One message for the agent with every comment, its place and the line it is about. */
|
|
23
|
+
export function commentsMessage(comments, note = '') {
|
|
24
|
+
const lines = ['Review of your changes. Please address each comment, then say what you changed.', ''];
|
|
25
|
+
comments.forEach((c, i) => {
|
|
26
|
+
const where = c.file ? `${c.file}${c.line ? `:${c.line}` : ''}` : 'General';
|
|
27
|
+
lines.push(`${i + 1}. ${where}${c.side === 'old' ? ' (removed line)' : ''}`);
|
|
28
|
+
if (c.code) lines.push(` > ${String(c.code).trim().slice(0, 200)}`);
|
|
29
|
+
lines.push(` ${String(c.text).trim().replace(/\n/g, '\n ')}`);
|
|
30
|
+
});
|
|
31
|
+
if (note.trim()) lines.push('', note.trim());
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Per-file status from `git diff --name-status` plus untracked files. */
|
|
36
|
+
function fileStatuses(nameStatus, untracked) {
|
|
37
|
+
const out = {};
|
|
38
|
+
const kinds = { A: 'added', D: 'deleted', M: 'modified', T: 'modified' };
|
|
39
|
+
for (const line of nameStatus.split('\n').filter(Boolean)) {
|
|
40
|
+
const [code, ...paths] = line.split('\t');
|
|
41
|
+
out[paths.at(-1)] = kinds[code[0]] ?? (code[0] === 'R' ? 'renamed' : 'modified');
|
|
42
|
+
}
|
|
43
|
+
for (const f of untracked) out[f] = 'added';
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function addedLines(diff, file) {
|
|
48
|
+
const start = diff.indexOf(`diff --git a/${file} b/${file}\n`);
|
|
49
|
+
if (start < 0) return null;
|
|
50
|
+
const end = diff.indexOf('\ndiff --git ', start + 1);
|
|
51
|
+
const part = diff.slice(start, end < 0 ? undefined : end);
|
|
52
|
+
if (/^Binary files /m.test(part)) return null;
|
|
53
|
+
return part.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class Review {
|
|
57
|
+
constructor({ manager }) {
|
|
58
|
+
this.manager = manager;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
agentWithRepo(ref) {
|
|
62
|
+
const agent = this.manager.mustGet(ref);
|
|
63
|
+
if (!agent.workdir || NO_REPO.has(agent.kind)) {
|
|
64
|
+
throw err(400, 'This agent has no repository to review');
|
|
65
|
+
}
|
|
66
|
+
return agent;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where the diff starts: the merge base with the branch it came from, else the recorded commit. */
|
|
70
|
+
async base(agent) {
|
|
71
|
+
const b = parseBaseRef(agent.baseRef);
|
|
72
|
+
const opts = { cwd: agent.workdir, timeout: 30_000 };
|
|
73
|
+
if (b?.ref && b.ref !== 'HEAD') {
|
|
74
|
+
const mb = await git.run('git', ['merge-base', 'HEAD', b.ref], opts).catch(() => null);
|
|
75
|
+
if (mb) return { ref: b.ref, sha: mb };
|
|
76
|
+
}
|
|
77
|
+
if (b?.sha) return { ref: b.ref, sha: b.sha };
|
|
78
|
+
// No record (imported folders): the remote default branch, if there is one.
|
|
79
|
+
const r = await git.resolveBase(agent.workdir).catch(() => null);
|
|
80
|
+
if (r && r.ref !== 'HEAD') {
|
|
81
|
+
const mb = await git.run('git', ['merge-base', 'HEAD', r.ref], opts).catch(() => null);
|
|
82
|
+
if (mb) return { ref: r.ref, sha: mb };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async get(ref) {
|
|
88
|
+
const agent = this.agentWithRepo(ref);
|
|
89
|
+
const dir = agent.workdir;
|
|
90
|
+
if (!(await git.isGitRepo(dir))) throw err(400, 'The agent folder is not a git repository');
|
|
91
|
+
const opts = { cwd: dir, timeout: 30_000 };
|
|
92
|
+
const safe = (p) => p.catch(() => '');
|
|
93
|
+
const base = await this.base(agent);
|
|
94
|
+
const branch = await safe(git.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], opts));
|
|
95
|
+
const from = base?.sha ?? 'HEAD';
|
|
96
|
+
const [nameStatus, numstat, untrackedOut, log, status, upstream] = await Promise.all([
|
|
97
|
+
safe(git.run('git', ['diff', '--name-status', from], opts)),
|
|
98
|
+
safe(git.run('git', ['diff', '--numstat', from], opts)),
|
|
99
|
+
safe(git.run('git', ['ls-files', '--others', '--exclude-standard'], opts)),
|
|
100
|
+
base ? safe(git.run('git', ['log', '--format=%h%x09%s%x09%cI', '-n', '50', `${base.sha}..HEAD`], opts)) : '',
|
|
101
|
+
safe(git.run('git', ['status', '--porcelain'], opts)),
|
|
102
|
+
safe(git.run('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], opts)),
|
|
103
|
+
]);
|
|
104
|
+
const untracked = untrackedOut.split('\n').filter(Boolean);
|
|
105
|
+
const statuses = fileStatuses(nameStatus, untracked);
|
|
106
|
+
const counts = {};
|
|
107
|
+
for (const line of numstat.split('\n').filter(Boolean)) {
|
|
108
|
+
const [a, d, ...p] = line.split('\t');
|
|
109
|
+
counts[p.join('\t').replace(/^.*=> /, '').replace(/[{}]/g, '')] = { added: a === '-' ? null : Number(a), removed: d === '-' ? null : Number(d) };
|
|
110
|
+
}
|
|
111
|
+
const diff = await git.fullDiff(dir, from);
|
|
112
|
+
const files = Object.entries(statuses)
|
|
113
|
+
.map(([path, st]) => ({ path, status: st, ...(counts[path] ?? { added: null, removed: 0 }), untracked: untracked.includes(path) }))
|
|
114
|
+
.sort((x, y) => (x.path < y.path ? -1 : 1));
|
|
115
|
+
// New files git doesn't count yet: their added lines, from the diff.
|
|
116
|
+
for (const f of files.filter((x) => x.untracked && x.added == null)) f.added = addedLines(diff, f.path);
|
|
117
|
+
let ahead = 0;
|
|
118
|
+
if (upstream) ahead = Number(await safe(git.run('git', ['rev-list', '--count', `${upstream}..HEAD`], opts))) || 0;
|
|
119
|
+
const rt = this.manager.runtimes.get(agent.id);
|
|
120
|
+
return {
|
|
121
|
+
id: agent.id,
|
|
122
|
+
name: agent.name,
|
|
123
|
+
branch,
|
|
124
|
+
base,
|
|
125
|
+
prBase: prBase(base?.ref),
|
|
126
|
+
commits: log.split('\n').filter(Boolean).map((l) => {
|
|
127
|
+
const [sha, subject, at] = l.split('\t');
|
|
128
|
+
return { sha, subject, at };
|
|
129
|
+
}),
|
|
130
|
+
files,
|
|
131
|
+
uncommitted: status ? status.split('\n').length : 0,
|
|
132
|
+
upstream: upstream || null,
|
|
133
|
+
unpushed: upstream ? ahead : null,
|
|
134
|
+
hasRemote: await git.hasRemote(dir),
|
|
135
|
+
prs: Object.values(agent.prs ?? {}),
|
|
136
|
+
busy: Boolean(rt?.pty && rt.busy),
|
|
137
|
+
diff,
|
|
138
|
+
truncated: diff.endsWith('… (diff truncated)'),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Send the comments to the agent as one message (typed in when it is idle). */
|
|
143
|
+
comment(ref, { comments = [], note = '' } = {}) {
|
|
144
|
+
const agent = this.agentWithRepo(ref);
|
|
145
|
+
const list = (Array.isArray(comments) ? comments : []).filter((c) => String(c?.text ?? '').trim()).slice(0, MAX_COMMENTS);
|
|
146
|
+
if (!list.length && !String(note).trim()) throw err(400, 'Add a comment first');
|
|
147
|
+
const text = commentsMessage(list, String(note));
|
|
148
|
+
this.manager.tell(agent.id, text, { source: 'review' });
|
|
149
|
+
agent.reviewsSent = [...(agent.reviewsSent ?? []), { at: new Date().toISOString(), comments: list.length }].slice(-20);
|
|
150
|
+
this.manager.store.save();
|
|
151
|
+
return { sent: list.length, text };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Commit everything in the worktree (new files included) with the person's git identity. */
|
|
155
|
+
async commit(ref, { message } = {}) {
|
|
156
|
+
const agent = this.agentWithRepo(ref);
|
|
157
|
+
message = String(message ?? '').trim();
|
|
158
|
+
if (!message) throw err(400, 'A commit message is required');
|
|
159
|
+
this.manager.plugins.check('agent.commit', { agent: this.manager.auditView(agent), message });
|
|
160
|
+
const opts = { cwd: agent.workdir, timeout: 60_000 };
|
|
161
|
+
const status = await git.run('git', ['status', '--porcelain'], opts);
|
|
162
|
+
if (!status.trim()) throw err(409, 'Nothing to commit');
|
|
163
|
+
await git.run('git', ['add', '-A'], opts);
|
|
164
|
+
await git.run('git', ['commit', '-m', message], opts);
|
|
165
|
+
const sha = await git.run('git', ['rev-parse', '--short', 'HEAD'], opts);
|
|
166
|
+
this.manager.plugins.emit('agent.commit', { agent: this.manager.auditView(agent), message, sha });
|
|
167
|
+
this.manager.refresh(agent.id).catch(() => {});
|
|
168
|
+
return { sha, files: status.split('\n').length };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Push the branch and open a pull request (or return the one already open). */
|
|
172
|
+
async pr(ref, { title = '', body = '', draft = false } = {}) {
|
|
173
|
+
const agent = this.agentWithRepo(ref);
|
|
174
|
+
const dir = agent.workdir;
|
|
175
|
+
this.manager.plugins.check('agent.pr', { agent: this.manager.auditView(agent), title: String(title), draft: Boolean(draft) });
|
|
176
|
+
const opts = { cwd: dir, timeout: 120_000 };
|
|
177
|
+
if (!(await git.hasRemote(dir))) throw err(409, 'The repository has no "origin" remote to push to');
|
|
178
|
+
const branch = await git.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], opts);
|
|
179
|
+
if (!branch || branch === 'HEAD') throw err(409, 'The worktree is not on a branch');
|
|
180
|
+
const dirty = await git.run('git', ['status', '--porcelain'], opts);
|
|
181
|
+
if (dirty.trim()) throw err(409, 'There are uncommitted changes: commit them first');
|
|
182
|
+
const base = await this.base(agent);
|
|
183
|
+
const target = prBase(base?.ref);
|
|
184
|
+
if (target && branch === target) throw err(409, `The worktree is on ${branch} itself; a pull request needs its own branch`);
|
|
185
|
+
await git.run('git', ['push', '-u', 'origin', `HEAD:refs/heads/${branch}`], opts);
|
|
186
|
+
const open = Object.values(agent.prs ?? {}).find((p) => p.state === 'OPEN');
|
|
187
|
+
if (open) {
|
|
188
|
+
this.manager.refresh(agent.id, { withPrs: true }).catch(() => {});
|
|
189
|
+
return { url: open.url, pushed: true, existing: true };
|
|
190
|
+
}
|
|
191
|
+
const args = ['pr', 'create', '--head', branch];
|
|
192
|
+
if (target) args.push('--base', target);
|
|
193
|
+
if (String(title).trim()) args.push('--title', String(title).trim(), '--body', String(body ?? ''));
|
|
194
|
+
else args.push('--fill');
|
|
195
|
+
if (draft) args.push('--draft');
|
|
196
|
+
let out;
|
|
197
|
+
try {
|
|
198
|
+
out = await git.run(ghBin(), args, opts);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
const m = /https:\/\/\S+\/pull\/\d+/.exec(e.message);
|
|
201
|
+
if (m && /already exists/i.test(e.message)) out = m[0];
|
|
202
|
+
else throw err(502, `Pushed, but opening the pull request failed: ${e.message.replace(/^gh .*? failed: /, '')}`);
|
|
203
|
+
}
|
|
204
|
+
const url = /https:\/\/\S+\/pull\/\d+/.exec(out)?.[0];
|
|
205
|
+
if (url) this.manager.addLink(agent.id, { type: 'pr', value: url });
|
|
206
|
+
this.manager.plugins.emit('agent.pr', { agent: this.manager.auditView(agent), url });
|
|
207
|
+
return { url: url ?? null, pushed: true, existing: false };
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/server.js
CHANGED
|
@@ -10,9 +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';
|
|
14
15
|
import { Assistant } from './assistant.js';
|
|
15
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';
|
|
16
24
|
import { listEngines, registerEngine } from './engines/index.js';
|
|
17
25
|
import { Plugins, pluginSpecs } from './plugins.js';
|
|
18
26
|
import { HubLink, linkUrls } from './hublink.js';
|
|
@@ -47,7 +55,9 @@ function isAllowedOrigin(origin, port) {
|
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
|
|
50
|
-
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']);
|
|
51
61
|
|
|
52
62
|
export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
53
63
|
ensureHome();
|
|
@@ -58,8 +68,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
58
68
|
const manager = new AgentManager({ store, config, plugins });
|
|
59
69
|
const backlog = new Backlog({ manager, store, config });
|
|
60
70
|
const races = new Races({ manager, store });
|
|
71
|
+
const review = new Review({ manager });
|
|
61
72
|
const teams = new Teams({ manager, store, config });
|
|
62
73
|
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
74
|
+
const landing = new Landing({ manager, store, config });
|
|
63
75
|
const assistant = new Assistant({ manager, store, config });
|
|
64
76
|
// Alerts link to the phone address when remote access is on, else the local UI.
|
|
65
77
|
const notifier = new Notifier({
|
|
@@ -71,6 +83,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
71
83
|
return `${phone ? phone.url : `http://127.0.0.1:${port}/`}#${id}/term`;
|
|
72
84
|
},
|
|
73
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 });
|
|
74
90
|
let remoteServer = null;
|
|
75
91
|
let remotePort = null;
|
|
76
92
|
// Live browser tabs: what a page does is saved on the agent's tab; popups become new tabs.
|
|
@@ -137,9 +153,13 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
137
153
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
138
154
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
139
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);
|
|
140
159
|
const why = denied(req);
|
|
141
|
-
if (why) return res.status(why === 'Sign in required' ? 401 : 403).json({ error: why });
|
|
142
|
-
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') {
|
|
143
163
|
return res.status(403).json({ error: `Missing ${CLIENT_HEADER} header` });
|
|
144
164
|
}
|
|
145
165
|
next();
|
|
@@ -173,7 +193,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
173
193
|
};
|
|
174
194
|
const fail = (status, message) => Object.assign(new Error(message), { status });
|
|
175
195
|
|
|
176
|
-
|
|
196
|
+
// `closing` while shutting down: it still answers until every agent is closed, then exits.
|
|
197
|
+
api.get('/health', wrap(() => ({ ok: true, pid: process.pid, home: paths.home, claudeBin: config.claudeBin, closing })));
|
|
177
198
|
api.get('/agents', wrap(() => manager.list()));
|
|
178
199
|
api.get('/engines', wrap(() => listEngines(config)));
|
|
179
200
|
api.post(
|
|
@@ -403,6 +424,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
403
424
|
await manager.checkpoint(id);
|
|
404
425
|
return manager.get(id);
|
|
405
426
|
}));
|
|
427
|
+
api.get('/agents/:id/changes', wrap((req) => review.get(req.params.id)));
|
|
428
|
+
api.post('/agents/:id/changes/comments', wrap((req) => review.comment(req.params.id, req.body ?? {})));
|
|
429
|
+
api.post('/agents/:id/changes/commit', wrap((req) => review.commit(req.params.id, req.body ?? {})));
|
|
430
|
+
api.post('/agents/:id/changes/pr', wrap((req) => review.pr(req.params.id, req.body ?? {})));
|
|
406
431
|
api.post('/agents/:id/fork', wrap((req) => manager.fork(req.params.id, req.body ?? {})));
|
|
407
432
|
|
|
408
433
|
api.get('/races', wrap(() => races.list()));
|
|
@@ -423,6 +448,56 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
423
448
|
api.post('/assistant/start', wrap(() => assistant.start()));
|
|
424
449
|
api.post('/assistant/pause', wrap(() => assistant.pause()));
|
|
425
450
|
api.post('/assistant/check', wrap(() => assistant.check()));
|
|
451
|
+
api.get('/triggers', wrap(() => triggers.view()));
|
|
452
|
+
api.put('/triggers', wrap((req) => {
|
|
453
|
+
const { saved, token } = triggers.update(req.body ?? {});
|
|
454
|
+
saveConfig({ triggers: saved });
|
|
455
|
+
return { ...triggers.view(), ...(token ? { token } : {}) };
|
|
456
|
+
}));
|
|
457
|
+
api.post('/triggers/poll', wrap(async () => ({ ...(await triggers.poll()), ...triggers.view() })));
|
|
458
|
+
api.post('/hooks/agents', wrap((req) => triggers.hook(req.body ?? {})));
|
|
459
|
+
api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
|
|
460
|
+
api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
|
|
461
|
+
api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
|
|
462
|
+
// The first-run checklist: what is set up, and what this machine is missing.
|
|
463
|
+
api.get('/setup', wrap(async () => {
|
|
464
|
+
const checks = (await doctor().catch(() => [])).filter((c) => c.status === 'fail' || c.status === 'warn').filter((c) => c.name !== 'Daemon');
|
|
465
|
+
const agentsNow = store.list();
|
|
466
|
+
return {
|
|
467
|
+
checks,
|
|
468
|
+
steps: {
|
|
469
|
+
agent: agentsNow.some((a) => !NO_REPO_KINDS.has(a.kind)),
|
|
470
|
+
alerts: Boolean(loadNotify().enabled),
|
|
471
|
+
assistant: agentsNow.some((a) => a.kind === 'assistant') || Boolean(config.triggers?.github?.enabled || config.triggers?.webhook?.enabled),
|
|
472
|
+
remote: Boolean(remote.loadSettings().enabled),
|
|
473
|
+
budget: Boolean(config.budgets?.perAgentUsd || config.budgets?.perDayUsd),
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}));
|
|
477
|
+
api.get('/playbooks', wrap(() => playbooks.list()));
|
|
478
|
+
api.post('/playbooks', wrap((req) => playbooks.create(req.body ?? {})));
|
|
479
|
+
api.put('/playbooks/:id', wrap((req) => playbooks.update(req.params.id, req.body ?? {})));
|
|
480
|
+
api.delete('/playbooks/:id', wrap((req) => playbooks.remove(req.params.id)));
|
|
481
|
+
api.post('/playbooks/:id/run', wrap((req) => playbooks.run(req.params.id, { vars: req.body?.vars ?? {} })));
|
|
482
|
+
api.get('/stats', wrap((req) => {
|
|
483
|
+
const days = req.query.days === undefined ? 30 : Math.max(0, Math.min(3650, Math.floor(Number(req.query.days)) || 0));
|
|
484
|
+
return stats(store.list(), { days, projectOf: (a) => manager.projectInfo(a)?.name ?? null });
|
|
485
|
+
}));
|
|
486
|
+
api.get('/budgets', wrap(() => budgets.status()));
|
|
487
|
+
api.put('/budgets', wrap((req) => {
|
|
488
|
+
const next = budgets.update(req.body ?? {});
|
|
489
|
+
saveConfig({ budgets: next });
|
|
490
|
+
return budgets.status();
|
|
491
|
+
}));
|
|
492
|
+
api.get('/landing', wrap(() => landing.list()));
|
|
493
|
+
api.put('/landing', wrap((req) => {
|
|
494
|
+
const next = landing.update(req.body ?? {});
|
|
495
|
+
saveConfig({ landing: next });
|
|
496
|
+
return landing.list();
|
|
497
|
+
}));
|
|
498
|
+
api.post('/agents/:id/land/update', wrap((req) => landing.updateBranch(req.params.id, req.body ?? {})));
|
|
499
|
+
api.post('/agents/:id/land/fix', wrap((req) => landing.fix(req.params.id, req.body ?? {})));
|
|
500
|
+
api.post('/agents/:id/land/merge', wrap((req) => landing.merge(req.params.id, req.body ?? {})));
|
|
426
501
|
api.get('/cleanup', wrap(() => cleanup.status()));
|
|
427
502
|
api.put('/cleanup', wrap(async (req) => {
|
|
428
503
|
const next = cleanup.update(req.body ?? {});
|
|
@@ -775,6 +850,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
775
850
|
if (closing) return;
|
|
776
851
|
closing = true;
|
|
777
852
|
cleanup.close();
|
|
853
|
+
landing.close();
|
|
854
|
+
triggers.close();
|
|
855
|
+
budgets.close();
|
|
856
|
+
playbooks.close();
|
|
778
857
|
assistant.close();
|
|
779
858
|
console.log(`[agentmgr] shutting down (${reason}); closing agents so their sessions are saved`);
|
|
780
859
|
await manager.stopAll({ final: true });
|
|
@@ -812,6 +891,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
812
891
|
manager,
|
|
813
892
|
teams,
|
|
814
893
|
cleanup,
|
|
894
|
+
landing,
|
|
895
|
+
triggers,
|
|
896
|
+
budgets,
|
|
897
|
+
playbooks,
|
|
815
898
|
assistant,
|
|
816
899
|
backlog,
|
|
817
900
|
live,
|
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
|
+
}
|