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/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
 
@@ -556,6 +573,11 @@ const copyBtn = (value) => `<button class="link" data-copy="${esc(value)}" title
556
573
  /** Agents waiting on you first, then running ones, then the rest. */
557
574
  const rank = (a) => (a.runtime.attention ? 0 : a.runtime.running ? 1 : 2);
558
575
 
576
+ // Agents with no work and no input from you for a day sink below the active ones.
577
+ const ACTIVE_MS = 24 * 3600 * 1000;
578
+ const isActive = (a) =>
579
+ Boolean(a.runtime.attention || a.runtime.busy || a.status === 'preparing' || (a.activeAt && Date.now() - Date.parse(a.activeAt) < ACTIVE_MS));
580
+
559
581
  // Projects the user collapsed in the tree (kept per browser).
560
582
  const collapsed = new Set(
561
583
  (() => {
@@ -578,12 +600,14 @@ const prioChip = (p, { always = false } = {}) =>
578
600
 
579
601
  function agentItem(a) {
580
602
  const st = statusOf(a);
581
- return `<li data-id="${a.id}" class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''}">
603
+ const idle = !isActive(a);
604
+ return `<li data-id="${a.id}" class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''} ${idle ? 'idle' : ''}">
582
605
  <div class="line1">${prioChip(a.priority ?? 2)}${a.results?.outcome === 'blocked' ? '<span class="outcome-mark" title="Blocked">⛔</span>' : a.results?.outcome === 'done' ? '<span class="outcome-mark" title="Done">✅</span>' : ''}<span class="name">${esc(a.name)}</span>${flags(a)}<span class="pill ${st}">${pillText(st)}</span></div>
583
606
  <div class="intent">${a.kind === 'general' ? '<span class="tag">general</span> ' : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(a.intent || 'General session')}</div>
584
607
  ${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
585
608
  <div class="meta">
586
609
  <span class="mono">${a.id}</span>
610
+ ${idle && a.activeAt ? `<span title="Idle: no work and no input from you for over a day">💤 ${ago(a.activeAt)}</span>` : ''}
587
611
  ${a.git?.branch ? `<span class="branch" title="${esc(a.git.branch)}">⎇ ${esc(a.git.branch)}</span>` : ''}
588
612
  ${a.prs.length ? `<span>${a.prs.length} PR</span>` : ''}
589
613
  ${a.bugs.length ? `<span>${a.bugs.length} bug</span>` : ''}
@@ -608,8 +632,15 @@ function projectGroups(list) {
608
632
  }
609
633
  const out = [...groups.values()];
610
634
  for (const g of out) {
611
- // Highest priority first, then agents needing you, then running ones, then newest.
612
- g.agents.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2) || rank(a) - rank(b) || b.createdAt.localeCompare(a.createdAt));
635
+ // Active in the last day first; then highest priority, agents needing you, running ones, most recent.
636
+ g.agents.sort(
637
+ (a, b) =>
638
+ isActive(b) - isActive(a) ||
639
+ (a.priority ?? 2) - (b.priority ?? 2) ||
640
+ rank(a) - rank(b) ||
641
+ (b.activeAt ?? b.createdAt).localeCompare(a.activeAt ?? a.createdAt),
642
+ );
643
+ g.active = g.agents.filter(isActive).length;
613
644
  g.top = Math.min(...g.agents.map((a) => a.priority ?? 2));
614
645
  g.running = g.agents.filter((a) => a.runtime.running).length;
615
646
  g.waiting = g.agents.filter((a) => a.runtime.attention).length;
@@ -623,7 +654,13 @@ function projectGroups(list) {
623
654
  }
624
655
  const special = (g) => (g.key === 'general' ? 1 : g.key === 'other' ? 2 : 0);
625
656
  return out.sort(
626
- (a, b) => special(a) - special(b) || a.top - b.top || b.waiting - a.waiting || b.running - a.running || a.label.localeCompare(b.label),
657
+ (a, b) =>
658
+ special(a) - special(b) ||
659
+ (b.active > 0) - (a.active > 0) ||
660
+ a.top - b.top ||
661
+ b.waiting - a.waiting ||
662
+ b.running - a.running ||
663
+ a.label.localeCompare(b.label),
627
664
  );
628
665
  }
629
666
 
@@ -641,6 +678,13 @@ function renderList() {
641
678
  .toLowerCase()
642
679
  .includes(filterText),
643
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>` : '';
644
688
  $('#empty').hidden = agents.size > 0;
645
689
 
646
690
  const all = [...agents.values()];
