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/landing.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Landing: getting agents' pull requests merged. Failing checks and requested changes are sent
|
|
2
|
+
// back to the agent that owns the PR (a few times per PR at most), a branch that fell behind is
|
|
3
|
+
// brought up to date with the branch it targets, and ready PRs are merged with one click.
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import * as git from './git.js';
|
|
6
|
+
import { NO_REPO } from './manager.js';
|
|
7
|
+
import { ghBin } from './tracker.js';
|
|
8
|
+
import { parseBaseRef } from './review.js';
|
|
9
|
+
|
|
10
|
+
export const MERGE_METHODS = ['squash', 'merge', 'rebase'];
|
|
11
|
+
export const LANDING_DEFAULTS = { fixChecks: true, fixReviews: true, maxFixes: 3, mergeMethod: 'squash', deleteBranch: true };
|
|
12
|
+
const LAND_ORDER = ['ready', 'fix', 'behind', 'waiting', 'draft'];
|
|
13
|
+
|
|
14
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
15
|
+
|
|
16
|
+
/** Settings from config.json's "landing", with defaults and sane bounds. */
|
|
17
|
+
export function landingSettings(config = {}) {
|
|
18
|
+
const c = { ...LANDING_DEFAULTS, ...config.landing };
|
|
19
|
+
const n = Number(c.maxFixes);
|
|
20
|
+
return {
|
|
21
|
+
fixChecks: c.fixChecks !== false,
|
|
22
|
+
fixReviews: c.fixReviews !== false,
|
|
23
|
+
maxFixes: Number.isFinite(n) && n >= 0 ? Math.min(20, Math.floor(n)) : LANDING_DEFAULTS.maxFixes,
|
|
24
|
+
mergeMethod: MERGE_METHODS.includes(c.mergeMethod) ? c.mergeMethod : LANDING_DEFAULTS.mergeMethod,
|
|
25
|
+
deleteBranch: c.deleteBranch !== false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function checksMessage(pr) {
|
|
30
|
+
const names = pr.checks?.failed?.length ? pr.checks.failed.join(', ') : 'some checks';
|
|
31
|
+
return [
|
|
32
|
+
`CI is failing on your pull request ${pr.url} (${names}).`,
|
|
33
|
+
`Look at the failures (\`gh pr checks ${pr.number ?? pr.url}\`, and \`gh run view <run id> --log-failed\` for the logs), fix the cause, run the relevant tests locally, then commit and push.`,
|
|
34
|
+
'If a failure is unrelated to your change (a flaky test or a broken main), say so instead of changing unrelated code.',
|
|
35
|
+
].join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function reviewMessage(pr) {
|
|
39
|
+
return [
|
|
40
|
+
`A reviewer requested changes on your pull request ${pr.url}.`,
|
|
41
|
+
`Read the review and its comments (\`gh pr view ${pr.number ?? pr.url} --comments\`, and \`gh api repos/{owner}/{repo}/pulls/${pr.number ?? '<number>'}/comments\` for line comments), address each one, then commit and push and reply on the PR with what you changed.`,
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function updateMessage(base) {
|
|
46
|
+
return `Your branch is behind ${base}. Merge ${base} into it (git fetch, then git merge ${base}), resolve any conflicts keeping both sides' intent, run the tests, then commit and push.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class Landing {
|
|
50
|
+
constructor({ manager, store, config = {}, log = (m) => console.log(`[agentmgr] ${m}`) }) {
|
|
51
|
+
this.manager = manager;
|
|
52
|
+
this.store = store;
|
|
53
|
+
this.config = config;
|
|
54
|
+
this.log = log;
|
|
55
|
+
this.onPrs = (agent) => this.react(agent).catch((e) => this.log(`landing: ${e.message}`));
|
|
56
|
+
manager.on('prs', this.onPrs);
|
|
57
|
+
// Running agents' PRs are refreshed by the manager; closed agents with an open PR every few minutes.
|
|
58
|
+
this.timer = setInterval(() => this.refreshClosed().catch(() => {}), (config.landingRefreshMin ?? 6) * 60_000);
|
|
59
|
+
this.timer.unref?.();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
close() {
|
|
63
|
+
clearInterval(this.timer);
|
|
64
|
+
this.manager.off('prs', this.onPrs);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get settings() {
|
|
68
|
+
return landingSettings(this.config);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
update(patch = {}) {
|
|
72
|
+
if (patch.mergeMethod !== undefined && !MERGE_METHODS.includes(patch.mergeMethod)) throw err(400, `mergeMethod must be one of ${MERGE_METHODS.join(', ')}`);
|
|
73
|
+
if (patch.maxFixes !== undefined && !(Number(patch.maxFixes) >= 0)) throw err(400, 'maxFixes must be 0 or more');
|
|
74
|
+
const next = landingSettings({ landing: { ...this.config.landing, ...patch } });
|
|
75
|
+
this.config.landing = next;
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Open PRs that the agent opened or that were found on its branch (not ones it only mentioned). */
|
|
80
|
+
openPrs(agent) {
|
|
81
|
+
return Object.values(agent.prs ?? {}).filter((p) => p.state === 'OPEN' && p.source !== 'mentioned');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** After a PR refresh: send failing checks and requested changes back to the agent, once per commit. */
|
|
85
|
+
async react(agent) {
|
|
86
|
+
const s = this.settings;
|
|
87
|
+
if (!this.store.get(agent.id) || NO_REPO.has(agent.kind) || !s.maxFixes) return;
|
|
88
|
+
agent.landing ??= { sent: {} };
|
|
89
|
+
let sent = false;
|
|
90
|
+
for (const pr of this.openPrs(agent)) {
|
|
91
|
+
if (!pr.head) continue;
|
|
92
|
+
const count = Object.keys(agent.landing.sent).filter((k) => k.startsWith(`${pr.url}#`)).length;
|
|
93
|
+
if (count >= s.maxFixes) continue;
|
|
94
|
+
const send = (kind, text) => {
|
|
95
|
+
const key = `${pr.url}#${kind}@${pr.head}`;
|
|
96
|
+
if (agent.landing.sent[key]) return;
|
|
97
|
+
agent.landing.sent[key] = new Date().toISOString();
|
|
98
|
+
this.manager.tell(agent.id, text, { source: 'landing' });
|
|
99
|
+
this.log(`landing: told ${agent.name} about ${kind} on ${pr.url}`);
|
|
100
|
+
sent = true;
|
|
101
|
+
};
|
|
102
|
+
if (s.fixChecks && pr.checks?.state === 'failed') send('checks', checksMessage(pr));
|
|
103
|
+
else if (s.fixReviews && pr.review === 'CHANGES_REQUESTED') send('review', reviewMessage(pr));
|
|
104
|
+
}
|
|
105
|
+
if (sent) this.store.save();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async refreshClosed() {
|
|
109
|
+
for (const agent of this.store.list()) {
|
|
110
|
+
if (this.manager.runtimes.get(agent.id)?.pty || !agent.workdir || !this.openPrs(agent).length) continue;
|
|
111
|
+
await this.manager.refresh(agent.id, { withPrs: true }).catch(() => {});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Every agent's open pull request with where it stands, ready ones first. */
|
|
116
|
+
list() {
|
|
117
|
+
const rows = [];
|
|
118
|
+
for (const agent of this.store.list()) {
|
|
119
|
+
const view = this.manager.view(agent);
|
|
120
|
+
for (const pr of view.prs.filter((p) => p.state === 'OPEN' && p.source !== 'mentioned')) {
|
|
121
|
+
const fixes = Object.keys(agent.landing?.sent ?? {}).filter((k) => k.startsWith(`${pr.url}#`)).length;
|
|
122
|
+
rows.push({ agent: { id: agent.id, name: agent.name, priority: view.priority, busy: view.runtime.busy, running: view.runtime.running }, pr, land: pr.land ?? 'waiting', fixes });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const rank = (r) => (LAND_ORDER.includes(r.land) ? LAND_ORDER.indexOf(r.land) : LAND_ORDER.length);
|
|
126
|
+
return { settings: this.settings, rows: rows.sort((a, b) => rank(a) - rank(b) || a.agent.priority - b.agent.priority) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
agentPr(ref, url) {
|
|
130
|
+
const agent = this.manager.mustGet(ref);
|
|
131
|
+
const open = this.openPrs(agent);
|
|
132
|
+
const pr = url ? open.find((p) => p.url === url) : open[0];
|
|
133
|
+
if (!pr) throw err(404, url ? 'That pull request is not open on this agent' : 'This agent has no open pull request');
|
|
134
|
+
return { agent, pr };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Bring the branch up to date with the one it targets: merge it in here when the agent is idle
|
|
139
|
+
* and its worktree clean (and push), else ask the agent. A conflict is aborted and handed to the agent.
|
|
140
|
+
*/
|
|
141
|
+
async updateBranch(ref, { via = 'auto' } = {}) {
|
|
142
|
+
const agent = this.manager.mustGet(ref);
|
|
143
|
+
if (!agent.workdir || NO_REPO.has(agent.kind)) throw err(400, 'This agent has no repository');
|
|
144
|
+
const dir = agent.workdir;
|
|
145
|
+
const opts = { cwd: dir, timeout: 120_000 };
|
|
146
|
+
const b = parseBaseRef(agent.baseRef);
|
|
147
|
+
const target = b?.ref && b.ref !== 'HEAD' ? b.ref : (await git.resolveBase(dir).catch(() => null))?.ref;
|
|
148
|
+
if (!target || target === 'HEAD') throw err(409, "Couldn't tell which branch this one should follow");
|
|
149
|
+
const ask = (why) => {
|
|
150
|
+
this.manager.tell(agent.id, updateMessage(target), { source: 'landing' });
|
|
151
|
+
return { asked: true, why, target };
|
|
152
|
+
};
|
|
153
|
+
if (via === 'agent') return ask('asked');
|
|
154
|
+
const rt = this.manager.runtimes.get(agent.id);
|
|
155
|
+
if (rt?.pty && rt.busy) return ask('the agent is working');
|
|
156
|
+
if ((await git.run('git', ['status', '--porcelain'], opts)).trim()) return ask('it has uncommitted changes');
|
|
157
|
+
if (target.startsWith('origin/')) await git.fetch(dir).catch(() => {});
|
|
158
|
+
const behind = Number(await git.run('git', ['rev-list', '--count', `HEAD..${target}`], opts).catch(() => '0')) || 0;
|
|
159
|
+
if (!behind) return { upToDate: true, target };
|
|
160
|
+
try {
|
|
161
|
+
await git.run('git', ['merge', '--no-edit', target], { ...opts, env: { GIT_MERGE_AUTOEDIT: 'no' } });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
await git.run('git', ['merge', '--abort'], opts).catch(() => {});
|
|
164
|
+
return { ...ask('the merge has conflicts'), conflict: true };
|
|
165
|
+
}
|
|
166
|
+
let pushed = false;
|
|
167
|
+
const upstream = await git.run('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], opts).catch(() => '');
|
|
168
|
+
if (upstream) {
|
|
169
|
+
await git.run('git', ['push'], opts);
|
|
170
|
+
pushed = true;
|
|
171
|
+
}
|
|
172
|
+
this.manager.refresh(agent.id, { withPrs: true }).catch(() => {});
|
|
173
|
+
return { merged: behind, target, pushed };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Ask the agent to fix what stands in the way: failing checks, requested changes, conflicts. */
|
|
177
|
+
fix(ref, { url } = {}) {
|
|
178
|
+
const { agent, pr } = this.agentPr(ref, url);
|
|
179
|
+
const parts = [];
|
|
180
|
+
if (pr.checks?.state === 'failed') parts.push(checksMessage(pr));
|
|
181
|
+
if (pr.review === 'CHANGES_REQUESTED') parts.push(reviewMessage(pr));
|
|
182
|
+
if (pr.mergeable === 'CONFLICTING' || pr.mergeState === 'DIRTY' || pr.mergeState === 'BEHIND') {
|
|
183
|
+
const b = parseBaseRef(agent.baseRef);
|
|
184
|
+
parts.push(updateMessage(b?.ref && b.ref !== 'HEAD' ? b.ref : 'the base branch'));
|
|
185
|
+
}
|
|
186
|
+
if (!parts.length) throw err(409, 'Nothing to fix on this pull request right now');
|
|
187
|
+
this.manager.tell(agent.id, parts.join('\n\n'), { source: 'landing' });
|
|
188
|
+
return { sent: parts.length };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Merge the PR with gh (or turn on auto-merge so GitHub merges it once it may). */
|
|
192
|
+
async merge(ref, { url, method, auto = false } = {}) {
|
|
193
|
+
const { agent, pr } = this.agentPr(ref, url);
|
|
194
|
+
const s = this.settings;
|
|
195
|
+
method = method ?? s.mergeMethod;
|
|
196
|
+
if (!MERGE_METHODS.includes(method)) throw err(400, `method must be one of ${MERGE_METHODS.join(', ')}`);
|
|
197
|
+
this.manager.plugins.check('agent.merge', { agent: this.manager.auditView(agent), url: pr.url, method, auto: Boolean(auto) });
|
|
198
|
+
const args = ['pr', 'merge', pr.url, `--${method}`];
|
|
199
|
+
if (auto) args.push('--auto');
|
|
200
|
+
if (s.deleteBranch && !auto) args.push('--delete-branch');
|
|
201
|
+
try {
|
|
202
|
+
// Outside the worktree, so gh deletes only the remote branch and leaves the worktree alone.
|
|
203
|
+
await git.run(ghBin(), args, { cwd: os.tmpdir(), timeout: 120_000 });
|
|
204
|
+
} catch (e) {
|
|
205
|
+
throw err(502, e.message.replace(/^\S+ pr merge .*? failed: /, ''));
|
|
206
|
+
}
|
|
207
|
+
this.manager.plugins.emit('agent.merge', { agent: this.manager.auditView(agent), url: pr.url, method, auto: Boolean(auto) });
|
|
208
|
+
this.log(`landing: ${auto ? 'auto-merge on for' : 'merged'} ${pr.url}`);
|
|
209
|
+
await this.manager.refresh(agent.id, { withPrs: true }).catch(() => {});
|
|
210
|
+
return { url: pr.url, merged: !auto, auto: Boolean(auto) };
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/manager.js
CHANGED
|
@@ -46,10 +46,13 @@ import { Judge, clashPrompt, finishMessage, hash, reviewPrompt, screenPrompt, st
|
|
|
46
46
|
import { findLocalUrls, findSignInLinks, parseCallback, parseUrl } from './preview.js';
|
|
47
47
|
import { Plugins } from './plugins.js';
|
|
48
48
|
import { leadPrompt, subagents, teamDir, teamPrompt } from './teams.js';
|
|
49
|
+
import { assistantDir, assistantPrompt, assistantSettings } from './assistant.js';
|
|
50
|
+
import { learnCommand, memoryKey, memoryPrompt, memoryText } from './memory.js';
|
|
49
51
|
import { runningClaude, scanClaudeSessions } from './sessions.js';
|
|
50
52
|
import {
|
|
51
53
|
bugFromRef,
|
|
52
54
|
extractRefs,
|
|
55
|
+
landState,
|
|
53
56
|
leadingJiraKey,
|
|
54
57
|
prDetails,
|
|
55
58
|
prFromUrl,
|
|
@@ -119,9 +122,15 @@ export function repoCacheDir(repo) {
|
|
|
119
122
|
return path.join(paths.repos, `${git.repoNameFromUrl(repo)}-${hash}`);
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
/** Sessions with no repo or folder of their own. */
|
|
126
|
+
export const NO_REPO = new Set(['general', 'assistant']);
|
|
127
|
+
|
|
122
128
|
export function projectOf(agent) {
|
|
129
|
+
// Moved under another known project by hand.
|
|
130
|
+
if (agent.projectMove?.key) return { key: agent.projectMove.key, name: agent.projectMove.name, ...(agent.projectMove.full ? { full: agent.projectMove.full } : {}), source: 'moved' };
|
|
123
131
|
if (agent.project) return { key: `custom:${agent.project.toLowerCase()}`, name: agent.project, source: 'custom' };
|
|
124
132
|
if (agent.kind === 'general') return { key: 'general', name: 'General', source: 'general' };
|
|
133
|
+
if (agent.kind === 'assistant') return { key: 'assistant', name: 'Assistant', source: 'assistant' };
|
|
125
134
|
const url = agent.repo || agent.git?.remote;
|
|
126
135
|
if (url && /[:/]/.test(url)) {
|
|
127
136
|
const path = url
|
|
@@ -197,7 +206,7 @@ export class AgentManager extends EventEmitter {
|
|
|
197
206
|
this.shells = new Map(); // agent id -> ancillary shell runtime in the agent's folder
|
|
198
207
|
this.cursors = new Map(); // session id -> transcript scan cursor
|
|
199
208
|
this.locks = new Map();
|
|
200
|
-
this.refreshing = new
|
|
209
|
+
this.refreshing = new Map(); // agent id -> the refresh in progress
|
|
201
210
|
this.lastPrCheck = new Map();
|
|
202
211
|
this.changeSets = new Map(); // agent id -> { files, baseSha, at }: what its branch changed (conflict radar)
|
|
203
212
|
this.conflicts = new Map(); // agent id -> [conflict]
|
|
@@ -276,7 +285,7 @@ export class AgentManager extends EventEmitter {
|
|
|
276
285
|
usage,
|
|
277
286
|
// Claude Code's own cost figure from the transcripts (absent until it records one).
|
|
278
287
|
costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
|
|
279
|
-
prs: Object.values(agent.prs ?? {}),
|
|
288
|
+
prs: Object.values(agent.prs ?? {}).map((p) => ({ ...p, land: landState(p) })),
|
|
280
289
|
bugs: Object.values(agent.bugs ?? {}),
|
|
281
290
|
conflicts: this.conflictGroups(agent),
|
|
282
291
|
stuck: this.activeStuck(agent),
|
|
@@ -363,7 +372,9 @@ export class AgentManager extends EventEmitter {
|
|
|
363
372
|
|
|
364
373
|
/** Make sure the agent's project has a shortcut; remember where it lives for "add @cc". */
|
|
365
374
|
registerProject(agent) {
|
|
366
|
-
if (!agent || agent.kind
|
|
375
|
+
if (!agent || NO_REPO.has(agent.kind)) return null;
|
|
376
|
+
// A moved agent's repo says nothing about the project it was moved to.
|
|
377
|
+
if (agent.projectMove) return null;
|
|
367
378
|
const info = projectOf(agent);
|
|
368
379
|
if (info.key === 'other') return null;
|
|
369
380
|
const reg = this.store.data.projects;
|
|
@@ -512,6 +523,8 @@ export class AgentManager extends EventEmitter {
|
|
|
512
523
|
engine,
|
|
513
524
|
sessionId: adopt,
|
|
514
525
|
imported,
|
|
526
|
+
startedBy,
|
|
527
|
+
trigger,
|
|
515
528
|
}) {
|
|
516
529
|
intent = (intent ?? '').trim();
|
|
517
530
|
const engineDef = engineOf(engine || this.config.engine || DEFAULT_ENGINE);
|
|
@@ -524,7 +537,7 @@ export class AgentManager extends EventEmitter {
|
|
|
524
537
|
}
|
|
525
538
|
// Planning while cloning moves the session between folders, which needs the engine's support.
|
|
526
539
|
if (!engineDef.supports.planHandoff) plan = false;
|
|
527
|
-
const general = kind
|
|
540
|
+
const general = NO_REPO.has(kind);
|
|
528
541
|
// A project shortcut (or name) fills in the repo / folder / base branch.
|
|
529
542
|
const entry = !general && project ? this.resolveProject(project) : null;
|
|
530
543
|
if (entry) {
|
|
@@ -548,10 +561,12 @@ export class AgentManager extends EventEmitter {
|
|
|
548
561
|
|
|
549
562
|
const agent = {
|
|
550
563
|
id,
|
|
551
|
-
kind: general ?
|
|
564
|
+
kind: general ? kind : 'task',
|
|
552
565
|
engine: engineId,
|
|
553
566
|
project: project?.trim() || null,
|
|
554
567
|
priority: parsePriority(priority),
|
|
568
|
+
...(startedBy ? { startedBy } : {}),
|
|
569
|
+
...(trigger ? { trigger } : {}),
|
|
555
570
|
name: name?.trim() || jiraKey || slug,
|
|
556
571
|
slug,
|
|
557
572
|
intent,
|
|
@@ -687,6 +702,10 @@ export class AgentManager extends EventEmitter {
|
|
|
687
702
|
fs.mkdirSync(paths.general, { recursive: true });
|
|
688
703
|
return { workdir: paths.general };
|
|
689
704
|
}
|
|
705
|
+
if (agent.kind === 'assistant') {
|
|
706
|
+
fs.mkdirSync(assistantDir(), { recursive: true });
|
|
707
|
+
return { workdir: assistantDir() };
|
|
708
|
+
}
|
|
690
709
|
let repo = agent.repo;
|
|
691
710
|
let dir = agent.requestedDir ? path.resolve(expandHome(agent.requestedDir)) : null;
|
|
692
711
|
const branch = `agent/${slug}-${id}`;
|
|
@@ -858,7 +877,7 @@ export class AgentManager extends EventEmitter {
|
|
|
858
877
|
let writableDir = null;
|
|
859
878
|
let agents = null;
|
|
860
879
|
const prompts = [];
|
|
861
|
-
const task = agent.kind
|
|
880
|
+
const task = !NO_REPO.has(agent.kind);
|
|
862
881
|
const withBrief = Boolean(this.config.brief && task);
|
|
863
882
|
if (this.config.reviewConventions && task) prompts.push(conventionsPrompt({ withBrief, rulesFiles: engine.rulesFiles ?? null }));
|
|
864
883
|
if (withBrief) {
|
|
@@ -876,6 +895,18 @@ export class AgentManager extends EventEmitter {
|
|
|
876
895
|
prompts.push(agent.planReview.status === 'approved' ? planApprovedPrompt(file) : planReviewPrompt(file));
|
|
877
896
|
}
|
|
878
897
|
if (planning) prompts.push(planningPrompt(agent));
|
|
898
|
+
// What earlier agents learned in this repo, and the one command to add to it.
|
|
899
|
+
let allowedTools = null;
|
|
900
|
+
if (task && this.config.memory !== false) {
|
|
901
|
+
prompts.push(memoryPrompt(memoryText(memoryKey(agent))));
|
|
902
|
+
allowedTools = [`Bash(${learnCommand()}:*)`];
|
|
903
|
+
}
|
|
904
|
+
if (agent.kind === 'assistant') {
|
|
905
|
+
fs.mkdirSync(assistantDir(), { recursive: true });
|
|
906
|
+
const dir = fs.realpathSync(assistantDir());
|
|
907
|
+
writableDir = dir;
|
|
908
|
+
prompts.push(assistantPrompt(assistantSettings(this.config), dir));
|
|
909
|
+
}
|
|
879
910
|
if (agent.team) {
|
|
880
911
|
// Team members write their handoff files in the team's folder.
|
|
881
912
|
fs.mkdirSync(teamDir(agent.team.id), { recursive: true });
|
|
@@ -890,7 +921,7 @@ export class AgentManager extends EventEmitter {
|
|
|
890
921
|
}
|
|
891
922
|
}
|
|
892
923
|
// Appended system prompts last only for the process they're passed to, so they go on every launch.
|
|
893
|
-
return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null, agents };
|
|
924
|
+
return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null, agents, allowedTools };
|
|
894
925
|
}
|
|
895
926
|
|
|
896
927
|
/** Spawn the agent's engine in a PTY, reusing its terminal clients and scrollback. */
|
|
@@ -1190,16 +1221,62 @@ export class AgentManager extends EventEmitter {
|
|
|
1190
1221
|
|
|
1191
1222
|
update(ref, fields) {
|
|
1192
1223
|
const agent = this.mustGet(ref);
|
|
1224
|
+
const bad = (m) => Object.assign(new Error(m), { status: 400 });
|
|
1193
1225
|
const allowed = {};
|
|
1194
|
-
for (const key of ['
|
|
1226
|
+
for (const key of ['notes']) if (key in fields) allowed[key] = fields[key];
|
|
1227
|
+
if ('name' in fields) {
|
|
1228
|
+
const name = String(fields.name ?? '').trim();
|
|
1229
|
+
if (!name) throw bad('Name is required');
|
|
1230
|
+
allowed.name = name.slice(0, 80);
|
|
1231
|
+
}
|
|
1232
|
+
// The request as recorded; the session only hears about a change when told.
|
|
1233
|
+
if ('intent' in fields) {
|
|
1234
|
+
const intent = String(fields.intent ?? '').trim();
|
|
1235
|
+
if (!intent && !NO_REPO.has(agent.kind)) throw bad('The request is required');
|
|
1236
|
+
allowed.intent = intent;
|
|
1237
|
+
}
|
|
1238
|
+
// Permission mode and model apply from the next launch.
|
|
1239
|
+
if ('permissionMode' in fields) {
|
|
1240
|
+
const mode = fields.permissionMode || null;
|
|
1241
|
+
const modes = engineOf(agent).permissionModes;
|
|
1242
|
+
if (mode && !modes.includes(mode)) throw bad(`${engineOf(agent).label} has no "${mode}" mode (use ${modes.join(', ')})`);
|
|
1243
|
+
allowed.permissionMode = mode;
|
|
1244
|
+
}
|
|
1245
|
+
if ('model' in fields) allowed.model = String(fields.model ?? '').trim() || null;
|
|
1195
1246
|
// Keep: clean up never closes or deletes it.
|
|
1196
1247
|
if ('keep' in fields) allowed.keep = Boolean(fields.keep);
|
|
1248
|
+
if ('budgetUsd' in fields) {
|
|
1249
|
+
// Its own spending limit in US dollars; empty for the default (budgets.perAgentUsd).
|
|
1250
|
+
const raw = fields.budgetUsd;
|
|
1251
|
+
const b = raw === null || raw === '' ? null : Number(raw);
|
|
1252
|
+
if (b !== null && !(Number.isFinite(b) && b > 0)) throw Object.assign(new Error('budgetUsd must be a positive number of dollars, or empty'), { status: 400 });
|
|
1253
|
+
allowed.budgetUsd = b === null ? null : Math.round(b * 100) / 100;
|
|
1254
|
+
allowed.budgetHit = null;
|
|
1255
|
+
}
|
|
1197
1256
|
if ('priority' in fields) {
|
|
1198
1257
|
const p = parsePriority(fields.priority, null);
|
|
1199
1258
|
if (p === null) throw Object.assign(new Error('priority must be P0, P1, P2 or P3'), { status: 400 });
|
|
1200
1259
|
allowed.priority = p;
|
|
1201
1260
|
}
|
|
1202
|
-
|
|
1261
|
+
// Move: under a known project (shortcut or name), under a new project name, or back to its own ("").
|
|
1262
|
+
if ('project' in fields) {
|
|
1263
|
+
if (NO_REPO.has(agent.kind)) throw bad(`A ${agent.kind} session has no project`);
|
|
1264
|
+
const ref = String(fields.project ?? '').trim();
|
|
1265
|
+
const entry = ref ? this.resolveProject(ref) : null;
|
|
1266
|
+
if (!ref) Object.assign(allowed, { project: null, projectMove: null });
|
|
1267
|
+
else if (entry && entry.key !== projectOf({ ...agent, project: null, projectMove: null }).key) {
|
|
1268
|
+
// Its full name (org/repo), as the project's own agents show it.
|
|
1269
|
+
const info = this.store.list().map((a) => (a.projectMove ? null : projectOf(a))).find((i) => i?.key === entry.key);
|
|
1270
|
+
Object.assign(allowed, { project: null, projectMove: { key: entry.key, name: entry.name, ...(info?.full ? { full: info.full } : {}), at: now() } });
|
|
1271
|
+
} else if (entry) Object.assign(allowed, { project: null, projectMove: null });
|
|
1272
|
+
else Object.assign(allowed, { project: ref.slice(0, 60), projectMove: null });
|
|
1273
|
+
}
|
|
1274
|
+
if ('intent' in allowed) {
|
|
1275
|
+
const refs = extractRefs(allowed.intent, this.config.bugPatterns);
|
|
1276
|
+
const bugs = { ...(agent.bugs ?? {}) };
|
|
1277
|
+
for (const b of refs.bugs) bugs[b.key] ??= { ...b, source: 'intent' };
|
|
1278
|
+
allowed.bugs = bugs;
|
|
1279
|
+
}
|
|
1203
1280
|
return this.view(this.patch(agent.id, allowed));
|
|
1204
1281
|
}
|
|
1205
1282
|
|
|
@@ -1912,80 +1989,93 @@ export class AgentManager extends EventEmitter {
|
|
|
1912
1989
|
|
|
1913
1990
|
async refresh(ref, { withPrs = false } = {}) {
|
|
1914
1991
|
const id = this.mustGet(ref).id;
|
|
1915
|
-
|
|
1916
|
-
|
|
1992
|
+
const running = this.refreshing.get(id);
|
|
1993
|
+
if (running) {
|
|
1994
|
+
// A pull request check asked for during a plain refresh runs right after it.
|
|
1995
|
+
if (!withPrs || running.withPrs) return running.promise.then(() => this.store.get(id) && this.view(this.store.get(id)));
|
|
1996
|
+
await running.promise.catch(() => {});
|
|
1997
|
+
return this.refresh(id, { withPrs });
|
|
1998
|
+
}
|
|
1999
|
+
const promise = this.doRefresh(id, { withPrs });
|
|
2000
|
+
this.refreshing.set(id, { promise, withPrs });
|
|
1917
2001
|
try {
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
2002
|
+
return await promise;
|
|
2003
|
+
} finally {
|
|
2004
|
+
this.refreshing.delete(id);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
async doRefresh(id, { withPrs }) {
|
|
2009
|
+
this.reloadBrief(id);
|
|
2010
|
+
const agent = this.store.get(id);
|
|
2011
|
+
// Before handoff, a planning session's transcript lives in the planning folder.
|
|
2012
|
+
const planningOnly = agent?.planning && !agent.planning.handedOffAt && !agent.workdir;
|
|
2013
|
+
const sessionDir = planningOnly ? agent.planning.dir : agent?.workdir;
|
|
2014
|
+
if (!sessionDir) return agent && this.view(agent);
|
|
2015
|
+
let changed = agent.workdir ? this.adoptSessions(agent) : false;
|
|
2016
|
+
|
|
2017
|
+
const info = agent.workdir ? await git.gitInfo(agent.workdir) : agent.git;
|
|
2018
|
+
if (!this.store.get(id)) return null;
|
|
2019
|
+
if (info !== undefined && JSON.stringify({ ...info, updatedAt: 0 }) !== JSON.stringify({ ...agent.git, updatedAt: 0 })) {
|
|
2020
|
+
agent.git = info;
|
|
2021
|
+
changed = true;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
const engine = engineOf(agent);
|
|
2025
|
+
for (const session of agent.sessions) {
|
|
2026
|
+
const file = engine.sessionFile(sessionDir, session.id);
|
|
2027
|
+
const key = `${session.id}:${file}`; // the transcript moves at handoff; rescan the new copy
|
|
2028
|
+
const cursor = this.cursors.get(key) ?? { offset: 0 };
|
|
2029
|
+
this.cursors.set(key, cursor);
|
|
2030
|
+
const res = engine.scan(file, cursor, this.config.bugPatterns);
|
|
2031
|
+
const subMaps = engine.subagentFiles(sessionDir, session.id).map((sub) => {
|
|
2032
|
+
const c = this.cursors.get(sub) ?? { offset: 0 };
|
|
2033
|
+
this.cursors.set(sub, c);
|
|
2034
|
+
engine.scan(sub, c);
|
|
2035
|
+
return c.usage;
|
|
2036
|
+
});
|
|
2037
|
+
const usage = sumUsage(cursor.usage, ...subMaps);
|
|
2038
|
+
if (usage.requests && JSON.stringify(session.usage) !== JSON.stringify(usage)) {
|
|
2039
|
+
session.usage = usage;
|
|
1930
2040
|
changed = true;
|
|
1931
2041
|
}
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
const file = engine.sessionFile(sessionDir, session.id);
|
|
1936
|
-
const key = `${session.id}:${file}`; // the transcript moves at handoff; rescan the new copy
|
|
1937
|
-
const cursor = this.cursors.get(key) ?? { offset: 0 };
|
|
1938
|
-
this.cursors.set(key, cursor);
|
|
1939
|
-
const res = engine.scan(file, cursor, this.config.bugPatterns);
|
|
1940
|
-
const subMaps = engine.subagentFiles(sessionDir, session.id).map((sub) => {
|
|
1941
|
-
const c = this.cursors.get(sub) ?? { offset: 0 };
|
|
1942
|
-
this.cursors.set(sub, c);
|
|
1943
|
-
engine.scan(sub, c);
|
|
1944
|
-
return c.usage;
|
|
1945
|
-
});
|
|
1946
|
-
const usage = sumUsage(cursor.usage, ...subMaps);
|
|
1947
|
-
if (usage.requests && JSON.stringify(session.usage) !== JSON.stringify(usage)) {
|
|
1948
|
-
session.usage = usage;
|
|
2042
|
+
for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity', 'lastMessageAt']) {
|
|
2043
|
+
if (res[key] != null && JSON.stringify(session[key]) !== JSON.stringify(res[key])) {
|
|
2044
|
+
session[key] = res[key];
|
|
1949
2045
|
changed = true;
|
|
1950
2046
|
}
|
|
1951
|
-
for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity', 'lastMessageAt']) {
|
|
1952
|
-
if (res[key] != null && JSON.stringify(session[key]) !== JSON.stringify(res[key])) {
|
|
1953
|
-
session[key] = res[key];
|
|
1954
|
-
changed = true;
|
|
1955
|
-
}
|
|
1956
|
-
}
|
|
1957
|
-
const prs = res.prs.map((p) => ({ ...p, sessionId: session.id }));
|
|
1958
|
-
changed = this.mergeLinks(agent, agent.prs, prs, (p) => p.url) || changed;
|
|
1959
|
-
changed = this.mergeLinks(agent, agent.bugs, res.bugs, (b) => b.key) || changed;
|
|
1960
2047
|
}
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
2048
|
+
const prs = res.prs.map((p) => ({ ...p, sessionId: session.id }));
|
|
2049
|
+
changed = this.mergeLinks(agent, agent.prs, prs, (p) => p.url) || changed;
|
|
2050
|
+
changed = this.mergeLinks(agent, agent.bugs, res.bugs, (b) => b.key) || changed;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
if (withPrs) {
|
|
2054
|
+
this.lastPrCheck.set(id, Date.now());
|
|
2055
|
+
// A shared worktree's pull requests belong to the agent that owns it.
|
|
2056
|
+
const branchPrs = agent.sharedWith ? [] : await prsForBranch(agent.workdir, info?.branch);
|
|
2057
|
+
changed = this.mergeLinks(agent, agent.prs, branchPrs, (p) => p.url) || changed;
|
|
2058
|
+
// Fill in title/state for PRs we only know by URL, and keep open ones current.
|
|
2059
|
+
const stale = Object.values(agent.prs).filter(
|
|
2060
|
+
(p) => p.source !== 'branch' && (!p.state || p.state === 'OPEN'),
|
|
2061
|
+
);
|
|
2062
|
+
for (const pr of stale.slice(0, 10)) {
|
|
2063
|
+
const d = await prDetails(pr.url);
|
|
2064
|
+
if (d) changed = this.mergeLinks(agent, agent.prs, [{ ...d, source: pr.source }], (p) => p.url) || changed;
|
|
1975
2065
|
}
|
|
2066
|
+
// Landing reacts to failing checks and requested changes.
|
|
2067
|
+
this.emit('prs', agent);
|
|
2068
|
+
}
|
|
1976
2069
|
|
|
1977
|
-
|
|
1978
|
-
|
|
2070
|
+
await this.updateChanges(agent);
|
|
2071
|
+
this.updateStuck(agent, sessionDir);
|
|
1979
2072
|
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
}
|
|
1985
|
-
return this.view(agent);
|
|
1986
|
-
} finally {
|
|
1987
|
-
this.refreshing.delete(id);
|
|
2073
|
+
if (changed && this.store.get(id)) {
|
|
2074
|
+
agent.updatedAt = now();
|
|
2075
|
+
this.store.save();
|
|
2076
|
+
this.changed(agent);
|
|
1988
2077
|
}
|
|
2078
|
+
return this.view(agent);
|
|
1989
2079
|
}
|
|
1990
2080
|
|
|
1991
2081
|
// ------------------------------------------------------ telling an agent
|
|
@@ -2049,7 +2139,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2049
2139
|
/** Recompute what an agent's branch changed since it branched (needs a base commit). */
|
|
2050
2140
|
async updateChanges(agent) {
|
|
2051
2141
|
const baseSha = agent.baseRef?.split(' @ ')[1];
|
|
2052
|
-
if (!agent.workdir || !baseSha || agent.kind
|
|
2142
|
+
if (!agent.workdir || !baseSha || NO_REPO.has(agent.kind) || !fs.existsSync(agent.workdir)) return;
|
|
2053
2143
|
// A full diff against the base is heavier than git status; once a minute is enough for overlaps.
|
|
2054
2144
|
const prev0 = this.changeSets.get(agent.id);
|
|
2055
2145
|
if (prev0 && Date.now() - Date.parse(prev0.at) < (this.config.conflictRefreshSec ?? 60) * 1000) return;
|
|
@@ -2087,7 +2177,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2087
2177
|
const byRepo = new Map();
|
|
2088
2178
|
for (const e of entries) {
|
|
2089
2179
|
const k = repoKey(e.agent);
|
|
2090
|
-
if (k && e.agent.kind
|
|
2180
|
+
if (k && !NO_REPO.has(e.agent.kind)) (byRepo.get(k) ?? byRepo.set(k, []).get(k)).push(e);
|
|
2091
2181
|
}
|
|
2092
2182
|
for (const group of byRepo.values()) {
|
|
2093
2183
|
for (let i = 0; i < group.length; i++) {
|
|
@@ -2323,7 +2413,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2323
2413
|
const a = this.store.get(id);
|
|
2324
2414
|
if (!a) return;
|
|
2325
2415
|
this.changed(a);
|
|
2326
|
-
if (sure && data.state === 'done' && a.kind
|
|
2416
|
+
if (sure && data.state === 'done' && !NO_REPO.has(a.kind)) this.requestReview(id, { trigger: 'screen' }).catch(() => {});
|
|
2327
2417
|
}
|
|
2328
2418
|
|
|
2329
2419
|
/** The person says the agent is not actually waiting on them. */
|
|
@@ -2404,7 +2494,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2404
2494
|
// ------------------------------------------------------- decision inbox
|
|
2405
2495
|
|
|
2406
2496
|
questionsView(agent) {
|
|
2407
|
-
if (agent.kind
|
|
2497
|
+
if (NO_REPO.has(agent.kind)) return [];
|
|
2408
2498
|
const answers = agent.answers ?? {};
|
|
2409
2499
|
return loadQuestions(agent.id).map((q) => ({ ...q, answer: answers[q.id] ?? null }));
|
|
2410
2500
|
}
|
|
@@ -2464,7 +2554,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2464
2554
|
*/
|
|
2465
2555
|
async checkpoint(id) {
|
|
2466
2556
|
const agent = this.store.get(id);
|
|
2467
|
-
if (!agent?.workdir || agent.kind
|
|
2557
|
+
if (!agent?.workdir || NO_REPO.has(agent.kind) || !agent.branch || this.checkpointing?.has(id)) return null;
|
|
2468
2558
|
// Output pauses mid-turn too (a slow command); one snapshot per half minute is plenty.
|
|
2469
2559
|
this.lastCheckpointTry ??= new Map();
|
|
2470
2560
|
if (Date.now() - (this.lastCheckpointTry.get(id) ?? 0) < 30_000) return null;
|