wisp-agents 0.15.0 → 0.16.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +28 -12
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +2 -2
  6. package/server/adapters/claude.js +31 -50
  7. package/server/adapters/codex.js +2 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +2 -2
  10. package/server/adapters/grok.js +2 -2
  11. package/server/adapters/index.js +16 -1
  12. package/server/adapters/opencode.js +47 -6
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +5 -3
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +70 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-wisp.js +17 -5
  25. package/server/openapi.js +33 -2
  26. package/server/paths.js +32 -0
  27. package/server/preferences.js +2 -6
  28. package/server/proc.js +54 -0
  29. package/server/recipes.js +2 -4
  30. package/server/remote.js +14 -14
  31. package/server/router.js +7 -3
  32. package/server/routes/auto.js +29 -0
  33. package/server/routes/automations.js +92 -0
  34. package/server/routes/internal.js +56 -0
  35. package/server/routes/machines.js +43 -0
  36. package/server/routes/meta.js +76 -0
  37. package/server/routes/proxy.js +47 -0
  38. package/server/routes/sessions.js +339 -0
  39. package/server/routes/settings.js +144 -0
  40. package/server/routes/ship.js +61 -0
  41. package/server/routes/uploads.js +19 -0
  42. package/server/runner.js +87 -36
  43. package/server/services/auto.js +67 -0
  44. package/server/services/feedback.js +53 -0
  45. package/server/services/install.js +69 -0
  46. package/server/services/launch.js +127 -0
  47. package/server/services/phone.js +65 -0
  48. package/server/services/ship.js +82 -0
  49. package/server/services/telemetry.js +34 -0
  50. package/server/services/uploads.js +59 -0
  51. package/server/services/webhooks.js +28 -0
  52. package/server/store.js +5 -3
  53. package/server/subagents.js +5 -4
  54. package/server/telemetry.js +1 -1
  55. package/server/terminal.js +2 -1
  56. package/server/triggers.js +4 -6
  57. package/server/update.js +4 -3
  58. package/server/util.js +42 -0
  59. package/server/welcome.js +1 -1
  60. package/web/css/auto.css +29 -0
  61. package/web/css/automations.css +71 -0
  62. package/web/css/base.css +83 -0
  63. package/web/css/blocks.css +461 -0
  64. package/web/css/composer.css +62 -0
  65. package/web/css/feed.css +208 -0
  66. package/web/css/launcher.css +204 -0
  67. package/web/css/layout.css +61 -0
  68. package/web/css/machines.css +125 -0
  69. package/web/css/modals.css +176 -0
  70. package/web/css/palette.css +79 -0
  71. package/web/css/responsive.css +35 -0
  72. package/web/css/ship.css +95 -0
  73. package/web/css/subagents.css +130 -0
  74. package/web/css/tokens.css +115 -0
  75. package/web/index.html +16 -1
  76. package/web/js/app.js +28 -5237
  77. package/web/js/core/api.js +25 -0
  78. package/web/js/core/bus.js +28 -0
  79. package/web/js/core/dom.js +7 -0
  80. package/web/js/core/format.js +27 -0
  81. package/web/js/core/state.js +43 -0
  82. package/web/js/features/actions.js +339 -0
  83. package/web/js/features/attachments.js +115 -0
  84. package/web/js/features/auto.js +54 -0
  85. package/web/js/features/automations.js +305 -0
  86. package/web/js/features/commands.js +123 -0
  87. package/web/js/features/compare.js +104 -0
  88. package/web/js/features/composer.js +311 -0
  89. package/web/js/features/feed.js +400 -0
  90. package/web/js/features/feedback.js +127 -0
  91. package/web/js/features/keys.js +83 -0
  92. package/web/js/features/launcher.js +559 -0
  93. package/web/js/features/live.js +91 -0
  94. package/web/js/features/machines.js +313 -0
  95. package/web/js/features/memory.js +154 -0
  96. package/web/js/features/notify.js +36 -0
  97. package/web/js/features/phone.js +124 -0
  98. package/web/js/features/places.js +38 -0
  99. package/web/js/features/projects.js +284 -0
  100. package/web/js/features/runner-picker.js +233 -0
  101. package/web/js/features/runners.js +161 -0
  102. package/web/js/features/scroll.js +75 -0
  103. package/web/js/features/settings.js +390 -0
  104. package/web/js/features/setup.js +94 -0
  105. package/web/js/features/ship.js +122 -0
  106. package/web/js/features/team.js +386 -0
  107. package/web/js/features/turns.js +401 -0
  108. package/web/js/features/update.js +48 -0
  109. package/web/js/features/usage.js +75 -0
  110. package/web/js/ui/kbd.js +46 -0
  111. package/web/js/ui/overlay.js +38 -0
  112. package/web/js/ui/popover.js +52 -0
  113. package/web/js/ui/toast.js +23 -0
  114. package/web/css/app.css +0 -1910