@@ -652,8 +696,14 @@ function renderList() {
652
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>`
653
697
  : '';
654
698
 
655
- $('#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)
656
704
  .map((g) => {
705
+ const total = totals.get(g.key) ?? g.agents.length;
706
+ const n = g.agents.length;
657
707
  // While filtering, show every match; otherwise respect collapsed projects.
658
708
  const open = filterText || !collapsed.has(g.key) || g.agents.some((a) => a.id === selectedId);
659
709
  return `<li class="project ${open ? 'open' : ''}">
@@ -664,7 +714,8 @@ function renderList() {
664
714
  <span class="pcounts">
665
715
  ${g.top <= 1 ? prioChip(g.top) : ''}
666
716
  ${g.waiting ? `<span class="pill attention" title="needs input">${g.waiting}</span>` : ''}
667
- <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>
668
719
  </span>
669
720
  </button>
670
721
  ${open ? `<ul class="project-agents">${g.agents.map(agentItem).join('')}</ul>` : ''}
@@ -824,12 +875,13 @@ function flags(a) {
824
875
  a.conflicts?.length ? `<span class="flag warn" title="Changes the same files as ${esc(a.conflicts.map((c) => c.agent.name).join(', '))}">⚔</span>` : '',
825
876
  q ? `<span class="flag ask" title="${q} open question${q > 1 ? 's' : ''}">?${q > 1 ? q : ''}</span>` : '',
826
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>` : '',
827
879
  a.forkOf ? `<span class="flag" title="Fork of ${esc(a.forkOf.name)}">⑂</span>` : '',
828
880
  ].join('');
829
881
  }
830
882
 
831
883
  const TRIAGED = { now: 'sent (AI: urgent)', digest: 'held for the digest', skip: 'dropped by the AI judge' };
832
- 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 };
833
885
  /** What an agent needs from the user. `both`: overlaps on both agents (detail), else only the one that should rebase (inbox). */
834
886
  function needsFor(a, { both = false } = {}) {
835
887
  const items = [];
@@ -842,13 +894,19 @@ function needsFor(a, { both = false } = {}) {
842
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 });
843
895
  return items;
844
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
+ }
845
904
  function inboxItems() {
846
- return [...agents.values()]
847
- .flatMap((a) => needsFor(a))
905
+ return [...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds()]
848
906
  .sort((x, y) => NEED_ORDER[x.kind] - NEED_ORDER[y.kind] || (x.a.priority ?? 2) - (y.a.priority ?? 2));
849
907
  }
850
908
 
851
- 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' };
852
910
  /** Why the AI judge thinks so, as a small tag. */
853
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>`;
854
912
  function needCard(it, { inDetail = false } = {}) {
@@ -887,6 +945,11 @@ function needCard(it, { inDetail = false } = {}) {
887
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>' : ''}
888
946
  <button class="small ${r.missing.length ? '' : 'primary'}" data-ib="review-accept">Looks good</button>
889
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>`;
890
953
  } else if (it.kind === 'plan') {
891
954
  const title = a.planReview.markdown?.match(/^#\s+(.+)$/m)?.[1] ?? 'Implementation plan';
892
955
  body = `<p class="need-title">${esc(title)}</p>
@@ -1002,6 +1065,8 @@ async function onNeed(e) {
1002
1065
  const q = key.split(':')[2];
1003
1066
  return act(async () => upsert(await api('POST', `/agents/${id}/answer`, { question: q, text: el.dataset.text })), 'Answer sent');
1004
1067
  }
1068
+ case 'team-open':
1069
+ return openTeam(el.dataset.team);
1005
1070
  case 'plan-open':
1006
1071
  return openPlan(id);
1007
1072
  case 'plan-approve':
@@ -1075,6 +1140,8 @@ $('#detail-body').addEventListener('click', (e) => {
1075
1140
  if (cp) forkAgent(selectedId, Number(cp.dataset.forkCp));
1076
1141
  const race = e.target.closest('[data-race-open]');
1077
1142
  if (race) openRace(race.dataset.raceOpen);
1143
+ const team = e.target.closest('[data-team-open]');
1144
+ if (team) openTeam(team.dataset.teamOpen);
1078
1145
  });
1079
1146
 
1080
1147
  function forkAgent(id, checkpoint) {
@@ -1090,6 +1157,209 @@ function forkAgent(id, checkpoint) {
1090
1157
  }, 'Fork created');
1091
1158
  }
1092
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
+
1093
1363
  // ------------------------------------------------------------------ races
1094
1364
 
1095
1365
  function raceBanner(a) {
@@ -1104,6 +1374,7 @@ function openRace(id) {
1104
1374
  openRaceId = id;
1105
1375
  raceDiff = null;
1106
1376
  renderRace();
1377
+ renderTeam();
1107
1378
  if (!$('#race-dialog').open) $('#race-dialog').showModal();
1108
1379
  act(async () => races.set(id, await api('GET', `/races/${id}`)));
1109
1380
  }
@@ -1292,6 +1563,7 @@ function renderDetail() {
1292
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>
1293
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>
1294
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>
1295
1567
  <button class="danger" data-act="delete">Delete</button>
1296
1568
  </div>
1297
1569
  </div>
@@ -1302,6 +1574,7 @@ function renderDetail() {
1302
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>`;
1303
1575
  })()}
1304
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>` : ''}
1305
1578
  ${a.status === 'preparing'
1306
1579
  ? `<div class="progress-box">
1307
1580
  <div>${esc(progressText(a.progress) || 'Preparing workspace…')}</div>
@@ -1319,6 +1592,7 @@ function renderDetail() {
1319
1592
  : ''}
1320
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>` : ''}
1321
1594
  ${raceBanner(a)}
1595
+ ${teamBanner(a)}
1322
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>` : ''}
1323
1597
  ${(() => {
1324
1598
  const items = needsFor(a, { both: true });
@@ -1424,6 +1698,7 @@ function render() {
1424
1698
  renderBacklog();
1425
1699
  renderInbox();
1426
1700
  renderRace();
1701
+ renderTeam();
1427
1702
  renderDocDialog();
1428
1703
  renderDetail();
1429
1704
  term.syncOverlay();
@@ -1725,11 +2000,107 @@ const term = {
1725
2000
  this.agentId = null;
1726
2001
  $('#term-wrap').hidden = true;
1727
2002
  $('#detail').classList.remove('with-term');
2003
+ if (document.body.classList.contains('term-max')) setExpanded(false);
2004
+ if (document.fullscreenElement) document.exitFullscreen();
1728
2005
  },
1729
2006
  };
1730
2007
 
1731
2008
  $('#term-close').onclick = () => term.close();
1732
2009
  $('#term-fit').onclick = () => term.refit();
2010
+
2011
+ // ------------------------------------------------------------------ resizing, expanding, full screen
2012
+
2013
+ const layoutPref = {
2014
+ get(key) {
2015
+ try {
2016
+ return localStorage.getItem(`agentmgr.${key}`);
2017
+ } catch {
2018
+ return null;
2019
+ }
2020
+ },
2021
+ set(key, value) {
2022
+ try {
2023
+ value == null ? localStorage.removeItem(`agentmgr.${key}`) : localStorage.setItem(`agentmgr.${key}`, value);
2024
+ } catch {}
2025
+ },
2026
+ };
2027
+
2028
+ function applyLayout() {
2029
+ const side = layoutPref.get('sideWidth');
2030
+ const brief = layoutPref.get('briefHeight');
2031
+ document.documentElement.style.setProperty('--side-w', side ? `${side}px` : '340px');
2032
+ if (brief) document.documentElement.style.setProperty('--brief-h', `${brief}px`);
2033
+ else document.documentElement.style.removeProperty('--brief-h');
2034
+ $('#detail').classList.toggle('brief-sized', Boolean(brief));
2035
+ }
2036
+ applyLayout();
2037
+
2038
+ /** Drag a handle; `move` gets the pointer position and returns the value to keep. */
2039
+ function dragHandle(el, axis, move, key) {
2040
+ el.addEventListener('pointerdown', (e) => {
2041
+ e.preventDefault();
2042
+ el.setPointerCapture(e.pointerId);
2043
+ el.classList.add('dragging');
2044
+ document.body.classList.add('resizing', axis);
2045
+ let value = null;
2046
+ const onMove = (ev) => {
2047
+ value = move(ev);
2048
+ applyLayout();
2049
+ };
2050
+ const onUp = () => {
2051
+ el.classList.remove('dragging');
2052
+ document.body.classList.remove('resizing', axis);
2053
+ el.removeEventListener('pointermove', onMove);
2054
+ el.removeEventListener('pointerup', onUp);
2055
+ if (value != null) layoutPref.set(key, Math.round(value));
2056
+ term.refit();
2057
+ };
2058
+ el.addEventListener('pointermove', onMove);
2059
+ el.addEventListener('pointerup', onUp);
2060
+ });
2061
+ el.addEventListener('dblclick', () => {
2062
+ layoutPref.set(key, null);
2063
+ applyLayout();
2064
+ requestAnimationFrame(() => term.refit());
2065
+ });
2066
+ }
2067
+
2068
+ dragHandle($('#split-side'), 'cols', (ev) => {
2069
+ const w = Math.min(Math.max(ev.clientX, 220), window.innerWidth * 0.6);
2070
+ layoutPref.set('sideWidth', Math.round(w));
2071
+ return w;
2072
+ }, 'sideWidth');
2073
+
2074
+ dragHandle($('#split-term'), 'rows', (ev) => {
2075
+ const top = $('#detail').getBoundingClientRect().top;
2076
+ const h = Math.min(Math.max(ev.clientY - top, 60), $('#detail').clientHeight - 160);
2077
+ layoutPref.set('briefHeight', Math.round(h));
2078
+ return h;
2079
+ }, 'briefHeight');
2080
+
2081
+ function setExpanded(on) {
2082
+ document.body.classList.toggle('term-max', on);
2083
+ $('#term-max').classList.toggle('on', on);
2084
+ $('#term-max').textContent = on ? '⤡ Restore' : '⤢ Expand';
2085
+ requestAnimationFrame(() => term.refit());
2086
+ }
2087
+ $('#term-max').onclick = () => setExpanded(!document.body.classList.contains('term-max'));
2088
+ $('#term-full').onclick = () => {
2089
+ if (document.fullscreenElement) document.exitFullscreen();
2090
+ else $('#term-wrap').requestFullscreen?.().catch(() => setExpanded(true));
2091
+ };
2092
+ document.addEventListener('fullscreenchange', () => {
2093
+ const on = document.fullscreenElement === $('#term-wrap');
2094
+ $('#term-full').classList.toggle('on', on);
2095
+ $('#term-full').textContent = on ? '⛶ Exit full screen' : '⛶ Full screen';
2096
+ requestAnimationFrame(() => term.refit());
2097
+ });
2098
+ document.addEventListener('keydown', (e) => {
2099
+ if (e.ctrlKey && e.shiftKey && e.code === 'KeyE' && !$('#term-wrap').hidden) {
2100
+ e.preventDefault();
2101
+ setExpanded(!document.body.classList.contains('term-max'));
2102
+ }
2103
+ }, true);
1733
2104
  $('#term-resume').onclick = () => {
1734
2105
  const id = term.agentId;
1735
2106
  if (isShell(term.kind)) {
@@ -2560,6 +2931,9 @@ $('#detail-body').addEventListener('click', async (e) => {
2560
2931
  case 'refresh':
2561
2932
  act(async () => upsert(await api('POST', `/agents/${a.id}/refresh`)), 'Refreshed');
2562
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;
2563
2937
  case 'delete': {
2564
2938
  const cancelNote = a.status === 'preparing' ? '\n\nThis also cancels the workspace setup (clone / checkout) in progress.' : '';
2565
2939
  if (!confirm(`Delete agent "${a.name}"? Its session IDs will be forgotten by AI Agent Manager.${cancelNote}`)) return;
@@ -2606,6 +2980,27 @@ $('#filter').addEventListener('input', (e) => {
2606
2980
  renderList();
2607
2981
  });
2608
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
+
2609
3004
  // ------------------------------------------------------------------ new agents dialog
2610
3005
 
2611
3006
  const dialog = $('#new-dialog');
@@ -2627,7 +3022,8 @@ function syncWhen() {
2627
3022
  const when = dialog.querySelector('input[name="when"]:checked').value;
2628
3023
  dialog.querySelector('.now-only').hidden = when !== 'now';
2629
3024
  dialog.querySelector('.race-only').hidden = when !== 'race';
2630
- 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';
2631
3027
  }
2632
3028
  for (const r of dialog.querySelectorAll('input[name="when"]')) r.addEventListener('change', syncWhen);
2633
3029
  $('#new-btn').onclick = () => openNewDialog('now');
@@ -2639,6 +3035,44 @@ $('#new-form').addEventListener('submit', async (e) => {
2639
3035
  .split('\n')
2640
3036
  .map((s) => s.trim())
2641
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
+ }
2642
3076
  if (f.get('when') === 'race') {
2643
3077
  // "model", "model | approach" or "| approach", one attempt per line.
2644
3078
  const variants = String(f.get('variants') || '')
@@ -2832,6 +3266,8 @@ function handleEvent(msg) {
2832
3266
  if (msg.projects) projectsState = msg.projects;
2833
3267
  races.clear();
2834
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);
2835
3271
  msg.agents.forEach(watchDocs);
2836
3272
  if (selectedId && !agents.has(selectedId)) selectedId = null;
2837
3273
  if (openTermOnLoad && selectedId) {
@@ -2850,6 +3286,10 @@ function handleEvent(msg) {
2850
3286
  races.set(msg.race.id, msg.race);
2851
3287
  } else if (msg.t === 'race-removed') {
2852
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);
2853
3293
  } else if (msg.t === 'backlog') {
2854
3294
  backlogState = msg.backlog;
2855
3295
  } else if (msg.t === 'projects') {
@@ -2996,6 +3436,73 @@ $('#remote-content').addEventListener('click', async (e) => {
2996
3436
  renderRemote();
2997
3437
  });
2998
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
+
2999
3506
  // ------------------------------------------------------------------ Slack alerts (from this computer)
3000
3507
 
3001
3508
  async function loadAlerts() {