ai-agentmanager 1.0.1 → 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
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import pty from 'node-pty';
6
6
  import { expandHome, paths } from './config.js';
7
- import { DEFAULT_ENGINE, ENGINES, engineOf, installed } from './engines/index.js';
7
+ import { DEFAULT_ENGINE, ENGINES, engineOf, installed, notInstalled } from './engines/index.js';
8
8
  import {
9
9
  DOC_KINDS,
10
10
  briefDir,
@@ -42,13 +42,15 @@ import * as git from './git.js';
42
42
  import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
43
43
  import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
44
44
  import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
45
- import { Judge, clashPrompt, finishMessage, hash, reviewPrompt, screenPrompt, stuckPrompt } from './judge.js';
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,
@@ -191,6 +193,7 @@ export class AgentManager extends EventEmitter {
191
193
  this.config = config;
192
194
  this.plugins = plugins; // policies before actions, listeners after (src/plugins.js)
193
195
  this.runtimes = new Map(); // agent id -> { pty, chunks, size, clients, lastOutputAt, busy, stopping, exited }
196
+ this.inputAt = new Map(); // agent id -> when you last typed into its session or a shell
194
197
  this.shells = new Map(); // agent id -> ancillary shell runtime in the agent's folder
195
198
  this.cursors = new Map(); // session id -> transcript scan cursor
196
199
  this.locks = new Map();
@@ -268,6 +271,7 @@ export class AgentManager extends EventEmitter {
268
271
  ...agent,
269
272
  results: this.results(agent, rt, prs),
270
273
  priority: agent.priority ?? 2,
274
+ activeAt: this.activeAt(agent, rt),
271
275
  projectInfo: this.projectInfo(agent),
272
276
  usage,
273
277
  // Claude Code's own cost figure from the transcripts (absent until it records one).
@@ -295,6 +299,19 @@ export class AgentManager extends EventEmitter {
295
299
  };
296
300
  }
297
301
 
302
+ /**
303
+ * When the agent last did real work or you last worked with it: its conversation,
304
+ * its brief, your typing, or output while busy (not the redraw after a restart).
305
+ */
306
+ activeAt(agent, rt) {
307
+ const times = [agent.createdAt, agent.brief?.updatedAt, ...(agent.sessions ?? []).map((s) => s.lastMessageAt ?? s.lastActivity)]
308
+ .map((t) => (t ? Date.parse(t) : 0));
309
+ times.push(this.inputAt.get(agent.id) ?? 0);
310
+ if (rt?.pty && rt.busy && rt.lastOutputAt - (rt.startedAt ?? 0) > 60_000) times.push(rt.lastOutputAt);
311
+ const t = Math.max(0, ...times.filter(Number.isFinite));
312
+ return t ? new Date(t).toISOString() : null;
313
+ }
314
+
298
315
  /**
299
316
  * The end result / status / blockers for an agent: what the agent wrote under
300
317
  * "## Results", plus what AI Agent Manager knows (PRs, checks, prompts, errors).
@@ -491,6 +508,7 @@ export class AgentManager extends EventEmitter {
491
508
  priority,
492
509
  workspace,
493
510
  race,
511
+ team,
494
512
  engine,
495
513
  sessionId: adopt,
496
514
  imported,
@@ -523,7 +541,9 @@ export class AgentManager extends EventEmitter {
523
541
  const id = crypto.randomBytes(3).toString('hex');
524
542
  // An imported session keeps its ID, so the first start resumes it.
525
543
  const sessionId = adopt || crypto.randomUUID();
526
- 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}`;
527
547
  const refs = extractRefs(intent, this.config.bugPatterns);
528
548
 
529
549
  const agent = {
@@ -532,7 +552,7 @@ export class AgentManager extends EventEmitter {
532
552
  engine: engineId,
533
553
  project: project?.trim() || null,
534
554
  priority: parsePriority(priority),
535
- name: name?.trim() || slug,
555
+ name: name?.trim() || jiraKey || slug,
536
556
  slug,
537
557
  intent,
538
558
  repo: general ? null : repo?.trim() || null,
@@ -547,7 +567,8 @@ export class AgentManager extends EventEmitter {
547
567
  permissionMode: permissionMode || this.config.permissionMode,
548
568
  model: model || null,
549
569
  // Review the implementation plan before the agent implements it: null = no review.
550
- 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,
551
572
  status: 'preparing',
552
573
  desired: start ? 'running' : 'stopped',
553
574
  error: null,
@@ -565,6 +586,7 @@ export class AgentManager extends EventEmitter {
565
586
  lastStartedAt: null,
566
587
  stoppedAt: null,
567
588
  ...(race ? { raceId: race.id, variant: race.variant } : {}),
589
+ ...(team ? { team } : {}),
568
590
  ...(imported ? { imported } : {}),
569
591
  };
570
592
  for (const pr of refs.prs) agent.prs[pr.url] = { ...pr, source: 'intent' };
@@ -805,7 +827,8 @@ export class AgentManager extends EventEmitter {
805
827
  sessionId: sid,
806
828
  resume,
807
829
  name: agent.name,
808
- 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),
809
832
  });
810
833
  const proc = this.spawnAgent(agent, agent.workdir, args);
811
834
 
@@ -823,6 +846,7 @@ export class AgentManager extends EventEmitter {
823
846
  lastStartedAt: now(),
824
847
  sessions: agent.sessions,
825
848
  sessionId: sid,
849
+ kickoff: undefined,
826
850
  ...(handoff ? { planning: { ...agent.planning, ready: false, handedOffAt: now() } } : {}),
827
851
  });
828
852
  return this.view(this.store.get(agent.id));
@@ -832,6 +856,7 @@ export class AgentManager extends EventEmitter {
832
856
  launchOptions(agent, { planning = false } = {}) {
833
857
  const engine = engineOf(agent);
834
858
  let writableDir = null;
859
+ let agents = null;
835
860
  const prompts = [];
836
861
  const task = agent.kind !== 'general';
837
862
  const withBrief = Boolean(this.config.brief && task);
@@ -851,8 +876,21 @@ export class AgentManager extends EventEmitter {
851
876
  prompts.push(agent.planReview.status === 'approved' ? planApprovedPrompt(file) : planReviewPrompt(file));
852
877
  }
853
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
+ }
854
892
  // Appended system prompts last only for the process they're passed to, so they go on every launch.
855
- 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 };
856
894
  }
857
895
 
858
896
  /** Spawn the agent's engine in a PTY, reusing its terminal clients and scrollback. */
@@ -892,6 +930,11 @@ export class AgentManager extends EventEmitter {
892
930
  }
893
931
  }
894
932
 
933
+ if (!installed(engine, this.config)) {
934
+ const error = notInstalled(engine, this.config);
935
+ this.patch(agent.id, { status: 'error', error });
936
+ throw Object.assign(new Error(error), { status: 400 });
937
+ }
895
938
  let proc;
896
939
  try {
897
940
  proc = pty.spawn(engine.bin(this.config), args, {
@@ -1012,7 +1055,8 @@ export class AgentManager extends EventEmitter {
1012
1055
  stop(ref, { timeoutMs = 5000, keepDesired = false } = {}) {
1013
1056
  const agent = this.mustGet(ref);
1014
1057
  if (this.runtimes.get(agent.id)?.pty) this.plugins.emit('agent.stopping', { agent: this.auditView(agent) });
1015
- 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 });
1016
1060
  const rt = this.runtimes.get(agent.id);
1017
1061
  if (!rt?.pty) return Promise.resolve(this.view(agent));
1018
1062
  rt.stopping = true;
@@ -1104,7 +1148,9 @@ export class AgentManager extends EventEmitter {
1104
1148
  }
1105
1149
  await this.stop(agent.id);
1106
1150
  const warnings = [];
1107
- 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) {
1108
1154
  try {
1109
1155
  if (agent.worktree && agent.sourceDir) {
1110
1156
  await git.removeWorktree(agent.sourceDir, agent.workdir);
@@ -1146,6 +1192,8 @@ export class AgentManager extends EventEmitter {
1146
1192
  const agent = this.mustGet(ref);
1147
1193
  const allowed = {};
1148
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);
1149
1197
  if ('priority' in fields) {
1150
1198
  const p = parsePriority(fields.priority, null);
1151
1199
  if (p === null) throw Object.assign(new Error('priority must be P0, P1, P2 or P3'), { status: 400 });
@@ -1198,6 +1246,8 @@ export class AgentManager extends EventEmitter {
1198
1246
  }
1199
1247
 
1200
1248
  write(id, data, kind = 'claude') {
1249
+ // Focus reports and terminal replies are not you typing.
1250
+ if (!/^(\x1b\[[\d;?>]*[IOcRnt])+$/.test(data)) this.inputAt.set(id, Date.now());
1201
1251
  this.termRuntime(id, kind)?.pty?.write(data);
1202
1252
  }
1203
1253
 
@@ -1898,7 +1948,7 @@ export class AgentManager extends EventEmitter {
1898
1948
  session.usage = usage;
1899
1949
  changed = true;
1900
1950
  }
1901
- for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity']) {
1951
+ for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity', 'lastMessageAt']) {
1902
1952
  if (res[key] != null && JSON.stringify(session[key]) !== JSON.stringify(res[key])) {
1903
1953
  session[key] = res[key];
1904
1954
  changed = true;
@@ -1911,7 +1961,8 @@ export class AgentManager extends EventEmitter {
1911
1961
 
1912
1962
  if (withPrs) {
1913
1963
  this.lastPrCheck.set(id, Date.now());
1914
- 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);
1915
1966
  changed = this.mergeLinks(agent, agent.prs, branchPrs, (p) => p.url) || changed;
1916
1967
  // Fill in title/state for PRs we only know by URL, and keep open ones current.
1917
1968
  const stale = Object.values(agent.prs).filter(
@@ -2253,7 +2304,7 @@ export class AgentManager extends EventEmitter {
2253
2304
  async judgeScreen(id, rt) {
2254
2305
  const agent = this.store.get(id);
2255
2306
  if (!agent) return;
2256
- const screen = this.screenText(rt);
2307
+ const screen = stripChrome(this.screenText(rt), engineOf(agent).screenChrome);
2257
2308
  if (!screen) return;
2258
2309
  const h = hash(screen);
2259
2310
  if (rt.screenVerdict?.hash === h) return this.applyScreen(id, rt, rt.screenVerdict.data);
@@ -2521,6 +2572,8 @@ export class AgentManager extends EventEmitter {
2521
2572
  const idleMs = this.judge.settings.idleSec * 1000;
2522
2573
  for (const [id, rt] of this.runtimes) {
2523
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;
2524
2577
  if (!rt.lastOutputAt || t - rt.lastOutputAt < idleMs) continue;
2525
2578
  // Output only right after launch (a resume repainting its screen) is not a turn worth reading.
2526
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}`,