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/public/app.js CHANGED
@@ -14,6 +14,9 @@ let showCompleted = false;
14
14
  const races = new Map(); // race id -> scoreboard
15
15
  let openRaceId = null;
16
16
  let raceDiff = null; // { agentId, text }
17
+ const teams = new Map(); // team id -> team view
18
+ let openTeamId = null;
19
+ let teamFile = null; // { name, markdown } shown in the team view
17
20
  // #<id> selects an agent; #<id>/term also opens its terminal.
18
21
  const loginHash = location.hash.match(/(?:^#|&)login=([\w-]+)/)?.[1] ?? null;
19
22
  // From the hub: #ticket=<one-time ticket>&go=<agent>/term
@@ -23,6 +26,20 @@ const [hashId, hashView] = loginHash ? [null, null] : (ticketHash ? goHash : loc
23
26
  let selectedId = hashId || null;
24
27
  let openTermOnLoad = hashView === 'term' || isShell(hashView) || isBrowser(hashView);
25
28
  let filterText = '';
29
+ // "Only agents created in the last N days": remembered in this browser.
30
+ const recent = (() => {
31
+ try {
32
+ return { on: localStorage.getItem('agentmgr.recentOn') === '1', days: Number(localStorage.getItem('agentmgr.recentDays')) || 7 };
33
+ } catch {
34
+ return { on: false, days: 7 };
35
+ }
36
+ })();
37
+ const saveRecent = () => {
38
+ try {
39
+ localStorage.setItem('agentmgr.recentOn', recent.on ? '1' : '0');
40
+ localStorage.setItem('agentmgr.recentDays', String(recent.days));
41
+ } catch {}
42
+ };
26
43
 
27
44
  // ------------------------------------------------------------------ helpers
28
45
 
@@ -661,6 +678,13 @@ function renderList() {
661
678
  .toLowerCase()
662
679
  .includes(filterText),
663
680
  );
681
+ // The open agent stays in the list even when it's older.
682
+ const since = recent.on ? Date.now() - recent.days * 86_400_000 : 0;
683
+ const shown = since ? list.filter((a) => a.id === selectedId || (Date.parse(a.createdAt) || 0) >= since) : list;
684
+ const hidden = list.length - shown.length;
685
+ $('#recent-bar').hidden = agents.size === 0;
686
+ $('#recent-on').checked = recent.on;
687
+ $('#recent-hidden').innerHTML = hidden ? `${hidden} older ${hidden === 1 ? 'agent' : 'agents'} hidden · <button class="link" id="recent-off">Show all</button>` : '';
664
688
  $('#empty').hidden = agents.size > 0;
665
689
 
666
690
  const all = [...agents.values()];
@@ -672,8 +696,14 @@ function renderList() {
672
696
  <span class="spacer"></span><button class="link" id="import-btn" title="Continue Claude sessions you started in a terminal on this machine">Import</button><button class="link" id="flush-btn" title="Save session IDs, terminal scrollback and state to disk now (also automatic every 30s)">Save now</button>`
673
697
  : '';
674
698
 
675
- $('#agent-list').innerHTML = projectGroups(list)
699
+ // Every agent a project has, also the ones a filter hides.
700
+ const totals = new Map();
701
+ for (const a of all) totals.set(a.projectInfo?.key ?? 'other', (totals.get(a.projectInfo?.key ?? 'other') ?? 0) + 1);
702
+
703
+ $('#agent-list').innerHTML = projectGroups(shown)
676
704
  .map((g) => {
705
+ const total = totals.get(g.key) ?? g.agents.length;
706
+ const n = g.agents.length;
677
707
  // While filtering, show every match; otherwise respect collapsed projects.
678
708
  const open = filterText || !collapsed.has(g.key) || g.agents.some((a) => a.id === selectedId);
679
709
  return `<li class="project ${open ? 'open' : ''}">
@@ -684,7 +714,8 @@ function renderList() {
684
714
  <span class="pcounts">
685
715
  ${g.top <= 1 ? prioChip(g.top) : ''}
686
716
  ${g.waiting ? `<span class="pill attention" title="needs input">${g.waiting}</span>` : ''}
687
- <span title="running / agents">${g.running}/${g.agents.length}</span>
717
+ ${g.running ? `<span class="prun" title="${g.running} running">● ${g.running}</span>` : ''}
718
+ <span class="pcount" title="${total} agent${total === 1 ? '' : 's'} in this project${n < total ? `, ${n} shown` : ''}">${n < total ? `${n} of ${total}` : total} agent${total === 1 ? '' : 's'}</span>
688
719
  </span>
689
720
  </button>
690
721
  ${open ? `<ul class="project-agents">${g.agents.map(agentItem).join('')}</ul>` : ''}
@@ -844,12 +875,13 @@ function flags(a) {
844
875
  a.conflicts?.length ? `<span class="flag warn" title="Changes the same files as ${esc(a.conflicts.map((c) => c.agent.name).join(', '))}">⚔</span>` : '',
845
876
  q ? `<span class="flag ask" title="${q} open question${q > 1 ? 's' : ''}">?${q > 1 ? q : ''}</span>` : '',
846
877
  a.raceId ? `<span class="flag" title="Race attempt${a.variant?.label ? `: ${esc(a.variant.label)}` : ''}">🏁</span>` : '',
878
+ a.team ? `<span class="flag role" title="Team ${esc(teams.get(a.team.id)?.name ?? '')}: ${esc(a.team.role)}">👥 ${esc(a.team.role)}${a.team.n && teams.get(a.team.id)?.settings.critics > 1 ? ` ${a.team.n}` : ''}</span>` : '',
847
879
  a.forkOf ? `<span class="flag" title="Fork of ${esc(a.forkOf.name)}">⑂</span>` : '',
848
880
  ].join('');
849
881
  }
850
882
 
851
883
  const TRIAGED = { now: 'sent (AI: urgent)', digest: 'held for the digest', skip: 'dropped by the AI judge' };
852
- const NEED_ORDER = { prompt: 0, waiting: 1, plan: 1, question: 1, review: 2, stuck: 3, conflict: 4 };
884
+ const NEED_ORDER = { prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, review: 2, stuck: 3, conflict: 4 };
853
885
  /** What an agent needs from the user. `both`: overlaps on both agents (detail), else only the one that should rebase (inbox). */
854
886
  function needsFor(a, { both = false } = {}) {
855
887
  const items = [];
@@ -862,13 +894,19 @@ function needsFor(a, { both = false } = {}) {
862
894
  for (const g of a.conflicts ?? []) if (both || g.mergeFirst !== a.id) items.push({ a, kind: 'conflict', key: `conflict:${a.id}:${g.agent.id}`, g });
863
895
  return items;
864
896
  }
897
+ /** Teams waiting on you (approved, or stuck on a disagreement), shown on the lead or the implementer. */
898
+ const teamOwner = (t) => t.members.find((m) => m.role === 'lead' || m.role === 'implementer')?.id;
899
+ function teamNeeds() {
900
+ return [...teams.values()]
901
+ .filter((t) => ['ready', 'blocked'].includes(t.stage) && agents.has(teamOwner(t)))
902
+ .map((t) => ({ a: agents.get(teamOwner(t)), kind: 'team', key: `team:${t.id}`, t }));
903
+ }
865
904
  function inboxItems() {
866
- return [...agents.values()]
867
- .flatMap((a) => needsFor(a))
905
+ return [...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds()]
868
906
  .sort((x, y) => NEED_ORDER[x.kind] - NEED_ORDER[y.kind] || (x.a.priority ?? 2) - (y.a.priority ?? 2));
869
907
  }
870
908
 
871
- const NEED_LABEL = { prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
909
+ const NEED_LABEL = { team: 'Team', prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
872
910
  /** Why the AI judge thinks so, as a small tag. */
873
911
  const aiTag = (confidence, what = 'AI') => `<span class="tag ai" title="Judged by a model from the agent's activity; your reactions tune later calls">🤖 ${what}${confidence != null ? ` ${Math.round(confidence * 100)}%` : ''}</span>`;
874
912
  function needCard(it, { inDetail = false } = {}) {
@@ -907,6 +945,11 @@ function needCard(it, { inDetail = false } = {}) {
907
945
  ${r.missing.length ? '<button class="small primary" data-ib="review-finish" title="Send the missing items to the agent">Send back to finish</button>' : ''}
908
946
  <button class="small ${r.missing.length ? '' : 'primary'}" data-ib="review-accept">Looks good</button>
909
947
  </div>`;
948
+ } else if (it.kind === 'team') {
949
+ const t = it.t;
950
+ body = `<p class="need-title">${t.stage === 'ready' ? `✓ The reviewer approved <b>${esc(t.name)}</b>${t.gaps ? ', with parts still missing: run a gap round?' : ''}` : `<b>${esc(t.name)}</b> needs a decision: ${esc(t.blocked?.reason ?? '')}`}</p>
951
+ <p class="hint">${esc(t.intent.split('\n')[0].slice(0, 140))}</p>
952
+ <div class="need-actions"><button class="small primary" data-ib="team-open" data-team="${t.id}">${t.stage !== 'ready' ? 'Decide' : t.gaps ? 'See the gaps' : 'Review and finalize'}</button></div>`;
910
953
  } else if (it.kind === 'plan') {
911
954
  const title = a.planReview.markdown?.match(/^#\s+(.+)$/m)?.[1] ?? 'Implementation plan';
912
955
  body = `<p class="need-title">${esc(title)}</p>
@@ -1022,6 +1065,8 @@ async function onNeed(e) {
1022
1065
  const q = key.split(':')[2];
1023
1066
  return act(async () => upsert(await api('POST', `/agents/${id}/answer`, { question: q, text: el.dataset.text })), 'Answer sent');
1024
1067
  }
1068
+ case 'team-open':
1069
+ return openTeam(el.dataset.team);
1025
1070
  case 'plan-open':
1026
1071
  return openPlan(id);
1027
1072
  case 'plan-approve':
@@ -1095,6 +1140,8 @@ $('#detail-body').addEventListener('click', (e) => {
1095
1140
  if (cp) forkAgent(selectedId, Number(cp.dataset.forkCp));
1096
1141
  const race = e.target.closest('[data-race-open]');
1097
1142
  if (race) openRace(race.dataset.raceOpen);
1143
+ const team = e.target.closest('[data-team-open]');
1144
+ if (team) openTeam(team.dataset.teamOpen);
1098
1145
  });
1099
1146
 
1100
1147
  function forkAgent(id, checkpoint) {
@@ -1110,6 +1157,209 @@ function forkAgent(id, checkpoint) {
1110
1157
  }, 'Fork created');
1111
1158
  }
1112
1159
 
1160
+ // ------------------------------------------------------------------ teams
1161
+
1162
+ const TEAM_STEPS = [['analyze', 'Analyze'], ['discuss', 'Discuss'], ['implement', 'Implement'], ['test', 'Test'], ['review', 'Review'], ['ready', 'Your call'], ['done', 'Done']];
1163
+ const STAGE_TEXT = { analyze: 'analyzing', discuss: 'discussing the analysis', implement: 'implementing', test: 'testing', review: 'reviewing', ready: 'approved, waiting for you', gaps: 'starting a round for the gaps', finalizing: 'opening the PR', done: 'done; ready for the next request', blocked: 'waiting for your decision', stopped: 'stopped' };
1164
+ const GOOD = new Set(['ready', 'agree', 'done', 'pass', 'approve']);
1165
+ const ROLE_NAME = (m) => `${m.role[0].toUpperCase()}${m.role.slice(1)}${m.n && teams.get(openTeamId)?.settings.critics > 1 ? ` ${m.n}` : ''}`;
1166
+
1167
+ function teamBanner(a) {
1168
+ if (!a.team) return '';
1169
+ const t = teams.get(a.team.id);
1170
+ const turn = t?.waiting?.some((w) => w.member === a.id && !w.verdict);
1171
+ return `<div class="bl-banner team">👥 <b>${esc(a.team.role)}</b> on team <b>${esc(t?.name ?? a.team.id)}</b>${t?.mode === 'lead' ? ` · quest ${t.quest ?? 1}` : ''}${t ? ` · ${esc(STAGE_TEXT[t.stage] ?? t.stage)}${t.loop > 1 ? ` · round ${t.loop}` : ''}` : ''}${turn ? ' · <b>its turn</b>' : ''}
1172
+ <span class="spacer"></span><button class="small primary" data-team-open="${a.team.id}">Open team</button></div>`;
1173
+ }
1174
+
1175
+ function openTeam(id) {
1176
+ openTeamId = id;
1177
+ teamFile = null;
1178
+ renderTeam();
1179
+ if (!$('#team-dialog').open) $('#team-dialog').showModal();
1180
+ act(async () => teams.set(id, await api('GET', `/teams/${id}`)));
1181
+ }
1182
+
1183
+ function teamSteps(t) {
1184
+ const at = ['finalizing', 'gaps'].includes(t.stage) ? 'ready' : t.stage;
1185
+ const idx = TEAM_STEPS.findIndex(([k]) => k === at);
1186
+ const stopped = ['blocked', 'stopped'].includes(t.stage);
1187
+ // Where a blocked or stopped team got to: the last step anyone finished.
1188
+ const reached = stopped ? Math.max(0, ...t.history.map((h) => TEAM_STEPS.findIndex(([k]) => k === h.stage))) : idx;
1189
+ return TEAM_STEPS.filter(([k]) => k !== 'discuss' || t.settings.critics)
1190
+ .map(([k, label]) => {
1191
+ const i = TEAM_STEPS.findIndex(([x]) => x === k);
1192
+ const cls = stopped ? (i < reached ? 'done' : i === reached ? 'stop' : '') : i < idx || t.stage === 'done' ? 'done' : i === idx ? 'now' : '';
1193
+ return `<span class="team-step ${cls}">${label}</span>`;
1194
+ })
1195
+ .join('<span class="team-arrow">→</span>');
1196
+ }
1197
+
1198
+ function renderTeam() {
1199
+ const dlg = $('#team-dialog');
1200
+ if (!dlg.open && !openTeamId) return;
1201
+ const t = teams.get(openTeamId);
1202
+ if (!t) {
1203
+ $('#team-body').innerHTML = '<p class="hint">Loading…</p>';
1204
+ return;
1205
+ }
1206
+ if ($('#team-body').contains(document.activeElement) && document.activeElement.tagName === 'TEXTAREA') return;
1207
+ const openQuests = new Set([...$('#team-body').querySelectorAll('details.team-quest[open] summary b')].map((b) => b.textContent));
1208
+ $('#team-title').textContent = `Team: ${t.name}`;
1209
+ $('#team-stop').hidden = ['done', 'stopped'].includes(t.stage);
1210
+ const memberOf = (id) => t.members.find((m) => m.id === id);
1211
+ const gate =
1212
+ t.stage === 'ready'
1213
+ ? `<div class="team-gate">${t.gaps
1214
+ ? `<b>✓ The reviewer approved the work so far, but parts of the request aren't built yet:</b>
1215
+ ${t.gapList ? `<article class="md team-gaps">${md(t.gapList)}</article>` : ''}
1216
+ <div class="need-actions"><button class="small primary" data-team-act="fix-gaps" title="Another round, from analysis to review, for what is missing (and your note below)">Run a gap round</button></div>
1217
+ <p class="hint">Or take the work as it is:</p>`
1218
+ : '<b>✓ The reviewer approved it.</b> Look at the review and the changes, then:'}
1219
+ <div class="need-actions">${t.remote
1220
+ ? `<button class="small${t.gaps ? '' : ' primary'}" data-team-act="pr">Open a pull request</button>`
1221
+ : `<button class="small${t.gaps ? '' : ' primary'}" data-team-act="merge" title="Fast-forward ${esc(t.folder ?? '')} to the team's branch">Merge into main</button>
1222
+ <button class="small" data-team-act="publish" title="The implementer creates a private GitHub repository, pushes and opens a pull request">Publish to GitHub</button>`}
1223
+ <button class="small" data-team-act="keep">Keep the branch${t.remote ? ', no PR' : ''}</button></div>
1224
+ <textarea id="team-note" rows="2" placeholder="${t.gaps ? 'A note for the gap round, or' : 'Or say'} what should change; the implementer fixes it and it goes through test and review again"></textarea>
1225
+ <div class="need-actions"><button class="small" data-team-act="send-back">Send back</button></div></div>`
1226
+ : t.stage === 'blocked'
1227
+ ? `<div class="team-gate blocked"><b>${esc(t.blocked.reason)}</b>
1228
+ <textarea id="team-note" rows="2" placeholder="Optional note for the next round"></textarea>
1229
+ <div class="need-actions">
1230
+ ${t.blocked.at !== 'member' ? `<button class="small primary" data-team-act="continue">${t.blocked.at === 'discuss' ? 'One more round' : 'One more fix round'}</button>` : ''}
1231
+ ${t.blocked.at === 'discuss' ? '<button class="small" data-team-act="skip" title="Implement the latest analysis as it is">Implement the latest analysis</button>' : ''}
1232
+ ${t.blocked.at === 'loops' ? '<button class="small" data-team-act="skip" title="Move to your final review without another round">Accept as is</button>' : ''}
1233
+ </div></div>`
1234
+ : t.stage === 'done' && t.gaps
1235
+ ? `<div class="team-gate"><b>The last review lists parts of the request that aren't built yet:</b>
1236
+ ${t.gapList ? `<article class="md team-gaps">${md(t.gapList)}</article>` : ''}
1237
+ <textarea id="team-note" rows="2" placeholder="Optional note for the gap round"></textarea>
1238
+ <div class="need-actions"><button class="small primary" data-team-act="fix-gaps">Run a gap round</button></div></div>`
1239
+ : '';
1240
+ const quest = t.mode === 'lead' || t.quest > 1 ? `<p class="hint">Quest ${t.quest ?? 1}${t.gapRound ? ` · gap round ${t.gapRound}` : ''}${t.quests?.length ? ` · ${t.quests.length} earlier` : ''}</p>` : '';
1241
+ const handoffs = (list) =>
1242
+ `<ul class="team-timeline">${list.slice().reverse().map((h) => {
1243
+ const m = memberOf(h.member);
1244
+ const who = t.mode === 'lead' ? `${h.role[0].toUpperCase()}${h.role.slice(1)}${h.n && t.settings.critics > 1 ? ` ${h.n}` : ''}` : m ? ROLE_NAME(m) : esc(h.role);
1245
+ return `<li><span><b>${who}</b> <span class="hint">${ago(h.at)}${h.loop > 1 && ['implement', 'test', 'review'].includes(h.stage) ? ` · r${h.loop}` : ''}</span></span>
1246
+ <span><span class="verdict-word ${GOOD.has(h.verdict) ? 'good' : 'bad'}">${esc(h.verdict)}</span></span>
1247
+ <span><a href="#" data-team-file="${esc(h.file)}">${esc(h.file.split('/').pop())}</a> <span class="summary">${esc(h.summary)}</span></span></li>`;
1248
+ }).join('')}</ul>`;
1249
+ const asking = t.mode === 'lead' && t.stage !== 'stopped'
1250
+ ? `<form class="team-ask" id="team-ask"><b>New request</b> <span class="hint">The lead runs it through the team as the next quest. You can also just type it in the lead's terminal.</span>
1251
+ <textarea id="team-ask-text" rows="2" placeholder="Now add a --json flag to the output"></textarea>
1252
+ <div class="need-actions"><button class="small${['ready', 'done'].includes(t.stage) ? ' primary' : ''}">Give to the team</button></div></form>`
1253
+ : '';
1254
+ $('#team-body').innerHTML = `
1255
+ ${quest}
1256
+ <p>${esc(t.request ?? t.intent)}</p>
1257
+ <div class="team-steps">${teamSteps(t)}</div>
1258
+ <p class="hint">${esc(STAGE_TEXT[t.stage] ?? t.stage)}${t.settings.critics ? ` · analysis round ${t.round}` : ''} · build round ${t.loop}${t.newProject ? ` · new project in <code>${esc(t.newProject)}</code>` : ''}${t.branch ? ` · branch <code>${esc(t.branch)}</code>` : ''}${t.outcome === 'merged' ? ' · merged into main' : ''}${t.costUsd ? ` · ${fmtCost(t.costUsd)}` : ''}
1259
+ ${t.prs.map((p) => ` · <a href="${esc(p.url)}" target="_blank" rel="noopener">PR #${p.number ?? ''}</a>`).join('')}</p>
1260
+ ${gate}
1261
+ <div class="race-table-wrap"><table class="race-table">
1262
+ <tr><th>Role</th><th>Agent</th><th>Status</th><th>Model</th><th>Cost</th></tr>
1263
+ ${t.members.map((m) => {
1264
+ const a = agents.get(m.id);
1265
+ const st = a ? statusOf(a) : m.status;
1266
+ return `<tr class="${m.turn ? 'diffing' : ''}"><td><b>${ROLE_NAME(m)}</b>${m.turn ? ' <span class="tag open">its turn</span>' : ''}</td>
1267
+ <td>${a ? `<a href="#${m.id}" data-team-goto="${m.id}">${esc(m.name)}</a>` : '<span class="hint">deleted</span>'}</td>
1268
+ <td><span class="pill ${st}">${pillText(st)}</span></td><td>${m.model ? esc(m.model) : '<span class="hint">default</span>'}</td><td>${fmtCost(m.costUsd)}${t.mode === 'lead' ? ' <span class="hint">with sub-agents</span>' : ''}</td></tr>`;
1269
+ }).join('')}
1270
+ ${t.subagents.map((r) => {
1271
+ const last = t.history.findLast((h) => h.role === r.role);
1272
+ return `<tr><td>${r.role[0].toUpperCase()}${r.role.slice(1)}</td><td><span class="hint">sub-agent of the lead</span></td>
1273
+ <td>${last ? `<span class="verdict-word ${GOOD.has(last.verdict) ? 'good' : 'bad'}">${esc(last.verdict)}</span> <span class="hint">${ago(last.at)}</span>` : '<span class="hint">—</span>'}</td>
1274
+ <td>${r.model ? esc(r.model) : '<span class="hint">lead\'s</span>'}</td><td></td></tr>`;
1275
+ }).join('')}
1276
+ </table></div>
1277
+ <h3>Handoffs</h3>
1278
+ ${t.history.length ? handoffs(t.history) : '<p class="hint">Nothing handed off yet. The analyst is reading the code.</p>'}
1279
+ ${asking}
1280
+ ${t.quests?.length
1281
+ ? `<h3>Earlier requests</h3>${t.quests.slice().reverse().map((q) => `<details class="team-quest"><summary><b>Quest ${q.n}</b> ${esc(q.request.split('\n')[0].slice(0, 100))} <span class="hint">· ${esc(q.outcome)}</span></summary>${q.history.length ? handoffs(q.history) : '<p class="hint">No handoffs.</p>'}</details>`).join('')}`
1282
+ : ''}
1283
+ ${teamFile ? `<div class="need-head"><b>${esc(teamFile.name.split('/').pop())}</b><span class="spacer"></span><button class="small ghost" data-team-file-close>Close</button></div><article class="md team-file">${md(teamFile.markdown)}</article>` : ''}`;
1284
+ for (const d of $('#team-body').querySelectorAll('details.team-quest')) if (openQuests.has(d.querySelector('summary b').textContent)) d.open = true;
1285
+ }
1286
+
1287
+ $('#team-body').addEventListener('submit', (e) => {
1288
+ if (e.target.id !== 'team-ask') return;
1289
+ e.preventDefault();
1290
+ const text = $('#team-ask-text').value.trim();
1291
+ if (!text) return;
1292
+ const id = openTeamId;
1293
+ act(async () => {
1294
+ teams.set(id, await api('POST', `/teams/${id}/ask`, { text }));
1295
+ $('#team-ask-text').value = '';
1296
+ renderTeam();
1297
+ }, 'Given to the lead');
1298
+ });
1299
+
1300
+ $('#team-close').onclick = () => {
1301
+ openTeamId = null;
1302
+ $('#team-dialog').close();
1303
+ };
1304
+ $('#team-stop').onclick = () => {
1305
+ if (!confirm("Stop the team? Its agents close (you can resume them); nothing is deleted.")) return;
1306
+ act(async () => teams.set(openTeamId, await api('POST', `/teams/${openTeamId}/stop`)), 'Team stopped');
1307
+ };
1308
+ $('#team-delete').onclick = () => {
1309
+ const id = openTeamId;
1310
+ const all = confirm('Also delete the team\'s agents and its worktree?\n\nOK: delete them too. Cancel: keep the agents, forget only the team.');
1311
+ act(async () => {
1312
+ await api('DELETE', `/teams/${id}${all ? '?agents=1' : ''}`);
1313
+ teams.delete(id);
1314
+ openTeamId = null;
1315
+ $('#team-dialog').close();
1316
+ render();
1317
+ });
1318
+ };
1319
+ $('#team-body').addEventListener('click', (e) => {
1320
+ const t = e.target;
1321
+ const go = t.closest('[data-team-goto]');
1322
+ if (go) {
1323
+ e.preventDefault();
1324
+ openTeamId = null;
1325
+ $('#team-dialog').close();
1326
+ showTab('agents');
1327
+ return select(go.dataset.teamGoto);
1328
+ }
1329
+ const file = t.closest('[data-team-file]');
1330
+ if (file) {
1331
+ e.preventDefault();
1332
+ return act(async () => {
1333
+ teamFile = await api('GET', `/teams/${openTeamId}/files/${encodeURIComponent(file.dataset.teamFile)}`);
1334
+ renderTeam();
1335
+ $('#team-body .team-file')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1336
+ });
1337
+ }
1338
+ if (t.closest('[data-team-file-close]')) {
1339
+ teamFile = null;
1340
+ return renderTeam();
1341
+ }
1342
+ const b = t.closest('[data-team-act]');
1343
+ if (!b) return;
1344
+ const note = $('#team-note')?.value.trim() || undefined;
1345
+ const id = openTeamId;
1346
+ const calls = {
1347
+ pr: () => api('POST', `/teams/${id}/finalize`, { mode: 'pr' }),
1348
+ publish: () => api('POST', `/teams/${id}/finalize`, { mode: 'publish' }),
1349
+ merge: () => api('POST', `/teams/${id}/finalize`, { mode: 'merge' }),
1350
+ keep: () => api('POST', `/teams/${id}/finalize`, { mode: 'keep' }),
1351
+ 'send-back': () => api('POST', `/teams/${id}/send-back`, { note }),
1352
+ 'fix-gaps': () => api('POST', `/teams/${id}/fix-gaps`, { note }),
1353
+ continue: () => api('POST', `/teams/${id}/resume`, { action: 'continue', note }),
1354
+ skip: () => api('POST', `/teams/${id}/resume`, { action: 'skip', note }),
1355
+ };
1356
+ const done = { pr: 'The implementer is opening the PR', publish: 'The implementer is publishing it to GitHub', merge: 'Merged into main', keep: 'Done; the branch is kept', 'send-back': 'Sent back to the implementer', 'fix-gaps': 'A gap round started', continue: 'Another round started', skip: 'Moving on' };
1357
+ act(async () => {
1358
+ teams.set(id, await calls[b.dataset.teamAct]());
1359
+ renderTeam();
1360
+ }, done[b.dataset.teamAct]);
1361
+ });
1362
+
1113
1363
  // ------------------------------------------------------------------ races
1114
1364
 
1115
1365
  function raceBanner(a) {
@@ -1124,6 +1374,7 @@ function openRace(id) {
1124
1374
  openRaceId = id;
1125
1375
  raceDiff = null;
1126
1376
  renderRace();
1377
+ renderTeam();
1127
1378
  if (!$('#race-dialog').open) $('#race-dialog').showModal();
1128
1379
  act(async () => races.set(id, await api('GET', `/races/${id}`)));
1129
1380
  }
@@ -1312,6 +1563,7 @@ function renderDetail() {
1312
1563
  <button data-act="shell" ${a.workdir && a.status !== 'preparing' ? '' : 'disabled'} title="A separate terminal in this agent's folder for running commands by hand">Open shell</button>
1313
1564
  <button data-act="fork" ${a.workdir && a.git?.branch ? '' : 'disabled'} title="A new agent with this one's code and conversation, in its own worktree, to try another approach">Fork</button>
1314
1565
  <button data-act="refresh" title="Re-scan git, PRs and transcripts">Refresh</button>
1566
+ <button data-act="keep" class="${a.keep ? 'on' : ''}" title="${a.keep ? 'Kept: clean up never closes or deletes it. Click to let clean up handle it again' : 'Clean up never closes or deletes an agent marked Keep'}">${a.keep ? '📌 Kept' : 'Keep'}</button>
1315
1567
  <button class="danger" data-act="delete">Delete</button>
1316
1568
  </div>
1317
1569
  </div>
@@ -1322,6 +1574,7 @@ function renderDetail() {
1322
1574
  ${it.status === 'review' ? `<span class="spacer"></span><button class="small primary" data-backlog-accept="${it.id}">Accept</button> <button class="small" data-backlog-reopen="${it.id}">Reopen</button>` : ''}</div>`;
1323
1575
  })()}
1324
1576
  ${a.error ? `<div class="error-box">${esc(a.error)}</div>` : ''}
1577
+ ${!running && a.closedBy === 'cleanup' ? `<p class="hint">Closed by clean up ${ago(a.closedAt)}, after a while with no activity. Resume it any time${a.keep ? '' : ', or mark it Keep'}.</p>` : ''}
1325
1578
  ${a.status === 'preparing'
1326
1579
  ? `<div class="progress-box">
1327
1580
  <div>${esc(progressText(a.progress) || 'Preparing workspace…')}</div>
@@ -1339,6 +1592,7 @@ function renderDetail() {
1339
1592
  : ''}
1340
1593
  ${a.runtime.attention ? `<div class="error-box">${esc(a.runtime.attention)}: the agent is waiting for an answer. Open its terminal to respond.</div>` : ''}
1341
1594
  ${raceBanner(a)}
1595
+ ${teamBanner(a)}
1342
1596
  ${a.forkOf ? `<div class="bl-banner">⑂ Forked from <a href="#${a.forkOf.agentId}" data-goto="${a.forkOf.agentId}">${esc(a.forkOf.name)}</a>${a.forkOf.checkpoint ? ` at checkpoint ${a.forkOf.checkpoint}` : ''} ${ago(a.forkOf.at)}</div>` : ''}
1343
1597
  ${(() => {
1344
1598
  const items = needsFor(a, { both: true });
@@ -1444,6 +1698,7 @@ function render() {
1444
1698
  renderBacklog();
1445
1699
  renderInbox();
1446
1700
  renderRace();
1701
+ renderTeam();
1447
1702
  renderDocDialog();
1448
1703
  renderDetail();
1449
1704
  term.syncOverlay();
@@ -2676,6 +2931,9 @@ $('#detail-body').addEventListener('click', async (e) => {
2676
2931
  case 'refresh':
2677
2932
  act(async () => upsert(await api('POST', `/agents/${a.id}/refresh`)), 'Refreshed');
2678
2933
  break;
2934
+ case 'keep':
2935
+ act(async () => upsert(await api('PATCH', `/agents/${a.id}`, { keep: !a.keep })), a.keep ? 'Clean up may close or delete it again' : 'Kept: clean up leaves it alone');
2936
+ break;
2679
2937
  case 'delete': {
2680
2938
  const cancelNote = a.status === 'preparing' ? '\n\nThis also cancels the workspace setup (clone / checkout) in progress.' : '';
2681
2939
  if (!confirm(`Delete agent "${a.name}"? Its session IDs will be forgotten by AI Agent Manager.${cancelNote}`)) return;
@@ -2722,6 +2980,27 @@ $('#filter').addEventListener('input', (e) => {
2722
2980
  renderList();
2723
2981
  });
2724
2982
 
2983
+ $('#recent-days').value = recent.days;
2984
+ $('#recent-on').addEventListener('change', (e) => {
2985
+ recent.on = e.target.checked;
2986
+ saveRecent();
2987
+ renderList();
2988
+ });
2989
+ $('#recent-days').addEventListener('input', (e) => {
2990
+ const n = Math.floor(Number(e.target.value));
2991
+ if (!(n >= 1)) return;
2992
+ recent.days = Math.min(3650, n);
2993
+ recent.on = true;
2994
+ saveRecent();
2995
+ renderList();
2996
+ });
2997
+ $('#recent-bar').addEventListener('click', (e) => {
2998
+ if (e.target.id !== 'recent-off') return;
2999
+ recent.on = false;
3000
+ saveRecent();
3001
+ renderList();
3002
+ });
3003
+
2725
3004
  // ------------------------------------------------------------------ new agents dialog
2726
3005
 
2727
3006
  const dialog = $('#new-dialog');
@@ -2743,7 +3022,8 @@ function syncWhen() {
2743
3022
  const when = dialog.querySelector('input[name="when"]:checked').value;
2744
3023
  dialog.querySelector('.now-only').hidden = when !== 'now';
2745
3024
  dialog.querySelector('.race-only').hidden = when !== 'race';
2746
- dialog.querySelector('button[type=submit]').textContent = when === 'backlog' ? 'Add to backlog' : when === 'race' ? 'Start race' : 'Create agents';
3025
+ dialog.querySelector('.team-only').hidden = when !== 'team';
3026
+ dialog.querySelector('button[type=submit]').textContent = { backlog: 'Add to backlog', race: 'Start race', team: 'Start team' }[when] ?? 'Create agents';
2747
3027
  }
2748
3028
  for (const r of dialog.querySelectorAll('input[name="when"]')) r.addEventListener('change', syncWhen);
2749
3029
  $('#new-btn').onclick = () => openNewDialog('now');
@@ -2755,6 +3035,44 @@ $('#new-form').addEventListener('submit', async (e) => {
2755
3035
  .split('\n')
2756
3036
  .map((s) => s.trim())
2757
3037
  .filter(Boolean);
3038
+ if (f.get('when') === 'team') {
3039
+ // "analyst=opus reviewer=opus"
3040
+ const models = Object.fromEntries(
3041
+ String(f.get('roleModels') || '')
3042
+ .split(/[\s,]+/)
3043
+ .map((p) => p.split('='))
3044
+ .filter(([k, v]) => k && v),
3045
+ );
3046
+ try {
3047
+ const team = await api('POST', '/teams', {
3048
+ intent: intents.join('\n'),
3049
+ critics: Number(f.get('critics')),
3050
+ mode: f.get('teamMode') || undefined,
3051
+ testCommand: f.get('teamTest') || undefined,
3052
+ // Empty: the defaults (3 rounds each; gap rounds 0, so it asks first).
3053
+ ...Object.fromEntries(['maxRounds', 'maxLoops', 'gapRounds'].filter((k) => String(f.get(k) ?? '') !== '').map((k) => [k, Number(f.get(k))])),
3054
+ models,
3055
+ repo: f.get('repo') || undefined,
3056
+ dir: f.get('dir') || undefined,
3057
+ base: f.get('base') || undefined,
3058
+ project: f.get('project') || undefined,
3059
+ permissionMode: f.get('permissionMode') || undefined,
3060
+ model: f.get('model') || undefined,
3061
+ engine: f.get('engine') || undefined,
3062
+ priority: Number(f.get('priority')),
3063
+ });
3064
+ teams.set(team.id, team);
3065
+ dialog.close();
3066
+ e.target.reset();
3067
+ syncWhen();
3068
+ openTeam(team.id);
3069
+ toast(team.mode === 'lead' ? 'Team started: one lead session with sub-agents' : `Team started: ${team.members.length} agents`);
3070
+ } catch (err) {
3071
+ $('#new-error').textContent = err.message;
3072
+ $('#new-error').hidden = false;
3073
+ }
3074
+ return;
3075
+ }
2758
3076
  if (f.get('when') === 'race') {
2759
3077
  // "model", "model | approach" or "| approach", one attempt per line.
2760
3078
  const variants = String(f.get('variants') || '')
@@ -2948,6 +3266,8 @@ function handleEvent(msg) {
2948
3266
  if (msg.projects) projectsState = msg.projects;
2949
3267
  races.clear();
2950
3268
  for (const r of msg.races ?? []) races.set(r.id, r);
3269
+ teams.clear();
3270
+ for (const t of msg.teams ?? []) teams.set(t.id, t);
2951
3271
  msg.agents.forEach(watchDocs);
2952
3272
  if (selectedId && !agents.has(selectedId)) selectedId = null;
2953
3273
  if (openTermOnLoad && selectedId) {
@@ -2966,6 +3286,10 @@ function handleEvent(msg) {
2966
3286
  races.set(msg.race.id, msg.race);
2967
3287
  } else if (msg.t === 'race-removed') {
2968
3288
  races.delete(msg.id);
3289
+ } else if (msg.t === 'team') {
3290
+ teams.set(msg.team.id, msg.team);
3291
+ } else if (msg.t === 'team-removed') {
3292
+ teams.delete(msg.id);
2969
3293
  } else if (msg.t === 'backlog') {
2970
3294
  backlogState = msg.backlog;
2971
3295
  } else if (msg.t === 'projects') {
@@ -3112,6 +3436,73 @@ $('#remote-content').addEventListener('click', async (e) => {
3112
3436
  renderRemote();
3113
3437
  });
3114
3438
 
3439
+ // ------------------------------------------------------------------ clean up old agents
3440
+
3441
+ const CLEANUP_TEXT = {
3442
+ 'close-delete': 'Checked every few hours: idle agents are closed, and closed ones deleted, on their own.',
3443
+ close: 'Checked every few hours: idle agents are closed on their own. Deleting waits for you.',
3444
+ manual: 'Nothing happens on its own. Use the buttons below when you want to clean up.',
3445
+ off: 'Nothing happens on its own.',
3446
+ };
3447
+
3448
+ async function loadCleanup() {
3449
+ const c = await api('GET', '/cleanup');
3450
+ const f = $('#cleanup-form');
3451
+ f.strategy.value = c.strategy;
3452
+ f.closeAfterDays.value = c.closeAfterDays;
3453
+ f.deleteAfterDays.value = c.deleteAfterDays;
3454
+ const list = (rows, age) => `<ul>${rows.map((x) => `<li><a href="#${x.id}" data-cleanup-goto="${x.id}">${esc(x.name)}</a><span class="hint">${age(x)}${x.reason ? ` · ${esc(x.reason)}` : ''}</span></li>`).join('')}</ul>`;
3455
+ $('#cleanup-plan').innerHTML = `<p class="hint" id="cleanup-what">${CLEANUP_TEXT[c.strategy]}</p>
3456
+ ${c.close.length ? `<h4>Due to close (${c.close.length})</h4>${list(c.close, (x) => `idle ${x.idleDays} days`)}` : ''}
3457
+ ${c.delete.length ? `<h4>Due to delete (${c.delete.length})</h4>${list(c.delete, (x) => `closed ${x.closedDays} days`)}` : ''}
3458
+ ${c.kept.length ? `<h4>Old, but left alone (${c.kept.length})</h4>${list(c.kept, (x) => (x.closedDays != null ? `closed ${x.closedDays} days` : `idle ${x.idleDays} days`))}` : ''}
3459
+ ${!c.close.length && !c.delete.length && !c.kept.length ? '<p>Nothing is due.</p>' : ''}
3460
+ ${c.lastRun ? `<p class="hint">Last run ${ago(c.lastRun.at)} (${c.lastRun.by === 'schedule' ? 'on its own' : 'by you'}): closed ${c.lastRun.closed.length}, deleted ${c.lastRun.deleted.length}${c.lastRun.failed?.length ? `, ${c.lastRun.failed.length} failed` : ''}.</p>` : ''}`;
3461
+ $('#cleanup-run-close').disabled = !c.close.length;
3462
+ $('#cleanup-run').disabled = !c.close.length && !c.delete.length;
3463
+ $('#cleanup-error').hidden = true;
3464
+ return c;
3465
+ }
3466
+
3467
+ $('#cleanup-btn').onclick = async () => {
3468
+ $('#cleanup-dialog').showModal();
3469
+ await loadCleanup().catch((err) => toast(err.message));
3470
+ };
3471
+ $('#cleanup-close').onclick = () => $('#cleanup-dialog').close();
3472
+ $('#cleanup-form').strategy.addEventListener('change', (e) => {
3473
+ if ($('#cleanup-what')) $('#cleanup-what').textContent = `${CLEANUP_TEXT[e.target.value]} (Save to apply.)`;
3474
+ });
3475
+ $('#cleanup-form').addEventListener('submit', async (e) => {
3476
+ e.preventDefault();
3477
+ const f = e.target;
3478
+ try {
3479
+ await api('PUT', '/cleanup', { strategy: f.strategy.value, closeAfterDays: Number(f.closeAfterDays.value), deleteAfterDays: Number(f.deleteAfterDays.value) });
3480
+ toast('Clean up settings saved');
3481
+ await loadCleanup();
3482
+ } catch (err) {
3483
+ $('#cleanup-error').textContent = err.message;
3484
+ $('#cleanup-error').hidden = false;
3485
+ }
3486
+ });
3487
+ async function runCleanup(del) {
3488
+ const c = await loadCleanup();
3489
+ const n = c.close.length + (del ? c.delete.length : 0);
3490
+ if (!n) return;
3491
+ if (del && c.delete.length && !confirm(`Delete ${c.delete.length} agent${c.delete.length > 1 ? 's' : ''} and their worktrees${c.close.length ? `, and close ${c.close.length}` : ''}?\n\n${c.delete.map((x) => x.name).join('\n')}`)) return;
3492
+ const r = await api('POST', '/cleanup/run', { delete: del });
3493
+ toast(`Closed ${r.closed.length}, deleted ${r.deleted.length}${r.failed.length ? `; ${r.failed.length} failed: ${r.failed[0].reason}` : ''}`);
3494
+ await loadCleanup();
3495
+ }
3496
+ $('#cleanup-run-close').onclick = () => runCleanup(false).catch((err) => toast(err.message));
3497
+ $('#cleanup-run').onclick = () => runCleanup(true).catch((err) => toast(err.message));
3498
+ $('#cleanup-plan').addEventListener('click', (e) => {
3499
+ const go = e.target.closest('[data-cleanup-goto]');
3500
+ if (!go) return;
3501
+ e.preventDefault();
3502
+ $('#cleanup-dialog').close();
3503
+ select(go.dataset.cleanupGoto);
3504
+ });
3505
+
3115
3506
  // ------------------------------------------------------------------ Slack alerts (from this computer)
3116
3507
 
3117
3508
  async function loadAlerts() {