ai-agentmanager 1.0.2 → 1.1.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 +13 -0
- package/README.md +58 -3
- package/docs/architecture.md +1 -0
- package/package.json +1 -1
- package/public/app.js +398 -7
- package/public/index.html +90 -0
- package/public/style.css +42 -1
- package/src/cleanup.js +185 -0
- package/src/cli.js +164 -0
- 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 +39 -8
- package/src/server.js +44 -2
- package/src/teams.js +984 -0
- package/src/tracker.js +6 -0
package/src/manager.js
CHANGED
|
@@ -45,10 +45,12 @@ import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
|
|
|
45
45
|
import { Judge, clashPrompt, finishMessage, hash, reviewPrompt, screenPrompt, stripChrome, stuckPrompt } from './judge.js';
|
|
46
46
|
import { findLocalUrls, findSignInLinks, parseCallback, parseUrl } from './preview.js';
|
|
47
47
|
import { Plugins } from './plugins.js';
|
|
48
|
+
import { leadPrompt, subagents, teamDir, teamPrompt } from './teams.js';
|
|
48
49
|
import { runningClaude, scanClaudeSessions } from './sessions.js';
|
|
49
50
|
import {
|
|
50
51
|
bugFromRef,
|
|
51
52
|
extractRefs,
|
|
53
|
+
leadingJiraKey,
|
|
52
54
|
prDetails,
|
|
53
55
|
prFromUrl,
|
|
54
56
|
prsForBranch,
|
|
@@ -506,6 +508,7 @@ export class AgentManager extends EventEmitter {
|
|
|
506
508
|
priority,
|
|
507
509
|
workspace,
|
|
508
510
|
race,
|
|
511
|
+
team,
|
|
509
512
|
engine,
|
|
510
513
|
sessionId: adopt,
|
|
511
514
|
imported,
|
|
@@ -538,7 +541,9 @@ export class AgentManager extends EventEmitter {
|
|
|
538
541
|
const id = crypto.randomBytes(3).toString('hex');
|
|
539
542
|
// An imported session keeps its ID, so the first start resumes it.
|
|
540
543
|
const sessionId = adopt || crypto.randomUUID();
|
|
541
|
-
|
|
544
|
+
// A request that starts with a Jira link is named after the issue ("ABC-123"), not the link.
|
|
545
|
+
const jiraKey = !name?.trim() && intent ? leadingJiraKey(intent) : null;
|
|
546
|
+
const slug = name || intent ? slugify(name || jiraKey || intent) : `general-${id}`;
|
|
542
547
|
const refs = extractRefs(intent, this.config.bugPatterns);
|
|
543
548
|
|
|
544
549
|
const agent = {
|
|
@@ -547,7 +552,7 @@ export class AgentManager extends EventEmitter {
|
|
|
547
552
|
engine: engineId,
|
|
548
553
|
project: project?.trim() || null,
|
|
549
554
|
priority: parsePriority(priority),
|
|
550
|
-
name: name?.trim() || slug,
|
|
555
|
+
name: name?.trim() || jiraKey || slug,
|
|
551
556
|
slug,
|
|
552
557
|
intent,
|
|
553
558
|
repo: general ? null : repo?.trim() || null,
|
|
@@ -562,7 +567,8 @@ export class AgentManager extends EventEmitter {
|
|
|
562
567
|
permissionMode: permissionMode || this.config.permissionMode,
|
|
563
568
|
model: model || null,
|
|
564
569
|
// Review the implementation plan before the agent implements it: null = no review.
|
|
565
|
-
|
|
570
|
+
// Team members get their review from the team instead.
|
|
571
|
+
planReview: !general && !team && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
|
|
566
572
|
status: 'preparing',
|
|
567
573
|
desired: start ? 'running' : 'stopped',
|
|
568
574
|
error: null,
|
|
@@ -580,6 +586,7 @@ export class AgentManager extends EventEmitter {
|
|
|
580
586
|
lastStartedAt: null,
|
|
581
587
|
stoppedAt: null,
|
|
582
588
|
...(race ? { raceId: race.id, variant: race.variant } : {}),
|
|
589
|
+
...(team ? { team } : {}),
|
|
583
590
|
...(imported ? { imported } : {}),
|
|
584
591
|
};
|
|
585
592
|
for (const pr of refs.prs) agent.prs[pr.url] = { ...pr, source: 'intent' };
|
|
@@ -820,7 +827,8 @@ export class AgentManager extends EventEmitter {
|
|
|
820
827
|
sessionId: sid,
|
|
821
828
|
resume,
|
|
822
829
|
name: agent.name,
|
|
823
|
-
|
|
830
|
+
// A team member's first message is its first step, not just the problem.
|
|
831
|
+
prompt: resume ? (handoff ? handoffMessage(agent) : null) : (agent.kickoff ?? agent.intent),
|
|
824
832
|
});
|
|
825
833
|
const proc = this.spawnAgent(agent, agent.workdir, args);
|
|
826
834
|
|
|
@@ -838,6 +846,7 @@ export class AgentManager extends EventEmitter {
|
|
|
838
846
|
lastStartedAt: now(),
|
|
839
847
|
sessions: agent.sessions,
|
|
840
848
|
sessionId: sid,
|
|
849
|
+
kickoff: undefined,
|
|
841
850
|
...(handoff ? { planning: { ...agent.planning, ready: false, handedOffAt: now() } } : {}),
|
|
842
851
|
});
|
|
843
852
|
return this.view(this.store.get(agent.id));
|
|
@@ -847,6 +856,7 @@ export class AgentManager extends EventEmitter {
|
|
|
847
856
|
launchOptions(agent, { planning = false } = {}) {
|
|
848
857
|
const engine = engineOf(agent);
|
|
849
858
|
let writableDir = null;
|
|
859
|
+
let agents = null;
|
|
850
860
|
const prompts = [];
|
|
851
861
|
const task = agent.kind !== 'general';
|
|
852
862
|
const withBrief = Boolean(this.config.brief && task);
|
|
@@ -866,8 +876,21 @@ export class AgentManager extends EventEmitter {
|
|
|
866
876
|
prompts.push(agent.planReview.status === 'approved' ? planApprovedPrompt(file) : planReviewPrompt(file));
|
|
867
877
|
}
|
|
868
878
|
if (planning) prompts.push(planningPrompt(agent));
|
|
879
|
+
if (agent.team) {
|
|
880
|
+
// Team members write their handoff files in the team's folder.
|
|
881
|
+
fs.mkdirSync(teamDir(agent.team.id), { recursive: true });
|
|
882
|
+
const dir = fs.realpathSync(teamDir(agent.team.id));
|
|
883
|
+
writableDir = writableDir ? [writableDir, dir] : dir;
|
|
884
|
+
const team = this.store.data.teams?.[agent.team.id];
|
|
885
|
+
if (agent.team.role === 'lead' && team) {
|
|
886
|
+
prompts.push(leadPrompt(team, dir, agent.baseRef));
|
|
887
|
+
agents = subagents(team, dir);
|
|
888
|
+
} else {
|
|
889
|
+
prompts.push(teamPrompt(agent.team.role, dir));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
869
892
|
// Appended system prompts last only for the process they're passed to, so they go on every launch.
|
|
870
|
-
return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null };
|
|
893
|
+
return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null, agents };
|
|
871
894
|
}
|
|
872
895
|
|
|
873
896
|
/** Spawn the agent's engine in a PTY, reusing its terminal clients and scrollback. */
|
|
@@ -1032,7 +1055,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1032
1055
|
stop(ref, { timeoutMs = 5000, keepDesired = false } = {}) {
|
|
1033
1056
|
const agent = this.mustGet(ref);
|
|
1034
1057
|
if (this.runtimes.get(agent.id)?.pty) this.plugins.emit('agent.stopping', { agent: this.auditView(agent) });
|
|
1035
|
-
|
|
1058
|
+
// When it was closed (not a daemon shutdown): clean up counts from here.
|
|
1059
|
+
if (!keepDesired) this.patch(agent.id, { desired: 'stopped', closedAt: now(), closedBy: null });
|
|
1036
1060
|
const rt = this.runtimes.get(agent.id);
|
|
1037
1061
|
if (!rt?.pty) return Promise.resolve(this.view(agent));
|
|
1038
1062
|
rt.stopping = true;
|
|
@@ -1124,7 +1148,9 @@ export class AgentManager extends EventEmitter {
|
|
|
1124
1148
|
}
|
|
1125
1149
|
await this.stop(agent.id);
|
|
1126
1150
|
const warnings = [];
|
|
1127
|
-
if (deleteWorkspace && agent.workdir) {
|
|
1151
|
+
if (deleteWorkspace && agent.workdir && agent.sharedWith) {
|
|
1152
|
+
warnings.push(`Kept ${agent.workdir}: it belongs to ${this.store.get(agent.sharedWith)?.name ?? 'another agent'}`);
|
|
1153
|
+
} else if (deleteWorkspace && agent.workdir) {
|
|
1128
1154
|
try {
|
|
1129
1155
|
if (agent.worktree && agent.sourceDir) {
|
|
1130
1156
|
await git.removeWorktree(agent.sourceDir, agent.workdir);
|
|
@@ -1166,6 +1192,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1166
1192
|
const agent = this.mustGet(ref);
|
|
1167
1193
|
const allowed = {};
|
|
1168
1194
|
for (const key of ['name', 'notes', 'permissionMode', 'model', 'project']) if (key in fields) allowed[key] = fields[key];
|
|
1195
|
+
// Keep: clean up never closes or deletes it.
|
|
1196
|
+
if ('keep' in fields) allowed.keep = Boolean(fields.keep);
|
|
1169
1197
|
if ('priority' in fields) {
|
|
1170
1198
|
const p = parsePriority(fields.priority, null);
|
|
1171
1199
|
if (p === null) throw Object.assign(new Error('priority must be P0, P1, P2 or P3'), { status: 400 });
|
|
@@ -1933,7 +1961,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1933
1961
|
|
|
1934
1962
|
if (withPrs) {
|
|
1935
1963
|
this.lastPrCheck.set(id, Date.now());
|
|
1936
|
-
|
|
1964
|
+
// A shared worktree's pull requests belong to the agent that owns it.
|
|
1965
|
+
const branchPrs = agent.sharedWith ? [] : await prsForBranch(agent.workdir, info?.branch);
|
|
1937
1966
|
changed = this.mergeLinks(agent, agent.prs, branchPrs, (p) => p.url) || changed;
|
|
1938
1967
|
// Fill in title/state for PRs we only know by URL, and keep open ones current.
|
|
1939
1968
|
const stale = Object.values(agent.prs).filter(
|
|
@@ -2543,6 +2572,8 @@ export class AgentManager extends EventEmitter {
|
|
|
2543
2572
|
const idleMs = this.judge.settings.idleSec * 1000;
|
|
2544
2573
|
for (const [id, rt] of this.runtimes) {
|
|
2545
2574
|
if (!rt.pty || rt.busy || rt.attention || rt.planning || rt.screenChecked || this.outbox.get(id)?.length) continue;
|
|
2575
|
+
// A team member that finished its step is meant to sit idle until its next turn.
|
|
2576
|
+
if (this.idleExpected?.(id)) continue;
|
|
2546
2577
|
if (!rt.lastOutputAt || t - rt.lastOutputAt < idleMs) continue;
|
|
2547
2578
|
// Output only right after launch (a resume repainting its screen) is not a turn worth reading.
|
|
2548
2579
|
if (rt.lastOutputAt - rt.startedAt < this.judge.settings.warmupSec * 1000) continue;
|
package/src/server.js
CHANGED
|
@@ -10,6 +10,8 @@ import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.
|
|
|
10
10
|
import { AgentManager, shellNumber } from './manager.js';
|
|
11
11
|
import { Backlog } from './backlog.js';
|
|
12
12
|
import { Races } from './races.js';
|
|
13
|
+
import { Teams } from './teams.js';
|
|
14
|
+
import { Cleanup } from './cleanup.js';
|
|
13
15
|
import { listEngines, registerEngine } from './engines/index.js';
|
|
14
16
|
import { Plugins, pluginSpecs } from './plugins.js';
|
|
15
17
|
import { HubLink, linkUrls } from './hublink.js';
|
|
@@ -55,9 +57,12 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
55
57
|
const manager = new AgentManager({ store, config, plugins });
|
|
56
58
|
const backlog = new Backlog({ manager, store, config });
|
|
57
59
|
const races = new Races({ manager, store });
|
|
60
|
+
const teams = new Teams({ manager, store, config });
|
|
61
|
+
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
58
62
|
// Alerts link to the phone address when remote access is on, else the local UI.
|
|
59
63
|
const notifier = new Notifier({
|
|
60
64
|
manager,
|
|
65
|
+
teams,
|
|
61
66
|
backlog,
|
|
62
67
|
linkFor: (id) => {
|
|
63
68
|
const phone = remoteServer ? remote.remoteUrls(remotePort).find((u) => !u.vpn) : null;
|
|
@@ -398,6 +403,29 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
398
403
|
api.post('/races/:id/pick', wrap((req) => races.pick(req.params.id, req.body?.agent, { cleanup: Boolean(req.body?.cleanup) })));
|
|
399
404
|
api.delete('/races/:id', wrap((req) => races.remove(req.params.id)));
|
|
400
405
|
|
|
406
|
+
// Clean up old agents: the strategy and days (kept in config.json), what a run would do, run now.
|
|
407
|
+
api.get('/cleanup', wrap(() => cleanup.status()));
|
|
408
|
+
api.put('/cleanup', wrap(async (req) => {
|
|
409
|
+
const next = cleanup.update(req.body ?? {});
|
|
410
|
+
saveConfig({ cleanup: next });
|
|
411
|
+
return cleanup.status();
|
|
412
|
+
}));
|
|
413
|
+
api.post('/cleanup/run', wrap(async (req) => {
|
|
414
|
+
const b = req.body ?? {};
|
|
415
|
+
return cleanup.run({ close: b.close !== false, delete: b.delete !== false, ids: Array.isArray(b.ids) ? b.ids : null });
|
|
416
|
+
}));
|
|
417
|
+
api.get('/teams', wrap(() => teams.list()));
|
|
418
|
+
api.post('/teams', wrap((req) => teams.create(req.body ?? {})));
|
|
419
|
+
api.get('/teams/:id', wrap((req) => teams.view(req.params.id)));
|
|
420
|
+
api.get('/teams/:id/files/:name', wrap((req) => teams.file(req.params.id, req.params.name)));
|
|
421
|
+
api.post('/teams/:id/resume', wrap((req) => teams.resume(req.params.id, req.body ?? {})));
|
|
422
|
+
api.post('/teams/:id/send-back', wrap((req) => teams.sendBack(req.params.id, req.body?.note)));
|
|
423
|
+
api.post('/teams/:id/fix-gaps', wrap((req) => teams.fixGaps(req.params.id, req.body?.note)));
|
|
424
|
+
api.post('/teams/:id/finalize', wrap((req) => teams.finalize(req.params.id, { mode: req.body?.mode, pr: req.body?.pr })));
|
|
425
|
+
api.post('/teams/:id/stop', wrap((req) => teams.stop(req.params.id)));
|
|
426
|
+
api.post('/teams/:id/ask', wrap((req) => teams.ask(req.params.id, req.body?.text)));
|
|
427
|
+
api.delete('/teams/:id', wrap((req) => teams.remove(req.params.id, { agents: req.query.agents === '1' })));
|
|
428
|
+
|
|
401
429
|
api.post('/backlog/settings', wrap((req) => backlog.settings(req.body ?? {})));
|
|
402
430
|
api.patch('/backlog/:id', wrap((req) => backlog.update(req.params.id, req.body ?? {})));
|
|
403
431
|
api.delete('/backlog/:id', wrap((req) => backlog.remove(req.params.id)));
|
|
@@ -523,7 +551,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
523
551
|
const eventSinks = new Set();
|
|
524
552
|
api.get('/events/stream', (req, res) => {
|
|
525
553
|
const send = sse(req, res);
|
|
526
|
-
send({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list() });
|
|
554
|
+
send({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list() });
|
|
527
555
|
eventSinks.add(send);
|
|
528
556
|
req.on('close', () => eventSinks.delete(send));
|
|
529
557
|
});
|
|
@@ -629,6 +657,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
629
657
|
}, 300));
|
|
630
658
|
});
|
|
631
659
|
races.on('removed', (id) => broadcast({ t: 'race-removed', id }));
|
|
660
|
+
const teamTimers = new Map();
|
|
661
|
+
teams.on('change', (id) => {
|
|
662
|
+
if (teamTimers.has(id)) return;
|
|
663
|
+
teamTimers.set(id, setTimeout(() => {
|
|
664
|
+
teamTimers.delete(id);
|
|
665
|
+
try {
|
|
666
|
+
broadcast({ t: 'team', team: teams.view(id) });
|
|
667
|
+
} catch {}
|
|
668
|
+
}, 300));
|
|
669
|
+
});
|
|
670
|
+
teams.on('removed', (id) => broadcast({ t: 'team-removed', id }));
|
|
632
671
|
manager.on('projects', (projects) => {
|
|
633
672
|
broadcast({ t: 'projects', projects });
|
|
634
673
|
broadcast({ t: 'backlog', backlog: backlog.view() });
|
|
@@ -638,7 +677,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
638
677
|
const url = new URL(req.url, 'http://x');
|
|
639
678
|
if (url.pathname === '/ws/events') {
|
|
640
679
|
eventClients.add(ws);
|
|
641
|
-
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list() }));
|
|
680
|
+
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list() }));
|
|
642
681
|
ws.on('close', () => eventClients.delete(ws));
|
|
643
682
|
return;
|
|
644
683
|
}
|
|
@@ -716,6 +755,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
716
755
|
async function shutdown(reason) {
|
|
717
756
|
if (closing) return;
|
|
718
757
|
closing = true;
|
|
758
|
+
cleanup.close();
|
|
719
759
|
console.log(`[agentmgr] shutting down (${reason}); closing agents so their sessions are saved`);
|
|
720
760
|
await manager.stopAll({ final: true });
|
|
721
761
|
await manager.flush().catch((err) => console.error(`[agentmgr] final flush failed: ${err.message}`));
|
|
@@ -750,6 +790,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
750
790
|
hubLink,
|
|
751
791
|
server,
|
|
752
792
|
manager,
|
|
793
|
+
teams,
|
|
794
|
+
cleanup,
|
|
753
795
|
backlog,
|
|
754
796
|
live,
|
|
755
797
|
url: `http://${host}:${port}`,
|