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/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 === 'general') return null;
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 === 'general';
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
- const slug = name || intent ? slugify(name || intent) : `general-${id}`;
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 ? 'general' : 'task',
561
+ kind: general ? kind : 'task',
547
562
  engine: engineId,
548
563
  project: project?.trim() || null,
549
564
  priority: parsePriority(priority),
550
- name: name?.trim() || slug,
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
- planReview: !general && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
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
- prompt: resume ? (handoff ? handoffMessage(agent) : null) : agent.intent,
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 !== 'general';
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
- if (!keepDesired && agent.desired !== 'stopped') this.patch(agent.id, { desired: 'stopped' });
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 ['name', 'notes', 'permissionMode', 'model', 'project']) if (key in fields) allowed[key] = fields[key];
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
- if ('project' in allowed) allowed.project = String(allowed.project ?? '').trim() || null;
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
- const branchPrs = await prsForBranch(agent.workdir, info?.branch);
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 === 'general' || !fs.existsSync(agent.workdir)) return;
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 !== 'general') (byRepo.get(k) ?? byRepo.set(k, []).get(k)).push(e);
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 !== 'general') this.requestReview(id, { trigger: 'screen' }).catch(() => {});
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 === 'general') return [];
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 === 'general' || !agent.branch || this.checkpointing?.has(id)) return null;
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
- return manager.createMany(Array.isArray(intents) ? intents : [intent], opts);
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}`,