@@ -0,0 +1,94 @@
1
+ // No harness on this computer yet: the launcher offers to install opencode (its free models need
2
+ // no account), so a first run goes from `npx wisp-agents` to a working task in one click.
3
+ // Settings → Agents uses the same install.
4
+
5
+ import { esc } from '../md.js';
6
+ import { api } from '../core/api.js';
7
+ import * as bus from '../core/bus.js';
8
+ import { $, icon } from '../core/dom.js';
9
+ import { launch, S } from '../core/state.js';
10
+ import { toast, toastError } from '../ui/toast.js';
11
+ import { harnessLogo } from './runners.js';
12
+
13
+ // What wisp can install itself (server/services/install.js).
14
+ export const INSTALLABLE = new Set(['opencode']);
15
+
16
+ let installing = null; // the running install's id
17
+ let failed = ''; // the last install's error, shown in the strip until the next try
18
+
19
+ export const isInstalling = (id) => installing === id;
20
+
21
+ // Installs, then swaps in the new harness list. One at a time; the server waits for npm.
22
+ export async function installHarness(id) {
23
+ if (installing) return;
24
+ installing = id;
25
+ failed = '';
26
+ bus.emit('install');
27
+ try {
28
+ S.agents = await api('/agents/install', { method: 'POST', body: { id } });
29
+ $('#launcher')?.placesChanged?.();
30
+ toast(`<b>${esc(S.agents.find((a) => a.id === id)?.name || id)}</b> is ready: free models, no account needed. Type a task and press Enter.`, { timeout: 7000 });
31
+ $('#prompt')?.focus();
32
+ } catch (err) {
33
+ failed = err.message;
34
+ toastError(err);
35
+ } finally {
36
+ installing = null;
37
+ bus.emit('agents');
38
+ bus.emit('install');
39
+ }
40
+ }
41
+
42
+ // The launch would have nowhere to run: this computer is picked and has no harness. Unknown
43
+ // until the first detection is back (an empty list), so a slow start doesn't flash the strip.
44
+ export const needsSetup = () => launch.where.includes('') && S.agents.length > 0 && !S.agents.some((a) => a.available);
45
+
46
+ export function renderSetup() {
47
+ const box = $('#launcher .l-setup');
48
+ if (!box) return;
49
+ box.hidden = !needsSetup();
50
+ if (box.hidden) return;
51
+ const busy = isInstalling('opencode');
52
+ box.classList.toggle('busy', busy);
53
+ const line = busy
54
+ ? 'Installing opencode into ~/.wisp/tools… usually under a minute'
55
+ : failed
56
+ ? `Couldn’t install it: ${failed}`
57
+ : 'No coding agent here yet. opencode runs free models, no account needed.';
58
+ box.innerHTML = `
59
+ <span class="model-logo">${harnessLogo('opencode')}</span>
60
+ <span class="ls-text ${failed && !busy ? 'bad' : ''}" title="${esc(line)}">${esc(line)}</span>
61
+ <button class="icon-btn ls-recheck" title="Installed one yourself? Look again" ${busy ? 'disabled' : ''}>${icon('replay')}</button>
62
+ <button class="primary sm ls-install" ${busy ? 'disabled' : ''}>${busy ? '<span class="ls-spin"></span>Installing…' : failed ? 'Try again' : 'Install opencode'}</button>`;
63
+ $('.ls-install', box).onclick = () => {
64
+ installHarness('opencode');
65
+ };
66
+ $('.ls-recheck', box).onclick = async (e) => {
67
+ const btn = e.currentTarget;
68
+ btn.disabled = true;
69
+ btn.classList.add('busy');
70
+ try {
71
+ S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
72
+ $('#launcher')?.placesChanged?.();
73
+ bus.emit('agents');
74
+ if (needsSetup()) toast('Still no harness found. Install opencode here, or Claude Code, Codex, Cursor… yourself.');
75
+ } catch (err) {
76
+ toastError(err);
77
+ } finally {
78
+ btn.disabled = false;
79
+ btn.classList.remove('busy');
80
+ }
81
+ };
82
+ }
83
+
84
+ // Enter with nowhere to run: point at the strip instead of sending a launch that can only fail.
85
+ export function nudgeSetup() {
86
+ const box = $('#launcher .l-setup');
87
+ if (!box) return;
88
+ box.classList.remove('nudge');
89
+ void box.offsetWidth; // restart the animation
90
+ box.classList.add('nudge'); // focus stays in the box: a second Enter mustn't start an install
91
+ }
92
+
93
+ bus.on('agents', renderSetup);
94
+ bus.on('install', renderSetup);
@@ -0,0 +1,122 @@
1
+ // Ship it: worktree → commit → PR → checks → merge.
2
+
3
+ import { esc } from '../md.js';
4
+ import { api } from '../core/api.js';
5
+ import { $, $$, el, icon } from '../core/dom.js';
6
+ import { S } from '../core/state.js';
7
+ import { overlay } from '../ui/overlay.js';
8
+ import { toast, toastError } from '../ui/toast.js';
9
+
10
+ const prTone = (pr) =>
11
+ pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
12
+ const REVIEW = { APPROVED: 'Approved', CHANGES_REQUESTED: 'Changes requested', REVIEW_REQUIRED: 'Review required' };
13
+ function prLine(pr) {
14
+ const c = pr.checks || {};
15
+ const checks = c.fail ? `${c.fail} check${c.fail > 1 ? 's' : ''} failing` : c.pending ? `${c.pending} check${c.pending > 1 ? 's' : ''} running` : c.pass ? `${c.pass} check${c.pass > 1 ? 's' : ''} passed` : 'No checks';
16
+ const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
17
+ return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
18
+ }
19
+
20
+ // Ship it only makes sense on a finished worktree task with something to ship: uncommitted
21
+ // changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
22
+ export function shippable(s) {
23
+ if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED') return false;
24
+ const t = s.toShip;
25
+ return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
26
+ }
27
+ export const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
28
+
29
+ export const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
30
+
31
+ // The PR row under a card's bar; reads the live summary, so polled check results show up.
32
+ export function renderPrStrip(id) {
33
+ const n = S.nodes.get(id);
34
+ const node = n?.body && $('.pr-strip', n.body);
35
+ const s = S.sessions.get(id);
36
+ if (!node || !s) return;
37
+ const pr = s.pr;
38
+ $$('[data-a="ship"]', n.body).forEach((b) => {
39
+ b.hidden = !shippable(s);
40
+ $('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
41
+ });
42
+ node.hidden = !pr;
43
+ if (!pr) return;
44
+ const tone = prTone(pr);
45
+ node.className = `pr-strip pr-${tone}`;
46
+ node.innerHTML = `
47
+ <span class="pr-dot"></span>
48
+ <a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
49
+ <span class="pr-line">${esc(prLine(pr))}</span>
50
+ <span class="grow"></span>
51
+ ${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
52
+ <a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
53
+ ${pr.state === 'OPEN' ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge, then fast-forward your local ${esc(s.branch ? 'base branch' : 'checkout')}">${icon('check')}<span>Merge</span></button>` : ''}`;
54
+ node.onclick = (e) => {
55
+ const a = e.target.closest('[data-pr]')?.dataset.pr;
56
+ if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch(toastError);
57
+ if (a === 'merge') mergePr(id);
58
+ };
59
+ }
60
+
61
+ export async function shipDialog(id) {
62
+ const s = S.sessions.get(id);
63
+ if (!s) return;
64
+ if (s.state === 'running' || s.state === 'waiting') return toast('Still working: let it finish first.');
65
+ let info;
66
+ try {
67
+ info = await api(`/sessions/${id}/ship`);
68
+ } catch (err) {
69
+ return toastError(err);
70
+ }
71
+ const open = info.pr?.state === 'OPEN';
72
+ const nothing = !info.dirty && !info.ahead;
73
+ const node = el(`
74
+ <div class="modal ship-modal">
75
+ <header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
76
+ <p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
77
+ <span class="muted">${info.dirty ? `${info.dirty} uncommitted change${info.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${info.ahead} commit${info.ahead === 1 ? '' : 's'} on the branch</span></p>
78
+ ${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
79
+ ${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
80
+ <label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
81
+ ${!info.remote ? '<p class="hint bad">This repo has no <code>origin</code> remote, so there is nowhere to push.</p>' : nothing ? '<p class="hint">Nothing to ship yet: the branch has no changes.</p>' : ''}
82
+ <div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in the worktree.</span><button class="primary sm" data-a="go" ${nothing || !info.remote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : 'Commit, push & open PR'}</button></div>
83
+ </div>`);
84
+ const close = overlay(node);
85
+ $('.close', node).onclick = close;
86
+ const title = $('[name="title"]', node);
87
+ const body = $('[name="body"]', node);
88
+ if (title) title.value = info.title;
89
+ if (body) body.value = info.body;
90
+ const go = async () => {
91
+ const btn = $('[data-a="go"]', node);
92
+ btn.disabled = true;
93
+ btn.lastChild.textContent = 'Shipping…';
94
+ try {
95
+ const pr = await api(`/sessions/${id}/ship`, { method: 'POST', body: { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked } });
96
+ close();
97
+ toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
98
+ } catch (err) {
99
+ btn.disabled = false;
100
+ btn.lastChild.textContent = open ? 'Commit & push' : 'Commit, push & open PR';
101
+ toastError(err, { timeout: 10_000 });
102
+ }
103
+ };
104
+ $('[data-a="go"]', node).onclick = go;
105
+ requestAnimationFrame(() => (title || $('[data-a="go"]', node)).focus());
106
+ }
107
+
108
+ export async function mergePr(id) {
109
+ const s = S.sessions.get(id);
110
+ const pr = s?.pr;
111
+ if (!pr || pr.state !== 'OPEN') return;
112
+ const c = pr.checks || {};
113
+ const warn = c.fail ? `\n\n⚠ ${c.fail} check${c.fail > 1 ? 's are' : ' is'} failing.` : c.pending ? `\n\n⚠ ${c.pending} check${c.pending > 1 ? 's are' : ' is'} still running.` : '';
114
+ if (!confirm(`Squash-merge PR #${pr.number}${pr.draft ? ' (marks it ready first)' : ''}, then fast-forward your local base branch and remove the worktree?${warn}`)) return;
115
+ toast(`Merging PR #${pr.number}…`);
116
+ try {
117
+ const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
118
+ toast(`${icon('check')} Merged <b>PR #${r.pr.number}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
119
+ } catch (err) {
120
+ toastError(err, { timeout: 12_000 });
121
+ }
122
+ }
@@ -0,0 +1,386 @@
1
+ // Subagents: a team the lead can delegate to, any harness, model and thinking level per member, plus
2
+ // the user's "use it for" note. Picked in the launcher, saved as presets, shown on cards and turns.
3
+
4
+ import { fmtDuration, renderPresentation } from '../blocks.js';
5
+ import { esc } from '../md.js';
6
+ import { api } from '../core/api.js';
7
+ import { $, $$, el, icon } from '../core/dom.js';
8
+ import { STATUS_LABEL } from '../core/format.js';
9
+ import { S } from '../core/state.js';
10
+ import { popover } from '../ui/popover.js';
11
+ import { toast, toastError, tryApi } from '../ui/toast.js';
12
+ import { draftInto } from './composer.js';
13
+ import { scheduleRefetch } from './feed.js';
14
+ import { runnerPicker } from './runner-picker.js';
15
+ import { agentById, agentName, effortLabel, harnessesAt, harnessLogo, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
16
+ import { ctxFor } from './turns.js';
17
+
18
+ const MAX_TEAM = 6; // rows
19
+ const MAX_COPIES = 5; // parallel copies of one row
20
+ const MAX_INSTANCES = 12; // copies across the team
21
+ export const copiesOf = (m) => Math.max(1, m.count || 1);
22
+ export const teamSize = (team) => team.reduce((n, m) => n + copiesOf(m), 0);
23
+ export const canLead = (id) => agentById(id)?.delegates !== false;
24
+ export const memberLabel = (m) => {
25
+ const a = agentById(m.agent);
26
+ const model = (m.model && a?.models?.find((x) => x.id === m.model)?.label) || m.model || 'Default model';
27
+ return `${a?.name || agentName(m.agent)} · ${model}${m.effort ? ` · ${effortLabel(m.effort)}` : ''}`;
28
+ };
29
+ const slugName = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
30
+
31
+ // Same rule as the server: blank names become the harness id, repeats get -2, -3…
32
+ function uniqueNames(members) {
33
+ const taken = new Set();
34
+ return members.map((m) => {
35
+ const base = slugName(m.name) || m.agent;
36
+ let name = base;
37
+ for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
38
+ taken.add(name);
39
+ return { ...m, name };
40
+ });
41
+ }
42
+
43
+ // The lead's logo with its subagents' tucked behind it; they fan out on hover. A member that
44
+ // is working pulses; one the lead never used is dimmed.
45
+ export function teamStack(lead, team) {
46
+ const shown = team.slice(0, 4);
47
+ const tip = `${agentName(lead)} with ${team.map((m) => `${m.name}${m.busy ? ' (working)' : m.used === false ? ' (unused)' : ''}`).join(', ')}`;
48
+ return `<span class="team-stack" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">
49
+ <span class="ts-l">${harnessLogo(lead)}</span>
50
+ ${shown.map((m, i) => `<span class="ts-m${m.used === false ? ' idle' : ''}${m.busy ? ' busy' : ''}" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}
51
+ ${team.length > shown.length ? `<span class="ts-m ts-more" style="--i:${shown.length}">+${team.length - shown.length}</span>` : ''}
52
+ </span>`;
53
+ }
54
+
55
+ // Launcher button: a stack of the members' logos and the preset's name, or just "Subagents".
56
+ export function paintTeamBtn(btn, team, presetId, lead) {
57
+ const ok = canLead(lead);
58
+ const flat = team.flatMap((m) => Array(copiesOf(m)).fill(m));
59
+ const n = flat.length;
60
+ const p = (S.config.teams || []).find((x) => x.id === presetId);
61
+ btn.classList.toggle('on', team.length > 0 && ok);
62
+ btn.classList.toggle('off', team.length > 0 && !ok);
63
+ btn.innerHTML = team.length
64
+ ? `<span class="tb-stack">${flat.slice(0, 5).map((m, i) => `<span class="ts-m" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}${n > 5 ? `<span class="ts-m ts-more" style="--i:5">+${n - 5}</span>` : ''}</span><span class="tb-label">${esc(p?.name || `${n} subagent${n > 1 ? 's' : ''}`)}</span>`
65
+ : `${icon('agent')}<span class="tb-label">Subagents</span>`;
66
+ btn.title = !team.length
67
+ ? 'Subagents: give the lead a team on other harnesses to delegate to'
68
+ : ok
69
+ ? `Subagents the lead can delegate to:\n${team.map((m) => `• ${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n')}`
70
+ : `${agentName(lead)} can't delegate (wisp can't give it tools), so this launch runs without subagents`;
71
+ }
72
+
73
+ export function teamEditor(anchor, { lead, team, presetId, places }, onChange) {
74
+ const open = $('.team-pop');
75
+ if (open) return open.close();
76
+ let members = team.map((m) => ({ ...m }));
77
+ let preset = presetId;
78
+ const r = anchor.getBoundingClientRect();
79
+ const width = Math.min(540, innerWidth - 24);
80
+ const left = Math.max(12, Math.min(r.left, innerWidth - 12 - width));
81
+ const below = r.bottom + 440 < innerHeight;
82
+ const node = el(`
83
+ <div class="team-pop" role="dialog" aria-label="Subagents" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
84
+ <header class="tp-head">
85
+ <div class="tp-title"><b>Subagents</b><small>The lead hands tasks to these, each on its own harness. Tell it what each one is for.</small></div>
86
+ <button class="ghost xs tp-presets" aria-haspopup="menu">${icon('agent')}<span>Presets</span>${icon('chevron', 'chev')}</button>
87
+ </header>
88
+ <div class="tp-warn" hidden></div>
89
+ <div class="tp-list"></div>
90
+ <footer class="tp-foot"></footer>
91
+ </div>`);
92
+ const list = $('.tp-list', node);
93
+ const foot = $('.tp-foot', node);
94
+ const commit = () => onChange(uniqueNames(members), preset);
95
+ let typing;
96
+ const commitSoon = () => (clearTimeout(typing), (typing = setTimeout(commit, 300)));
97
+
98
+ const paintWarn = () => {
99
+ const w = $('.tp-warn', node);
100
+ w.hidden = canLead(lead);
101
+ w.innerHTML = `${icon('warn')}<span>${esc(agentName(lead))} can't delegate: wisp can't give it tools. Pick Claude Code, Codex or opencode as the lead to use these.</span>`;
102
+ };
103
+ const paintFoot = () => {
104
+ foot.innerHTML = `
105
+ <button class="ghost sm tp-add" ${members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}Add subagent</button>
106
+ <span class="tp-count" title="Agents in all, counting copies (max ${MAX_INSTANCES})">${teamSize(members)} agent${teamSize(members) === 1 ? '' : 's'}</span>
107
+ <span class="grow"></span>
108
+ ${members.length ? `<button class="link xs tp-clear">Clear</button><button class="ghost sm tp-save">${icon('check')}Save as preset</button>` : ''}`;
109
+ };
110
+ const paintList = () => {
111
+ const named = uniqueNames(members);
112
+ list.innerHTML = members.length
113
+ ? members
114
+ .map(
115
+ (m, i) => `<div class="tp-row" data-i="${i}">
116
+ <div class="tp-top">
117
+ <button class="runner-btn tp-runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
118
+ <span class="tp-copies${copiesOf(m) > 1 ? ' on' : ''}" title="Copies: identical agents that take tasks in parallel">
119
+ <button class="tp-dec" aria-label="One copy fewer" ${copiesOf(m) <= 1 ? 'disabled' : ''}>${icon('minus')}</button>
120
+ <b>×${copiesOf(m)}</b>
121
+ <button class="tp-inc" aria-label="One copy more" ${copiesOf(m) >= MAX_COPIES || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}</button>
122
+ </span>
123
+ <label class="field sm mono tp-name" title="What the lead calls it"><span>@</span><input value="${esc(m.name || '')}" placeholder="${esc(named[i].name)}" maxlength="24" spellcheck="false" autocomplete="off"></label>
124
+ <button class="icon-btn tp-del" title="Remove">${icon('x')}</button>
125
+ </div>
126
+ <label class="field sm tp-use"><span>Use for</span><input value="${esc(m.use || '')}" placeholder="e.g. image and UI work · research · writing tests" maxlength="300" spellcheck="false" autocomplete="off"></label>
127
+ </div>`,
128
+ )
129
+ .join('')
130
+ : `<div class="tp-empty">${icon('agent')}<p>No subagents yet. Add one per harness you want the lead to use, like Codex for tests or opencode for research.</p></div>`;
131
+ $$('.tp-row', list).forEach((row, i) => paintRunnerBtn($('.tp-runner', row), members[i].agent, members[i].model, members[i].effort));
132
+ paintFoot();
133
+ };
134
+ const add = () => {
135
+ if (members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES) return;
136
+ const used = new Set([lead, ...members.map((m) => m.agent)]);
137
+ const pool = places ? harnessesAt(places) : S.agents;
138
+ const a = pool.find((x) => x.available && !used.has(x.id)) || pool.find((x) => x.available && x.id !== lead) || pool.find((x) => x.available);
139
+ if (!a) return toast('No harness is installed.', { tone: 'bad' });
140
+ const model = savedModel(a.id);
141
+ members.push({ name: '', agent: a.id, model, effort: savedEffort(a.id, model), use: '', count: 1 });
142
+ paintList();
143
+ commit();
144
+ $(`.tp-row[data-i="${members.length - 1}"] .tp-use input`, list)?.focus();
145
+ };
146
+
147
+ // Save: name it; an existing preset's name updates that preset.
148
+ const askName = () => {
149
+ const cur = (S.config.teams || []).find((x) => x.id === preset);
150
+ foot.innerHTML = `<form class="tp-saveform"><input class="input sm" placeholder="Preset name, e.g. Review crew" value="${esc(cur?.name || '')}" maxlength="60"><button class="primary sm">Save</button><button type="button" class="link xs tp-cancel">Cancel</button></form>`;
151
+ const input = $('input', foot);
152
+ input.focus();
153
+ input.select();
154
+ $('.tp-cancel', foot).onclick = paintFoot;
155
+ $('form', foot).onsubmit = async (e) => {
156
+ e.preventDefault();
157
+ const name = input.value.trim();
158
+ if (!name) return input.focus();
159
+ const same = (S.config.teams || []).find((x) => x.name.toLowerCase() === name.toLowerCase());
160
+ try {
161
+ const p = await api(same ? `/teams/${same.id}` : '/teams', { method: same ? 'PATCH' : 'POST', body: { name, members: uniqueNames(members) } });
162
+ S.config.teams = [...(S.config.teams || []).filter((x) => x.id !== p.id), p];
163
+ preset = p.id;
164
+ commit();
165
+ paintFoot();
166
+ toast(`${same ? 'Updated' : 'Saved'} preset <b>${esc(p.name)}</b>`);
167
+ } catch (err) {
168
+ toastError(err);
169
+ }
170
+ };
171
+ };
172
+
173
+ const presetMenu = (btn) => {
174
+ const prev = $('.menu.tp-menu');
175
+ if (prev) return prev.close();
176
+ const teams = S.config.teams || [];
177
+ const b = btn.getBoundingClientRect();
178
+ const menu = el(`
179
+ <div class="menu tp-menu" role="menu" style="top:${b.bottom + 4}px;right:${innerWidth - b.right}px">
180
+ ${teams.length
181
+ ? teams
182
+ .map(
183
+ (t) => `<button role="menuitem" data-id="${esc(t.id)}" class="${t.id === preset ? 'on' : ''}">
184
+ <span class="tb-stack">${t.members.slice(0, 4).map((m, i) => `<span class="ts-m" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}</span>
185
+ <span class="mm-text"><b>${esc(t.name)}</b><small>${esc(t.members.map((m) => m.name).join(', '))}</small></span>
186
+ <span class="tp-mdel" data-del="${esc(t.id)}" title="Delete preset">${icon('trash')}</span>
187
+ </button>`,
188
+ )
189
+ .join('')
190
+ : `<div class="tp-mempty">No presets yet. Set up a team, then <b>Save as preset</b>.</div>`}
191
+ </div>`);
192
+ menu.onclick = async (ev) => {
193
+ const del = ev.target.closest('[data-del]');
194
+ if (del) {
195
+ ev.stopPropagation();
196
+ const t = teams.find((x) => x.id === del.dataset.del);
197
+ if (!(await tryApi(() => api(`/teams/${t.id}`, { method: 'DELETE' })))) return close();
198
+ S.config.teams = (S.config.teams || []).filter((x) => x.id !== t.id);
199
+ if (preset === t.id) (preset = ''), commit();
200
+ close();
201
+ return toast(`Deleted preset <b>${esc(t.name)}</b>`);
202
+ }
203
+ const item = ev.target.closest('button[data-id]');
204
+ if (!item) return;
205
+ const t = teams.find((x) => x.id === item.dataset.id);
206
+ members = t.members.map((m) => ({ ...m }));
207
+ preset = t.id;
208
+ close();
209
+ paintList();
210
+ commit();
211
+ };
212
+ const close = popover(menu, { anchor: btn, capture: true });
213
+ };
214
+
215
+ list.addEventListener('click', (e) => {
216
+ const row = e.target.closest('.tp-row');
217
+ if (!row) return;
218
+ const i = Number(row.dataset.i);
219
+ if (e.target.closest('.tp-del')) {
220
+ members.splice(i, 1);
221
+ paintList();
222
+ return commit();
223
+ }
224
+ const step = e.target.closest('.tp-inc') ? 1 : e.target.closest('.tp-dec') ? -1 : 0;
225
+ if (step) {
226
+ const m = members[i];
227
+ const next = copiesOf(m) + step;
228
+ if (next < 1 || next > MAX_COPIES || (step > 0 && teamSize(members) >= MAX_INSTANCES)) return;
229
+ m.count = next;
230
+ paintList();
231
+ $(`.tp-row[data-i="${i}"] ${step > 0 ? '.tp-inc' : '.tp-dec'}`, list)?.focus();
232
+ return commit();
233
+ }
234
+ const rb = e.target.closest('.tp-runner');
235
+ if (rb) {
236
+ const m = members[i];
237
+ runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort, places, auto: true }, (agent, model, effort) => {
238
+ Object.assign(m, { agent, model, effort });
239
+ paintList();
240
+ commit();
241
+ });
242
+ }
243
+ });
244
+ list.addEventListener('input', (e) => {
245
+ const row = e.target.closest('.tp-row');
246
+ if (!row) return;
247
+ const m = members[Number(row.dataset.i)];
248
+ if (e.target.closest('.tp-name')) m.name = e.target.value;
249
+ else m.use = e.target.value;
250
+ commitSoon();
251
+ });
252
+ foot.addEventListener('click', (e) => {
253
+ if (e.target.closest('.tp-add')) add();
254
+ if (e.target.closest('.tp-save')) askName();
255
+ if (e.target.closest('.tp-clear')) {
256
+ members = [];
257
+ preset = '';
258
+ paintList();
259
+ commit();
260
+ }
261
+ });
262
+ $('.tp-presets', node).onclick = (e) => presetMenu(e.currentTarget);
263
+
264
+ paintWarn();
265
+ paintList();
266
+ // The harness picker and preset menu float outside the popover; clicks there keep it open.
267
+ const close = popover(node, {
268
+ anchor,
269
+ keep: '.picker, .tp-menu',
270
+ onClose: () => {
271
+ clearTimeout(typing);
272
+ commit();
273
+ $('.menu.tp-menu')?.close();
274
+ anchor.classList.remove('open');
275
+ },
276
+ });
277
+ anchor.classList.add('open');
278
+ if (!members.length) $('.tp-add', node)?.focus();
279
+ }
280
+
281
+ // One delegation with the child session and turn it ran as (from the lead's childSessions).
282
+ function delegationsOf(s, t) {
283
+ return (t.delegations || []).map((d) => {
284
+ const child = s.childSessions?.[d.sessionId];
285
+ return { d, child, ct: child?.turns.find((x) => x.id === d.turnId) };
286
+ });
287
+ }
288
+ export const subKey = (s, t) => delegationsOf(s, t).map(({ ct }) => ct?.status || '?').join(',');
289
+ const subStatus = (ct) => {
290
+ if (!ct || ct.status === 'running') return { cls: 'waiting', label: 'Working' };
291
+ if (ct.status === 'stopped') return { cls: 'partial', label: 'Stopped' };
292
+ if (ct.status === 'error') return { cls: 'blocked', label: 'Failed' };
293
+ const st = ct.presentation?.blocks?.find((b) => b.type === 'hero')?.status || 'done';
294
+ return { cls: st, label: STATUS_LABEL[st] || 'Done' };
295
+ };
296
+
297
+ // While the lead works: one live line per subagent it's waiting on.
298
+ export function teamLiveHtml(s, t) {
299
+ const list = delegationsOf(s, t);
300
+ if (!list.length) return '';
301
+ return `<div class="team-live">${list
302
+ .map(({ d, ct }) => {
303
+ const running = !ct || ct.status === 'running';
304
+ const hero = ct?.presentation?.blocks?.find((b) => b.type === 'hero');
305
+ const last = ct?.activity?.findLast((a) => a.kind !== 'text')?.label;
306
+ const st = subStatus(ct);
307
+ return `<div class="tl-row ${running ? 'live' : `st-${st.cls}`}" data-sub="${esc(d.sessionId)}">
308
+ <span class="tl-logo">${harnessLogo(d.agent)}</span>
309
+ <b class="tl-name">@${esc(d.name)}</b>
310
+ <span class="tl-live">${esc(running ? last || 'Starting up' : hero?.title || st.label)}</span>
311
+ ${running ? `<span class="elapsed" data-start="${ct?.startedAt || d.startedAt}">${fmtDuration(Date.now() - (ct?.startedAt || d.startedAt))}</span>` : `<span class="pill st-${st.cls}"><i></i>${st.label}</span>`}
312
+ </div>`;
313
+ })
314
+ .join('')}</div>`;
315
+ }
316
+
317
+ // After the run: the team's reports, one card each, opening into the subagent's own presentation.
318
+ const subOpen = new Set(); // delegation ids the user unfolded
319
+ export function renderSubs(s, t) {
320
+ const list = delegationsOf(s, t);
321
+ if (!list.length) return null;
322
+ const names = [...new Set(list.map(({ d }) => `@${d.name}`))];
323
+ const node = el(`
324
+ <section class="subs">
325
+ <div class="subs-head">
326
+ ${teamStack(s.agent, list.map(({ d, ct }) => ({ name: d.name, agent: d.agent, busy: ct?.status === 'running' })))}
327
+ <b>${list.length} delegated task${list.length > 1 ? 's' : ''}</b>
328
+ <span class="subs-to">${esc(names.join(', '))}</span>
329
+ </div>
330
+ <div class="subs-list"></div>
331
+ </section>`);
332
+ const box = $('.subs-list', node);
333
+ for (const { d, child, ct } of list) {
334
+ const st = subStatus(ct);
335
+ const hero = ct?.presentation?.blocks?.find((b) => b.type === 'hero');
336
+ const files = ct?.changes?.length || 0;
337
+ const card = el(`
338
+ <article class="sub st-${st.cls} ${subOpen.has(d.id) ? 'open' : ''}">
339
+ <button class="sub-head" aria-expanded="${subOpen.has(d.id)}">
340
+ <span class="sub-logo">${harnessLogo(d.agent)}</span>
341
+ <span class="sub-main">
342
+ <span class="sub-top"><b>@${esc(d.name)}</b><span class="sub-model">${esc(memberLabel(d))}</span></span>
343
+ <span class="sub-title">${esc(hero?.title || (ct?.status === 'running' ? ct.activity?.findLast((a) => a.kind !== 'text')?.label || 'Working' : d.prompt))}</span>
344
+ </span>
345
+ <span class="sub-side">
346
+ ${files ? `<span class="sub-files">${files} file${files > 1 ? 's' : ''}</span>` : ''}
347
+ ${ct?.endedAt ? `<span class="sub-dur">${fmtDuration(ct.endedAt - ct.startedAt)}</span>` : ''}
348
+ <span class="pill st-${st.cls}"><i></i>${st.label}</span>
349
+ ${icon('chevron', 'chev')}
350
+ </span>
351
+ </button>
352
+ <div class="sub-body" ${subOpen.has(d.id) ? '' : 'hidden'}></div>
353
+ </article>`);
354
+ const body = $('.sub-body', card);
355
+ const fill = () => {
356
+ body.innerHTML = `<div class="sub-task"><span>Task from ${esc(agentName(s.agent))}</span><p>${esc(d.prompt)}</p></div>`;
357
+ if (ct?.presentation && child) {
358
+ // Its "next" and question blocks draft into the lead's composer: the lead stays in charge.
359
+ const ctx = { ...ctxFor(child, ct, false), reply: (text) => draftInto(s.id, `Ask @${d.name} to: ${text}`), draft: (text) => draftInto(s.id, `Ask @${d.name} to: ${text}`) };
360
+ body.append(renderPresentation(ct.presentation, ctx));
361
+ } else if (ct?.status === 'running') body.append(el(`<p class="muted sub-wait">Still working…</p>`));
362
+ };
363
+ if (subOpen.has(d.id)) fill();
364
+ $('.sub-head', card).onclick = () => {
365
+ const opening = body.hidden;
366
+ opening ? subOpen.add(d.id) : subOpen.delete(d.id);
367
+ card.classList.toggle('open', opening);
368
+ $('.sub-head', card).setAttribute('aria-expanded', String(opening));
369
+ if (opening) fill();
370
+ body.hidden = !opening;
371
+ };
372
+ box.append(card);
373
+ }
374
+ return node;
375
+ }
376
+
377
+ // A subagent's summary: its lead's card shows it, so update the live line or re-fetch the lead.
378
+ const childState = new Map();
379
+ export function onChildSummary(c) {
380
+ const row = $(`.tl-row[data-sub="${CSS.escape(c.id)}"] .tl-live`);
381
+ if (row && c.state === 'running' && c.live) row.textContent = c.live;
382
+ if (childState.get(c.id) !== c.state) {
383
+ childState.set(c.id, c.state);
384
+ scheduleRefetch(c.parentId);
385
+ }
386
+ }