ai-agentmanager 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +76 -4
- package/bin/agentmgr.js +10 -3
- package/docs/architecture.md +1 -0
- package/docs/images/agents.jpg +0 -0
- package/docs/images/conflict-radar.jpg +0 -0
- package/package.json +1 -1
- package/public/app.js +518 -11
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon.svg +1 -0
- package/public/index.html +97 -0
- package/public/manifest.webmanifest +16 -0
- package/public/style.css +69 -3
- package/src/cleanup.js +185 -0
- package/src/cli.js +181 -0
- package/src/config.js +4 -0
- package/src/doctor.js +113 -0
- package/src/engines/antigravity.js +1 -1
- package/src/engines/claude.js +22 -5
- package/src/engines/codex.js +1 -1
- package/src/engines/index.js +8 -0
- package/src/judge.js +12 -0
- package/src/manager.js +65 -12
- package/src/server.js +44 -2
- package/src/teams.js +984 -0
- package/src/tracker.js +11 -1
package/public/app.js
CHANGED
|
@@ -14,6 +14,9 @@ let showCompleted = false;
|
|
|
14
14
|
const races = new Map(); // race id -> scoreboard
|
|
15
15
|
let openRaceId = null;
|
|
16
16
|
let raceDiff = null; // { agentId, text }
|
|
17
|
+
const teams = new Map(); // team id -> team view
|
|
18
|
+
let openTeamId = null;
|
|
19
|
+
let teamFile = null; // { name, markdown } shown in the team view
|
|
17
20
|
// #<id> selects an agent; #<id>/term also opens its terminal.
|
|
18
21
|
const loginHash = location.hash.match(/(?:^#|&)login=([\w-]+)/)?.[1] ?? null;
|
|
19
22
|
// From the hub: #ticket=<one-time ticket>&go=<agent>/term
|
|
@@ -23,6 +26,20 @@ const [hashId, hashView] = loginHash ? [null, null] : (ticketHash ? goHash : loc
|
|
|
23
26
|
let selectedId = hashId || null;
|
|
24
27
|
let openTermOnLoad = hashView === 'term' || isShell(hashView) || isBrowser(hashView);
|
|
25
28
|
let filterText = '';
|
|
29
|
+
// "Only agents created in the last N days": remembered in this browser.
|
|
30
|
+
const recent = (() => {
|
|
31
|
+
try {
|
|
32
|
+
return { on: localStorage.getItem('agentmgr.recentOn') === '1', days: Number(localStorage.getItem('agentmgr.recentDays')) || 7 };
|
|
33
|
+
} catch {
|
|
34
|
+
return { on: false, days: 7 };
|
|
35
|
+
}
|
|
36
|
+
})();
|
|
37
|
+
const saveRecent = () => {
|
|
38
|
+
try {
|
|
39
|
+
localStorage.setItem('agentmgr.recentOn', recent.on ? '1' : '0');
|
|
40
|
+
localStorage.setItem('agentmgr.recentDays', String(recent.days));
|
|
41
|
+
} catch {}
|
|
42
|
+
};
|
|
26
43
|
|
|
27
44
|
// ------------------------------------------------------------------ helpers
|
|
28
45
|
|
|
@@ -556,6 +573,11 @@ const copyBtn = (value) => `<button class="link" data-copy="${esc(value)}" title
|
|
|
556
573
|
/** Agents waiting on you first, then running ones, then the rest. */
|
|
557
574
|
const rank = (a) => (a.runtime.attention ? 0 : a.runtime.running ? 1 : 2);
|
|
558
575
|
|
|
576
|
+
// Agents with no work and no input from you for a day sink below the active ones.
|
|
577
|
+
const ACTIVE_MS = 24 * 3600 * 1000;
|
|
578
|
+
const isActive = (a) =>
|
|
579
|
+
Boolean(a.runtime.attention || a.runtime.busy || a.status === 'preparing' || (a.activeAt && Date.now() - Date.parse(a.activeAt) < ACTIVE_MS));
|
|
580
|
+
|
|
559
581
|
// Projects the user collapsed in the tree (kept per browser).
|
|
560
582
|
const collapsed = new Set(
|
|
561
583
|
(() => {
|
|
@@ -578,12 +600,14 @@ const prioChip = (p, { always = false } = {}) =>
|
|
|
578
600
|
|
|
579
601
|
function agentItem(a) {
|
|
580
602
|
const st = statusOf(a);
|
|
581
|
-
|
|
603
|
+
const idle = !isActive(a);
|
|
604
|
+
return `<li data-id="${a.id}" class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''} ${idle ? 'idle' : ''}">
|
|
582
605
|
<div class="line1">${prioChip(a.priority ?? 2)}${a.results?.outcome === 'blocked' ? '<span class="outcome-mark" title="Blocked">⛔</span>' : a.results?.outcome === 'done' ? '<span class="outcome-mark" title="Done">✅</span>' : ''}<span class="name">${esc(a.name)}</span>${flags(a)}<span class="pill ${st}">${pillText(st)}</span></div>
|
|
583
606
|
<div class="intent">${a.kind === 'general' ? '<span class="tag">general</span> ' : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(a.intent || 'General session')}</div>
|
|
584
607
|
${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
|
|
585
608
|
<div class="meta">
|
|
586
609
|
<span class="mono">${a.id}</span>
|
|
610
|
+
${idle && a.activeAt ? `<span title="Idle: no work and no input from you for over a day">💤 ${ago(a.activeAt)}</span>` : ''}
|
|
587
611
|
${a.git?.branch ? `<span class="branch" title="${esc(a.git.branch)}">⎇ ${esc(a.git.branch)}</span>` : ''}
|
|
588
612
|
${a.prs.length ? `<span>${a.prs.length} PR</span>` : ''}
|
|
589
613
|
${a.bugs.length ? `<span>${a.bugs.length} bug</span>` : ''}
|
|
@@ -608,8 +632,15 @@ function projectGroups(list) {
|
|
|
608
632
|
}
|
|
609
633
|
const out = [...groups.values()];
|
|
610
634
|
for (const g of out) {
|
|
611
|
-
//
|
|
612
|
-
g.agents.sort(
|
|
635
|
+
// Active in the last day first; then highest priority, agents needing you, running ones, most recent.
|
|
636
|
+
g.agents.sort(
|
|
637
|
+
(a, b) =>
|
|
638
|
+
isActive(b) - isActive(a) ||
|
|
639
|
+
(a.priority ?? 2) - (b.priority ?? 2) ||
|
|
640
|
+
rank(a) - rank(b) ||
|
|
641
|
+
(b.activeAt ?? b.createdAt).localeCompare(a.activeAt ?? a.createdAt),
|
|
642
|
+
);
|
|
643
|
+
g.active = g.agents.filter(isActive).length;
|
|
613
644
|
g.top = Math.min(...g.agents.map((a) => a.priority ?? 2));
|
|
614
645
|
g.running = g.agents.filter((a) => a.runtime.running).length;
|
|
615
646
|
g.waiting = g.agents.filter((a) => a.runtime.attention).length;
|
|
@@ -623,7 +654,13 @@ function projectGroups(list) {
|
|
|
623
654
|
}
|
|
624
655
|
const special = (g) => (g.key === 'general' ? 1 : g.key === 'other' ? 2 : 0);
|
|
625
656
|
return out.sort(
|
|
626
|
-
(a, b) =>
|
|
657
|
+
(a, b) =>
|
|
658
|
+
special(a) - special(b) ||
|
|
659
|
+
(b.active > 0) - (a.active > 0) ||
|
|
660
|
+
a.top - b.top ||
|
|
661
|
+
b.waiting - a.waiting ||
|
|
662
|
+
b.running - a.running ||
|
|
663
|
+
a.label.localeCompare(b.label),
|
|
627
664
|
);
|
|
628
665
|
}
|
|
629
666
|
|
|
@@ -641,6 +678,13 @@ function renderList() {
|
|
|
641
678
|
.toLowerCase()
|
|
642
679
|
.includes(filterText),
|
|
643
680
|
);
|
|
681
|
+
// The open agent stays in the list even when it's older.
|
|
682
|
+
const since = recent.on ? Date.now() - recent.days * 86_400_000 : 0;
|
|
683
|
+
const shown = since ? list.filter((a) => a.id === selectedId || (Date.parse(a.createdAt) || 0) >= since) : list;
|
|
684
|
+
const hidden = list.length - shown.length;
|
|
685
|
+
$('#recent-bar').hidden = agents.size === 0;
|
|
686
|
+
$('#recent-on').checked = recent.on;
|
|
687
|
+
$('#recent-hidden').innerHTML = hidden ? `${hidden} older ${hidden === 1 ? 'agent' : 'agents'} hidden · <button class="link" id="recent-off">Show all</button>` : '';
|
|
644
688
|
$('#empty').hidden = agents.size > 0;
|
|
645
689
|
|
|
646
690
|
const all = [...agents.values()];
|
|
@@ -652,8 +696,14 @@ function renderList() {
|
|
|
652
696
|
<span class="spacer"></span><button class="link" id="import-btn" title="Continue Claude sessions you started in a terminal on this machine">Import</button><button class="link" id="flush-btn" title="Save session IDs, terminal scrollback and state to disk now (also automatic every 30s)">Save now</button>`
|
|
653
697
|
: '';
|
|
654
698
|
|
|
655
|
-
|
|
699
|
+
// Every agent a project has, also the ones a filter hides.
|
|
700
|
+
const totals = new Map();
|
|
701
|
+
for (const a of all) totals.set(a.projectInfo?.key ?? 'other', (totals.get(a.projectInfo?.key ?? 'other') ?? 0) + 1);
|
|
702
|
+
|
|
703
|
+
$('#agent-list').innerHTML = projectGroups(shown)
|
|
656
704
|
.map((g) => {
|
|
705
|
+
const total = totals.get(g.key) ?? g.agents.length;
|
|
706
|
+
const n = g.agents.length;
|
|
657
707
|
// While filtering, show every match; otherwise respect collapsed projects.
|
|
658
708
|
const open = filterText || !collapsed.has(g.key) || g.agents.some((a) => a.id === selectedId);
|
|
659
709
|
return `<li class="project ${open ? 'open' : ''}">
|
|
@@ -664,7 +714,8 @@ function renderList() {
|
|
|
664
714
|
<span class="pcounts">
|
|
665
715
|
${g.top <= 1 ? prioChip(g.top) : ''}
|
|
666
716
|
${g.waiting ? `<span class="pill attention" title="needs input">${g.waiting}</span>` : ''}
|
|
667
|
-
|
|
717
|
+
${g.running ? `<span class="prun" title="${g.running} running">● ${g.running}</span>` : ''}
|
|
718
|
+
<span class="pcount" title="${total} agent${total === 1 ? '' : 's'} in this project${n < total ? `, ${n} shown` : ''}">${n < total ? `${n} of ${total}` : total} agent${total === 1 ? '' : 's'}</span>
|
|
668
719
|
</span>
|
|
669
720
|
</button>
|
|
670
721
|
${open ? `<ul class="project-agents">${g.agents.map(agentItem).join('')}</ul>` : ''}
|
|
@@ -824,12 +875,13 @@ function flags(a) {
|
|
|
824
875
|
a.conflicts?.length ? `<span class="flag warn" title="Changes the same files as ${esc(a.conflicts.map((c) => c.agent.name).join(', '))}">⚔</span>` : '',
|
|
825
876
|
q ? `<span class="flag ask" title="${q} open question${q > 1 ? 's' : ''}">?${q > 1 ? q : ''}</span>` : '',
|
|
826
877
|
a.raceId ? `<span class="flag" title="Race attempt${a.variant?.label ? `: ${esc(a.variant.label)}` : ''}">🏁</span>` : '',
|
|
878
|
+
a.team ? `<span class="flag role" title="Team ${esc(teams.get(a.team.id)?.name ?? '')}: ${esc(a.team.role)}">👥 ${esc(a.team.role)}${a.team.n && teams.get(a.team.id)?.settings.critics > 1 ? ` ${a.team.n}` : ''}</span>` : '',
|
|
827
879
|
a.forkOf ? `<span class="flag" title="Fork of ${esc(a.forkOf.name)}">⑂</span>` : '',
|
|
828
880
|
].join('');
|
|
829
881
|
}
|
|
830
882
|
|
|
831
883
|
const TRIAGED = { now: 'sent (AI: urgent)', digest: 'held for the digest', skip: 'dropped by the AI judge' };
|
|
832
|
-
const NEED_ORDER = { prompt: 0, waiting: 1, plan: 1, question: 1, review: 2, stuck: 3, conflict: 4 };
|
|
884
|
+
const NEED_ORDER = { prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, review: 2, stuck: 3, conflict: 4 };
|
|
833
885
|
/** What an agent needs from the user. `both`: overlaps on both agents (detail), else only the one that should rebase (inbox). */
|
|
834
886
|
function needsFor(a, { both = false } = {}) {
|
|
835
887
|
const items = [];
|
|
@@ -842,13 +894,19 @@ function needsFor(a, { both = false } = {}) {
|
|
|
842
894
|
for (const g of a.conflicts ?? []) if (both || g.mergeFirst !== a.id) items.push({ a, kind: 'conflict', key: `conflict:${a.id}:${g.agent.id}`, g });
|
|
843
895
|
return items;
|
|
844
896
|
}
|
|
897
|
+
/** Teams waiting on you (approved, or stuck on a disagreement), shown on the lead or the implementer. */
|
|
898
|
+
const teamOwner = (t) => t.members.find((m) => m.role === 'lead' || m.role === 'implementer')?.id;
|
|
899
|
+
function teamNeeds() {
|
|
900
|
+
return [...teams.values()]
|
|
901
|
+
.filter((t) => ['ready', 'blocked'].includes(t.stage) && agents.has(teamOwner(t)))
|
|
902
|
+
.map((t) => ({ a: agents.get(teamOwner(t)), kind: 'team', key: `team:${t.id}`, t }));
|
|
903
|
+
}
|
|
845
904
|
function inboxItems() {
|
|
846
|
-
return [...agents.values()]
|
|
847
|
-
.flatMap((a) => needsFor(a))
|
|
905
|
+
return [...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds()]
|
|
848
906
|
.sort((x, y) => NEED_ORDER[x.kind] - NEED_ORDER[y.kind] || (x.a.priority ?? 2) - (y.a.priority ?? 2));
|
|
849
907
|
}
|
|
850
908
|
|
|
851
|
-
const NEED_LABEL = { prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
|
|
909
|
+
const NEED_LABEL = { team: 'Team', prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
|
|
852
910
|
/** Why the AI judge thinks so, as a small tag. */
|
|
853
911
|
const aiTag = (confidence, what = 'AI') => `<span class="tag ai" title="Judged by a model from the agent's activity; your reactions tune later calls">🤖 ${what}${confidence != null ? ` ${Math.round(confidence * 100)}%` : ''}</span>`;
|
|
854
912
|
function needCard(it, { inDetail = false } = {}) {
|
|
@@ -887,6 +945,11 @@ function needCard(it, { inDetail = false } = {}) {
|
|
|
887
945
|
${r.missing.length ? '<button class="small primary" data-ib="review-finish" title="Send the missing items to the agent">Send back to finish</button>' : ''}
|
|
888
946
|
<button class="small ${r.missing.length ? '' : 'primary'}" data-ib="review-accept">Looks good</button>
|
|
889
947
|
</div>`;
|
|
948
|
+
} else if (it.kind === 'team') {
|
|
949
|
+
const t = it.t;
|
|
950
|
+
body = `<p class="need-title">${t.stage === 'ready' ? `✓ The reviewer approved <b>${esc(t.name)}</b>${t.gaps ? ', with parts still missing: run a gap round?' : ''}` : `<b>${esc(t.name)}</b> needs a decision: ${esc(t.blocked?.reason ?? '')}`}</p>
|
|
951
|
+
<p class="hint">${esc(t.intent.split('\n')[0].slice(0, 140))}</p>
|
|
952
|
+
<div class="need-actions"><button class="small primary" data-ib="team-open" data-team="${t.id}">${t.stage !== 'ready' ? 'Decide' : t.gaps ? 'See the gaps' : 'Review and finalize'}</button></div>`;
|
|
890
953
|
} else if (it.kind === 'plan') {
|
|
891
954
|
const title = a.planReview.markdown?.match(/^#\s+(.+)$/m)?.[1] ?? 'Implementation plan';
|
|
892
955
|
body = `<p class="need-title">${esc(title)}</p>
|
|
@@ -1002,6 +1065,8 @@ async function onNeed(e) {
|
|
|
1002
1065
|
const q = key.split(':')[2];
|
|
1003
1066
|
return act(async () => upsert(await api('POST', `/agents/${id}/answer`, { question: q, text: el.dataset.text })), 'Answer sent');
|
|
1004
1067
|
}
|
|
1068
|
+
case 'team-open':
|
|
1069
|
+
return openTeam(el.dataset.team);
|
|
1005
1070
|
case 'plan-open':
|
|
1006
1071
|
return openPlan(id);
|
|
1007
1072
|
case 'plan-approve':
|
|
@@ -1075,6 +1140,8 @@ $('#detail-body').addEventListener('click', (e) => {
|
|
|
1075
1140
|
if (cp) forkAgent(selectedId, Number(cp.dataset.forkCp));
|
|
1076
1141
|
const race = e.target.closest('[data-race-open]');
|
|
1077
1142
|
if (race) openRace(race.dataset.raceOpen);
|
|
1143
|
+
const team = e.target.closest('[data-team-open]');
|
|
1144
|
+
if (team) openTeam(team.dataset.teamOpen);
|
|
1078
1145
|
});
|
|
1079
1146
|
|
|
1080
1147
|
function forkAgent(id, checkpoint) {
|
|
@@ -1090,6 +1157,209 @@ function forkAgent(id, checkpoint) {
|
|
|
1090
1157
|
}, 'Fork created');
|
|
1091
1158
|
}
|
|
1092
1159
|
|
|
1160
|
+
// ------------------------------------------------------------------ teams
|
|
1161
|
+
|
|
1162
|
+
const TEAM_STEPS = [['analyze', 'Analyze'], ['discuss', 'Discuss'], ['implement', 'Implement'], ['test', 'Test'], ['review', 'Review'], ['ready', 'Your call'], ['done', 'Done']];
|
|
1163
|
+
const STAGE_TEXT = { analyze: 'analyzing', discuss: 'discussing the analysis', implement: 'implementing', test: 'testing', review: 'reviewing', ready: 'approved, waiting for you', gaps: 'starting a round for the gaps', finalizing: 'opening the PR', done: 'done; ready for the next request', blocked: 'waiting for your decision', stopped: 'stopped' };
|
|
1164
|
+
const GOOD = new Set(['ready', 'agree', 'done', 'pass', 'approve']);
|
|
1165
|
+
const ROLE_NAME = (m) => `${m.role[0].toUpperCase()}${m.role.slice(1)}${m.n && teams.get(openTeamId)?.settings.critics > 1 ? ` ${m.n}` : ''}`;
|
|
1166
|
+
|
|
1167
|
+
function teamBanner(a) {
|
|
1168
|
+
if (!a.team) return '';
|
|
1169
|
+
const t = teams.get(a.team.id);
|
|
1170
|
+
const turn = t?.waiting?.some((w) => w.member === a.id && !w.verdict);
|
|
1171
|
+
return `<div class="bl-banner team">👥 <b>${esc(a.team.role)}</b> on team <b>${esc(t?.name ?? a.team.id)}</b>${t?.mode === 'lead' ? ` · quest ${t.quest ?? 1}` : ''}${t ? ` · ${esc(STAGE_TEXT[t.stage] ?? t.stage)}${t.loop > 1 ? ` · round ${t.loop}` : ''}` : ''}${turn ? ' · <b>its turn</b>' : ''}
|
|
1172
|
+
<span class="spacer"></span><button class="small primary" data-team-open="${a.team.id}">Open team</button></div>`;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function openTeam(id) {
|
|
1176
|
+
openTeamId = id;
|
|
1177
|
+
teamFile = null;
|
|
1178
|
+
renderTeam();
|
|
1179
|
+
if (!$('#team-dialog').open) $('#team-dialog').showModal();
|
|
1180
|
+
act(async () => teams.set(id, await api('GET', `/teams/${id}`)));
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function teamSteps(t) {
|
|
1184
|
+
const at = ['finalizing', 'gaps'].includes(t.stage) ? 'ready' : t.stage;
|
|
1185
|
+
const idx = TEAM_STEPS.findIndex(([k]) => k === at);
|
|
1186
|
+
const stopped = ['blocked', 'stopped'].includes(t.stage);
|
|
1187
|
+
// Where a blocked or stopped team got to: the last step anyone finished.
|
|
1188
|
+
const reached = stopped ? Math.max(0, ...t.history.map((h) => TEAM_STEPS.findIndex(([k]) => k === h.stage))) : idx;
|
|
1189
|
+
return TEAM_STEPS.filter(([k]) => k !== 'discuss' || t.settings.critics)
|
|
1190
|
+
.map(([k, label]) => {
|
|
1191
|
+
const i = TEAM_STEPS.findIndex(([x]) => x === k);
|
|
1192
|
+
const cls = stopped ? (i < reached ? 'done' : i === reached ? 'stop' : '') : i < idx || t.stage === 'done' ? 'done' : i === idx ? 'now' : '';
|
|
1193
|
+
return `<span class="team-step ${cls}">${label}</span>`;
|
|
1194
|
+
})
|
|
1195
|
+
.join('<span class="team-arrow">→</span>');
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function renderTeam() {
|
|
1199
|
+
const dlg = $('#team-dialog');
|
|
1200
|
+
if (!dlg.open && !openTeamId) return;
|
|
1201
|
+
const t = teams.get(openTeamId);
|
|
1202
|
+
if (!t) {
|
|
1203
|
+
$('#team-body').innerHTML = '<p class="hint">Loading…</p>';
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if ($('#team-body').contains(document.activeElement) && document.activeElement.tagName === 'TEXTAREA') return;
|
|
1207
|
+
const openQuests = new Set([...$('#team-body').querySelectorAll('details.team-quest[open] summary b')].map((b) => b.textContent));
|
|
1208
|
+
$('#team-title').textContent = `Team: ${t.name}`;
|
|
1209
|
+
$('#team-stop').hidden = ['done', 'stopped'].includes(t.stage);
|
|
1210
|
+
const memberOf = (id) => t.members.find((m) => m.id === id);
|
|
1211
|
+
const gate =
|
|
1212
|
+
t.stage === 'ready'
|
|
1213
|
+
? `<div class="team-gate">${t.gaps
|
|
1214
|
+
? `<b>✓ The reviewer approved the work so far, but parts of the request aren't built yet:</b>
|
|
1215
|
+
${t.gapList ? `<article class="md team-gaps">${md(t.gapList)}</article>` : ''}
|
|
1216
|
+
<div class="need-actions"><button class="small primary" data-team-act="fix-gaps" title="Another round, from analysis to review, for what is missing (and your note below)">Run a gap round</button></div>
|
|
1217
|
+
<p class="hint">Or take the work as it is:</p>`
|
|
1218
|
+
: '<b>✓ The reviewer approved it.</b> Look at the review and the changes, then:'}
|
|
1219
|
+
<div class="need-actions">${t.remote
|
|
1220
|
+
? `<button class="small${t.gaps ? '' : ' primary'}" data-team-act="pr">Open a pull request</button>`
|
|
1221
|
+
: `<button class="small${t.gaps ? '' : ' primary'}" data-team-act="merge" title="Fast-forward ${esc(t.folder ?? '')} to the team's branch">Merge into main</button>
|
|
1222
|
+
<button class="small" data-team-act="publish" title="The implementer creates a private GitHub repository, pushes and opens a pull request">Publish to GitHub</button>`}
|
|
1223
|
+
<button class="small" data-team-act="keep">Keep the branch${t.remote ? ', no PR' : ''}</button></div>
|
|
1224
|
+
<textarea id="team-note" rows="2" placeholder="${t.gaps ? 'A note for the gap round, or' : 'Or say'} what should change; the implementer fixes it and it goes through test and review again"></textarea>
|
|
1225
|
+
<div class="need-actions"><button class="small" data-team-act="send-back">Send back</button></div></div>`
|
|
1226
|
+
: t.stage === 'blocked'
|
|
1227
|
+
? `<div class="team-gate blocked"><b>${esc(t.blocked.reason)}</b>
|
|
1228
|
+
<textarea id="team-note" rows="2" placeholder="Optional note for the next round"></textarea>
|
|
1229
|
+
<div class="need-actions">
|
|
1230
|
+
${t.blocked.at !== 'member' ? `<button class="small primary" data-team-act="continue">${t.blocked.at === 'discuss' ? 'One more round' : 'One more fix round'}</button>` : ''}
|
|
1231
|
+
${t.blocked.at === 'discuss' ? '<button class="small" data-team-act="skip" title="Implement the latest analysis as it is">Implement the latest analysis</button>' : ''}
|
|
1232
|
+
${t.blocked.at === 'loops' ? '<button class="small" data-team-act="skip" title="Move to your final review without another round">Accept as is</button>' : ''}
|
|
1233
|
+
</div></div>`
|
|
1234
|
+
: t.stage === 'done' && t.gaps
|
|
1235
|
+
? `<div class="team-gate"><b>The last review lists parts of the request that aren't built yet:</b>
|
|
1236
|
+
${t.gapList ? `<article class="md team-gaps">${md(t.gapList)}</article>` : ''}
|
|
1237
|
+
<textarea id="team-note" rows="2" placeholder="Optional note for the gap round"></textarea>
|
|
1238
|
+
<div class="need-actions"><button class="small primary" data-team-act="fix-gaps">Run a gap round</button></div></div>`
|
|
1239
|
+
: '';
|
|
1240
|
+
const quest = t.mode === 'lead' || t.quest > 1 ? `<p class="hint">Quest ${t.quest ?? 1}${t.gapRound ? ` · gap round ${t.gapRound}` : ''}${t.quests?.length ? ` · ${t.quests.length} earlier` : ''}</p>` : '';
|
|
1241
|
+
const handoffs = (list) =>
|
|
1242
|
+
`<ul class="team-timeline">${list.slice().reverse().map((h) => {
|
|
1243
|
+
const m = memberOf(h.member);
|
|
1244
|
+
const who = t.mode === 'lead' ? `${h.role[0].toUpperCase()}${h.role.slice(1)}${h.n && t.settings.critics > 1 ? ` ${h.n}` : ''}` : m ? ROLE_NAME(m) : esc(h.role);
|
|
1245
|
+
return `<li><span><b>${who}</b> <span class="hint">${ago(h.at)}${h.loop > 1 && ['implement', 'test', 'review'].includes(h.stage) ? ` · r${h.loop}` : ''}</span></span>
|
|
1246
|
+
<span><span class="verdict-word ${GOOD.has(h.verdict) ? 'good' : 'bad'}">${esc(h.verdict)}</span></span>
|
|
1247
|
+
<span><a href="#" data-team-file="${esc(h.file)}">${esc(h.file.split('/').pop())}</a> <span class="summary">${esc(h.summary)}</span></span></li>`;
|
|
1248
|
+
}).join('')}</ul>`;
|
|
1249
|
+
const asking = t.mode === 'lead' && t.stage !== 'stopped'
|
|
1250
|
+
? `<form class="team-ask" id="team-ask"><b>New request</b> <span class="hint">The lead runs it through the team as the next quest. You can also just type it in the lead's terminal.</span>
|
|
1251
|
+
<textarea id="team-ask-text" rows="2" placeholder="Now add a --json flag to the output"></textarea>
|
|
1252
|
+
<div class="need-actions"><button class="small${['ready', 'done'].includes(t.stage) ? ' primary' : ''}">Give to the team</button></div></form>`
|
|
1253
|
+
: '';
|
|
1254
|
+
$('#team-body').innerHTML = `
|
|
1255
|
+
${quest}
|
|
1256
|
+
<p>${esc(t.request ?? t.intent)}</p>
|
|
1257
|
+
<div class="team-steps">${teamSteps(t)}</div>
|
|
1258
|
+
<p class="hint">${esc(STAGE_TEXT[t.stage] ?? t.stage)}${t.settings.critics ? ` · analysis round ${t.round}` : ''} · build round ${t.loop}${t.newProject ? ` · new project in <code>${esc(t.newProject)}</code>` : ''}${t.branch ? ` · branch <code>${esc(t.branch)}</code>` : ''}${t.outcome === 'merged' ? ' · merged into main' : ''}${t.costUsd ? ` · ${fmtCost(t.costUsd)}` : ''}
|
|
1259
|
+
${t.prs.map((p) => ` · <a href="${esc(p.url)}" target="_blank" rel="noopener">PR #${p.number ?? ''}</a>`).join('')}</p>
|
|
1260
|
+
${gate}
|
|
1261
|
+
<div class="race-table-wrap"><table class="race-table">
|
|
1262
|
+
<tr><th>Role</th><th>Agent</th><th>Status</th><th>Model</th><th>Cost</th></tr>
|
|
1263
|
+
${t.members.map((m) => {
|
|
1264
|
+
const a = agents.get(m.id);
|
|
1265
|
+
const st = a ? statusOf(a) : m.status;
|
|
1266
|
+
return `<tr class="${m.turn ? 'diffing' : ''}"><td><b>${ROLE_NAME(m)}</b>${m.turn ? ' <span class="tag open">its turn</span>' : ''}</td>
|
|
1267
|
+
<td>${a ? `<a href="#${m.id}" data-team-goto="${m.id}">${esc(m.name)}</a>` : '<span class="hint">deleted</span>'}</td>
|
|
1268
|
+
<td><span class="pill ${st}">${pillText(st)}</span></td><td>${m.model ? esc(m.model) : '<span class="hint">default</span>'}</td><td>${fmtCost(m.costUsd)}${t.mode === 'lead' ? ' <span class="hint">with sub-agents</span>' : ''}</td></tr>`;
|
|
1269
|
+
}).join('')}
|
|
1270
|
+
${t.subagents.map((r) => {
|
|
1271
|
+
const last = t.history.findLast((h) => h.role === r.role);
|
|
1272
|
+
return `<tr><td>${r.role[0].toUpperCase()}${r.role.slice(1)}</td><td><span class="hint">sub-agent of the lead</span></td>
|
|
1273
|
+
<td>${last ? `<span class="verdict-word ${GOOD.has(last.verdict) ? 'good' : 'bad'}">${esc(last.verdict)}</span> <span class="hint">${ago(last.at)}</span>` : '<span class="hint">—</span>'}</td>
|
|
1274
|
+
<td>${r.model ? esc(r.model) : '<span class="hint">lead\'s</span>'}</td><td></td></tr>`;
|
|
1275
|
+
}).join('')}
|
|
1276
|
+
</table></div>
|
|
1277
|
+
<h3>Handoffs</h3>
|
|
1278
|
+
${t.history.length ? handoffs(t.history) : '<p class="hint">Nothing handed off yet. The analyst is reading the code.</p>'}
|
|
1279
|
+
${asking}
|
|
1280
|
+
${t.quests?.length
|
|
1281
|
+
? `<h3>Earlier requests</h3>${t.quests.slice().reverse().map((q) => `<details class="team-quest"><summary><b>Quest ${q.n}</b> ${esc(q.request.split('\n')[0].slice(0, 100))} <span class="hint">· ${esc(q.outcome)}</span></summary>${q.history.length ? handoffs(q.history) : '<p class="hint">No handoffs.</p>'}</details>`).join('')}`
|
|
1282
|
+
: ''}
|
|
1283
|
+
${teamFile ? `<div class="need-head"><b>${esc(teamFile.name.split('/').pop())}</b><span class="spacer"></span><button class="small ghost" data-team-file-close>Close</button></div><article class="md team-file">${md(teamFile.markdown)}</article>` : ''}`;
|
|
1284
|
+
for (const d of $('#team-body').querySelectorAll('details.team-quest')) if (openQuests.has(d.querySelector('summary b').textContent)) d.open = true;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
$('#team-body').addEventListener('submit', (e) => {
|
|
1288
|
+
if (e.target.id !== 'team-ask') return;
|
|
1289
|
+
e.preventDefault();
|
|
1290
|
+
const text = $('#team-ask-text').value.trim();
|
|
1291
|
+
if (!text) return;
|
|
1292
|
+
const id = openTeamId;
|
|
1293
|
+
act(async () => {
|
|
1294
|
+
teams.set(id, await api('POST', `/teams/${id}/ask`, { text }));
|
|
1295
|
+
$('#team-ask-text').value = '';
|
|
1296
|
+
renderTeam();
|
|
1297
|
+
}, 'Given to the lead');
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
$('#team-close').onclick = () => {
|
|
1301
|
+
openTeamId = null;
|
|
1302
|
+
$('#team-dialog').close();
|
|
1303
|
+
};
|
|
1304
|
+
$('#team-stop').onclick = () => {
|
|
1305
|
+
if (!confirm("Stop the team? Its agents close (you can resume them); nothing is deleted.")) return;
|
|
1306
|
+
act(async () => teams.set(openTeamId, await api('POST', `/teams/${openTeamId}/stop`)), 'Team stopped');
|
|
1307
|
+
};
|
|
1308
|
+
$('#team-delete').onclick = () => {
|
|
1309
|
+
const id = openTeamId;
|
|
1310
|
+
const all = confirm('Also delete the team\'s agents and its worktree?\n\nOK: delete them too. Cancel: keep the agents, forget only the team.');
|
|
1311
|
+
act(async () => {
|
|
1312
|
+
await api('DELETE', `/teams/${id}${all ? '?agents=1' : ''}`);
|
|
1313
|
+
teams.delete(id);
|
|
1314
|
+
openTeamId = null;
|
|
1315
|
+
$('#team-dialog').close();
|
|
1316
|
+
render();
|
|
1317
|
+
});
|
|
1318
|
+
};
|
|
1319
|
+
$('#team-body').addEventListener('click', (e) => {
|
|
1320
|
+
const t = e.target;
|
|
1321
|
+
const go = t.closest('[data-team-goto]');
|
|
1322
|
+
if (go) {
|
|
1323
|
+
e.preventDefault();
|
|
1324
|
+
openTeamId = null;
|
|
1325
|
+
$('#team-dialog').close();
|
|
1326
|
+
showTab('agents');
|
|
1327
|
+
return select(go.dataset.teamGoto);
|
|
1328
|
+
}
|
|
1329
|
+
const file = t.closest('[data-team-file]');
|
|
1330
|
+
if (file) {
|
|
1331
|
+
e.preventDefault();
|
|
1332
|
+
return act(async () => {
|
|
1333
|
+
teamFile = await api('GET', `/teams/${openTeamId}/files/${encodeURIComponent(file.dataset.teamFile)}`);
|
|
1334
|
+
renderTeam();
|
|
1335
|
+
$('#team-body .team-file')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
if (t.closest('[data-team-file-close]')) {
|
|
1339
|
+
teamFile = null;
|
|
1340
|
+
return renderTeam();
|
|
1341
|
+
}
|
|
1342
|
+
const b = t.closest('[data-team-act]');
|
|
1343
|
+
if (!b) return;
|
|
1344
|
+
const note = $('#team-note')?.value.trim() || undefined;
|
|
1345
|
+
const id = openTeamId;
|
|
1346
|
+
const calls = {
|
|
1347
|
+
pr: () => api('POST', `/teams/${id}/finalize`, { mode: 'pr' }),
|
|
1348
|
+
publish: () => api('POST', `/teams/${id}/finalize`, { mode: 'publish' }),
|
|
1349
|
+
merge: () => api('POST', `/teams/${id}/finalize`, { mode: 'merge' }),
|
|
1350
|
+
keep: () => api('POST', `/teams/${id}/finalize`, { mode: 'keep' }),
|
|
1351
|
+
'send-back': () => api('POST', `/teams/${id}/send-back`, { note }),
|
|
1352
|
+
'fix-gaps': () => api('POST', `/teams/${id}/fix-gaps`, { note }),
|
|
1353
|
+
continue: () => api('POST', `/teams/${id}/resume`, { action: 'continue', note }),
|
|
1354
|
+
skip: () => api('POST', `/teams/${id}/resume`, { action: 'skip', note }),
|
|
1355
|
+
};
|
|
1356
|
+
const done = { pr: 'The implementer is opening the PR', publish: 'The implementer is publishing it to GitHub', merge: 'Merged into main', keep: 'Done; the branch is kept', 'send-back': 'Sent back to the implementer', 'fix-gaps': 'A gap round started', continue: 'Another round started', skip: 'Moving on' };
|
|
1357
|
+
act(async () => {
|
|
1358
|
+
teams.set(id, await calls[b.dataset.teamAct]());
|
|
1359
|
+
renderTeam();
|
|
1360
|
+
}, done[b.dataset.teamAct]);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1093
1363
|
// ------------------------------------------------------------------ races
|
|
1094
1364
|
|
|
1095
1365
|
function raceBanner(a) {
|
|
@@ -1104,6 +1374,7 @@ function openRace(id) {
|
|
|
1104
1374
|
openRaceId = id;
|
|
1105
1375
|
raceDiff = null;
|
|
1106
1376
|
renderRace();
|
|
1377
|
+
renderTeam();
|
|
1107
1378
|
if (!$('#race-dialog').open) $('#race-dialog').showModal();
|
|
1108
1379
|
act(async () => races.set(id, await api('GET', `/races/${id}`)));
|
|
1109
1380
|
}
|
|
@@ -1292,6 +1563,7 @@ function renderDetail() {
|
|
|
1292
1563
|
<button data-act="shell" ${a.workdir && a.status !== 'preparing' ? '' : 'disabled'} title="A separate terminal in this agent's folder for running commands by hand">Open shell</button>
|
|
1293
1564
|
<button data-act="fork" ${a.workdir && a.git?.branch ? '' : 'disabled'} title="A new agent with this one's code and conversation, in its own worktree, to try another approach">Fork</button>
|
|
1294
1565
|
<button data-act="refresh" title="Re-scan git, PRs and transcripts">Refresh</button>
|
|
1566
|
+
<button data-act="keep" class="${a.keep ? 'on' : ''}" title="${a.keep ? 'Kept: clean up never closes or deletes it. Click to let clean up handle it again' : 'Clean up never closes or deletes an agent marked Keep'}">${a.keep ? '📌 Kept' : 'Keep'}</button>
|
|
1295
1567
|
<button class="danger" data-act="delete">Delete</button>
|
|
1296
1568
|
</div>
|
|
1297
1569
|
</div>
|
|
@@ -1302,6 +1574,7 @@ function renderDetail() {
|
|
|
1302
1574
|
${it.status === 'review' ? `<span class="spacer"></span><button class="small primary" data-backlog-accept="${it.id}">Accept</button> <button class="small" data-backlog-reopen="${it.id}">Reopen</button>` : ''}</div>`;
|
|
1303
1575
|
})()}
|
|
1304
1576
|
${a.error ? `<div class="error-box">${esc(a.error)}</div>` : ''}
|
|
1577
|
+
${!running && a.closedBy === 'cleanup' ? `<p class="hint">Closed by clean up ${ago(a.closedAt)}, after a while with no activity. Resume it any time${a.keep ? '' : ', or mark it Keep'}.</p>` : ''}
|
|
1305
1578
|
${a.status === 'preparing'
|
|
1306
1579
|
? `<div class="progress-box">
|
|
1307
1580
|
<div>${esc(progressText(a.progress) || 'Preparing workspace…')}</div>
|
|
@@ -1319,6 +1592,7 @@ function renderDetail() {
|
|
|
1319
1592
|
: ''}
|
|
1320
1593
|
${a.runtime.attention ? `<div class="error-box">${esc(a.runtime.attention)}: the agent is waiting for an answer. Open its terminal to respond.</div>` : ''}
|
|
1321
1594
|
${raceBanner(a)}
|
|
1595
|
+
${teamBanner(a)}
|
|
1322
1596
|
${a.forkOf ? `<div class="bl-banner">⑂ Forked from <a href="#${a.forkOf.agentId}" data-goto="${a.forkOf.agentId}">${esc(a.forkOf.name)}</a>${a.forkOf.checkpoint ? ` at checkpoint ${a.forkOf.checkpoint}` : ''} ${ago(a.forkOf.at)}</div>` : ''}
|
|
1323
1597
|
${(() => {
|
|
1324
1598
|
const items = needsFor(a, { both: true });
|
|
@@ -1424,6 +1698,7 @@ function render() {
|
|
|
1424
1698
|
renderBacklog();
|
|
1425
1699
|
renderInbox();
|
|
1426
1700
|
renderRace();
|
|
1701
|
+
renderTeam();
|
|
1427
1702
|
renderDocDialog();
|
|
1428
1703
|
renderDetail();
|
|
1429
1704
|
term.syncOverlay();
|
|
@@ -1725,11 +2000,107 @@ const term = {
|
|
|
1725
2000
|
this.agentId = null;
|
|
1726
2001
|
$('#term-wrap').hidden = true;
|
|
1727
2002
|
$('#detail').classList.remove('with-term');
|
|
2003
|
+
if (document.body.classList.contains('term-max')) setExpanded(false);
|
|
2004
|
+
if (document.fullscreenElement) document.exitFullscreen();
|
|
1728
2005
|
},
|
|
1729
2006
|
};
|
|
1730
2007
|
|
|
1731
2008
|
$('#term-close').onclick = () => term.close();
|
|
1732
2009
|
$('#term-fit').onclick = () => term.refit();
|
|
2010
|
+
|
|
2011
|
+
// ------------------------------------------------------------------ resizing, expanding, full screen
|
|
2012
|
+
|
|
2013
|
+
const layoutPref = {
|
|
2014
|
+
get(key) {
|
|
2015
|
+
try {
|
|
2016
|
+
return localStorage.getItem(`agentmgr.${key}`);
|
|
2017
|
+
} catch {
|
|
2018
|
+
return null;
|
|
2019
|
+
}
|
|
2020
|
+
},
|
|
2021
|
+
set(key, value) {
|
|
2022
|
+
try {
|
|
2023
|
+
value == null ? localStorage.removeItem(`agentmgr.${key}`) : localStorage.setItem(`agentmgr.${key}`, value);
|
|
2024
|
+
} catch {}
|
|
2025
|
+
},
|
|
2026
|
+
};
|
|
2027
|
+
|
|
2028
|
+
function applyLayout() {
|
|
2029
|
+
const side = layoutPref.get('sideWidth');
|
|
2030
|
+
const brief = layoutPref.get('briefHeight');
|
|
2031
|
+
document.documentElement.style.setProperty('--side-w', side ? `${side}px` : '340px');
|
|
2032
|
+
if (brief) document.documentElement.style.setProperty('--brief-h', `${brief}px`);
|
|
2033
|
+
else document.documentElement.style.removeProperty('--brief-h');
|
|
2034
|
+
$('#detail').classList.toggle('brief-sized', Boolean(brief));
|
|
2035
|
+
}
|
|
2036
|
+
applyLayout();
|
|
2037
|
+
|
|
2038
|
+
/** Drag a handle; `move` gets the pointer position and returns the value to keep. */
|
|
2039
|
+
function dragHandle(el, axis, move, key) {
|
|
2040
|
+
el.addEventListener('pointerdown', (e) => {
|
|
2041
|
+
e.preventDefault();
|
|
2042
|
+
el.setPointerCapture(e.pointerId);
|
|
2043
|
+
el.classList.add('dragging');
|
|
2044
|
+
document.body.classList.add('resizing', axis);
|
|
2045
|
+
let value = null;
|
|
2046
|
+
const onMove = (ev) => {
|
|
2047
|
+
value = move(ev);
|
|
2048
|
+
applyLayout();
|
|
2049
|
+
};
|
|
2050
|
+
const onUp = () => {
|
|
2051
|
+
el.classList.remove('dragging');
|
|
2052
|
+
document.body.classList.remove('resizing', axis);
|
|
2053
|
+
el.removeEventListener('pointermove', onMove);
|
|
2054
|
+
el.removeEventListener('pointerup', onUp);
|
|
2055
|
+
if (value != null) layoutPref.set(key, Math.round(value));
|
|
2056
|
+
term.refit();
|
|
2057
|
+
};
|
|
2058
|
+
el.addEventListener('pointermove', onMove);
|
|
2059
|
+
el.addEventListener('pointerup', onUp);
|
|
2060
|
+
});
|
|
2061
|
+
el.addEventListener('dblclick', () => {
|
|
2062
|
+
layoutPref.set(key, null);
|
|
2063
|
+
applyLayout();
|
|
2064
|
+
requestAnimationFrame(() => term.refit());
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
dragHandle($('#split-side'), 'cols', (ev) => {
|
|
2069
|
+
const w = Math.min(Math.max(ev.clientX, 220), window.innerWidth * 0.6);
|
|
2070
|
+
layoutPref.set('sideWidth', Math.round(w));
|
|
2071
|
+
return w;
|
|
2072
|
+
}, 'sideWidth');
|
|
2073
|
+
|
|
2074
|
+
dragHandle($('#split-term'), 'rows', (ev) => {
|
|
2075
|
+
const top = $('#detail').getBoundingClientRect().top;
|
|
2076
|
+
const h = Math.min(Math.max(ev.clientY - top, 60), $('#detail').clientHeight - 160);
|
|
2077
|
+
layoutPref.set('briefHeight', Math.round(h));
|
|
2078
|
+
return h;
|
|
2079
|
+
}, 'briefHeight');
|
|
2080
|
+
|
|
2081
|
+
function setExpanded(on) {
|
|
2082
|
+
document.body.classList.toggle('term-max', on);
|
|
2083
|
+
$('#term-max').classList.toggle('on', on);
|
|
2084
|
+
$('#term-max').textContent = on ? '⤡ Restore' : '⤢ Expand';
|
|
2085
|
+
requestAnimationFrame(() => term.refit());
|
|
2086
|
+
}
|
|
2087
|
+
$('#term-max').onclick = () => setExpanded(!document.body.classList.contains('term-max'));
|
|
2088
|
+
$('#term-full').onclick = () => {
|
|
2089
|
+
if (document.fullscreenElement) document.exitFullscreen();
|
|
2090
|
+
else $('#term-wrap').requestFullscreen?.().catch(() => setExpanded(true));
|
|
2091
|
+
};
|
|
2092
|
+
document.addEventListener('fullscreenchange', () => {
|
|
2093
|
+
const on = document.fullscreenElement === $('#term-wrap');
|
|
2094
|
+
$('#term-full').classList.toggle('on', on);
|
|
2095
|
+
$('#term-full').textContent = on ? '⛶ Exit full screen' : '⛶ Full screen';
|
|
2096
|
+
requestAnimationFrame(() => term.refit());
|
|
2097
|
+
});
|
|
2098
|
+
document.addEventListener('keydown', (e) => {
|
|
2099
|
+
if (e.ctrlKey && e.shiftKey && e.code === 'KeyE' && !$('#term-wrap').hidden) {
|
|
2100
|
+
e.preventDefault();
|
|
2101
|
+
setExpanded(!document.body.classList.contains('term-max'));
|
|
2102
|
+
}
|
|
2103
|
+
}, true);
|
|
1733
2104
|
$('#term-resume').onclick = () => {
|
|
1734
2105
|
const id = term.agentId;
|
|
1735
2106
|
if (isShell(term.kind)) {
|
|
@@ -2560,6 +2931,9 @@ $('#detail-body').addEventListener('click', async (e) => {
|
|
|
2560
2931
|
case 'refresh':
|
|
2561
2932
|
act(async () => upsert(await api('POST', `/agents/${a.id}/refresh`)), 'Refreshed');
|
|
2562
2933
|
break;
|
|
2934
|
+
case 'keep':
|
|
2935
|
+
act(async () => upsert(await api('PATCH', `/agents/${a.id}`, { keep: !a.keep })), a.keep ? 'Clean up may close or delete it again' : 'Kept: clean up leaves it alone');
|
|
2936
|
+
break;
|
|
2563
2937
|
case 'delete': {
|
|
2564
2938
|
const cancelNote = a.status === 'preparing' ? '\n\nThis also cancels the workspace setup (clone / checkout) in progress.' : '';
|
|
2565
2939
|
if (!confirm(`Delete agent "${a.name}"? Its session IDs will be forgotten by AI Agent Manager.${cancelNote}`)) return;
|
|
@@ -2606,6 +2980,27 @@ $('#filter').addEventListener('input', (e) => {
|
|
|
2606
2980
|
renderList();
|
|
2607
2981
|
});
|
|
2608
2982
|
|
|
2983
|
+
$('#recent-days').value = recent.days;
|
|
2984
|
+
$('#recent-on').addEventListener('change', (e) => {
|
|
2985
|
+
recent.on = e.target.checked;
|
|
2986
|
+
saveRecent();
|
|
2987
|
+
renderList();
|
|
2988
|
+
});
|
|
2989
|
+
$('#recent-days').addEventListener('input', (e) => {
|
|
2990
|
+
const n = Math.floor(Number(e.target.value));
|
|
2991
|
+
if (!(n >= 1)) return;
|
|
2992
|
+
recent.days = Math.min(3650, n);
|
|
2993
|
+
recent.on = true;
|
|
2994
|
+
saveRecent();
|
|
2995
|
+
renderList();
|
|
2996
|
+
});
|
|
2997
|
+
$('#recent-bar').addEventListener('click', (e) => {
|
|
2998
|
+
if (e.target.id !== 'recent-off') return;
|
|
2999
|
+
recent.on = false;
|
|
3000
|
+
saveRecent();
|
|
3001
|
+
renderList();
|
|
3002
|
+
});
|
|
3003
|
+
|
|
2609
3004
|
// ------------------------------------------------------------------ new agents dialog
|
|
2610
3005
|
|
|
2611
3006
|
const dialog = $('#new-dialog');
|
|
@@ -2627,7 +3022,8 @@ function syncWhen() {
|
|
|
2627
3022
|
const when = dialog.querySelector('input[name="when"]:checked').value;
|
|
2628
3023
|
dialog.querySelector('.now-only').hidden = when !== 'now';
|
|
2629
3024
|
dialog.querySelector('.race-only').hidden = when !== 'race';
|
|
2630
|
-
dialog.querySelector('
|
|
3025
|
+
dialog.querySelector('.team-only').hidden = when !== 'team';
|
|
3026
|
+
dialog.querySelector('button[type=submit]').textContent = { backlog: 'Add to backlog', race: 'Start race', team: 'Start team' }[when] ?? 'Create agents';
|
|
2631
3027
|
}
|
|
2632
3028
|
for (const r of dialog.querySelectorAll('input[name="when"]')) r.addEventListener('change', syncWhen);
|
|
2633
3029
|
$('#new-btn').onclick = () => openNewDialog('now');
|
|
@@ -2639,6 +3035,44 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
2639
3035
|
.split('\n')
|
|
2640
3036
|
.map((s) => s.trim())
|
|
2641
3037
|
.filter(Boolean);
|
|
3038
|
+
if (f.get('when') === 'team') {
|
|
3039
|
+
// "analyst=opus reviewer=opus"
|
|
3040
|
+
const models = Object.fromEntries(
|
|
3041
|
+
String(f.get('roleModels') || '')
|
|
3042
|
+
.split(/[\s,]+/)
|
|
3043
|
+
.map((p) => p.split('='))
|
|
3044
|
+
.filter(([k, v]) => k && v),
|
|
3045
|
+
);
|
|
3046
|
+
try {
|
|
3047
|
+
const team = await api('POST', '/teams', {
|
|
3048
|
+
intent: intents.join('\n'),
|
|
3049
|
+
critics: Number(f.get('critics')),
|
|
3050
|
+
mode: f.get('teamMode') || undefined,
|
|
3051
|
+
testCommand: f.get('teamTest') || undefined,
|
|
3052
|
+
// Empty: the defaults (3 rounds each; gap rounds 0, so it asks first).
|
|
3053
|
+
...Object.fromEntries(['maxRounds', 'maxLoops', 'gapRounds'].filter((k) => String(f.get(k) ?? '') !== '').map((k) => [k, Number(f.get(k))])),
|
|
3054
|
+
models,
|
|
3055
|
+
repo: f.get('repo') || undefined,
|
|
3056
|
+
dir: f.get('dir') || undefined,
|
|
3057
|
+
base: f.get('base') || undefined,
|
|
3058
|
+
project: f.get('project') || undefined,
|
|
3059
|
+
permissionMode: f.get('permissionMode') || undefined,
|
|
3060
|
+
model: f.get('model') || undefined,
|
|
3061
|
+
engine: f.get('engine') || undefined,
|
|
3062
|
+
priority: Number(f.get('priority')),
|
|
3063
|
+
});
|
|
3064
|
+
teams.set(team.id, team);
|
|
3065
|
+
dialog.close();
|
|
3066
|
+
e.target.reset();
|
|
3067
|
+
syncWhen();
|
|
3068
|
+
openTeam(team.id);
|
|
3069
|
+
toast(team.mode === 'lead' ? 'Team started: one lead session with sub-agents' : `Team started: ${team.members.length} agents`);
|
|
3070
|
+
} catch (err) {
|
|
3071
|
+
$('#new-error').textContent = err.message;
|
|
3072
|
+
$('#new-error').hidden = false;
|
|
3073
|
+
}
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
2642
3076
|
if (f.get('when') === 'race') {
|
|
2643
3077
|
// "model", "model | approach" or "| approach", one attempt per line.
|
|
2644
3078
|
const variants = String(f.get('variants') || '')
|
|
@@ -2832,6 +3266,8 @@ function handleEvent(msg) {
|
|
|
2832
3266
|
if (msg.projects) projectsState = msg.projects;
|
|
2833
3267
|
races.clear();
|
|
2834
3268
|
for (const r of msg.races ?? []) races.set(r.id, r);
|
|
3269
|
+
teams.clear();
|
|
3270
|
+
for (const t of msg.teams ?? []) teams.set(t.id, t);
|
|
2835
3271
|
msg.agents.forEach(watchDocs);
|
|
2836
3272
|
if (selectedId && !agents.has(selectedId)) selectedId = null;
|
|
2837
3273
|
if (openTermOnLoad && selectedId) {
|
|
@@ -2850,6 +3286,10 @@ function handleEvent(msg) {
|
|
|
2850
3286
|
races.set(msg.race.id, msg.race);
|
|
2851
3287
|
} else if (msg.t === 'race-removed') {
|
|
2852
3288
|
races.delete(msg.id);
|
|
3289
|
+
} else if (msg.t === 'team') {
|
|
3290
|
+
teams.set(msg.team.id, msg.team);
|
|
3291
|
+
} else if (msg.t === 'team-removed') {
|
|
3292
|
+
teams.delete(msg.id);
|
|
2853
3293
|
} else if (msg.t === 'backlog') {
|
|
2854
3294
|
backlogState = msg.backlog;
|
|
2855
3295
|
} else if (msg.t === 'projects') {
|
|
@@ -2996,6 +3436,73 @@ $('#remote-content').addEventListener('click', async (e) => {
|
|
|
2996
3436
|
renderRemote();
|
|
2997
3437
|
});
|
|
2998
3438
|
|
|
3439
|
+
// ------------------------------------------------------------------ clean up old agents
|
|
3440
|
+
|
|
3441
|
+
const CLEANUP_TEXT = {
|
|
3442
|
+
'close-delete': 'Checked every few hours: idle agents are closed, and closed ones deleted, on their own.',
|
|
3443
|
+
close: 'Checked every few hours: idle agents are closed on their own. Deleting waits for you.',
|
|
3444
|
+
manual: 'Nothing happens on its own. Use the buttons below when you want to clean up.',
|
|
3445
|
+
off: 'Nothing happens on its own.',
|
|
3446
|
+
};
|
|
3447
|
+
|
|
3448
|
+
async function loadCleanup() {
|
|
3449
|
+
const c = await api('GET', '/cleanup');
|
|
3450
|
+
const f = $('#cleanup-form');
|
|
3451
|
+
f.strategy.value = c.strategy;
|
|
3452
|
+
f.closeAfterDays.value = c.closeAfterDays;
|
|
3453
|
+
f.deleteAfterDays.value = c.deleteAfterDays;
|
|
3454
|
+
const list = (rows, age) => `<ul>${rows.map((x) => `<li><a href="#${x.id}" data-cleanup-goto="${x.id}">${esc(x.name)}</a><span class="hint">${age(x)}${x.reason ? ` · ${esc(x.reason)}` : ''}</span></li>`).join('')}</ul>`;
|
|
3455
|
+
$('#cleanup-plan').innerHTML = `<p class="hint" id="cleanup-what">${CLEANUP_TEXT[c.strategy]}</p>
|
|
3456
|
+
${c.close.length ? `<h4>Due to close (${c.close.length})</h4>${list(c.close, (x) => `idle ${x.idleDays} days`)}` : ''}
|
|
3457
|
+
${c.delete.length ? `<h4>Due to delete (${c.delete.length})</h4>${list(c.delete, (x) => `closed ${x.closedDays} days`)}` : ''}
|
|
3458
|
+
${c.kept.length ? `<h4>Old, but left alone (${c.kept.length})</h4>${list(c.kept, (x) => (x.closedDays != null ? `closed ${x.closedDays} days` : `idle ${x.idleDays} days`))}` : ''}
|
|
3459
|
+
${!c.close.length && !c.delete.length && !c.kept.length ? '<p>Nothing is due.</p>' : ''}
|
|
3460
|
+
${c.lastRun ? `<p class="hint">Last run ${ago(c.lastRun.at)} (${c.lastRun.by === 'schedule' ? 'on its own' : 'by you'}): closed ${c.lastRun.closed.length}, deleted ${c.lastRun.deleted.length}${c.lastRun.failed?.length ? `, ${c.lastRun.failed.length} failed` : ''}.</p>` : ''}`;
|
|
3461
|
+
$('#cleanup-run-close').disabled = !c.close.length;
|
|
3462
|
+
$('#cleanup-run').disabled = !c.close.length && !c.delete.length;
|
|
3463
|
+
$('#cleanup-error').hidden = true;
|
|
3464
|
+
return c;
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
$('#cleanup-btn').onclick = async () => {
|
|
3468
|
+
$('#cleanup-dialog').showModal();
|
|
3469
|
+
await loadCleanup().catch((err) => toast(err.message));
|
|
3470
|
+
};
|
|
3471
|
+
$('#cleanup-close').onclick = () => $('#cleanup-dialog').close();
|
|
3472
|
+
$('#cleanup-form').strategy.addEventListener('change', (e) => {
|
|
3473
|
+
if ($('#cleanup-what')) $('#cleanup-what').textContent = `${CLEANUP_TEXT[e.target.value]} (Save to apply.)`;
|
|
3474
|
+
});
|
|
3475
|
+
$('#cleanup-form').addEventListener('submit', async (e) => {
|
|
3476
|
+
e.preventDefault();
|
|
3477
|
+
const f = e.target;
|
|
3478
|
+
try {
|
|
3479
|
+
await api('PUT', '/cleanup', { strategy: f.strategy.value, closeAfterDays: Number(f.closeAfterDays.value), deleteAfterDays: Number(f.deleteAfterDays.value) });
|
|
3480
|
+
toast('Clean up settings saved');
|
|
3481
|
+
await loadCleanup();
|
|
3482
|
+
} catch (err) {
|
|
3483
|
+
$('#cleanup-error').textContent = err.message;
|
|
3484
|
+
$('#cleanup-error').hidden = false;
|
|
3485
|
+
}
|
|
3486
|
+
});
|
|
3487
|
+
async function runCleanup(del) {
|
|
3488
|
+
const c = await loadCleanup();
|
|
3489
|
+
const n = c.close.length + (del ? c.delete.length : 0);
|
|
3490
|
+
if (!n) return;
|
|
3491
|
+
if (del && c.delete.length && !confirm(`Delete ${c.delete.length} agent${c.delete.length > 1 ? 's' : ''} and their worktrees${c.close.length ? `, and close ${c.close.length}` : ''}?\n\n${c.delete.map((x) => x.name).join('\n')}`)) return;
|
|
3492
|
+
const r = await api('POST', '/cleanup/run', { delete: del });
|
|
3493
|
+
toast(`Closed ${r.closed.length}, deleted ${r.deleted.length}${r.failed.length ? `; ${r.failed.length} failed: ${r.failed[0].reason}` : ''}`);
|
|
3494
|
+
await loadCleanup();
|
|
3495
|
+
}
|
|
3496
|
+
$('#cleanup-run-close').onclick = () => runCleanup(false).catch((err) => toast(err.message));
|
|
3497
|
+
$('#cleanup-run').onclick = () => runCleanup(true).catch((err) => toast(err.message));
|
|
3498
|
+
$('#cleanup-plan').addEventListener('click', (e) => {
|
|
3499
|
+
const go = e.target.closest('[data-cleanup-goto]');
|
|
3500
|
+
if (!go) return;
|
|
3501
|
+
e.preventDefault();
|
|
3502
|
+
$('#cleanup-dialog').close();
|
|
3503
|
+
select(go.dataset.cleanupGoto);
|
|
3504
|
+
});
|
|
3505
|
+
|
|
2999
3506
|
// ------------------------------------------------------------------ Slack alerts (from this computer)
|
|
3000
3507
|
|
|
3001
3508
|
async function loadAlerts() {
|