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/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
- const slug = name || intent ? slugify(name || intent) : `general-${id}`;
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
- planReview: !general && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
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
- prompt: resume ? (handoff ? handoffMessage(agent) : null) : agent.intent,
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
- if (!keepDesired && agent.desired !== 'stopped') this.patch(agent.id, { desired: 'stopped' });
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
- const branchPrs = await prsForBranch(agent.workdir, info?.branch);
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}`,