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/manager.js
CHANGED
|
@@ -45,10 +45,13 @@ 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';
|
|
49
|
+
import { assistantDir, assistantPrompt, assistantSettings } from './assistant.js';
|
|
48
50
|
import { runningClaude, scanClaudeSessions } from './sessions.js';
|
|
49
51
|
import {
|
|
50
52
|
bugFromRef,
|
|
51
53
|
extractRefs,
|
|
54
|
+
leadingJiraKey,
|
|
52
55
|
prDetails,
|
|
53
56
|
prFromUrl,
|
|
54
57
|
prsForBranch,
|
|
@@ -117,9 +120,15 @@ export function repoCacheDir(repo) {
|
|
|
117
120
|
return path.join(paths.repos, `${git.repoNameFromUrl(repo)}-${hash}`);
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
/** Sessions with no repo or folder of their own. */
|
|
124
|
+
export const NO_REPO = new Set(['general', 'assistant']);
|
|
125
|
+
|
|
120
126
|
export function projectOf(agent) {
|
|
127
|
+
// Moved under another known project by hand.
|
|
128
|
+
if (agent.projectMove?.key) return { key: agent.projectMove.key, name: agent.projectMove.name, ...(agent.projectMove.full ? { full: agent.projectMove.full } : {}), source: 'moved' };
|
|
121
129
|
if (agent.project) return { key: `custom:${agent.project.toLowerCase()}`, name: agent.project, source: 'custom' };
|
|
122
130
|
if (agent.kind === 'general') return { key: 'general', name: 'General', source: 'general' };
|
|
131
|
+
if (agent.kind === 'assistant') return { key: 'assistant', name: 'Assistant', source: 'assistant' };
|
|
123
132
|
const url = agent.repo || agent.git?.remote;
|
|
124
133
|
if (url && /[:/]/.test(url)) {
|
|
125
134
|
const path = url
|
|
@@ -361,7 +370,9 @@ export class AgentManager extends EventEmitter {
|
|
|
361
370
|
|
|
362
371
|
/** Make sure the agent's project has a shortcut; remember where it lives for "add @cc". */
|
|
363
372
|
registerProject(agent) {
|
|
364
|
-
if (!agent || agent.kind
|
|
373
|
+
if (!agent || NO_REPO.has(agent.kind)) return null;
|
|
374
|
+
// A moved agent's repo says nothing about the project it was moved to.
|
|
375
|
+
if (agent.projectMove) return null;
|
|
365
376
|
const info = projectOf(agent);
|
|
366
377
|
if (info.key === 'other') return null;
|
|
367
378
|
const reg = this.store.data.projects;
|
|
@@ -506,9 +517,11 @@ export class AgentManager extends EventEmitter {
|
|
|
506
517
|
priority,
|
|
507
518
|
workspace,
|
|
508
519
|
race,
|
|
520
|
+
team,
|
|
509
521
|
engine,
|
|
510
522
|
sessionId: adopt,
|
|
511
523
|
imported,
|
|
524
|
+
startedBy,
|
|
512
525
|
}) {
|
|
513
526
|
intent = (intent ?? '').trim();
|
|
514
527
|
const engineDef = engineOf(engine || this.config.engine || DEFAULT_ENGINE);
|
|
@@ -521,7 +534,7 @@ export class AgentManager extends EventEmitter {
|
|
|
521
534
|
}
|
|
522
535
|
// Planning while cloning moves the session between folders, which needs the engine's support.
|
|
523
536
|
if (!engineDef.supports.planHandoff) plan = false;
|
|
524
|
-
const general = kind
|
|
537
|
+
const general = NO_REPO.has(kind);
|
|
525
538
|
// A project shortcut (or name) fills in the repo / folder / base branch.
|
|
526
539
|
const entry = !general && project ? this.resolveProject(project) : null;
|
|
527
540
|
if (entry) {
|
|
@@ -538,16 +551,19 @@ export class AgentManager extends EventEmitter {
|
|
|
538
551
|
const id = crypto.randomBytes(3).toString('hex');
|
|
539
552
|
// An imported session keeps its ID, so the first start resumes it.
|
|
540
553
|
const sessionId = adopt || crypto.randomUUID();
|
|
541
|
-
|
|
554
|
+
// A request that starts with a Jira link is named after the issue ("ABC-123"), not the link.
|
|
555
|
+
const jiraKey = !name?.trim() && intent ? leadingJiraKey(intent) : null;
|
|
556
|
+
const slug = name || intent ? slugify(name || jiraKey || intent) : `general-${id}`;
|
|
542
557
|
const refs = extractRefs(intent, this.config.bugPatterns);
|
|
543
558
|
|
|
544
559
|
const agent = {
|
|
545
560
|
id,
|
|
546
|
-
kind: general ?
|
|
561
|
+
kind: general ? kind : 'task',
|
|
547
562
|
engine: engineId,
|
|
548
563
|
project: project?.trim() || null,
|
|
549
564
|
priority: parsePriority(priority),
|
|
550
|
-
|
|
565
|
+
...(startedBy ? { startedBy } : {}),
|
|
566
|
+
name: name?.trim() || jiraKey || slug,
|
|
551
567
|
slug,
|
|
552
568
|
intent,
|
|
553
569
|
repo: general ? null : repo?.trim() || null,
|
|
@@ -562,7 +578,8 @@ export class AgentManager extends EventEmitter {
|
|
|
562
578
|
permissionMode: permissionMode || this.config.permissionMode,
|
|
563
579
|
model: model || null,
|
|
564
580
|
// Review the implementation plan before the agent implements it: null = no review.
|
|
565
|
-
|
|
581
|
+
// Team members get their review from the team instead.
|
|
582
|
+
planReview: !general && !team && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
|
|
566
583
|
status: 'preparing',
|
|
567
584
|
desired: start ? 'running' : 'stopped',
|
|
568
585
|
error: null,
|
|
@@ -580,6 +597,7 @@ export class AgentManager extends EventEmitter {
|
|
|
580
597
|
lastStartedAt: null,
|
|
581
598
|
stoppedAt: null,
|
|
582
599
|
...(race ? { raceId: race.id, variant: race.variant } : {}),
|
|
600
|
+
...(team ? { team } : {}),
|
|
583
601
|
...(imported ? { imported } : {}),
|
|
584
602
|
};
|
|
585
603
|
for (const pr of refs.prs) agent.prs[pr.url] = { ...pr, source: 'intent' };
|
|
@@ -680,6 +698,10 @@ export class AgentManager extends EventEmitter {
|
|
|
680
698
|
fs.mkdirSync(paths.general, { recursive: true });
|
|
681
699
|
return { workdir: paths.general };
|
|
682
700
|
}
|
|
701
|
+
if (agent.kind === 'assistant') {
|
|
702
|
+
fs.mkdirSync(assistantDir(), { recursive: true });
|
|
703
|
+
return { workdir: assistantDir() };
|
|
704
|
+
}
|
|
683
705
|
let repo = agent.repo;
|
|
684
706
|
let dir = agent.requestedDir ? path.resolve(expandHome(agent.requestedDir)) : null;
|
|
685
707
|
const branch = `agent/${slug}-${id}`;
|
|
@@ -820,7 +842,8 @@ export class AgentManager extends EventEmitter {
|
|
|
820
842
|
sessionId: sid,
|
|
821
843
|
resume,
|
|
822
844
|
name: agent.name,
|
|
823
|
-
|
|
845
|
+
// A team member's first message is its first step, not just the problem.
|
|
846
|
+
prompt: resume ? (handoff ? handoffMessage(agent) : null) : (agent.kickoff ?? agent.intent),
|
|
824
847
|
});
|
|
825
848
|
const proc = this.spawnAgent(agent, agent.workdir, args);
|
|
826
849
|
|
|
@@ -838,6 +861,7 @@ export class AgentManager extends EventEmitter {
|
|
|
838
861
|
lastStartedAt: now(),
|
|
839
862
|
sessions: agent.sessions,
|
|
840
863
|
sessionId: sid,
|
|
864
|
+
kickoff: undefined,
|
|
841
865
|
...(handoff ? { planning: { ...agent.planning, ready: false, handedOffAt: now() } } : {}),
|
|
842
866
|
});
|
|
843
867
|
return this.view(this.store.get(agent.id));
|
|
@@ -847,8 +871,9 @@ export class AgentManager extends EventEmitter {
|
|
|
847
871
|
launchOptions(agent, { planning = false } = {}) {
|
|
848
872
|
const engine = engineOf(agent);
|
|
849
873
|
let writableDir = null;
|
|
874
|
+
let agents = null;
|
|
850
875
|
const prompts = [];
|
|
851
|
-
const task = agent.kind
|
|
876
|
+
const task = !NO_REPO.has(agent.kind);
|
|
852
877
|
const withBrief = Boolean(this.config.brief && task);
|
|
853
878
|
if (this.config.reviewConventions && task) prompts.push(conventionsPrompt({ withBrief, rulesFiles: engine.rulesFiles ?? null }));
|
|
854
879
|
if (withBrief) {
|
|
@@ -866,8 +891,27 @@ export class AgentManager extends EventEmitter {
|
|
|
866
891
|
prompts.push(agent.planReview.status === 'approved' ? planApprovedPrompt(file) : planReviewPrompt(file));
|
|
867
892
|
}
|
|
868
893
|
if (planning) prompts.push(planningPrompt(agent));
|
|
894
|
+
if (agent.kind === 'assistant') {
|
|
895
|
+
fs.mkdirSync(assistantDir(), { recursive: true });
|
|
896
|
+
const dir = fs.realpathSync(assistantDir());
|
|
897
|
+
writableDir = dir;
|
|
898
|
+
prompts.push(assistantPrompt(assistantSettings(this.config), dir));
|
|
899
|
+
}
|
|
900
|
+
if (agent.team) {
|
|
901
|
+
// Team members write their handoff files in the team's folder.
|
|
902
|
+
fs.mkdirSync(teamDir(agent.team.id), { recursive: true });
|
|
903
|
+
const dir = fs.realpathSync(teamDir(agent.team.id));
|
|
904
|
+
writableDir = writableDir ? [writableDir, dir] : dir;
|
|
905
|
+
const team = this.store.data.teams?.[agent.team.id];
|
|
906
|
+
if (agent.team.role === 'lead' && team) {
|
|
907
|
+
prompts.push(leadPrompt(team, dir, agent.baseRef));
|
|
908
|
+
agents = subagents(team, dir);
|
|
909
|
+
} else {
|
|
910
|
+
prompts.push(teamPrompt(agent.team.role, dir));
|
|
911
|
+
}
|
|
912
|
+
}
|
|
869
913
|
// 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 };
|
|
914
|
+
return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null, agents };
|
|
871
915
|
}
|
|
872
916
|
|
|
873
917
|
/** Spawn the agent's engine in a PTY, reusing its terminal clients and scrollback. */
|
|
@@ -1032,7 +1076,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1032
1076
|
stop(ref, { timeoutMs = 5000, keepDesired = false } = {}) {
|
|
1033
1077
|
const agent = this.mustGet(ref);
|
|
1034
1078
|
if (this.runtimes.get(agent.id)?.pty) this.plugins.emit('agent.stopping', { agent: this.auditView(agent) });
|
|
1035
|
-
|
|
1079
|
+
// When it was closed (not a daemon shutdown): clean up counts from here.
|
|
1080
|
+
if (!keepDesired) this.patch(agent.id, { desired: 'stopped', closedAt: now(), closedBy: null });
|
|
1036
1081
|
const rt = this.runtimes.get(agent.id);
|
|
1037
1082
|
if (!rt?.pty) return Promise.resolve(this.view(agent));
|
|
1038
1083
|
rt.stopping = true;
|
|
@@ -1124,7 +1169,9 @@ export class AgentManager extends EventEmitter {
|
|
|
1124
1169
|
}
|
|
1125
1170
|
await this.stop(agent.id);
|
|
1126
1171
|
const warnings = [];
|
|
1127
|
-
if (deleteWorkspace && agent.workdir) {
|
|
1172
|
+
if (deleteWorkspace && agent.workdir && agent.sharedWith) {
|
|
1173
|
+
warnings.push(`Kept ${agent.workdir}: it belongs to ${this.store.get(agent.sharedWith)?.name ?? 'another agent'}`);
|
|
1174
|
+
} else if (deleteWorkspace && agent.workdir) {
|
|
1128
1175
|
try {
|
|
1129
1176
|
if (agent.worktree && agent.sourceDir) {
|
|
1130
1177
|
await git.removeWorktree(agent.sourceDir, agent.workdir);
|
|
@@ -1164,14 +1211,54 @@ export class AgentManager extends EventEmitter {
|
|
|
1164
1211
|
|
|
1165
1212
|
update(ref, fields) {
|
|
1166
1213
|
const agent = this.mustGet(ref);
|
|
1214
|
+
const bad = (m) => Object.assign(new Error(m), { status: 400 });
|
|
1167
1215
|
const allowed = {};
|
|
1168
|
-
for (const key of ['
|
|
1216
|
+
for (const key of ['notes']) if (key in fields) allowed[key] = fields[key];
|
|
1217
|
+
if ('name' in fields) {
|
|
1218
|
+
const name = String(fields.name ?? '').trim();
|
|
1219
|
+
if (!name) throw bad('Name is required');
|
|
1220
|
+
allowed.name = name.slice(0, 80);
|
|
1221
|
+
}
|
|
1222
|
+
// The request as recorded; the session only hears about a change when told.
|
|
1223
|
+
if ('intent' in fields) {
|
|
1224
|
+
const intent = String(fields.intent ?? '').trim();
|
|
1225
|
+
if (!intent && !NO_REPO.has(agent.kind)) throw bad('The request is required');
|
|
1226
|
+
allowed.intent = intent;
|
|
1227
|
+
}
|
|
1228
|
+
// Permission mode and model apply from the next launch.
|
|
1229
|
+
if ('permissionMode' in fields) {
|
|
1230
|
+
const mode = fields.permissionMode || null;
|
|
1231
|
+
const modes = engineOf(agent).permissionModes;
|
|
1232
|
+
if (mode && !modes.includes(mode)) throw bad(`${engineOf(agent).label} has no "${mode}" mode (use ${modes.join(', ')})`);
|
|
1233
|
+
allowed.permissionMode = mode;
|
|
1234
|
+
}
|
|
1235
|
+
if ('model' in fields) allowed.model = String(fields.model ?? '').trim() || null;
|
|
1236
|
+
// Keep: clean up never closes or deletes it.
|
|
1237
|
+
if ('keep' in fields) allowed.keep = Boolean(fields.keep);
|
|
1169
1238
|
if ('priority' in fields) {
|
|
1170
1239
|
const p = parsePriority(fields.priority, null);
|
|
1171
1240
|
if (p === null) throw Object.assign(new Error('priority must be P0, P1, P2 or P3'), { status: 400 });
|
|
1172
1241
|
allowed.priority = p;
|
|
1173
1242
|
}
|
|
1174
|
-
|
|
1243
|
+
// Move: under a known project (shortcut or name), under a new project name, or back to its own ("").
|
|
1244
|
+
if ('project' in fields) {
|
|
1245
|
+
if (NO_REPO.has(agent.kind)) throw bad(`A ${agent.kind} session has no project`);
|
|
1246
|
+
const ref = String(fields.project ?? '').trim();
|
|
1247
|
+
const entry = ref ? this.resolveProject(ref) : null;
|
|
1248
|
+
if (!ref) Object.assign(allowed, { project: null, projectMove: null });
|
|
1249
|
+
else if (entry && entry.key !== projectOf({ ...agent, project: null, projectMove: null }).key) {
|
|
1250
|
+
// Its full name (org/repo), as the project's own agents show it.
|
|
1251
|
+
const info = this.store.list().map((a) => (a.projectMove ? null : projectOf(a))).find((i) => i?.key === entry.key);
|
|
1252
|
+
Object.assign(allowed, { project: null, projectMove: { key: entry.key, name: entry.name, ...(info?.full ? { full: info.full } : {}), at: now() } });
|
|
1253
|
+
} else if (entry) Object.assign(allowed, { project: null, projectMove: null });
|
|
1254
|
+
else Object.assign(allowed, { project: ref.slice(0, 60), projectMove: null });
|
|
1255
|
+
}
|
|
1256
|
+
if ('intent' in allowed) {
|
|
1257
|
+
const refs = extractRefs(allowed.intent, this.config.bugPatterns);
|
|
1258
|
+
const bugs = { ...(agent.bugs ?? {}) };
|
|
1259
|
+
for (const b of refs.bugs) bugs[b.key] ??= { ...b, source: 'intent' };
|
|
1260
|
+
allowed.bugs = bugs;
|
|
1261
|
+
}
|
|
1175
1262
|
return this.view(this.patch(agent.id, allowed));
|
|
1176
1263
|
}
|
|
1177
1264
|
|
|
@@ -1933,7 +2020,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1933
2020
|
|
|
1934
2021
|
if (withPrs) {
|
|
1935
2022
|
this.lastPrCheck.set(id, Date.now());
|
|
1936
|
-
|
|
2023
|
+
// A shared worktree's pull requests belong to the agent that owns it.
|
|
2024
|
+
const branchPrs = agent.sharedWith ? [] : await prsForBranch(agent.workdir, info?.branch);
|
|
1937
2025
|
changed = this.mergeLinks(agent, agent.prs, branchPrs, (p) => p.url) || changed;
|
|
1938
2026
|
// Fill in title/state for PRs we only know by URL, and keep open ones current.
|
|
1939
2027
|
const stale = Object.values(agent.prs).filter(
|
|
@@ -2020,7 +2108,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2020
2108
|
/** Recompute what an agent's branch changed since it branched (needs a base commit). */
|
|
2021
2109
|
async updateChanges(agent) {
|
|
2022
2110
|
const baseSha = agent.baseRef?.split(' @ ')[1];
|
|
2023
|
-
if (!agent.workdir || !baseSha || agent.kind
|
|
2111
|
+
if (!agent.workdir || !baseSha || NO_REPO.has(agent.kind) || !fs.existsSync(agent.workdir)) return;
|
|
2024
2112
|
// A full diff against the base is heavier than git status; once a minute is enough for overlaps.
|
|
2025
2113
|
const prev0 = this.changeSets.get(agent.id);
|
|
2026
2114
|
if (prev0 && Date.now() - Date.parse(prev0.at) < (this.config.conflictRefreshSec ?? 60) * 1000) return;
|
|
@@ -2058,7 +2146,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2058
2146
|
const byRepo = new Map();
|
|
2059
2147
|
for (const e of entries) {
|
|
2060
2148
|
const k = repoKey(e.agent);
|
|
2061
|
-
if (k && e.agent.kind
|
|
2149
|
+
if (k && !NO_REPO.has(e.agent.kind)) (byRepo.get(k) ?? byRepo.set(k, []).get(k)).push(e);
|
|
2062
2150
|
}
|
|
2063
2151
|
for (const group of byRepo.values()) {
|
|
2064
2152
|
for (let i = 0; i < group.length; i++) {
|
|
@@ -2294,7 +2382,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2294
2382
|
const a = this.store.get(id);
|
|
2295
2383
|
if (!a) return;
|
|
2296
2384
|
this.changed(a);
|
|
2297
|
-
if (sure && data.state === 'done' && a.kind
|
|
2385
|
+
if (sure && data.state === 'done' && !NO_REPO.has(a.kind)) this.requestReview(id, { trigger: 'screen' }).catch(() => {});
|
|
2298
2386
|
}
|
|
2299
2387
|
|
|
2300
2388
|
/** The person says the agent is not actually waiting on them. */
|
|
@@ -2375,7 +2463,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2375
2463
|
// ------------------------------------------------------- decision inbox
|
|
2376
2464
|
|
|
2377
2465
|
questionsView(agent) {
|
|
2378
|
-
if (agent.kind
|
|
2466
|
+
if (NO_REPO.has(agent.kind)) return [];
|
|
2379
2467
|
const answers = agent.answers ?? {};
|
|
2380
2468
|
return loadQuestions(agent.id).map((q) => ({ ...q, answer: answers[q.id] ?? null }));
|
|
2381
2469
|
}
|
|
@@ -2435,7 +2523,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2435
2523
|
*/
|
|
2436
2524
|
async checkpoint(id) {
|
|
2437
2525
|
const agent = this.store.get(id);
|
|
2438
|
-
if (!agent?.workdir || agent.kind
|
|
2526
|
+
if (!agent?.workdir || NO_REPO.has(agent.kind) || !agent.branch || this.checkpointing?.has(id)) return null;
|
|
2439
2527
|
// Output pauses mid-turn too (a slow command); one snapshot per half minute is plenty.
|
|
2440
2528
|
this.lastCheckpointTry ??= new Map();
|
|
2441
2529
|
if (Date.now() - (this.lastCheckpointTry.get(id) ?? 0) < 30_000) return null;
|
|
@@ -2543,6 +2631,8 @@ export class AgentManager extends EventEmitter {
|
|
|
2543
2631
|
const idleMs = this.judge.settings.idleSec * 1000;
|
|
2544
2632
|
for (const [id, rt] of this.runtimes) {
|
|
2545
2633
|
if (!rt.pty || rt.busy || rt.attention || rt.planning || rt.screenChecked || this.outbox.get(id)?.length) continue;
|
|
2634
|
+
// A team member that finished its step is meant to sit idle until its next turn.
|
|
2635
|
+
if (this.idleExpected?.(id)) continue;
|
|
2546
2636
|
if (!rt.lastOutputAt || t - rt.lastOutputAt < idleMs) continue;
|
|
2547
2637
|
// Output only right after launch (a resume repainting its screen) is not a turn worth reading.
|
|
2548
2638
|
if (rt.lastOutputAt - rt.startedAt < this.judge.settings.warmupSec * 1000) continue;
|
package/src/notify.js
CHANGED
|
@@ -16,6 +16,7 @@ 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, // the assistant started an agent for new work
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
// Alerts the AI judge may hold for the digest or drop; the rest are always sent at once.
|
|
@@ -264,7 +265,7 @@ export class Notifier {
|
|
|
264
265
|
|
|
265
266
|
deliver(kind, agent, what, n, urgency = null) {
|
|
266
267
|
if (!n.enabled || !n.events[kind]) return;
|
|
267
|
-
const icon = { needsInput: ':raised_hand:', review: ':white_check_mark:', error: ':x:', waiting: ':hourglass_flowing_sand:', blocked: ':no_entry:', stuck: ':repeat:', conflict: ':crossed_swords:', question: ':question:' }[kind];
|
|
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];
|
|
268
269
|
const project = agent.projectInfo?.name ? ` (${agent.projectInfo.name})` : '';
|
|
269
270
|
const link = this.linkFor(agent.id);
|
|
270
271
|
const intent = n.includeIntent && agent.intent ? `\n> ${agent.intent.replace(/\s+/g, ' ').slice(0, 200)}` : '';
|
package/src/server.js
CHANGED
|
@@ -10,6 +10,9 @@ 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 { Assistant } from './assistant.js';
|
|
15
|
+
import { Cleanup } from './cleanup.js';
|
|
13
16
|
import { listEngines, registerEngine } from './engines/index.js';
|
|
14
17
|
import { Plugins, pluginSpecs } from './plugins.js';
|
|
15
18
|
import { HubLink, linkUrls } from './hublink.js';
|
|
@@ -55,9 +58,13 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
55
58
|
const manager = new AgentManager({ store, config, plugins });
|
|
56
59
|
const backlog = new Backlog({ manager, store, config });
|
|
57
60
|
const races = new Races({ manager, store });
|
|
61
|
+
const teams = new Teams({ manager, store, config });
|
|
62
|
+
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
63
|
+
const assistant = new Assistant({ manager, store, config });
|
|
58
64
|
// Alerts link to the phone address when remote access is on, else the local UI.
|
|
59
65
|
const notifier = new Notifier({
|
|
60
66
|
manager,
|
|
67
|
+
teams,
|
|
61
68
|
backlog,
|
|
62
69
|
linkFor: (id) => {
|
|
63
70
|
const phone = remoteServer ? remote.remoteUrls(remotePort).find((u) => !u.vpn) : null;
|
|
@@ -172,8 +179,16 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
172
179
|
api.post(
|
|
173
180
|
'/agents',
|
|
174
181
|
wrap((req) => {
|
|
175
|
-
const { intents, intent, ...opts } = req.body ?? {};
|
|
176
|
-
|
|
182
|
+
const { intents, intent, startedBy, ...opts } = req.body ?? {};
|
|
183
|
+
if (opts.kind === 'assistant') throw fail(400, 'Start the assistant with POST /assistant/start');
|
|
184
|
+
const list = (Array.isArray(intents) ? intents : [intent]).map((i) => String(i ?? '').trim()).filter(Boolean);
|
|
185
|
+
// Agents the assistant's own session starts: its limits apply, and they are marked as its.
|
|
186
|
+
const extra = assistant.admit(req.headers['x-agentmgr-agent'], list, opts);
|
|
187
|
+
const created = manager.createMany(list, { ...opts, ...extra });
|
|
188
|
+
if (extra.startedBy) {
|
|
189
|
+
for (const a of created) notifier.send('assistant', a, `was started by the assistant: ${a.intent.replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
190
|
+
}
|
|
191
|
+
return created;
|
|
177
192
|
}),
|
|
178
193
|
);
|
|
179
194
|
api.post('/general', wrap((req) => manager.createGeneral(req.body ?? {})));
|
|
@@ -398,6 +413,38 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
398
413
|
api.post('/races/:id/pick', wrap((req) => races.pick(req.params.id, req.body?.agent, { cleanup: Boolean(req.body?.cleanup) })));
|
|
399
414
|
api.delete('/races/:id', wrap((req) => races.remove(req.params.id)));
|
|
400
415
|
|
|
416
|
+
// Clean up old agents: the strategy and days (kept in config.json), what a run would do, run now.
|
|
417
|
+
api.get('/assistant', wrap(() => assistant.status()));
|
|
418
|
+
api.put('/assistant', wrap((req) => {
|
|
419
|
+
const next = assistant.update(req.body ?? {});
|
|
420
|
+
saveConfig({ assistant: next });
|
|
421
|
+
return assistant.status();
|
|
422
|
+
}));
|
|
423
|
+
api.post('/assistant/start', wrap(() => assistant.start()));
|
|
424
|
+
api.post('/assistant/pause', wrap(() => assistant.pause()));
|
|
425
|
+
api.post('/assistant/check', wrap(() => assistant.check()));
|
|
426
|
+
api.get('/cleanup', wrap(() => cleanup.status()));
|
|
427
|
+
api.put('/cleanup', wrap(async (req) => {
|
|
428
|
+
const next = cleanup.update(req.body ?? {});
|
|
429
|
+
saveConfig({ cleanup: next });
|
|
430
|
+
return cleanup.status();
|
|
431
|
+
}));
|
|
432
|
+
api.post('/cleanup/run', wrap(async (req) => {
|
|
433
|
+
const b = req.body ?? {};
|
|
434
|
+
return cleanup.run({ close: b.close !== false, delete: b.delete !== false, ids: Array.isArray(b.ids) ? b.ids : null });
|
|
435
|
+
}));
|
|
436
|
+
api.get('/teams', wrap(() => teams.list()));
|
|
437
|
+
api.post('/teams', wrap((req) => teams.create(req.body ?? {})));
|
|
438
|
+
api.get('/teams/:id', wrap((req) => teams.view(req.params.id)));
|
|
439
|
+
api.get('/teams/:id/files/:name', wrap((req) => teams.file(req.params.id, req.params.name)));
|
|
440
|
+
api.post('/teams/:id/resume', wrap((req) => teams.resume(req.params.id, req.body ?? {})));
|
|
441
|
+
api.post('/teams/:id/send-back', wrap((req) => teams.sendBack(req.params.id, req.body?.note)));
|
|
442
|
+
api.post('/teams/:id/fix-gaps', wrap((req) => teams.fixGaps(req.params.id, req.body?.note)));
|
|
443
|
+
api.post('/teams/:id/finalize', wrap((req) => teams.finalize(req.params.id, { mode: req.body?.mode, pr: req.body?.pr })));
|
|
444
|
+
api.post('/teams/:id/stop', wrap((req) => teams.stop(req.params.id)));
|
|
445
|
+
api.post('/teams/:id/ask', wrap((req) => teams.ask(req.params.id, req.body?.text)));
|
|
446
|
+
api.delete('/teams/:id', wrap((req) => teams.remove(req.params.id, { agents: req.query.agents === '1' })));
|
|
447
|
+
|
|
401
448
|
api.post('/backlog/settings', wrap((req) => backlog.settings(req.body ?? {})));
|
|
402
449
|
api.patch('/backlog/:id', wrap((req) => backlog.update(req.params.id, req.body ?? {})));
|
|
403
450
|
api.delete('/backlog/:id', wrap((req) => backlog.remove(req.params.id)));
|
|
@@ -523,7 +570,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
523
570
|
const eventSinks = new Set();
|
|
524
571
|
api.get('/events/stream', (req, res) => {
|
|
525
572
|
const send = sse(req, res);
|
|
526
|
-
send({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list() });
|
|
573
|
+
send({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list() });
|
|
527
574
|
eventSinks.add(send);
|
|
528
575
|
req.on('close', () => eventSinks.delete(send));
|
|
529
576
|
});
|
|
@@ -629,6 +676,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
629
676
|
}, 300));
|
|
630
677
|
});
|
|
631
678
|
races.on('removed', (id) => broadcast({ t: 'race-removed', id }));
|
|
679
|
+
const teamTimers = new Map();
|
|
680
|
+
teams.on('change', (id) => {
|
|
681
|
+
if (teamTimers.has(id)) return;
|
|
682
|
+
teamTimers.set(id, setTimeout(() => {
|
|
683
|
+
teamTimers.delete(id);
|
|
684
|
+
try {
|
|
685
|
+
broadcast({ t: 'team', team: teams.view(id) });
|
|
686
|
+
} catch {}
|
|
687
|
+
}, 300));
|
|
688
|
+
});
|
|
689
|
+
teams.on('removed', (id) => broadcast({ t: 'team-removed', id }));
|
|
632
690
|
manager.on('projects', (projects) => {
|
|
633
691
|
broadcast({ t: 'projects', projects });
|
|
634
692
|
broadcast({ t: 'backlog', backlog: backlog.view() });
|
|
@@ -638,7 +696,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
638
696
|
const url = new URL(req.url, 'http://x');
|
|
639
697
|
if (url.pathname === '/ws/events') {
|
|
640
698
|
eventClients.add(ws);
|
|
641
|
-
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list() }));
|
|
699
|
+
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list() }));
|
|
642
700
|
ws.on('close', () => eventClients.delete(ws));
|
|
643
701
|
return;
|
|
644
702
|
}
|
|
@@ -716,6 +774,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
716
774
|
async function shutdown(reason) {
|
|
717
775
|
if (closing) return;
|
|
718
776
|
closing = true;
|
|
777
|
+
cleanup.close();
|
|
778
|
+
assistant.close();
|
|
719
779
|
console.log(`[agentmgr] shutting down (${reason}); closing agents so their sessions are saved`);
|
|
720
780
|
await manager.stopAll({ final: true });
|
|
721
781
|
await manager.flush().catch((err) => console.error(`[agentmgr] final flush failed: ${err.message}`));
|
|
@@ -750,6 +810,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
750
810
|
hubLink,
|
|
751
811
|
server,
|
|
752
812
|
manager,
|
|
813
|
+
teams,
|
|
814
|
+
cleanup,
|
|
815
|
+
assistant,
|
|
753
816
|
backlog,
|
|
754
817
|
live,
|
|
755
818
|
url: `http://${host}:${port}`,
|