ai-agentmanager 1.0.2 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -183,7 +200,7 @@ function explainBox(a) {
183
200
  const REVIEW_VERDICT = { complete: '✓ Complete', partial: '≈ Partly done', not_done: '✗ Not done' };
184
201
  /** The AI judge's review of the work against the intent and brief: button, and the last result once handled. */
185
202
  function reviewBox(a) {
186
- if (a.kind === 'general') return { button: '', box: '' };
203
+ if (noRepo(a)) return { button: '', box: '' };
187
204
  const r = a.aiReview;
188
205
  const button = `<button class="small" data-act="review" ${r?.pending ? 'disabled' : ''}
189
206
  title="Check the work against the intent and brief: what is met, missing or risky. A separate one-off model call; runs by itself when the agent says it is done">${r?.pending ? 'Reviewing…' : 'Review with AI'}</button>`;
@@ -412,7 +429,7 @@ function openPlan(agentId) {
412
429
  const PLAN_LABEL = { writing: 'being written', review: 'waiting for your review', changes: 'changes requested', approved: 'approved' };
413
430
  /** The plan-review row in the brief card: status, open, approve; or the switch to turn review on. */
414
431
  function planRow(a) {
415
- if (a.kind === 'general') return '';
432
+ if (noRepo(a)) return '';
416
433
  const p = a.planReview;
417
434
  if (!p) {
418
435
  return a.status === 'done' ? '' : `<div class="plan-row"><button class="small ghost" data-act="plan-on" title="The agent writes an implementation plan and waits for your approval before changing anything">Review its plan first</button></div>`;
@@ -472,7 +489,7 @@ function resultsCard(a) {
472
489
  }
473
490
 
474
491
  function briefPanel(a) {
475
- if (a.kind === 'general') return '';
492
+ if (noRepo(a)) return '';
476
493
  const b = a.brief;
477
494
  const ex = explainBox(a);
478
495
  const rv = reviewBox(a);
@@ -581,12 +598,14 @@ function saveCollapsed() {
581
598
  const prioChip = (p, { always = false } = {}) =>
582
599
  p === 2 && !always ? '' : `<span class="prio p${p}" title="Priority P${p}">P${p}</span>`;
583
600
 
601
+ const noRepo = (a) => a.kind === 'general' || a.kind === 'assistant';
602
+
584
603
  function agentItem(a) {
585
604
  const st = statusOf(a);
586
605
  const idle = !isActive(a);
587
606
  return `<li data-id="${a.id}" class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''} ${idle ? 'idle' : ''}">
588
607
  <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>
589
- <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>
608
+ <div class="intent">${a.kind === 'general' ? '<span class="tag">general</span> ' : a.kind === 'assistant' ? '<span class="tag assistant">assistant</span> ' : a.startedBy ? '<span class="tag assistant" title="Started by the assistant">🤖 by assistant</span> ' : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(a.intent || (a.kind === 'assistant' ? 'Finds new work and starts agents for it' : 'General session'))}</div>
590
609
  ${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
591
610
  <div class="meta">
592
611
  <span class="mono">${a.id}</span>
@@ -635,7 +654,7 @@ function projectGroups(list) {
635
654
  const same = byName ? byName.get(g.name).length : out.filter((x) => x.name === g.name).length;
636
655
  g.label = same > 1 && g.full ? g.full : g.name;
637
656
  }
638
- const special = (g) => (g.key === 'general' ? 1 : g.key === 'other' ? 2 : 0);
657
+ const special = (g) => (g.key === 'assistant' ? -1 : g.key === 'general' ? 1 : g.key === 'other' ? 2 : 0);
639
658
  return out.sort(
640
659
  (a, b) =>
641
660
  special(a) - special(b) ||
@@ -661,6 +680,13 @@ function renderList() {
661
680
  .toLowerCase()
662
681
  .includes(filterText),
663
682
  );
683
+ // The open agent stays in the list even when it's older.
684
+ const since = recent.on ? Date.now() - recent.days * 86_400_000 : 0;
685
+ const shown = since ? list.filter((a) => a.id === selectedId || (Date.parse(a.createdAt) || 0) >= since) : list;
686
+ const hidden = list.length - shown.length;
687
+ $('#recent-bar').hidden = agents.size === 0;
688
+ $('#recent-on').checked = recent.on;
689
+ $('#recent-hidden').innerHTML = hidden ? `${hidden} older ${hidden === 1 ? 'agent' : 'agents'} hidden · <button class="link" id="recent-off">Show all</button>` : '';
664
690
  $('#empty').hidden = agents.size > 0;
665
691
 
666
692
  const all = [...agents.values()];
@@ -672,8 +698,14 @@ function renderList() {
672
698
  <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
699
  : '';
674
700
 
675
- $('#agent-list').innerHTML = projectGroups(list)
701
+ // Every agent a project has, also the ones a filter hides.
702
+ const totals = new Map();
703
+ for (const a of all) totals.set(a.projectInfo?.key ?? 'other', (totals.get(a.projectInfo?.key ?? 'other') ?? 0) + 1);
704
+
705
+ $('#agent-list').innerHTML = projectGroups(shown)
676
706
  .map((g) => {
707
+ const total = totals.get(g.key) ?? g.agents.length;
708
+ const n = g.agents.length;
677
709
  // While filtering, show every match; otherwise respect collapsed projects.
678
710
  const open = filterText || !collapsed.has(g.key) || g.agents.some((a) => a.id === selectedId);
679
711
  return `<li class="project ${open ? 'open' : ''}">
@@ -684,7 +716,8 @@ function renderList() {
684
716
  <span class="pcounts">
685
717
  ${g.top <= 1 ? prioChip(g.top) : ''}
686
718
  ${g.waiting ? `<span class="pill attention" title="needs input">${g.waiting}</span>` : ''}
687
- <span title="running / agents">${g.running}/${g.agents.length}</span>
719
+ ${g.running ? `<span class="prun" title="${g.running} running">● ${g.running}</span>` : ''}
720
+ <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
721
  </span>
689
722
  </button>
690
723
  ${open ? `<ul class="project-agents">${g.agents.map(agentItem).join('')}</ul>` : ''}
@@ -844,12 +877,13 @@ function flags(a) {
844
877
  a.conflicts?.length ? `<span class="flag warn" title="Changes the same files as ${esc(a.conflicts.map((c) => c.agent.name).join(', '))}">⚔</span>` : '',
845
878
  q ? `<span class="flag ask" title="${q} open question${q > 1 ? 's' : ''}">?${q > 1 ? q : ''}</span>` : '',
846
879
  a.raceId ? `<span class="flag" title="Race attempt${a.variant?.label ? `: ${esc(a.variant.label)}` : ''}">🏁</span>` : '',
880
+ 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
881
  a.forkOf ? `<span class="flag" title="Fork of ${esc(a.forkOf.name)}">⑂</span>` : '',
848
882
  ].join('');
849
883
  }
850
884
 
851
885
  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 };
886
+ const NEED_ORDER = { prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, review: 2, stuck: 3, conflict: 4 };
853
887
  /** What an agent needs from the user. `both`: overlaps on both agents (detail), else only the one that should rebase (inbox). */
854
888
  function needsFor(a, { both = false } = {}) {
855
889
  const items = [];
@@ -862,13 +896,19 @@ function needsFor(a, { both = false } = {}) {
862
896
  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
897
  return items;
864
898
  }
899
+ /** Teams waiting on you (approved, or stuck on a disagreement), shown on the lead or the implementer. */
900
+ const teamOwner = (t) => t.members.find((m) => m.role === 'lead' || m.role === 'implementer')?.id;
901
+ function teamNeeds() {
902
+ return [...teams.values()]
903
+ .filter((t) => ['ready', 'blocked'].includes(t.stage) && agents.has(teamOwner(t)))
904
+ .map((t) => ({ a: agents.get(teamOwner(t)), kind: 'team', key: `team:${t.id}`, t }));
905
+ }
865
906
  function inboxItems() {
866
- return [...agents.values()]
867
- .flatMap((a) => needsFor(a))
907
+ return [...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds()]
868
908
  .sort((x, y) => NEED_ORDER[x.kind] - NEED_ORDER[y.kind] || (x.a.priority ?? 2) - (y.a.priority ?? 2));
869
909
  }
870
910
 
871
- const NEED_LABEL = { prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
911
+ 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
912
  /** Why the AI judge thinks so, as a small tag. */
873
913
  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
914
  function needCard(it, { inDetail = false } = {}) {
@@ -907,6 +947,11 @@ function needCard(it, { inDetail = false } = {}) {
907
947
  ${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
948
  <button class="small ${r.missing.length ? '' : 'primary'}" data-ib="review-accept">Looks good</button>
909
949
  </div>`;
950
+ } else if (it.kind === 'team') {
951
+ const t = it.t;
952
+ 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>
953
+ <p class="hint">${esc(t.intent.split('\n')[0].slice(0, 140))}</p>
954
+ <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
955
  } else if (it.kind === 'plan') {
911
956
  const title = a.planReview.markdown?.match(/^#\s+(.+)$/m)?.[1] ?? 'Implementation plan';
912
957
  body = `<p class="need-title">${esc(title)}</p>
@@ -1022,6 +1067,8 @@ async function onNeed(e) {
1022
1067
  const q = key.split(':')[2];
1023
1068
  return act(async () => upsert(await api('POST', `/agents/${id}/answer`, { question: q, text: el.dataset.text })), 'Answer sent');
1024
1069
  }
1070
+ case 'team-open':
1071
+ return openTeam(el.dataset.team);
1025
1072
  case 'plan-open':
1026
1073
  return openPlan(id);
1027
1074
  case 'plan-approve':
@@ -1074,7 +1121,7 @@ for (const root of [$('#inbox-view'), $('#detail-body')]) {
1074
1121
  // ------------------------------------------------------------------ checkpoints and forks
1075
1122
 
1076
1123
  function checkpointsCard(a) {
1077
- if (a.kind === 'general' || !a.git?.branch) return '';
1124
+ if (noRepo(a) || !a.git?.branch) return '';
1078
1125
  const cps = [...(a.checkpoints ?? [])].reverse();
1079
1126
  const forks = [...agents.values()].filter((x) => x.forkOf?.agentId === a.id);
1080
1127
  return `<div class="card">
@@ -1095,6 +1142,8 @@ $('#detail-body').addEventListener('click', (e) => {
1095
1142
  if (cp) forkAgent(selectedId, Number(cp.dataset.forkCp));
1096
1143
  const race = e.target.closest('[data-race-open]');
1097
1144
  if (race) openRace(race.dataset.raceOpen);
1145
+ const team = e.target.closest('[data-team-open]');
1146
+ if (team) openTeam(team.dataset.teamOpen);
1098
1147
  });
1099
1148
 
1100
1149
  function forkAgent(id, checkpoint) {
@@ -1110,6 +1159,209 @@ function forkAgent(id, checkpoint) {
1110
1159
  }, 'Fork created');
1111
1160
  }
1112
1161
 
1162
+ // ------------------------------------------------------------------ teams
1163
+
1164
+ const TEAM_STEPS = [['analyze', 'Analyze'], ['discuss', 'Discuss'], ['implement', 'Implement'], ['test', 'Test'], ['review', 'Review'], ['ready', 'Your call'], ['done', 'Done']];
1165
+ 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' };
1166
+ const GOOD = new Set(['ready', 'agree', 'done', 'pass', 'approve']);
1167
+ const ROLE_NAME = (m) => `${m.role[0].toUpperCase()}${m.role.slice(1)}${m.n && teams.get(openTeamId)?.settings.critics > 1 ? ` ${m.n}` : ''}`;
1168
+
1169
+ function teamBanner(a) {
1170
+ if (!a.team) return '';
1171
+ const t = teams.get(a.team.id);
1172
+ const turn = t?.waiting?.some((w) => w.member === a.id && !w.verdict);
1173
+ 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>' : ''}
1174
+ <span class="spacer"></span><button class="small primary" data-team-open="${a.team.id}">Open team</button></div>`;
1175
+ }
1176
+
1177
+ function openTeam(id) {
1178
+ openTeamId = id;
1179
+ teamFile = null;
1180
+ renderTeam();
1181
+ if (!$('#team-dialog').open) $('#team-dialog').showModal();
1182
+ act(async () => teams.set(id, await api('GET', `/teams/${id}`)));
1183
+ }
1184
+
1185
+ function teamSteps(t) {
1186
+ const at = ['finalizing', 'gaps'].includes(t.stage) ? 'ready' : t.stage;
1187
+ const idx = TEAM_STEPS.findIndex(([k]) => k === at);
1188
+ const stopped = ['blocked', 'stopped'].includes(t.stage);
1189
+ // Where a blocked or stopped team got to: the last step anyone finished.
1190
+ const reached = stopped ? Math.max(0, ...t.history.map((h) => TEAM_STEPS.findIndex(([k]) => k === h.stage))) : idx;
1191
+ return TEAM_STEPS.filter(([k]) => k !== 'discuss' || t.settings.critics)
1192
+ .map(([k, label]) => {
1193
+ const i = TEAM_STEPS.findIndex(([x]) => x === k);
1194
+ const cls = stopped ? (i < reached ? 'done' : i === reached ? 'stop' : '') : i < idx || t.stage === 'done' ? 'done' : i === idx ? 'now' : '';
1195
+ return `<span class="team-step ${cls}">${label}</span>`;
1196
+ })
1197
+ .join('<span class="team-arrow">→</span>');
1198
+ }
1199
+
1200
+ function renderTeam() {
1201
+ const dlg = $('#team-dialog');
1202
+ if (!dlg.open && !openTeamId) return;
1203
+ const t = teams.get(openTeamId);
1204
+ if (!t) {
1205
+ $('#team-body').innerHTML = '<p class="hint">Loading…</p>';
1206
+ return;
1207
+ }
1208
+ if ($('#team-body').contains(document.activeElement) && document.activeElement.tagName === 'TEXTAREA') return;
1209
+ const openQuests = new Set([...$('#team-body').querySelectorAll('details.team-quest[open] summary b')].map((b) => b.textContent));
1210
+ $('#team-title').textContent = `Team: ${t.name}`;
1211
+ $('#team-stop').hidden = ['done', 'stopped'].includes(t.stage);
1212
+ const memberOf = (id) => t.members.find((m) => m.id === id);
1213
+ const gate =
1214
+ t.stage === 'ready'
1215
+ ? `<div class="team-gate">${t.gaps
1216
+ ? `<b>✓ The reviewer approved the work so far, but parts of the request aren't built yet:</b>
1217
+ ${t.gapList ? `<article class="md team-gaps">${md(t.gapList)}</article>` : ''}
1218
+ <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>
1219
+ <p class="hint">Or take the work as it is:</p>`
1220
+ : '<b>✓ The reviewer approved it.</b> Look at the review and the changes, then:'}
1221
+ <div class="need-actions">${t.remote
1222
+ ? `<button class="small${t.gaps ? '' : ' primary'}" data-team-act="pr">Open a pull request</button>`
1223
+ : `<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>
1224
+ <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>`}
1225
+ <button class="small" data-team-act="keep">Keep the branch${t.remote ? ', no PR' : ''}</button></div>
1226
+ <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>
1227
+ <div class="need-actions"><button class="small" data-team-act="send-back">Send back</button></div></div>`
1228
+ : t.stage === 'blocked'
1229
+ ? `<div class="team-gate blocked"><b>${esc(t.blocked.reason)}</b>
1230
+ <textarea id="team-note" rows="2" placeholder="Optional note for the next round"></textarea>
1231
+ <div class="need-actions">
1232
+ ${t.blocked.at !== 'member' ? `<button class="small primary" data-team-act="continue">${t.blocked.at === 'discuss' ? 'One more round' : 'One more fix round'}</button>` : ''}
1233
+ ${t.blocked.at === 'discuss' ? '<button class="small" data-team-act="skip" title="Implement the latest analysis as it is">Implement the latest analysis</button>' : ''}
1234
+ ${t.blocked.at === 'loops' ? '<button class="small" data-team-act="skip" title="Move to your final review without another round">Accept as is</button>' : ''}
1235
+ </div></div>`
1236
+ : t.stage === 'done' && t.gaps
1237
+ ? `<div class="team-gate"><b>The last review lists parts of the request that aren't built yet:</b>
1238
+ ${t.gapList ? `<article class="md team-gaps">${md(t.gapList)}</article>` : ''}
1239
+ <textarea id="team-note" rows="2" placeholder="Optional note for the gap round"></textarea>
1240
+ <div class="need-actions"><button class="small primary" data-team-act="fix-gaps">Run a gap round</button></div></div>`
1241
+ : '';
1242
+ 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>` : '';
1243
+ const handoffs = (list) =>
1244
+ `<ul class="team-timeline">${list.slice().reverse().map((h) => {
1245
+ const m = memberOf(h.member);
1246
+ 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);
1247
+ 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>
1248
+ <span><span class="verdict-word ${GOOD.has(h.verdict) ? 'good' : 'bad'}">${esc(h.verdict)}</span></span>
1249
+ <span><a href="#" data-team-file="${esc(h.file)}">${esc(h.file.split('/').pop())}</a> <span class="summary">${esc(h.summary)}</span></span></li>`;
1250
+ }).join('')}</ul>`;
1251
+ const asking = t.mode === 'lead' && t.stage !== 'stopped'
1252
+ ? `<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>
1253
+ <textarea id="team-ask-text" rows="2" placeholder="Now add a --json flag to the output"></textarea>
1254
+ <div class="need-actions"><button class="small${['ready', 'done'].includes(t.stage) ? ' primary' : ''}">Give to the team</button></div></form>`
1255
+ : '';
1256
+ $('#team-body').innerHTML = `
1257
+ ${quest}
1258
+ <p>${esc(t.request ?? t.intent)}</p>
1259
+ <div class="team-steps">${teamSteps(t)}</div>
1260
+ <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)}` : ''}
1261
+ ${t.prs.map((p) => ` · <a href="${esc(p.url)}" target="_blank" rel="noopener">PR #${p.number ?? ''}</a>`).join('')}</p>
1262
+ ${gate}
1263
+ <div class="race-table-wrap"><table class="race-table">
1264
+ <tr><th>Role</th><th>Agent</th><th>Status</th><th>Model</th><th>Cost</th></tr>
1265
+ ${t.members.map((m) => {
1266
+ const a = agents.get(m.id);
1267
+ const st = a ? statusOf(a) : m.status;
1268
+ return `<tr class="${m.turn ? 'diffing' : ''}"><td><b>${ROLE_NAME(m)}</b>${m.turn ? ' <span class="tag open">its turn</span>' : ''}</td>
1269
+ <td>${a ? `<a href="#${m.id}" data-team-goto="${m.id}">${esc(m.name)}</a>` : '<span class="hint">deleted</span>'}</td>
1270
+ <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>`;
1271
+ }).join('')}
1272
+ ${t.subagents.map((r) => {
1273
+ const last = t.history.findLast((h) => h.role === r.role);
1274
+ return `<tr><td>${r.role[0].toUpperCase()}${r.role.slice(1)}</td><td><span class="hint">sub-agent of the lead</span></td>
1275
+ <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>
1276
+ <td>${r.model ? esc(r.model) : '<span class="hint">lead\'s</span>'}</td><td></td></tr>`;
1277
+ }).join('')}
1278
+ </table></div>
1279
+ <h3>Handoffs</h3>
1280
+ ${t.history.length ? handoffs(t.history) : '<p class="hint">Nothing handed off yet. The analyst is reading the code.</p>'}
1281
+ ${asking}
1282
+ ${t.quests?.length
1283
+ ? `<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('')}`
1284
+ : ''}
1285
+ ${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>` : ''}`;
1286
+ for (const d of $('#team-body').querySelectorAll('details.team-quest')) if (openQuests.has(d.querySelector('summary b').textContent)) d.open = true;
1287
+ }
1288
+
1289
+ $('#team-body').addEventListener('submit', (e) => {
1290
+ if (e.target.id !== 'team-ask') return;
1291
+ e.preventDefault();
1292
+ const text = $('#team-ask-text').value.trim();
1293
+ if (!text) return;
1294
+ const id = openTeamId;
1295
+ act(async () => {
1296
+ teams.set(id, await api('POST', `/teams/${id}/ask`, { text }));
1297
+ $('#team-ask-text').value = '';
1298
+ renderTeam();
1299
+ }, 'Given to the lead');
1300
+ });
1301
+
1302
+ $('#team-close').onclick = () => {
1303
+ openTeamId = null;
1304
+ $('#team-dialog').close();
1305
+ };
1306
+ $('#team-stop').onclick = () => {
1307
+ if (!confirm("Stop the team? Its agents close (you can resume them); nothing is deleted.")) return;
1308
+ act(async () => teams.set(openTeamId, await api('POST', `/teams/${openTeamId}/stop`)), 'Team stopped');
1309
+ };
1310
+ $('#team-delete').onclick = () => {
1311
+ const id = openTeamId;
1312
+ 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.');
1313
+ act(async () => {
1314
+ await api('DELETE', `/teams/${id}${all ? '?agents=1' : ''}`);
1315
+ teams.delete(id);
1316
+ openTeamId = null;
1317
+ $('#team-dialog').close();
1318
+ render();
1319
+ });
1320
+ };
1321
+ $('#team-body').addEventListener('click', (e) => {
1322
+ const t = e.target;
1323
+ const go = t.closest('[data-team-goto]');
1324
+ if (go) {
1325
+ e.preventDefault();
1326
+ openTeamId = null;
1327
+ $('#team-dialog').close();
1328
+ showTab('agents');
1329
+ return select(go.dataset.teamGoto);
1330
+ }
1331
+ const file = t.closest('[data-team-file]');
1332
+ if (file) {
1333
+ e.preventDefault();
1334
+ return act(async () => {
1335
+ teamFile = await api('GET', `/teams/${openTeamId}/files/${encodeURIComponent(file.dataset.teamFile)}`);
1336
+ renderTeam();
1337
+ $('#team-body .team-file')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1338
+ });
1339
+ }
1340
+ if (t.closest('[data-team-file-close]')) {
1341
+ teamFile = null;
1342
+ return renderTeam();
1343
+ }
1344
+ const b = t.closest('[data-team-act]');
1345
+ if (!b) return;
1346
+ const note = $('#team-note')?.value.trim() || undefined;
1347
+ const id = openTeamId;
1348
+ const calls = {
1349
+ pr: () => api('POST', `/teams/${id}/finalize`, { mode: 'pr' }),
1350
+ publish: () => api('POST', `/teams/${id}/finalize`, { mode: 'publish' }),
1351
+ merge: () => api('POST', `/teams/${id}/finalize`, { mode: 'merge' }),
1352
+ keep: () => api('POST', `/teams/${id}/finalize`, { mode: 'keep' }),
1353
+ 'send-back': () => api('POST', `/teams/${id}/send-back`, { note }),
1354
+ 'fix-gaps': () => api('POST', `/teams/${id}/fix-gaps`, { note }),
1355
+ continue: () => api('POST', `/teams/${id}/resume`, { action: 'continue', note }),
1356
+ skip: () => api('POST', `/teams/${id}/resume`, { action: 'skip', note }),
1357
+ };
1358
+ 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' };
1359
+ act(async () => {
1360
+ teams.set(id, await calls[b.dataset.teamAct]());
1361
+ renderTeam();
1362
+ }, done[b.dataset.teamAct]);
1363
+ });
1364
+
1113
1365
  // ------------------------------------------------------------------ races
1114
1366
 
1115
1367
  function raceBanner(a) {
@@ -1124,6 +1376,7 @@ function openRace(id) {
1124
1376
  openRaceId = id;
1125
1377
  raceDiff = null;
1126
1378
  renderRace();
1379
+ renderTeam();
1127
1380
  if (!$('#race-dialog').open) $('#race-dialog').showModal();
1128
1381
  act(async () => races.set(id, await api('GET', `/races/${id}`)));
1129
1382
  }
@@ -1294,7 +1547,7 @@ function renderDetail() {
1294
1547
  ${[0, 1, 2, 3].map((p) => `<option value="${p}" ${p === (a.priority ?? 2) ? 'selected' : ''}>P${p}</option>`).join('')}
1295
1548
  </select>
1296
1549
  <span class="d-name">${esc(a.name)}</span> <span class="pill ${st}">${pillText(st)}</span>${a.kind === 'general' ? ' <span class="tag">general</span>' : ''}</h2>
1297
- <p class="intent">${esc(a.intent || (a.kind === 'general' ? 'General session: ask Claude anything in the terminal.' : ''))}</p>
1550
+ <p class="intent">${esc(a.intent || (a.kind === 'general' ? 'General session: ask Claude anything in the terminal.' : a.kind === 'assistant' ? 'Your assistant: it checks for new work every few minutes and starts agents for it. Settings and activity: Assistant in the top bar.' : ''))}</p>
1298
1551
  </div>
1299
1552
  <div class="d-actions">
1300
1553
  ${a.status === 'error' && !a.workdir
@@ -1311,7 +1564,9 @@ function renderDetail() {
1311
1564
  <button data-act="fresh" ${running || a.status === 'preparing' ? 'disabled' : ''} title="Start over with a new session ID (old ones are kept)">New session</button>
1312
1565
  <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
1566
  <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>
1567
+ <button data-act="edit" title="Change its name, request, project, priority, permission mode or model">Edit</button>
1314
1568
  <button data-act="refresh" title="Re-scan git, PRs and transcripts">Refresh</button>
1569
+ <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
1570
  <button class="danger" data-act="delete">Delete</button>
1316
1571
  </div>
1317
1572
  </div>
@@ -1322,6 +1577,7 @@ function renderDetail() {
1322
1577
  ${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
1578
  })()}
1324
1579
  ${a.error ? `<div class="error-box">${esc(a.error)}</div>` : ''}
1580
+ ${!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
1581
  ${a.status === 'preparing'
1326
1582
  ? `<div class="progress-box">
1327
1583
  <div>${esc(progressText(a.progress) || 'Preparing workspace…')}</div>
@@ -1339,6 +1595,7 @@ function renderDetail() {
1339
1595
  : ''}
1340
1596
  ${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
1597
  ${raceBanner(a)}
1598
+ ${teamBanner(a)}
1342
1599
  ${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
1600
  ${(() => {
1344
1601
  const items = needsFor(a, { both: true });
@@ -1372,7 +1629,7 @@ function renderDetail() {
1372
1629
  <h3>Workspace</h3>
1373
1630
  <dl class="kv">
1374
1631
  <dt>Folder</dt><dd><code>${esc(a.workdir ?? (a.status === 'preparing' ? 'preparing…' : '—'))}</code>${a.workdir ? copyBtn(a.workdir) : ''}</dd>
1375
- <dt>Project</dt><dd>${esc(a.projectInfo?.full || a.projectInfo?.name || '—')} <button class="link" data-act="project" title="Group this agent under a different project name">✎</button></dd>
1632
+ <dt>Project</dt><dd>${esc(a.projectInfo?.full || a.projectInfo?.name || '—')} ${noRepo(a) ? '' : '<button class="link" data-act="project" title="Move this agent under another project">✎ Move</button>'}</dd>
1376
1633
  <dt>Repo</dt><dd>${a.repo ? `<code>${esc(a.repo)}</code>` : '—'}</dd>
1377
1634
  <dt>Branch</dt><dd>${a.git?.branch ? `<code>${esc(a.git.branch)}</code>` : '—'}${a.git?.dirtyFiles ? ` <span class="tag">${a.git.dirtyFiles} changed</span>` : ''}</dd>
1378
1635
  <dt>Head</dt><dd>${a.git?.head ? `<code>${esc(a.git.head.sha)}</code> ${esc(a.git.head.subject)}` : '—'}</dd>
@@ -1444,6 +1701,7 @@ function render() {
1444
1701
  renderBacklog();
1445
1702
  renderInbox();
1446
1703
  renderRace();
1704
+ renderTeam();
1447
1705
  renderDocDialog();
1448
1706
  renderDetail();
1449
1707
  term.syncOverlay();
@@ -2620,12 +2878,10 @@ $('#detail-body').addEventListener('click', async (e) => {
2620
2878
  term.open(a.id);
2621
2879
  }, 'Moved into the repository');
2622
2880
  break;
2623
- case 'project': {
2624
- const name = prompt('Project name for this agent (leave empty to use its repo):', a.project ?? a.projectInfo?.name ?? '');
2625
- if (name === null) return;
2626
- act(async () => upsert(await api('PATCH', `/agents/${a.id}`, { project: name })));
2627
- break;
2628
- }
2881
+ case 'project':
2882
+ return openEdit(a, 'project');
2883
+ case 'edit':
2884
+ return openEdit(a);
2629
2885
  case 'plan-open':
2630
2886
  return openPlan(a.id);
2631
2887
  case 'plan-on':
@@ -2676,6 +2932,9 @@ $('#detail-body').addEventListener('click', async (e) => {
2676
2932
  case 'refresh':
2677
2933
  act(async () => upsert(await api('POST', `/agents/${a.id}/refresh`)), 'Refreshed');
2678
2934
  break;
2935
+ case 'keep':
2936
+ 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');
2937
+ break;
2679
2938
  case 'delete': {
2680
2939
  const cancelNote = a.status === 'preparing' ? '\n\nThis also cancels the workspace setup (clone / checkout) in progress.' : '';
2681
2940
  if (!confirm(`Delete agent "${a.name}"? Its session IDs will be forgotten by AI Agent Manager.${cancelNote}`)) return;
@@ -2722,6 +2981,27 @@ $('#filter').addEventListener('input', (e) => {
2722
2981
  renderList();
2723
2982
  });
2724
2983
 
2984
+ $('#recent-days').value = recent.days;
2985
+ $('#recent-on').addEventListener('change', (e) => {
2986
+ recent.on = e.target.checked;
2987
+ saveRecent();
2988
+ renderList();
2989
+ });
2990
+ $('#recent-days').addEventListener('input', (e) => {
2991
+ const n = Math.floor(Number(e.target.value));
2992
+ if (!(n >= 1)) return;
2993
+ recent.days = Math.min(3650, n);
2994
+ recent.on = true;
2995
+ saveRecent();
2996
+ renderList();
2997
+ });
2998
+ $('#recent-bar').addEventListener('click', (e) => {
2999
+ if (e.target.id !== 'recent-off') return;
3000
+ recent.on = false;
3001
+ saveRecent();
3002
+ renderList();
3003
+ });
3004
+
2725
3005
  // ------------------------------------------------------------------ new agents dialog
2726
3006
 
2727
3007
  const dialog = $('#new-dialog');
@@ -2743,7 +3023,8 @@ function syncWhen() {
2743
3023
  const when = dialog.querySelector('input[name="when"]:checked').value;
2744
3024
  dialog.querySelector('.now-only').hidden = when !== 'now';
2745
3025
  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';
3026
+ dialog.querySelector('.team-only').hidden = when !== 'team';
3027
+ dialog.querySelector('button[type=submit]').textContent = { backlog: 'Add to backlog', race: 'Start race', team: 'Start team' }[when] ?? 'Create agents';
2747
3028
  }
2748
3029
  for (const r of dialog.querySelectorAll('input[name="when"]')) r.addEventListener('change', syncWhen);
2749
3030
  $('#new-btn').onclick = () => openNewDialog('now');
@@ -2755,6 +3036,44 @@ $('#new-form').addEventListener('submit', async (e) => {
2755
3036
  .split('\n')
2756
3037
  .map((s) => s.trim())
2757
3038
  .filter(Boolean);
3039
+ if (f.get('when') === 'team') {
3040
+ // "analyst=opus reviewer=opus"
3041
+ const models = Object.fromEntries(
3042
+ String(f.get('roleModels') || '')
3043
+ .split(/[\s,]+/)
3044
+ .map((p) => p.split('='))
3045
+ .filter(([k, v]) => k && v),
3046
+ );
3047
+ try {
3048
+ const team = await api('POST', '/teams', {
3049
+ intent: intents.join('\n'),
3050
+ critics: Number(f.get('critics')),
3051
+ mode: f.get('teamMode') || undefined,
3052
+ testCommand: f.get('teamTest') || undefined,
3053
+ // Empty: the defaults (3 rounds each; gap rounds 0, so it asks first).
3054
+ ...Object.fromEntries(['maxRounds', 'maxLoops', 'gapRounds'].filter((k) => String(f.get(k) ?? '') !== '').map((k) => [k, Number(f.get(k))])),
3055
+ models,
3056
+ repo: f.get('repo') || undefined,
3057
+ dir: f.get('dir') || undefined,
3058
+ base: f.get('base') || undefined,
3059
+ project: f.get('project') || undefined,
3060
+ permissionMode: f.get('permissionMode') || undefined,
3061
+ model: f.get('model') || undefined,
3062
+ engine: f.get('engine') || undefined,
3063
+ priority: Number(f.get('priority')),
3064
+ });
3065
+ teams.set(team.id, team);
3066
+ dialog.close();
3067
+ e.target.reset();
3068
+ syncWhen();
3069
+ openTeam(team.id);
3070
+ toast(team.mode === 'lead' ? 'Team started: one lead session with sub-agents' : `Team started: ${team.members.length} agents`);
3071
+ } catch (err) {
3072
+ $('#new-error').textContent = err.message;
3073
+ $('#new-error').hidden = false;
3074
+ }
3075
+ return;
3076
+ }
2758
3077
  if (f.get('when') === 'race') {
2759
3078
  // "model", "model | approach" or "| approach", one attempt per line.
2760
3079
  const variants = String(f.get('variants') || '')
@@ -2948,6 +3267,8 @@ function handleEvent(msg) {
2948
3267
  if (msg.projects) projectsState = msg.projects;
2949
3268
  races.clear();
2950
3269
  for (const r of msg.races ?? []) races.set(r.id, r);
3270
+ teams.clear();
3271
+ for (const t of msg.teams ?? []) teams.set(t.id, t);
2951
3272
  msg.agents.forEach(watchDocs);
2952
3273
  if (selectedId && !agents.has(selectedId)) selectedId = null;
2953
3274
  if (openTermOnLoad && selectedId) {
@@ -2966,6 +3287,10 @@ function handleEvent(msg) {
2966
3287
  races.set(msg.race.id, msg.race);
2967
3288
  } else if (msg.t === 'race-removed') {
2968
3289
  races.delete(msg.id);
3290
+ } else if (msg.t === 'team') {
3291
+ teams.set(msg.team.id, msg.team);
3292
+ } else if (msg.t === 'team-removed') {
3293
+ teams.delete(msg.id);
2969
3294
  } else if (msg.t === 'backlog') {
2970
3295
  backlogState = msg.backlog;
2971
3296
  } else if (msg.t === 'projects') {
@@ -3112,6 +3437,254 @@ $('#remote-content').addEventListener('click', async (e) => {
3112
3437
  renderRemote();
3113
3438
  });
3114
3439
 
3440
+ // ------------------------------------------------------------------ clean up old agents
3441
+
3442
+ const CLEANUP_TEXT = {
3443
+ 'close-delete': 'Checked every few hours: idle agents are closed, and closed ones deleted, on their own.',
3444
+ close: 'Checked every few hours: idle agents are closed on their own. Deleting waits for you.',
3445
+ manual: 'Nothing happens on its own. Use the buttons below when you want to clean up.',
3446
+ off: 'Nothing happens on its own.',
3447
+ };
3448
+
3449
+ async function loadCleanup() {
3450
+ const c = await api('GET', '/cleanup');
3451
+ const f = $('#cleanup-form');
3452
+ f.strategy.value = c.strategy;
3453
+ f.closeAfterDays.value = c.closeAfterDays;
3454
+ f.deleteAfterDays.value = c.deleteAfterDays;
3455
+ 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>`;
3456
+ $('#cleanup-plan').innerHTML = `<p class="hint" id="cleanup-what">${CLEANUP_TEXT[c.strategy]}</p>
3457
+ ${c.close.length ? `<h4>Due to close (${c.close.length})</h4>${list(c.close, (x) => `idle ${x.idleDays} days`)}` : ''}
3458
+ ${c.delete.length ? `<h4>Due to delete (${c.delete.length})</h4>${list(c.delete, (x) => `closed ${x.closedDays} days`)}` : ''}
3459
+ ${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`))}` : ''}
3460
+ ${!c.close.length && !c.delete.length && !c.kept.length ? '<p>Nothing is due.</p>' : ''}
3461
+ ${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>` : ''}`;
3462
+ $('#cleanup-run-close').disabled = !c.close.length;
3463
+ $('#cleanup-run').disabled = !c.close.length && !c.delete.length;
3464
+ $('#cleanup-error').hidden = true;
3465
+ return c;
3466
+ }
3467
+
3468
+ $('#cleanup-btn').onclick = async () => {
3469
+ $('#cleanup-dialog').showModal();
3470
+ await loadCleanup().catch((err) => toast(err.message));
3471
+ };
3472
+ $('#cleanup-close').onclick = () => $('#cleanup-dialog').close();
3473
+ $('#cleanup-form').strategy.addEventListener('change', (e) => {
3474
+ if ($('#cleanup-what')) $('#cleanup-what').textContent = `${CLEANUP_TEXT[e.target.value]} (Save to apply.)`;
3475
+ });
3476
+ $('#cleanup-form').addEventListener('submit', async (e) => {
3477
+ e.preventDefault();
3478
+ const f = e.target;
3479
+ try {
3480
+ await api('PUT', '/cleanup', { strategy: f.strategy.value, closeAfterDays: Number(f.closeAfterDays.value), deleteAfterDays: Number(f.deleteAfterDays.value) });
3481
+ toast('Clean up settings saved');
3482
+ await loadCleanup();
3483
+ } catch (err) {
3484
+ $('#cleanup-error').textContent = err.message;
3485
+ $('#cleanup-error').hidden = false;
3486
+ }
3487
+ });
3488
+ async function runCleanup(del) {
3489
+ const c = await loadCleanup();
3490
+ const n = c.close.length + (del ? c.delete.length : 0);
3491
+ if (!n) return;
3492
+ 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;
3493
+ const r = await api('POST', '/cleanup/run', { delete: del });
3494
+ toast(`Closed ${r.closed.length}, deleted ${r.deleted.length}${r.failed.length ? `; ${r.failed.length} failed: ${r.failed[0].reason}` : ''}`);
3495
+ await loadCleanup();
3496
+ }
3497
+ $('#cleanup-run-close').onclick = () => runCleanup(false).catch((err) => toast(err.message));
3498
+ $('#cleanup-run').onclick = () => runCleanup(true).catch((err) => toast(err.message));
3499
+ $('#cleanup-plan').addEventListener('click', (e) => {
3500
+ const go = e.target.closest('[data-cleanup-goto]');
3501
+ if (!go) return;
3502
+ e.preventDefault();
3503
+ $('#cleanup-dialog').close();
3504
+ select(go.dataset.cleanupGoto);
3505
+ });
3506
+
3507
+ // ------------------------------------------------------------------ edit an agent
3508
+
3509
+ let editing = null;
3510
+ let enginesCache = null;
3511
+ // The project picked in the dialog: "" while it is listed under its own repo or folder.
3512
+ const editProject = (a) => (a.projectMove || a.project ? (a.projectInfo?.shortcut ?? '') : '');
3513
+
3514
+ async function openEdit(a, focus) {
3515
+ editing = a;
3516
+ const f = $('#edit-form');
3517
+ f.name.value = a.name;
3518
+ f.intent.value = a.intent ?? '';
3519
+ f.tell.checked = false;
3520
+ f.priority.value = String(a.priority ?? 2);
3521
+ f.model.value = a.model ?? '';
3522
+ $('#edit-project-row').hidden = noRepo(a);
3523
+ $('#edit-tell-row').hidden = !a.intent;
3524
+ // Its own repo or folder, every known project, or a new name.
3525
+ f.project.innerHTML = [
3526
+ '<option value="">Its own repo or folder</option>',
3527
+ ...projectsState.map((p) => `<option value="${esc(p.shortcut)}">${esc(p.name)} (@${esc(p.shortcut)})</option>`),
3528
+ '<option value="__new">New project name…</option>',
3529
+ ].join('');
3530
+ f.project.value = editProject(a);
3531
+ $('#edit-newproject-row').hidden = true;
3532
+ f.newProject.value = '';
3533
+ enginesCache ??= await api('GET', '/engines').catch(() => []);
3534
+ const modes = enginesCache.find((e) => e.id === (a.engine ?? 'claude'))?.permissionModes ?? [];
3535
+ f.permissionMode.innerHTML = ['<option value="">As configured</option>', ...modes.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`)].join('');
3536
+ f.permissionMode.value = a.permissionMode ?? '';
3537
+ $('#edit-error').hidden = true;
3538
+ $('#edit-dialog').showModal();
3539
+ (focus === 'project' ? f.project : f.name).focus();
3540
+ }
3541
+
3542
+ $('#edit-form').project.addEventListener('change', (e) => {
3543
+ $('#edit-newproject-row').hidden = e.target.value !== '__new';
3544
+ if (e.target.value === '__new') $('#edit-form').newProject.focus();
3545
+ });
3546
+ $('#edit-close').onclick = () => $('#edit-dialog').close();
3547
+ $('#edit-form').addEventListener('submit', async (e) => {
3548
+ e.preventDefault();
3549
+ const a = editing;
3550
+ const f = e.target;
3551
+ const patch = {};
3552
+ if (f.name.value.trim() !== a.name) patch.name = f.name.value.trim();
3553
+ if (f.intent.value.trim() !== (a.intent ?? '')) patch.intent = f.intent.value.trim();
3554
+ if (Number(f.priority.value) !== (a.priority ?? 2)) patch.priority = Number(f.priority.value);
3555
+ if ((f.permissionMode.value || null) !== (a.permissionMode ?? null)) patch.permissionMode = f.permissionMode.value || null;
3556
+ if ((f.model.value.trim() || null) !== (a.model ?? null)) patch.model = f.model.value.trim() || null;
3557
+ if (!noRepo(a)) {
3558
+ const target = f.project.value === '__new' ? f.newProject.value.trim() : f.project.value;
3559
+ const was = editProject(a);
3560
+ if (f.project.value === '__new' && !target) {
3561
+ $('#edit-error').textContent = 'Give the new project a name';
3562
+ $('#edit-error').hidden = false;
3563
+ return;
3564
+ }
3565
+ if (target !== was) patch.project = target;
3566
+ }
3567
+ try {
3568
+ if (Object.keys(patch).length) upsert(await api('PATCH', `/agents/${a.id}`, patch));
3569
+ if (patch.intent && f.tell.checked) await api('POST', `/agents/${a.id}/tell`, { text: `The request changed. It is now:\n${patch.intent}` });
3570
+ $('#edit-dialog').close();
3571
+ const relaunch = 'permissionMode' in patch || 'model' in patch;
3572
+ toast(!Object.keys(patch).length ? 'Nothing changed' : `Saved${patch.project !== undefined ? '; moved' : ''}${relaunch ? '. Permission mode and model apply from its next launch' : ''}`);
3573
+ } catch (err) {
3574
+ $('#edit-error').textContent = err.message;
3575
+ $('#edit-error').hidden = false;
3576
+ }
3577
+ });
3578
+
3579
+ // ------------------------------------------------------------------ assistant
3580
+
3581
+ const ASSISTANT_SOURCES = ['jira', 'comments', 'reviews', 'ci', 'slack'];
3582
+ let assistantState = null;
3583
+
3584
+ function renderAssistantDot() {
3585
+ const a = assistantState;
3586
+ const dot = $('#assistant-dot');
3587
+ dot.hidden = !a?.agentId;
3588
+ if (a) {
3589
+ dot.className = `assistant-dot ${a.on ? (a.busy ? 'busy' : 'on') : 'off'}`;
3590
+ dot.title = a.on ? (a.busy ? 'Checking now' : 'On') : 'Paused';
3591
+ }
3592
+ }
3593
+
3594
+ async function loadAssistant({ fill = true } = {}) {
3595
+ const a = await api('GET', '/assistant');
3596
+ assistantState = a;
3597
+ renderAssistantDot();
3598
+ const f = $('#assistant-form');
3599
+ if (fill) {
3600
+ for (const k of ASSISTANT_SOURCES) f[`src_${k}`].checked = a.sources.includes(k);
3601
+ f.everyMin.value = a.everyMin;
3602
+ f.maxPerDay.value = a.maxPerDay;
3603
+ f.maxRunning.value = a.maxRunning;
3604
+ f.notes.value = a.notes ?? '';
3605
+ }
3606
+ const state = !a.agentId ? 'Not set up yet. Start it to create its session.' : a.on ? (a.busy ? 'On: checking now.' : 'On.') : 'Paused: not checking.';
3607
+ $('#assistant-status').innerHTML = `<p><b>${esc(state)}</b>
3608
+ ${a.lastCheckAt ? ` Last check ${ago(a.lastCheckAt)}.` : ''}${a.nextAt && a.on ? ` Next ${new Date(a.nextAt) <= new Date() ? 'when it is free' : `at ${new Date(a.nextAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}.` : ''}
3609
+ ${a.agentId ? ` Started ${a.startedToday} agent${a.startedToday === 1 ? '' : 's'} today, ${a.startedTotal} in all.` : ''}</p>
3610
+ ${a.started.length ? `<ul class="assistant-started">${a.started.slice(0, 8).map((x) => `<li><a href="#${x.id}" data-assistant-goto="${x.id}">${esc(x.name)}</a> <span class="hint">${ago(x.createdAt)} · ${esc(x.intent.slice(0, 90))}</span></li>`).join('')}</ul>` : ''}`;
3611
+ $('#assistant-activity').innerHTML = a.activity ? `<h4>Activity</h4><div class="md">${md(a.activity.split(/\n(?=## )/).reverse().slice(0, 10).join('\n').replace(/([^\n])\n(?=[^\n#\-*])/g, '$1 \n'))}</div>` : a.agentId ? '<p class="hint">No activity yet. Its first check writes here.</p>' : '';
3612
+ $('#assistant-start').hidden = Boolean(a.on);
3613
+ $('#assistant-start').textContent = a.agentId ? 'Turn on' : 'Start';
3614
+ $('#assistant-pause').hidden = !a.on;
3615
+ $('#assistant-check').hidden = !a.on;
3616
+ $('#assistant-open').hidden = !a.agentId;
3617
+ $('#assistant-error').hidden = true;
3618
+ return a;
3619
+ }
3620
+
3621
+ const assistantPatch = (f) => ({
3622
+ sources: ASSISTANT_SOURCES.filter((k) => f[`src_${k}`].checked),
3623
+ everyMin: Number(f.everyMin.value),
3624
+ maxPerDay: Number(f.maxPerDay.value),
3625
+ maxRunning: Number(f.maxRunning.value),
3626
+ notes: f.notes.value,
3627
+ });
3628
+ const assistantError = (err) => {
3629
+ $('#assistant-error').textContent = err.message;
3630
+ $('#assistant-error').hidden = false;
3631
+ };
3632
+ $('#assistant-btn').onclick = async () => {
3633
+ $('#assistant-dialog').showModal();
3634
+ await loadAssistant().catch(assistantError);
3635
+ };
3636
+ $('#assistant-close').onclick = () => $('#assistant-dialog').close();
3637
+ $('#assistant-form').addEventListener('submit', async (e) => {
3638
+ e.preventDefault();
3639
+ try {
3640
+ await api('PUT', '/assistant', assistantPatch(e.target));
3641
+ toast(assistantState?.running ? 'Saved. A running assistant uses the new watch list and notes after Pause and Turn on.' : 'Assistant settings saved');
3642
+ await loadAssistant();
3643
+ } catch (err) {
3644
+ assistantError(err);
3645
+ }
3646
+ });
3647
+ $('#assistant-start').onclick = async () => {
3648
+ try {
3649
+ if (!assistantState?.agentId) await api('PUT', '/assistant', assistantPatch($('#assistant-form')));
3650
+ await api('POST', '/assistant/start');
3651
+ toast('Assistant on: its first check starts in a moment');
3652
+ await loadAssistant({ fill: false });
3653
+ } catch (err) {
3654
+ assistantError(err);
3655
+ }
3656
+ };
3657
+ $('#assistant-pause').onclick = async () => {
3658
+ try {
3659
+ await api('POST', '/assistant/pause');
3660
+ await loadAssistant({ fill: false });
3661
+ } catch (err) {
3662
+ assistantError(err);
3663
+ }
3664
+ };
3665
+ $('#assistant-check').onclick = async () => {
3666
+ try {
3667
+ await api('POST', '/assistant/check');
3668
+ toast('Asked the assistant to check now');
3669
+ await loadAssistant({ fill: false });
3670
+ } catch (err) {
3671
+ assistantError(err);
3672
+ }
3673
+ };
3674
+ $('#assistant-open').onclick = () => {
3675
+ $('#assistant-dialog').close();
3676
+ if (assistantState?.agentId) select(assistantState.agentId);
3677
+ };
3678
+ $('#assistant-dialog').addEventListener('click', (e) => {
3679
+ const go = e.target.closest('[data-assistant-goto]');
3680
+ if (!go) return;
3681
+ e.preventDefault();
3682
+ $('#assistant-dialog').close();
3683
+ select(go.dataset.assistantGoto);
3684
+ });
3685
+ loadAssistant({ fill: false }).catch(() => {});
3686
+ setInterval(() => loadAssistant({ fill: false }).catch(() => {}), 60_000);
3687
+
3115
3688
  // ------------------------------------------------------------------ Slack alerts (from this computer)
3116
3689
 
3117
3690
  async function loadAlerts() {