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/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
|
@@ -47,10 +47,12 @@ import { findLocalUrls, findSignInLinks, parseCallback, parseUrl } from './previ
|
|
|
47
47
|
import { Plugins } from './plugins.js';
|
|
48
48
|
import { leadPrompt, subagents, teamDir, teamPrompt } from './teams.js';
|
|
49
49
|
import { assistantDir, assistantPrompt, assistantSettings } from './assistant.js';
|
|
50
|
+
import { learnCommand, memoryKey, memoryPrompt, memoryText } from './memory.js';
|
|
50
51
|
import { runningClaude, scanClaudeSessions } from './sessions.js';
|
|
51
52
|
import {
|
|
52
53
|
bugFromRef,
|
|
53
54
|
extractRefs,
|
|
55
|
+
landState,
|
|
54
56
|
leadingJiraKey,
|
|
55
57
|
prDetails,
|
|
56
58
|
prFromUrl,
|
|
@@ -204,7 +206,7 @@ export class AgentManager extends EventEmitter {
|
|
|
204
206
|
this.shells = new Map(); // agent id -> ancillary shell runtime in the agent's folder
|
|
205
207
|
this.cursors = new Map(); // session id -> transcript scan cursor
|
|
206
208
|
this.locks = new Map();
|
|
207
|
-
this.refreshing = new
|
|
209
|
+
this.refreshing = new Map(); // agent id -> the refresh in progress
|
|
208
210
|
this.lastPrCheck = new Map();
|
|
209
211
|
this.changeSets = new Map(); // agent id -> { files, baseSha, at }: what its branch changed (conflict radar)
|
|
210
212
|
this.conflicts = new Map(); // agent id -> [conflict]
|
|
@@ -283,7 +285,7 @@ export class AgentManager extends EventEmitter {
|
|
|
283
285
|
usage,
|
|
284
286
|
// Claude Code's own cost figure from the transcripts (absent until it records one).
|
|
285
287
|
costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
|
|
286
|
-
prs: Object.values(agent.prs ?? {}),
|
|
288
|
+
prs: Object.values(agent.prs ?? {}).map((p) => ({ ...p, land: landState(p) })),
|
|
287
289
|
bugs: Object.values(agent.bugs ?? {}),
|
|
288
290
|
conflicts: this.conflictGroups(agent),
|
|
289
291
|
stuck: this.activeStuck(agent),
|
|
@@ -522,6 +524,7 @@ export class AgentManager extends EventEmitter {
|
|
|
522
524
|
sessionId: adopt,
|
|
523
525
|
imported,
|
|
524
526
|
startedBy,
|
|
527
|
+
trigger,
|
|
525
528
|
}) {
|
|
526
529
|
intent = (intent ?? '').trim();
|
|
527
530
|
const engineDef = engineOf(engine || this.config.engine || DEFAULT_ENGINE);
|
|
@@ -563,6 +566,7 @@ export class AgentManager extends EventEmitter {
|
|
|
563
566
|
project: project?.trim() || null,
|
|
564
567
|
priority: parsePriority(priority),
|
|
565
568
|
...(startedBy ? { startedBy } : {}),
|
|
569
|
+
...(trigger ? { trigger } : {}),
|
|
566
570
|
name: name?.trim() || jiraKey || slug,
|
|
567
571
|
slug,
|
|
568
572
|
intent,
|
|
@@ -891,6 +895,12 @@ export class AgentManager extends EventEmitter {
|
|
|
891
895
|
prompts.push(agent.planReview.status === 'approved' ? planApprovedPrompt(file) : planReviewPrompt(file));
|
|
892
896
|
}
|
|
893
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
|
+
}
|
|
894
904
|
if (agent.kind === 'assistant') {
|
|
895
905
|
fs.mkdirSync(assistantDir(), { recursive: true });
|
|
896
906
|
const dir = fs.realpathSync(assistantDir());
|
|
@@ -911,7 +921,7 @@ export class AgentManager extends EventEmitter {
|
|
|
911
921
|
}
|
|
912
922
|
}
|
|
913
923
|
// Appended system prompts last only for the process they're passed to, so they go on every launch.
|
|
914
|
-
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 };
|
|
915
925
|
}
|
|
916
926
|
|
|
917
927
|
/** Spawn the agent's engine in a PTY, reusing its terminal clients and scrollback. */
|
|
@@ -1235,6 +1245,14 @@ export class AgentManager extends EventEmitter {
|
|
|
1235
1245
|
if ('model' in fields) allowed.model = String(fields.model ?? '').trim() || null;
|
|
1236
1246
|
// Keep: clean up never closes or deletes it.
|
|
1237
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
|
+
}
|
|
1238
1256
|
if ('priority' in fields) {
|
|
1239
1257
|
const p = parsePriority(fields.priority, null);
|
|
1240
1258
|
if (p === null) throw Object.assign(new Error('priority must be P0, P1, P2 or P3'), { status: 400 });
|
|
@@ -1971,80 +1989,94 @@ export class AgentManager extends EventEmitter {
|
|
|
1971
1989
|
|
|
1972
1990
|
async refresh(ref, { withPrs = false } = {}) {
|
|
1973
1991
|
const id = this.mustGet(ref).id;
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1992
|
+
const running = this.refreshing.get(id);
|
|
1993
|
+
if (running) {
|
|
1994
|
+
if (!withPrs) return running.promise.then(() => this.store.get(id) && this.view(this.store.get(id)));
|
|
1995
|
+
// A pull request check runs again after the one in progress, which may have read PRs from
|
|
1996
|
+
// before this request; callers arriving meanwhile share that next run.
|
|
1997
|
+
running.next ??= running.promise.catch(() => {}).then(() => this.refresh(id, { withPrs }));
|
|
1998
|
+
return running.next;
|
|
1999
|
+
}
|
|
2000
|
+
// Cleared before anyone waiting on it continues, so the next run starts afresh.
|
|
2001
|
+
const entry = { withPrs };
|
|
2002
|
+
entry.promise = this.doRefresh(id, { withPrs }).finally(() => {
|
|
2003
|
+
if (this.refreshing.get(id) === entry) this.refreshing.delete(id);
|
|
2004
|
+
});
|
|
2005
|
+
this.refreshing.set(id, entry);
|
|
2006
|
+
return entry.promise;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
async doRefresh(id, { withPrs }) {
|
|
2010
|
+
this.reloadBrief(id);
|
|
2011
|
+
const agent = this.store.get(id);
|
|
2012
|
+
// Before handoff, a planning session's transcript lives in the planning folder.
|
|
2013
|
+
const planningOnly = agent?.planning && !agent.planning.handedOffAt && !agent.workdir;
|
|
2014
|
+
const sessionDir = planningOnly ? agent.planning.dir : agent?.workdir;
|
|
2015
|
+
if (!sessionDir) return agent && this.view(agent);
|
|
2016
|
+
let changed = agent.workdir ? this.adoptSessions(agent) : false;
|
|
2017
|
+
|
|
2018
|
+
const info = agent.workdir ? await git.gitInfo(agent.workdir) : agent.git;
|
|
2019
|
+
if (!this.store.get(id)) return null;
|
|
2020
|
+
if (info !== undefined && JSON.stringify({ ...info, updatedAt: 0 }) !== JSON.stringify({ ...agent.git, updatedAt: 0 })) {
|
|
2021
|
+
agent.git = info;
|
|
2022
|
+
changed = true;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
const engine = engineOf(agent);
|
|
2026
|
+
for (const session of agent.sessions) {
|
|
2027
|
+
const file = engine.sessionFile(sessionDir, session.id);
|
|
2028
|
+
const key = `${session.id}:${file}`; // the transcript moves at handoff; rescan the new copy
|
|
2029
|
+
const cursor = this.cursors.get(key) ?? { offset: 0 };
|
|
2030
|
+
this.cursors.set(key, cursor);
|
|
2031
|
+
const res = engine.scan(file, cursor, this.config.bugPatterns);
|
|
2032
|
+
const subMaps = engine.subagentFiles(sessionDir, session.id).map((sub) => {
|
|
2033
|
+
const c = this.cursors.get(sub) ?? { offset: 0 };
|
|
2034
|
+
this.cursors.set(sub, c);
|
|
2035
|
+
engine.scan(sub, c);
|
|
2036
|
+
return c.usage;
|
|
2037
|
+
});
|
|
2038
|
+
const usage = sumUsage(cursor.usage, ...subMaps);
|
|
2039
|
+
if (usage.requests && JSON.stringify(session.usage) !== JSON.stringify(usage)) {
|
|
2040
|
+
session.usage = usage;
|
|
1989
2041
|
changed = true;
|
|
1990
2042
|
}
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
const file = engine.sessionFile(sessionDir, session.id);
|
|
1995
|
-
const key = `${session.id}:${file}`; // the transcript moves at handoff; rescan the new copy
|
|
1996
|
-
const cursor = this.cursors.get(key) ?? { offset: 0 };
|
|
1997
|
-
this.cursors.set(key, cursor);
|
|
1998
|
-
const res = engine.scan(file, cursor, this.config.bugPatterns);
|
|
1999
|
-
const subMaps = engine.subagentFiles(sessionDir, session.id).map((sub) => {
|
|
2000
|
-
const c = this.cursors.get(sub) ?? { offset: 0 };
|
|
2001
|
-
this.cursors.set(sub, c);
|
|
2002
|
-
engine.scan(sub, c);
|
|
2003
|
-
return c.usage;
|
|
2004
|
-
});
|
|
2005
|
-
const usage = sumUsage(cursor.usage, ...subMaps);
|
|
2006
|
-
if (usage.requests && JSON.stringify(session.usage) !== JSON.stringify(usage)) {
|
|
2007
|
-
session.usage = usage;
|
|
2043
|
+
for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity', 'lastMessageAt']) {
|
|
2044
|
+
if (res[key] != null && JSON.stringify(session[key]) !== JSON.stringify(res[key])) {
|
|
2045
|
+
session[key] = res[key];
|
|
2008
2046
|
changed = true;
|
|
2009
2047
|
}
|
|
2010
|
-
for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity', 'lastMessageAt']) {
|
|
2011
|
-
if (res[key] != null && JSON.stringify(session[key]) !== JSON.stringify(res[key])) {
|
|
2012
|
-
session[key] = res[key];
|
|
2013
|
-
changed = true;
|
|
2014
|
-
}
|
|
2015
|
-
}
|
|
2016
|
-
const prs = res.prs.map((p) => ({ ...p, sessionId: session.id }));
|
|
2017
|
-
changed = this.mergeLinks(agent, agent.prs, prs, (p) => p.url) || changed;
|
|
2018
|
-
changed = this.mergeLinks(agent, agent.bugs, res.bugs, (b) => b.key) || changed;
|
|
2019
2048
|
}
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2049
|
+
const prs = res.prs.map((p) => ({ ...p, sessionId: session.id }));
|
|
2050
|
+
changed = this.mergeLinks(agent, agent.prs, prs, (p) => p.url) || changed;
|
|
2051
|
+
changed = this.mergeLinks(agent, agent.bugs, res.bugs, (b) => b.key) || changed;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
if (withPrs) {
|
|
2055
|
+
this.lastPrCheck.set(id, Date.now());
|
|
2056
|
+
// A shared worktree's pull requests belong to the agent that owns it.
|
|
2057
|
+
const branchPrs = agent.sharedWith ? [] : await prsForBranch(agent.workdir, info?.branch);
|
|
2058
|
+
changed = this.mergeLinks(agent, agent.prs, branchPrs, (p) => p.url) || changed;
|
|
2059
|
+
// Fill in title/state for PRs we only know by URL, and keep open ones current.
|
|
2060
|
+
const stale = Object.values(agent.prs).filter(
|
|
2061
|
+
(p) => p.source !== 'branch' && (!p.state || p.state === 'OPEN'),
|
|
2062
|
+
);
|
|
2063
|
+
for (const pr of stale.slice(0, 10)) {
|
|
2064
|
+
const d = await prDetails(pr.url);
|
|
2065
|
+
if (d) changed = this.mergeLinks(agent, agent.prs, [{ ...d, source: pr.source }], (p) => p.url) || changed;
|
|
2034
2066
|
}
|
|
2067
|
+
// Landing reacts to failing checks and requested changes.
|
|
2068
|
+
this.emit('prs', agent);
|
|
2069
|
+
}
|
|
2035
2070
|
|
|
2036
|
-
|
|
2037
|
-
|
|
2071
|
+
await this.updateChanges(agent);
|
|
2072
|
+
this.updateStuck(agent, sessionDir);
|
|
2038
2073
|
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
}
|
|
2044
|
-
return this.view(agent);
|
|
2045
|
-
} finally {
|
|
2046
|
-
this.refreshing.delete(id);
|
|
2074
|
+
if (changed && this.store.get(id)) {
|
|
2075
|
+
agent.updatedAt = now();
|
|
2076
|
+
this.store.save();
|
|
2077
|
+
this.changed(agent);
|
|
2047
2078
|
}
|
|
2079
|
+
return this.view(agent);
|
|
2048
2080
|
}
|
|
2049
2081
|
|
|
2050
2082
|
// ------------------------------------------------------ telling an agent
|
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,7 +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, //
|
|
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
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
// Alerts the AI judge may hold for the digest or drop; the rest are always sent at once.
|
|
@@ -265,7 +266,7 @@ export class Notifier {
|
|
|
265
266
|
|
|
266
267
|
deliver(kind, agent, what, n, urgency = null) {
|
|
267
268
|
if (!n.enabled || !n.events[kind]) return;
|
|
268
|
-
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:' }[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];
|
|
269
270
|
const project = agent.projectInfo?.name ? ` (${agent.projectInfo.name})` : '';
|
|
270
271
|
const link = this.linkFor(agent.id);
|
|
271
272
|
const intent = n.includeIntent && agent.intent ? `\n> ${agent.intent.replace(/\s+/g, ' ').slice(0, 200)}` : '';
|