ai-agentmanager 1.0.2 → 1.2.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 +23 -0
- package/README.md +95 -3
- package/docs/architecture.md +1 -0
- package/package.json +1 -1
- package/public/app.js +594 -21
- package/public/index.html +156 -0
- package/public/style.css +54 -2
- package/src/assistant.js +269 -0
- package/src/cleanup.js +186 -0
- package/src/cli.js +263 -1
- package/src/client.js +2 -1
- package/src/config.js +4 -0
- package/src/engines/antigravity.js +1 -1
- package/src/engines/claude.js +8 -5
- package/src/engines/codex.js +1 -1
- package/src/manager.js +109 -19
- package/src/notify.js +2 -1
- package/src/server.js +67 -4
- package/src/teams.js +984 -0
- package/src/tracker.js +6 -0
package/src/cleanup.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Clean up old agents: close sessions nobody has used for a while (they can be resumed), and
|
|
2
|
+
// delete agents that stayed closed long after that, with the worktrees agentmgr created.
|
|
3
|
+
// Strategies: "close-delete" (both, automatically), "close" (close automatically, delete by
|
|
4
|
+
// hand), "manual" (nothing automatic; run it from the Clean up dialog or `agentmgr cleanup`),
|
|
5
|
+
// and "off". Never touched: agents marked Keep, agents still setting up or working, members of a
|
|
6
|
+
// team that is still going, and, for deleting, agents with an open pull request or with work
|
|
7
|
+
// that exists only in their worktree (uncommitted changes or commits on no other branch).
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import * as git from './git.js';
|
|
10
|
+
|
|
11
|
+
export const STRATEGIES = ['close-delete', 'close', 'manual', 'off'];
|
|
12
|
+
export const CLEANUP_DEFAULTS = { strategy: 'close', closeAfterDays: 14, deleteAfterDays: 14 };
|
|
13
|
+
const DAY = 86_400_000;
|
|
14
|
+
const TEAM_DONE = new Set(['done', 'stopped']);
|
|
15
|
+
|
|
16
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
17
|
+
const days = (ms) => Math.floor(ms / DAY);
|
|
18
|
+
|
|
19
|
+
/** Settings from config.json's "cleanup", with defaults and sane bounds. */
|
|
20
|
+
export function cleanupSettings(config = {}) {
|
|
21
|
+
const c = { ...CLEANUP_DEFAULTS, ...config.cleanup };
|
|
22
|
+
const n = (v, d) => (Number.isFinite(Number(v)) && Number(v) >= 1 ? Math.min(3650, Math.floor(Number(v))) : d);
|
|
23
|
+
return {
|
|
24
|
+
strategy: STRATEGIES.includes(c.strategy) ? c.strategy : CLEANUP_DEFAULTS.strategy,
|
|
25
|
+
closeAfterDays: n(c.closeAfterDays, CLEANUP_DEFAULTS.closeAfterDays),
|
|
26
|
+
deleteAfterDays: n(c.deleteAfterDays, CLEANUP_DEFAULTS.deleteAfterDays),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Work that would be lost with the worktree: uncommitted changes, or commits on no other branch or remote. */
|
|
31
|
+
export async function unsavedWork(agent) {
|
|
32
|
+
const dir = agent.workdir;
|
|
33
|
+
if (!dir || agent.sharedWith || !fs.existsSync(dir)) return null;
|
|
34
|
+
try {
|
|
35
|
+
const dirty = await git.run('git', ['status', '--porcelain'], { cwd: dir });
|
|
36
|
+
if (dirty.trim()) return 'uncommitted changes';
|
|
37
|
+
const branch = agent.branch ?? (await git.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir })).trim();
|
|
38
|
+
const only = await git.run('git', ['rev-list', '--count', 'HEAD', '--not', '--remotes', `--exclude=${branch}`, '--branches'], { cwd: dir });
|
|
39
|
+
const n = Number(only.trim());
|
|
40
|
+
return n > 0 ? `${n} commit${n > 1 ? 's' : ''} not pushed or merged` : null;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return `couldn't check its worktree (${e.message.split('\n')[0]})`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class Cleanup {
|
|
47
|
+
constructor({ manager, store, config = {}, teams = null, log = (m) => console.log(`[agentmgr] ${m}`) }) {
|
|
48
|
+
this.manager = manager;
|
|
49
|
+
this.store = store;
|
|
50
|
+
this.config = config;
|
|
51
|
+
this.teams = teams;
|
|
52
|
+
this.log = log;
|
|
53
|
+
store.data.cleanup ??= {};
|
|
54
|
+
// A first look a couple of minutes after start, then every few hours.
|
|
55
|
+
this.firstTimer = setTimeout(() => this.sweep().catch((e) => this.log(`clean up: ${e.message}`)), config.cleanupFirstSec ? config.cleanupFirstSec * 1000 : 120_000);
|
|
56
|
+
this.firstTimer.unref?.();
|
|
57
|
+
this.timer = setInterval(() => this.sweep().catch((e) => this.log(`clean up: ${e.message}`)), (config.cleanupEveryMin ?? 360) * 60_000);
|
|
58
|
+
this.timer.unref?.();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
close() {
|
|
62
|
+
clearTimeout(this.firstTimer);
|
|
63
|
+
clearInterval(this.timer);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get settings() {
|
|
67
|
+
return cleanupSettings(this.config);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Change the strategy or the days; kept in config.json by the caller. */
|
|
71
|
+
update(patch = {}) {
|
|
72
|
+
if (patch.strategy !== undefined && !STRATEGIES.includes(patch.strategy)) throw err(400, `strategy must be one of ${STRATEGIES.join(', ')}`);
|
|
73
|
+
for (const k of ['closeAfterDays', 'deleteAfterDays']) {
|
|
74
|
+
if (patch[k] !== undefined && !(Number(patch[k]) >= 1)) throw err(400, `${k} must be 1 or more`);
|
|
75
|
+
}
|
|
76
|
+
const next = cleanupSettings({ cleanup: { ...this.config.cleanup, ...patch } });
|
|
77
|
+
this.config.cleanup = next;
|
|
78
|
+
return next;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Why an agent is left alone whatever its age, or null. */
|
|
82
|
+
protectedBy(agent) {
|
|
83
|
+
if (agent.keep) return 'marked Keep';
|
|
84
|
+
if (agent.kind === 'assistant') return 'the assistant';
|
|
85
|
+
if (agent.status === 'preparing') return 'still setting up';
|
|
86
|
+
const rt = this.manager.runtimes.get(agent.id);
|
|
87
|
+
if (rt?.pty && rt.busy) return 'working';
|
|
88
|
+
const team = agent.team && this.store.data.teams?.[agent.team.id];
|
|
89
|
+
if (team && !TEAM_DONE.has(team.stage)) return 'its team is still going';
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What a run would do now: agents to close (idle closeAfterDays), agents to delete (closed
|
|
95
|
+
* deleteAfterDays), and agents left alone with the reason. Deleting checks the worktree.
|
|
96
|
+
*/
|
|
97
|
+
async plan({ checkWork = true } = {}) {
|
|
98
|
+
const s = this.settings;
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const close = [];
|
|
101
|
+
const del = [];
|
|
102
|
+
const kept = [];
|
|
103
|
+
for (const agent of this.store.list()) {
|
|
104
|
+
const rt = this.manager.runtimes.get(agent.id);
|
|
105
|
+
const running = Boolean(rt?.pty);
|
|
106
|
+
const activeAt = Date.parse(this.manager.activeAt(agent, rt) ?? agent.createdAt) || now;
|
|
107
|
+
const idle = now - activeAt;
|
|
108
|
+
const row = { id: agent.id, name: agent.name, idleDays: days(idle) };
|
|
109
|
+
if (running) {
|
|
110
|
+
if (idle < s.closeAfterDays * DAY) continue;
|
|
111
|
+
const why = this.protectedBy(agent);
|
|
112
|
+
if (why) kept.push({ ...row, action: 'close', reason: why });
|
|
113
|
+
else close.push(row);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// Closed since: when it was closed, or its last activity if that is later (or unknown).
|
|
117
|
+
const closedAt = Math.max(Date.parse(agent.closedAt ?? '') || 0, activeAt);
|
|
118
|
+
const closedFor = now - closedAt;
|
|
119
|
+
if (!agent.closedAt || closedFor < s.deleteAfterDays * DAY) continue;
|
|
120
|
+
const r = { ...row, closedDays: days(closedFor) };
|
|
121
|
+
const why =
|
|
122
|
+
this.protectedBy(agent) ??
|
|
123
|
+
(Object.values(agent.prs ?? {}).some((p) => p.state === 'OPEN') ? 'has an open pull request' : null) ??
|
|
124
|
+
(checkWork ? await unsavedWork(agent) : null);
|
|
125
|
+
if (why) kept.push({ ...r, action: 'delete', reason: why });
|
|
126
|
+
else del.push(r);
|
|
127
|
+
}
|
|
128
|
+
return { close, delete: del, kept };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Close and/or delete what the plan says; `ids` limits it to those agents. */
|
|
132
|
+
async run({ close = true, delete: del = true, ids = null, by = 'person' } = {}) {
|
|
133
|
+
const only = ids ? new Set(ids) : null;
|
|
134
|
+
const plan = await this.plan();
|
|
135
|
+
const done = { at: new Date().toISOString(), by, closed: [], deleted: [], kept: plan.kept, failed: [] };
|
|
136
|
+
if (close) {
|
|
137
|
+
for (const row of plan.close.filter((r) => !only || only.has(r.id))) {
|
|
138
|
+
try {
|
|
139
|
+
await this.manager.stop(row.id);
|
|
140
|
+
this.manager.patch(row.id, { closedBy: 'cleanup' });
|
|
141
|
+
done.closed.push(row);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
done.failed.push({ ...row, reason: e.message });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (del) {
|
|
148
|
+
for (const row of plan.delete.filter((r) => !only || only.has(r.id))) {
|
|
149
|
+
try {
|
|
150
|
+
await this.manager.remove(row.id, { deleteWorkspace: true });
|
|
151
|
+
done.deleted.push(row);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
done.failed.push({ ...row, reason: e.message });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (done.closed.length || done.deleted.length || done.failed.length) {
|
|
158
|
+
this.log(`clean up (${by}): closed ${done.closed.length}, deleted ${done.deleted.length}${done.failed.length ? `, failed ${done.failed.length}` : ''}`);
|
|
159
|
+
}
|
|
160
|
+
this.store.data.cleanup.lastRun = done;
|
|
161
|
+
this.store.save();
|
|
162
|
+
return done;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The automatic pass, as the strategy says. */
|
|
166
|
+
async sweep() {
|
|
167
|
+
// Agents closed before clean up existed have no closing time: count from now, so nothing is
|
|
168
|
+
// deleted the day it is turned on.
|
|
169
|
+
let marked = false;
|
|
170
|
+
for (const agent of this.store.list()) {
|
|
171
|
+
if (!agent.closedAt && !this.manager.runtimes.get(agent.id)?.pty && agent.desired !== 'running') {
|
|
172
|
+
agent.closedAt = new Date().toISOString();
|
|
173
|
+
marked = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (marked) this.store.save();
|
|
177
|
+
const { strategy } = this.settings;
|
|
178
|
+
if (strategy === 'off' || strategy === 'manual') return null;
|
|
179
|
+
return this.run({ close: true, delete: strategy === 'close-delete', by: 'schedule' });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async status() {
|
|
183
|
+
const s = this.settings;
|
|
184
|
+
return { ...s, ...(await this.plan()), lastRun: this.store.data.cleanup.lastRun ?? null };
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -451,6 +451,42 @@ export async function main(argv) {
|
|
|
451
451
|
console.log(`${res.name}: P${res.priority}`);
|
|
452
452
|
});
|
|
453
453
|
|
|
454
|
+
program
|
|
455
|
+
.command('edit')
|
|
456
|
+
.description("Change an agent's name, request, project, priority, permission mode or model")
|
|
457
|
+
.argument('<agent>')
|
|
458
|
+
.option('-n, --name <name>', 'display name')
|
|
459
|
+
.option('-i, --intent <text>', 'the request as recorded (add --tell to send it to the session too)')
|
|
460
|
+
.option('--tell', 'with --intent: also tell the agent about the changed request')
|
|
461
|
+
.option('--project <project>', 'move under this project (shortcut or name); "" for its own repo or folder')
|
|
462
|
+
.option('--priority <P0-P3>', 'priority')
|
|
463
|
+
.option('-p, --permission-mode <mode>', 'permission mode, from the next launch ("" for the default)')
|
|
464
|
+
.option('-m, --model <model>', 'model, from the next launch ("" for the default)')
|
|
465
|
+
.action(async (ref, opts) => {
|
|
466
|
+
await daemon();
|
|
467
|
+
const a = await resolveAgent(ref);
|
|
468
|
+
const patch = Object.fromEntries(
|
|
469
|
+
[['name', opts.name], ['intent', opts.intent], ['project', opts.project], ['priority', opts.priority], ['permissionMode', opts.permissionMode], ['model', opts.model]].filter(([, v]) => v !== undefined),
|
|
470
|
+
);
|
|
471
|
+
if (!Object.keys(patch).length) throw new Error('Nothing to change: give --name, --intent, --project, --priority, --permission-mode or --model');
|
|
472
|
+
const res = await api('PATCH', `/agents/${a.id}`, patch);
|
|
473
|
+
if (opts.tell && opts.intent) await api('POST', `/agents/${a.id}/tell`, { text: `The request changed. It is now:\n${opts.intent}` });
|
|
474
|
+
console.log(`${green('✓')} ${bold(res.name)} ${dim(res.id)} ${res.projectInfo?.name ?? ''} P${res.priority}${res.permissionMode ? ` ${res.permissionMode}` : ''}${res.model ? ` ${res.model}` : ''}`);
|
|
475
|
+
if (patch.permissionMode !== undefined || patch.model !== undefined) console.log(dim('Permission mode and model apply from its next launch.'));
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
program
|
|
479
|
+
.command('move')
|
|
480
|
+
.description('Move an agent under another project (shortcut or name); "" moves it back to its own')
|
|
481
|
+
.argument('<agent>')
|
|
482
|
+
.argument('<project>')
|
|
483
|
+
.action(async (ref, project) => {
|
|
484
|
+
await daemon();
|
|
485
|
+
const a = await resolveAgent(ref);
|
|
486
|
+
const res = await api('PATCH', `/agents/${a.id}`, { project });
|
|
487
|
+
console.log(`${green('✓')} ${bold(res.name)} is now under ${bold(res.projectInfo?.full || res.projectInfo?.name)}`);
|
|
488
|
+
});
|
|
489
|
+
|
|
454
490
|
program
|
|
455
491
|
.command('plan')
|
|
456
492
|
.description('While the workspace is still being prepared, plan the task with Claude and attach')
|
|
@@ -626,6 +662,133 @@ export async function main(argv) {
|
|
|
626
662
|
console.log(`${green('🏆')} winner: ${r.rows.find((x) => x.id === r.winner)?.variant?.label ?? r.winner}`);
|
|
627
663
|
});
|
|
628
664
|
|
|
665
|
+
const teamCmd = program.command('team').description('Agents in roles solve one problem: analyze, critique, implement, test, review');
|
|
666
|
+
teamCmd
|
|
667
|
+
.command('start')
|
|
668
|
+
.description('Start a team on a problem')
|
|
669
|
+
.argument('<problem>')
|
|
670
|
+
.option('-r, --repo <url>', 'git repository URL')
|
|
671
|
+
.option('-d, --dir <path>', 'git folder (the team gets a worktree), or a new folder for a new project')
|
|
672
|
+
.option('--project <name>', 'project shortcut or name')
|
|
673
|
+
.option('-b, --base <ref>', 'branch the team starts from')
|
|
674
|
+
.option('-c, --critics <n>', 'critics who challenge the analysis (0-2)', '1')
|
|
675
|
+
.option('-t, --test <command>', 'test command, a hint for the tester')
|
|
676
|
+
.option('-m, --model <model>', 'model for every role')
|
|
677
|
+
.option('--role-model <role=model...>', 'model for one role, e.g. --role-model analyst=opus reviewer=opus')
|
|
678
|
+
.option('--max-rounds <n>', 'analysis rounds before it asks you (default 3)')
|
|
679
|
+
.option('--max-loops <n>', 'fix rounds per request before it asks you (default 3)')
|
|
680
|
+
.option('--gap-rounds <n>', 'gap rounds (for what the reviewer says is still missing) it runs without asking you (default 0: it asks first)')
|
|
681
|
+
.option('--separate', 'one agent per role instead of one lead session with sub-agents')
|
|
682
|
+
.action(async (problem, opts) => {
|
|
683
|
+
await daemon();
|
|
684
|
+
const models = Object.fromEntries((opts.roleModel ?? []).map((p) => p.split('=')).filter(([k, v]) => k && v));
|
|
685
|
+
const t = await api('POST', '/teams', {
|
|
686
|
+
intent: problem, repo: opts.repo, dir: opts.dir, project: opts.project, base: opts.base, critics: Number(opts.critics),
|
|
687
|
+
testCommand: opts.test, model: opts.model, models, maxRounds: opts.maxRounds, maxLoops: opts.maxLoops, gapRounds: opts.gapRounds,
|
|
688
|
+
...(opts.separate ? { mode: 'agents' } : {}),
|
|
689
|
+
});
|
|
690
|
+
console.log(`${green('👥')} team ${bold(t.id)}: ${t.mode === 'lead' ? `lead ${t.members[0].id}, with ${t.subagents.map((r) => r.role).join(', ')} as sub-agents` : t.members.map((m) => `${m.role} (${m.id})`).join(', ')}`);
|
|
691
|
+
if (t.newProject) console.log(dim(` new project: ${t.newProject}`));
|
|
692
|
+
console.log(dim(` follow it: agentmgr team show ${t.id}`));
|
|
693
|
+
});
|
|
694
|
+
teamCmd
|
|
695
|
+
.command('show')
|
|
696
|
+
.description('Where a team stands and what each member handed off (or list teams)')
|
|
697
|
+
.argument('[team]')
|
|
698
|
+
.action(async (id) => {
|
|
699
|
+
await daemon();
|
|
700
|
+
if (!id) {
|
|
701
|
+
const list = await api('GET', '/teams');
|
|
702
|
+
if (!list.length) return console.log(dim('No teams yet: agentmgr team start "<problem>" -r <repo>'));
|
|
703
|
+
return table(list.map((t) => [t.id, t.name, t.stage, `${t.loop}`, t.intent.split('\n')[0].slice(0, 50)]), ['TEAM', 'NAME', 'STAGE', 'ROUND', 'PROBLEM']);
|
|
704
|
+
}
|
|
705
|
+
const t = await api('GET', `/teams/${id}`);
|
|
706
|
+
console.log(`${bold(t.name)} ${t.mode === 'lead' || t.quest > 1 ? `quest ${t.quest}${t.gapRound ? ` (gap round ${t.gapRound})` : ''} · ` : ''}${t.stage}${t.blocked ? `: ${t.blocked.reason}` : ''}`);
|
|
707
|
+
console.log(dim(` ${(t.request ?? t.intent).split('\n')[0]}`));
|
|
708
|
+
for (const q of t.quests ?? []) console.log(dim(` quest ${q.n}: ${q.request.split('\n')[0].slice(0, 60)} (${q.outcome})`));
|
|
709
|
+
console.log(dim(` folder ${t.dir}${t.branch ? ` · branch ${t.branch}` : ''}`));
|
|
710
|
+
table(t.members.map((m) => [m.role + (m.n && t.settings.critics > 1 ? ` ${m.n}` : ''), m.id, m.status, m.turn ? 'its turn' : '', m.costUsd ? `$${m.costUsd.toFixed(2)}` : '']), ['ROLE', 'AGENT', 'STATUS', '', 'COST']);
|
|
711
|
+
if (t.history.length) {
|
|
712
|
+
console.log('');
|
|
713
|
+
table(t.history.map((h) => [h.role, h.verdict, h.file, h.summary.slice(0, 70)]), ['FROM', 'VERDICT', 'FILE', 'SUMMARY']);
|
|
714
|
+
}
|
|
715
|
+
if (t.stage === 'ready') console.log(`\n${green('✓')} approved: agentmgr team finalize ${t.id} ${t.remote ? '[--keep]' : '--merge | --publish | --keep'}, or agentmgr team send-back ${t.id} "<what to change>"${t.gaps ? `, or agentmgr team fix-gaps ${t.id}` : ''}`);
|
|
716
|
+
else if (t.stage === 'done' && t.gaps) console.log(`\n${yellow('!')} the last review lists gaps: agentmgr team fix-gaps ${t.id} ["note"]`);
|
|
717
|
+
if (t.stage === 'blocked') console.log(`\n${yellow('!')} decide: agentmgr team continue ${t.id} ["note"], or agentmgr team skip ${t.id}`);
|
|
718
|
+
});
|
|
719
|
+
teamCmd
|
|
720
|
+
.command('finalize')
|
|
721
|
+
.description('Accept the approved work: the implementer opens a PR, or --merge / --publish / --keep')
|
|
722
|
+
.argument('<team>')
|
|
723
|
+
.option('--merge', 'fast-forward the local main branch to the team branch')
|
|
724
|
+
.option('--publish', 'no GitHub repository yet: create a private one, push and open a PR')
|
|
725
|
+
.option('--keep', 'keep the branch as it is')
|
|
726
|
+
.option('--no-pr', 'same as --keep')
|
|
727
|
+
.action(async (id, opts) => {
|
|
728
|
+
await daemon();
|
|
729
|
+
const mode = opts.merge ? 'merge' : opts.publish ? 'publish' : opts.keep || opts.pr === false ? 'keep' : 'pr';
|
|
730
|
+
const t = await api('POST', `/teams/${id}/finalize`, { mode });
|
|
731
|
+
console.log(`${green('✓')} ${{ merged: 'merged into main', kept: 'done; the branch is kept' }[t.outcome] ?? (mode === 'publish' ? 'the implementer is publishing it to GitHub' : 'the implementer is opening the pull request')}`);
|
|
732
|
+
});
|
|
733
|
+
teamCmd
|
|
734
|
+
.command('send-back')
|
|
735
|
+
.description('Send the approved work back with what should change')
|
|
736
|
+
.argument('<team>')
|
|
737
|
+
.argument('<note>')
|
|
738
|
+
.action(async (id, note) => {
|
|
739
|
+
await daemon();
|
|
740
|
+
await api('POST', `/teams/${id}/send-back`, { note });
|
|
741
|
+
console.log(`${green('↩')} sent back to the implementer`);
|
|
742
|
+
});
|
|
743
|
+
teamCmd
|
|
744
|
+
.command('fix-gaps')
|
|
745
|
+
.description('Another round, from analysis to review, for what the last review says is missing')
|
|
746
|
+
.argument('<team>')
|
|
747
|
+
.argument('[note]', "what's missing, if the review lists nothing")
|
|
748
|
+
.action(async (id, note) => {
|
|
749
|
+
await daemon();
|
|
750
|
+
await api('POST', `/teams/${id}/fix-gaps`, { note });
|
|
751
|
+
console.log(`${green('…')} gap round started`);
|
|
752
|
+
});
|
|
753
|
+
teamCmd
|
|
754
|
+
.command('continue')
|
|
755
|
+
.description('After a disagreement: one more round, with an optional note')
|
|
756
|
+
.argument('<team>')
|
|
757
|
+
.argument('[note]')
|
|
758
|
+
.action(async (id, note) => {
|
|
759
|
+
await daemon();
|
|
760
|
+
await api('POST', `/teams/${id}/resume`, { action: 'continue', note });
|
|
761
|
+
console.log(`${green('…')} another round started`);
|
|
762
|
+
});
|
|
763
|
+
teamCmd
|
|
764
|
+
.command('skip')
|
|
765
|
+
.description('After a disagreement: implement the latest analysis, or accept the work as is')
|
|
766
|
+
.argument('<team>')
|
|
767
|
+
.action(async (id) => {
|
|
768
|
+
await daemon();
|
|
769
|
+
const t = await api('POST', `/teams/${id}/resume`, { action: 'skip' });
|
|
770
|
+
console.log(`${green('→')} ${t.stage === 'ready' ? 'accepted; finalize when ready' : 'implementing the latest analysis'}`);
|
|
771
|
+
});
|
|
772
|
+
teamCmd
|
|
773
|
+
.command('ask')
|
|
774
|
+
.description("Give a team's lead new work: it runs it through the team as the next quest")
|
|
775
|
+
.argument('<team>')
|
|
776
|
+
.argument('<request>')
|
|
777
|
+
.action(async (id, request) => {
|
|
778
|
+
await daemon();
|
|
779
|
+
await api('POST', `/teams/${id}/ask`, { text: request });
|
|
780
|
+
console.log(`${green('→')} given to the lead`);
|
|
781
|
+
});
|
|
782
|
+
teamCmd
|
|
783
|
+
.command('stop')
|
|
784
|
+
.description("Stop a team: its agents close; nothing is deleted")
|
|
785
|
+
.argument('<team>')
|
|
786
|
+
.action(async (id) => {
|
|
787
|
+
await daemon();
|
|
788
|
+
await api('POST', `/teams/${id}/stop`);
|
|
789
|
+
console.log(`${green('■')} team stopped`);
|
|
790
|
+
});
|
|
791
|
+
|
|
629
792
|
const projCmd = program.command('project').description('Project shortcuts: short names like "cc" for repos you work on');
|
|
630
793
|
projCmd
|
|
631
794
|
.command('ls')
|
|
@@ -879,6 +1042,105 @@ export async function main(argv) {
|
|
|
879
1042
|
}
|
|
880
1043
|
});
|
|
881
1044
|
|
|
1045
|
+
program
|
|
1046
|
+
.command('cleanup')
|
|
1047
|
+
.description('Close agents idle for a while and delete ones closed long after that (see what it would do, run it, or pick the strategy)')
|
|
1048
|
+
.option('--run', 'close and delete what is due now')
|
|
1049
|
+
.option('--close-only', 'with --run: only close sessions, delete nothing')
|
|
1050
|
+
.option('--strategy <s>', 'close-delete (both automatically), close (close automatically, delete by hand), manual, or off')
|
|
1051
|
+
.option('--close-after <days>', 'close sessions idle this many days')
|
|
1052
|
+
.option('--delete-after <days>', 'delete agents closed this many days')
|
|
1053
|
+
.action(async (opts) => {
|
|
1054
|
+
await daemon();
|
|
1055
|
+
const patch = {
|
|
1056
|
+
...(opts.strategy ? { strategy: opts.strategy } : {}),
|
|
1057
|
+
...(opts.closeAfter ? { closeAfterDays: Number(opts.closeAfter) } : {}),
|
|
1058
|
+
...(opts.deleteAfter ? { deleteAfterDays: Number(opts.deleteAfter) } : {}),
|
|
1059
|
+
};
|
|
1060
|
+
if (Object.keys(patch).length) await api('PUT', '/cleanup', patch);
|
|
1061
|
+
if (opts.run) {
|
|
1062
|
+
const r = await api('POST', '/cleanup/run', { delete: !opts.closeOnly });
|
|
1063
|
+
for (const x of r.closed) console.log(`${yellow('■')} closed ${x.name} (idle ${x.idleDays} days)`);
|
|
1064
|
+
for (const x of r.deleted) console.log(`${red('×')} deleted ${x.name} (closed ${x.closedDays} days)`);
|
|
1065
|
+
for (const x of r.failed) console.log(`${red('!')} ${x.name}: ${x.reason}`);
|
|
1066
|
+
if (!r.closed.length && !r.deleted.length) console.log(dim('Nothing to clean up.'));
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const c = await api('GET', '/cleanup');
|
|
1070
|
+
const what = { 'close-delete': 'closes and deletes automatically', close: 'closes automatically; delete with --run', manual: 'nothing automatic; use --run', off: 'off' }[c.strategy];
|
|
1071
|
+
console.log(`${bold('Clean up')}: ${c.strategy} (${what}) · close after ${c.closeAfterDays} days idle · delete ${c.deleteAfterDays} days after closing`);
|
|
1072
|
+
const rows = [
|
|
1073
|
+
...c.close.map((x) => [yellow('close'), x.name, `idle ${x.idleDays}d`, '']),
|
|
1074
|
+
...c.delete.map((x) => [red('delete'), x.name, `closed ${x.closedDays}d`, '']),
|
|
1075
|
+
...c.kept.map((x) => [dim('keep'), x.name, x.closedDays != null ? `closed ${x.closedDays}d` : `idle ${x.idleDays}d`, x.reason]),
|
|
1076
|
+
];
|
|
1077
|
+
if (rows.length) table(rows, ['DUE', 'AGENT', 'AGE', 'KEPT BECAUSE']);
|
|
1078
|
+
else console.log(dim('Nothing is due.'));
|
|
1079
|
+
if (c.lastRun) console.log(dim(`\nLast run ${new Date(c.lastRun.at).toLocaleString()} (${c.lastRun.by}): closed ${c.lastRun.closed.length}, deleted ${c.lastRun.deleted.length}`));
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
const asCmd = program.command('assistant').description('An always-on session that finds new work (Jira, GitHub, Slack) and starts agents for it');
|
|
1083
|
+
const showAssistant = (a) => {
|
|
1084
|
+
const state = !a.agentId ? dim('not set up (agentmgr assistant start)') : a.on ? green(a.busy ? 'on, checking now' : 'on') : yellow('paused');
|
|
1085
|
+
console.log(`${bold('Assistant')}: ${state}${a.agentId ? dim(` agent ${a.agentId}`) : ''}`);
|
|
1086
|
+
console.log(`checks every ${a.everyMin} min · watches ${a.sources.join(', ') || 'nothing'} · at most ${a.maxPerDay} new agents a day, ${a.maxRunning} running`);
|
|
1087
|
+
if (a.lastCheckAt) console.log(dim(`last check ${new Date(a.lastCheckAt).toLocaleString()}${a.nextAt ? ` · next ${new Date(a.nextAt).toLocaleTimeString()}` : ''} · started ${a.startedToday} today, ${a.startedTotal} in all`));
|
|
1088
|
+
for (const x of a.started.slice(0, 10)) console.log(` ${green('+')} ${bold(x.name)} ${dim(x.id)} ${truncate(x.intent, 70)}`);
|
|
1089
|
+
};
|
|
1090
|
+
asCmd
|
|
1091
|
+
.command('status', { isDefault: true })
|
|
1092
|
+
.description('Show whether it is on, its settings, and the agents it started')
|
|
1093
|
+
.option('--activity', 'also print its activity log')
|
|
1094
|
+
.action(async (opts) => {
|
|
1095
|
+
await daemon();
|
|
1096
|
+
const a = await api('GET', '/assistant');
|
|
1097
|
+
showAssistant(a);
|
|
1098
|
+
if (opts.activity) console.log(`\n${a.activity || dim('No activity yet.')}`);
|
|
1099
|
+
});
|
|
1100
|
+
asCmd
|
|
1101
|
+
.command('start')
|
|
1102
|
+
.description('Set it up (first time) or turn it back on')
|
|
1103
|
+
.action(async () => {
|
|
1104
|
+
await daemon();
|
|
1105
|
+
showAssistant(await api('POST', '/assistant/start'));
|
|
1106
|
+
});
|
|
1107
|
+
asCmd
|
|
1108
|
+
.command('pause')
|
|
1109
|
+
.description('Stop checking and close its session (start resumes it with its memory)')
|
|
1110
|
+
.action(async () => {
|
|
1111
|
+
await daemon();
|
|
1112
|
+
showAssistant(await api('POST', '/assistant/pause'));
|
|
1113
|
+
});
|
|
1114
|
+
asCmd
|
|
1115
|
+
.command('check')
|
|
1116
|
+
.description('Check for new work now')
|
|
1117
|
+
.action(async () => {
|
|
1118
|
+
await daemon();
|
|
1119
|
+
await api('POST', '/assistant/check');
|
|
1120
|
+
console.log(`${green('✓')} asked the assistant to check now`);
|
|
1121
|
+
});
|
|
1122
|
+
asCmd
|
|
1123
|
+
.command('set')
|
|
1124
|
+
.description('Change its settings (they apply from its next launch; pause and start to apply now)')
|
|
1125
|
+
.option('--every <minutes>', 'check this often')
|
|
1126
|
+
.option('--sources <list>', 'comma-separated: jira,comments,reviews,ci,slack')
|
|
1127
|
+
.option('--max-per-day <n>', 'new agents a day, at most')
|
|
1128
|
+
.option('--max-running <n>', 'of its agents running at once, at most')
|
|
1129
|
+
.option('--permission-mode <mode>', 'its own permission mode (default auto, so it can run agentmgr and gh without asking)')
|
|
1130
|
+
.option('--notes <text>', 'standing notes, e.g. which Jira project goes to which repo')
|
|
1131
|
+
.action(async (opts) => {
|
|
1132
|
+
await daemon();
|
|
1133
|
+
const patch = {
|
|
1134
|
+
...(opts.every ? { everyMin: Number(opts.every) } : {}),
|
|
1135
|
+
...(opts.sources ? { sources: opts.sources.split(',').map((x) => x.trim()).filter(Boolean) } : {}),
|
|
1136
|
+
...(opts.maxPerDay ? { maxPerDay: Number(opts.maxPerDay) } : {}),
|
|
1137
|
+
...(opts.maxRunning ? { maxRunning: Number(opts.maxRunning) } : {}),
|
|
1138
|
+
...(opts.permissionMode ? { permissionMode: opts.permissionMode } : {}),
|
|
1139
|
+
...(opts.notes !== undefined ? { notes: opts.notes } : {}),
|
|
1140
|
+
};
|
|
1141
|
+
showAssistant(await api('PUT', '/assistant', patch));
|
|
1142
|
+
});
|
|
1143
|
+
|
|
882
1144
|
program
|
|
883
1145
|
.command('doctor')
|
|
884
1146
|
.description('Check what AI Agent Manager needs on this machine (Node.js, Claude Code, git, gh, the daemon) and how to fix it')
|
|
@@ -1295,7 +1557,7 @@ export async function main(argv) {
|
|
|
1295
1557
|
i === 0 || proj(agents[i - 1]) !== proj(a) ? bold(truncate(proj(a), 30)) : '',
|
|
1296
1558
|
a.priority === 0 ? red(bold('P0')) : a.priority === 1 ? red('P1') : a.priority === 3 ? dim('P3') : 'P2',
|
|
1297
1559
|
a.id,
|
|
1298
|
-
truncate(a.kind === 'general' ? `${a.name} (general)` : a.name, 28),
|
|
1560
|
+
truncate(a.kind === 'general' ? `${a.name} (general)` : a.startedBy ? `${a.name} (assistant)` : a.name, 28),
|
|
1299
1561
|
statusLabel(a),
|
|
1300
1562
|
a.git?.branch ?? '',
|
|
1301
1563
|
String(a.prs.length || ''),
|
package/src/client.js
CHANGED
|
@@ -10,7 +10,8 @@ const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin',
|
|
|
10
10
|
export async function api(method, route, body) {
|
|
11
11
|
const res = await fetch(`${BASE}/api${route}`, {
|
|
12
12
|
method,
|
|
13
|
-
|
|
13
|
+
// From inside an agent's session, say which agent is asking (the assistant's limits use it).
|
|
14
|
+
headers: { 'content-type': 'application/json', 'x-agentmgr': '1', ...(process.env.AGENTMGR_AGENT_ID ? { 'x-agentmgr-agent': process.env.AGENTMGR_AGENT_ID } : {}) },
|
|
14
15
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
15
16
|
});
|
|
16
17
|
const data = await res.json().catch(() => ({}));
|
package/src/config.js
CHANGED
|
@@ -24,6 +24,8 @@ export const paths = {
|
|
|
24
24
|
scrollback: path.join(HOME, 'scrollback'),
|
|
25
25
|
// Files attached from the UI (dropped or pasted into a terminal), per agent.
|
|
26
26
|
uploads: path.join(HOME, 'uploads'),
|
|
27
|
+
// Agent teams' handoff files (analysis, critiques, test reports, reviews), per team.
|
|
28
|
+
teams: path.join(HOME, 'teams'),
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
const DEFAULTS = {
|
|
@@ -65,6 +67,8 @@ const DEFAULTS = {
|
|
|
65
67
|
handoffIdleSec: 5,
|
|
66
68
|
// The AI judge (stuck, idle screens, reviews); see JUDGE_DEFAULTS in judge.js for every field.
|
|
67
69
|
judge: { enabled: true },
|
|
70
|
+
// Clean up old agents: "close-delete", "close" (delete by hand), "manual" or "off"; see cleanup.js.
|
|
71
|
+
cleanup: { strategy: 'close', closeAfterDays: 14, deleteAfterDays: 14 },
|
|
68
72
|
// Shell used for the ancillary terminal in an agent's folder.
|
|
69
73
|
shell: process.env.SHELL || '/bin/zsh',
|
|
70
74
|
};
|
|
@@ -109,7 +109,7 @@ export default {
|
|
|
109
109
|
launchArgs({ extra = [], permissionMode, model, writableDir, instructions, sessionId, resume, prompt }) {
|
|
110
110
|
const opts = [...extra, ...(MODES[permissionMode] ?? MODES.default)];
|
|
111
111
|
if (model) opts.push('--model', model);
|
|
112
|
-
|
|
112
|
+
for (const d of [writableDir ?? []].flat()) opts.push('--add-dir', d);
|
|
113
113
|
// "--flag=value" so a message starting with "-" isn't read as a flag.
|
|
114
114
|
if (resume) return ['--conversation', sessionId, ...opts, ...(prompt ? [`--prompt-interactive=${prompt}`] : [])];
|
|
115
115
|
const first = [instructions, prompt && (instructions ? `Your task:\n${prompt}` : prompt)].filter(Boolean).join('\n\n---\n\n');
|
package/src/engines/claude.js
CHANGED
|
@@ -11,25 +11,28 @@ export default {
|
|
|
11
11
|
id: 'claude',
|
|
12
12
|
label: 'Claude Code',
|
|
13
13
|
// What the UI may offer for this engine's agents.
|
|
14
|
-
supports: { chooseSessionId: true, cost: true, planHandoff: true, fork: true, oneShot: true },
|
|
14
|
+
supports: { chooseSessionId: true, cost: true, planHandoff: true, fork: true, oneShot: true, subagents: true },
|
|
15
15
|
permissionModes: ['default', 'acceptEdits', 'plan', 'auto', 'bypassPermissions'],
|
|
16
16
|
|
|
17
17
|
bin: (config) => config.engines?.claude?.bin ?? config.claudeBin ?? 'claude',
|
|
18
18
|
extraArgs: (config) => [...(config.engines?.claude?.args ?? config.claudeArgs ?? [])],
|
|
19
19
|
|
|
20
20
|
/** Options for every launch: permissions, model, a folder it may write to, extra instructions. */
|
|
21
|
-
optionArgs({ permissionMode, model, writableDir, instructions }) {
|
|
21
|
+
optionArgs({ permissionMode, model, writableDir, instructions, agents }) {
|
|
22
22
|
const args = [];
|
|
23
23
|
if (permissionMode && permissionMode !== 'default') args.push('--permission-mode', permissionMode);
|
|
24
24
|
if (model) args.push('--model', model);
|
|
25
|
-
|
|
25
|
+
const dirs = [writableDir ?? []].flat();
|
|
26
|
+
if (dirs.length) args.push('--add-dir', ...dirs, '--allowedTools', ...dirs.flatMap((d) => [`Write(/${d}/**)`, `Edit(/${d}/**)`]));
|
|
26
27
|
if (instructions) args.push('--append-system-prompt', instructions);
|
|
28
|
+
// Sub-agents for this session only (a team lead's roles).
|
|
29
|
+
if (agents) args.push('--agents', JSON.stringify(agents));
|
|
27
30
|
return args;
|
|
28
31
|
},
|
|
29
32
|
|
|
30
33
|
/** Full arguments for a launch: options, then the session. */
|
|
31
|
-
launchArgs({ extra = [], permissionMode, model, writableDir, instructions, sessionId, resume, name, prompt }) {
|
|
32
|
-
return [...extra, ...this.optionArgs({ permissionMode, model, writableDir, instructions }), ...this.sessionArgs({ sessionId, resume, name, prompt })];
|
|
34
|
+
launchArgs({ extra = [], permissionMode, model, writableDir, instructions, agents, sessionId, resume, name, prompt }) {
|
|
35
|
+
return [...extra, ...this.optionArgs({ permissionMode, model, writableDir, instructions, agents }), ...this.sessionArgs({ sessionId, resume, name, prompt })];
|
|
33
36
|
},
|
|
34
37
|
|
|
35
38
|
/** A new session under the ID we chose, or a resumed one; prompt is the first (or next) message. */
|
package/src/engines/codex.js
CHANGED
|
@@ -158,7 +158,7 @@ export default {
|
|
|
158
158
|
// Inline mode keeps the conversation in the terminal's scrollback, which AI Agent Manager replays.
|
|
159
159
|
const opts = [...extra, '--no-alt-screen', ...(MODES[permissionMode] ?? MODES.default)];
|
|
160
160
|
if (model) opts.push('--model', model);
|
|
161
|
-
|
|
161
|
+
for (const d of [writableDir ?? []].flat()) opts.push('--add-dir', d);
|
|
162
162
|
if (resume) return ['resume', ...opts, sessionId, ...(prompt ? [prompt] : [])];
|
|
163
163
|
const first = [instructions, prompt && (instructions ? `Your task:\n${prompt}` : prompt)].filter(Boolean).join('\n\n---\n\n');
|
|
164
164
|
return [...opts, ...(first ? [first] : [])];
|