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/src/memory.js ADDED
@@ -0,0 +1,106 @@
1
+ // Shared memory per repository: what agents learned working in it (how to build and test,
2
+ // gotchas, where things are), so the next agent there starts with it instead of finding it out
3
+ // again. One Markdown file per repo under ~/.agentmanager/memory, read by every agent in that
4
+ // repo, added to with `agentmgr learn "<one line>"`, and editable by people in the UI.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { paths } from './config.js';
9
+ import { NO_REPO, projectOf } from './manager.js';
10
+
11
+ export const memoryText = (key) => {
12
+ try {
13
+ return fs.readFileSync(memoryFile(key), 'utf8');
14
+ } catch {
15
+ return '';
16
+ }
17
+ };
18
+
19
+ const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'agentmgr.js');
20
+ export const MAX_BYTES = 16_000;
21
+ const err = (status, message) => Object.assign(new Error(message), { status });
22
+
23
+ /** The repo an agent works in, ignoring where it is listed (moved or custom project). */
24
+ export const memoryKey = (agent) => {
25
+ const key = projectOf({ ...agent, projectMove: null, project: null }).key;
26
+ if (!key.startsWith('dir:')) return key;
27
+ // The same folder by any path (macOS /var is /private/var) is one repo.
28
+ try {
29
+ return `dir:${fs.realpathSync(key.slice(4))}`;
30
+ } catch {
31
+ return key;
32
+ }
33
+ };
34
+ export const memoryFile = (key) => path.join(paths.home, 'memory', `${String(key).replace(/[^\w.-]+/g, '_').slice(0, 120)}.md`);
35
+
36
+ export const learnCommand = (bin = `node ${BIN}`) => `${bin} learn`;
37
+
38
+ /** What earlier agents learned here (inlined, so no file access is needed), and how to add to it. */
39
+ export function memoryPrompt(text, bin = `node ${BIN}`) {
40
+ const known = String(text ?? '').trim();
41
+ return [
42
+ known
43
+ ? `What earlier agents learned working in this repository (shared memory; trust it, but check anything that looks out of date):\n${known}`
44
+ : 'There is a shared memory for this repository, empty so far.',
45
+ `When you learn something non-obvious that would save the next agent in this repo real time (how to build or run the tests, a gotcha, where something lives, a convention that isn't written down), and it isn't already above or in CLAUDE.md or the README, record it as one short line: ${learnCommand(bin)} "<the learning>". ` +
46
+ 'Record facts about the repo, not about your task; never secrets, tokens or personal data.',
47
+ ].join('\n\n');
48
+ }
49
+
50
+ /** One line, trimmed, without Markdown that could break the list. */
51
+ export function cleanLearning(text) {
52
+ const t = String(text ?? '').replace(/\s+/g, ' ').replace(/^[-*#>\s]+/, '').trim();
53
+ if (!t) throw err(400, 'Nothing to learn: give one line of text');
54
+ if (t.length > 400) throw err(400, 'Keep a learning to one short line (400 characters at most)');
55
+ return t;
56
+ }
57
+
58
+ export class Memory {
59
+ constructor({ manager, config = {} }) {
60
+ this.manager = manager;
61
+ this.config = config;
62
+ }
63
+
64
+ get enabled() {
65
+ return this.config.memory !== false;
66
+ }
67
+
68
+ agentKey(ref) {
69
+ const agent = this.manager.mustGet(ref);
70
+ if (NO_REPO.has(agent.kind)) throw err(400, 'This agent has no repository');
71
+ return { agent, key: memoryKey(agent) };
72
+ }
73
+
74
+ read(ref) {
75
+ const { key } = this.agentKey(ref);
76
+ const file = memoryFile(key);
77
+ const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
78
+ return { key, file, text, entries: text.split('\n').filter((l) => l.startsWith('- ')).length, bytes: Buffer.byteLength(text), max: MAX_BYTES };
79
+ }
80
+
81
+ write(ref, text) {
82
+ const { key } = this.agentKey(ref);
83
+ text = String(text ?? '');
84
+ if (Buffer.byteLength(text) > MAX_BYTES) throw err(413, `Keep the memory under ${MAX_BYTES / 1000} KB; it is read by every agent in the repo`);
85
+ const file = memoryFile(key);
86
+ fs.mkdirSync(path.dirname(file), { recursive: true });
87
+ if (!text.trim()) fs.rmSync(file, { force: true });
88
+ else fs.writeFileSync(file, text.endsWith('\n') ? text : `${text}\n`);
89
+ return this.read(ref);
90
+ }
91
+
92
+ /** Append one learning from an agent (or a person), skipping one already there. */
93
+ learn(ref, text, { by } = {}) {
94
+ const { agent, key } = this.agentKey(ref);
95
+ const line = cleanLearning(text);
96
+ const file = memoryFile(key);
97
+ const cur = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : `# What agents learned in ${projectOf(agent).full ?? projectOf(agent).name}\n\n`;
98
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
99
+ if (cur.split('\n').some((l) => l.startsWith('- ') && norm(l.replace(/ \([^)]*\)$/, '')).includes(norm(line)))) return { ...this.read(ref), added: false };
100
+ const entry = `- ${line} (${by ?? agent.name}, ${new Date().toISOString().slice(0, 10)})\n`;
101
+ if (Buffer.byteLength(cur + entry) > MAX_BYTES) throw err(413, 'The repo memory is full; ask the person to tidy it (Memory on the agent)');
102
+ fs.mkdirSync(path.dirname(file), { recursive: true });
103
+ fs.writeFileSync(file, cur.endsWith('\n') ? cur + entry : `${cur}\n${entry}`);
104
+ return { ...this.read(ref), added: true };
105
+ }
106
+ }
package/src/notify.js CHANGED
@@ -16,6 +16,8 @@ export const EVENT_DEFAULTS = {
16
16
  stuck: true, // going in circles: same failure, undoing its edits, or long stretches without changes
17
17
  conflict: true, // changing the same files as another agent in the same repo
18
18
  question: true, // asked a question in the decision inbox
19
+ assistant: true, // an agent was started for new work (assistant, GitHub label, webhook)
20
+ budget: true, // an agent neared or reached its budget, or today's spend reached the daily limit
19
21
  };
20
22
 
21
23
  // Alerts the AI judge may hold for the digest or drop; the rest are always sent at once.
@@ -264,7 +266,7 @@ export class Notifier {
264
266
 
265
267
  deliver(kind, agent, what, n, urgency = null) {
266
268
  if (!n.enabled || !n.events[kind]) return;
267
- const icon = { needsInput: ':raised_hand:', review: ':white_check_mark:', error: ':x:', waiting: ':hourglass_flowing_sand:', blocked: ':no_entry:', stuck: ':repeat:', conflict: ':crossed_swords:', question: ':question:' }[kind];
269
+ const icon = { needsInput: ':raised_hand:', review: ':white_check_mark:', error: ':x:', waiting: ':hourglass_flowing_sand:', blocked: ':no_entry:', stuck: ':repeat:', conflict: ':crossed_swords:', question: ':question:', assistant: ':robot_face:', budget: ':moneybag:' }[kind];
268
270
  const project = agent.projectInfo?.name ? ` (${agent.projectInfo.name})` : '';
269
271
  const link = this.linkFor(agent.id);
270
272
  const intent = n.includeIntent && agent.intent ? `\n> ${agent.intent.replace(/\s+/g, ' ').slice(0, 200)}` : '';
@@ -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
+ }