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/CHANGELOG.md +23 -0
- package/README.md +95 -3
- package/docs/architecture.md +1 -0
- package/package.json +1 -1
- package/public/app.js +594 -21
- package/public/index.html +156 -0
- package/public/style.css +54 -2
- package/src/assistant.js +269 -0
- package/src/cleanup.js +186 -0
- package/src/cli.js +263 -1
- package/src/client.js +2 -1
- package/src/config.js +4 -0
- package/src/engines/antigravity.js +1 -1
- package/src/engines/claude.js +8 -5
- package/src/engines/codex.js +1 -1
- package/src/manager.js +109 -19
- package/src/notify.js +2 -1
- package/src/server.js +67 -4
- package/src/teams.js +984 -0
- package/src/tracker.js +6 -0
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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="
|
|
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
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
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('
|
|
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() {
|