wisp-agents 0.18.0 → 0.20.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.
@@ -1,15 +1,17 @@
1
- // Auto: what the router (server/router.js) learned about which harness and model work for you.
1
+ // Auto: what the router (server/router.js) learned about which harness, model and thinking level work for you.
2
2
 
3
3
  import { esc } from '../md.js';
4
4
  import { api } from '../core/api.js';
5
5
  import * as bus from '../core/bus.js';
6
- import { $, el, icon } from '../core/dom.js';
6
+ import { $, $$, el, icon } from '../core/dom.js';
7
7
  import { S } from '../core/state.js';
8
8
  import { present } from '../ui/overlay.js';
9
- import { toastError } from '../ui/toast.js';
9
+ import { toast, toastError } from '../ui/toast.js';
10
10
  import { AUTO_GOALS, harnessLogo } from './runners.js';
11
11
 
12
- const ARM_STATE = { ready: ['Ready', 'ok'], new: ['Trying out', 'warn'], untried: ['No data yet', ''], gone: ['Not installed', 'bad'] };
12
+ const ARM_STATE = { ready: ['Ready', 'ok'], new: ['Trying out', 'warn'], untried: ['No data yet', ''], cooling: ['At its limit', 'warn'], gone: ['Not installed', 'bad'] };
13
+ const TIER = { 1: 'small', 2: 'everyday', 3: 'frontier' };
14
+ const untilLabel = (t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
13
15
  export async function openRouter({ host } = {}) {
14
16
  const node = el(`<div class="modal router-modal">
15
17
  <header><h2>Auto</h2><button class="icon-btn close">${icon('x')}</button></header>
@@ -23,15 +25,22 @@ export async function openRouter({ host } = {}) {
23
25
  if (!r || !node.isConnected) return;
24
26
  const arms = r.arms.filter((a) => a.state !== 'gone' || a.n);
25
27
  $('.ro-body', node).innerHTML = `
26
- <p class="hint">For each task Auto estimates every harness · model’s chance of doing it well, from wisp-bench (${esc(r.priors.version)}) and then more and more from your own tasks: merged PRs, 👍 / 👎, rollbacks, follow-ups and how the run ended. It picks the best value for your goal (<b>${esc(AUTO_GOALS.find((g) => g.id === r.goal)?.label || '')}</b>), now and then trying an alternative so the estimates stay honest.</p>
27
- <h4>Harness · model</h4>
28
+ <p class="hint">For each task Auto estimates every harness · model · thinking level’s chance of doing it well, from wisp-bench (${esc(r.priors.version)}) and then more and more from your own tasks: merged PRs, 👍 / 👎, rollbacks, follow-ups and how the run ended. It picks the best value for your goal (<b>${esc(AUTO_GOALS.find((g) => g.id === r.goal)?.label || '')}</b>), which also sets how hard it thinks: Cheap low, Balanced default or medium, Best high. Best runs frontier models only (Opus, not Sonnet or Haiku). Now and then it tries an alternative so the estimates stay honest.${r.coldStart && r.coldStart.have < r.coldStart.needed ? ` Until it has ${r.coldStart.needed} of your own outcomes (${r.coldStart.have} so far), Balanced uses everyday and frontier models only.` : ''}</p>
29
+ <h4>Harness · model · thinking</h4>
28
30
  <table class="us-table ro-table"><thead><tr><th></th><th>Works</th><th>Your tasks</th><th>~Cost</th><th>~Time</th><th></th></tr></thead><tbody>${arms
29
31
  .map((a) => {
30
32
  const [state, tone] = ARM_STATE[a.state] || ARM_STATE.ready;
31
33
  const types = Object.entries(a.byType).map(([t, x]) => `${t} ${pctOf(x.chance)} (${x.n})`).join(' · ');
32
- return `<tr><td><span class="us-runner"><span class="model-logo">${harnessLogo(a.agent)}</span>${esc(a.label)}</span>${types ? `<small class="ro-types">${esc(types)}</small>` : ''}</td>${a.state === 'untried' ? '<td>—</td><td>—</td><td>—</td><td>—</td>' : `<td><b>${pctOf(a.chance)}</b></td><td>${a.n || '—'}</td><td>${a.usd ? `$${a.usd.toFixed(2)}` : '$0'}</td><td>${a.minutes}m</td>`}<td><span class="sx-state ${tone}"><i class="dot ${tone}"></i>${state}</span></td></tr>`;
34
+ return `<tr><td><span class="us-runner"><span class="model-logo">${harnessLogo(a.agent)}</span>${esc(a.label)}<i class="ro-tier t${a.tier}" title="${TIER[a.tier]} tier">${TIER[a.tier]}</i></span>${types ? `<small class="ro-types">${esc(types)}</small>` : ''}</td>${a.state === 'untried' ? '<td>—</td><td>—</td><td>—</td><td>—</td>' : `<td><b>${pctOf(a.chance)}</b></td><td>${a.n || '—'}</td><td>${a.usd ? `$${a.usd.toFixed(2)}` : '$0'}</td><td>${a.minutes}m</td>`}<td><span class="sx-state ${tone}" ${a.coolingUntil ? `title="${esc(`${a.coolingWhy || 'At its limit'} · back around ${untilLabel(a.coolingUntil)}`)}"` : ''}><i class="dot ${tone}"></i>${state}${a.coolingUntil ? ` until ${untilLabel(a.coolingUntil)}` : ''}</span></td></tr>`;
33
35
  })
34
36
  .join('')}</tbody></table>
37
+ <h4>Tests per project</h4>
38
+ <p class="hint">What wisp runs to check an Auto task. Leave it empty to use what wisp found, or type <code>off</code> to skip the check in that project.</p>
39
+ ${r.projects?.length
40
+ ? `<div class="ro-tests">${r.projects
41
+ .map((p) => `<label class="ro-test"><span class="us-proj c-${esc(p.color || 'slate')}"><i class="p-dot"></i>${esc(p.name)}</span><input class="input mono" data-id="${esc(p.id)}" value="${esc(p.testCommand)}" placeholder="${esc(p.detected?.command || (p.detected?.skip ? `none: ${p.detected.skip}` : 'none found'))}" spellcheck="false"></label>`)
42
+ .join('')}</div>`
43
+ : '<p class="mem-empty">Add a project to set its test command.</p>'}
35
44
  <h4>Recent picks</h4>
36
45
  ${r.recent.length
37
46
  ? `<ul class="ro-recent">${r.recent
@@ -42,6 +51,28 @@ export async function openRouter({ host } = {}) {
42
51
  .join('')}</ul>`
43
52
  : '<p class="mem-empty">Nothing yet. Launch with Auto and its picks show up here.</p>'}
44
53
  <div class="ro-foot"><button class="ghost sm danger" id="roReset">Forget what Auto learned</button><span class="hint">The wisp-bench starting point stays.</span></div>`;
54
+ for (const input of $$('.ro-test input', node)) {
55
+ let saved = input.value;
56
+ const save = async () => {
57
+ const v = input.value.trim();
58
+ if (v === saved) return;
59
+ const prev = saved;
60
+ saved = v;
61
+ try {
62
+ await api(`/projects/${input.dataset.id}`, { method: 'PATCH', body: { testCommand: v } });
63
+ toast(v ? `Auto will check with <code>${esc(v)}</code>` : 'Back to the test command wisp finds');
64
+ } catch (err) {
65
+ saved = input.value = prev;
66
+ toastError(err);
67
+ }
68
+ };
69
+ input.onblur = () => {
70
+ save();
71
+ };
72
+ input.onkeydown = (e) => {
73
+ if (e.key === 'Enter') input.blur();
74
+ };
75
+ }
45
76
  $('#roReset', node).onclick = async () => {
46
77
  if (!confirm('Forget what Auto learned from your tasks?')) return;
47
78
  await api('/router/reset', { method: 'POST', body: {} }).catch(toastError);
@@ -5,7 +5,7 @@ import { mentionHtml } from '../mention-text.js';
5
5
  import { api } from '../core/api.js';
6
6
  import { $, el, icon } from '../core/dom.js';
7
7
  import { S } from '../core/state.js';
8
- import { popover } from '../ui/popover.js';
8
+ import { clampToViewport, popover } from '../ui/popover.js';
9
9
  import { toast, toastError } from '../ui/toast.js';
10
10
  import { toggleDone } from './actions.js';
11
11
  import { acceptFiles, attachTray, autosize } from './attachments.js';
@@ -226,9 +226,7 @@ function queuedMenu(e, cancel) {
226
226
  if (a === 'cancel') cancel();
227
227
  };
228
228
  const close = popover(menu);
229
- const r = menu.getBoundingClientRect();
230
- if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
231
- if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
229
+ clampToViewport(menu, e.clientX, e.clientY);
232
230
  }
233
231
 
234
232
  // Follow-ups sent while an agent works interrupt it, unless the user flipped this on.
@@ -208,6 +208,7 @@ function renderHead(n, s) {
208
208
  <span class="it-side">
209
209
  ${pill}
210
210
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
211
+ ${autoPill(s)}
211
212
  ${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
212
213
  ${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort, s.route?.switched ? null : s.route)}
213
214
  ${s.machine ? machinePill(s.machine) : ''}
@@ -218,6 +219,28 @@ function renderHead(n, s) {
218
219
  </span>`;
219
220
  }
220
221
 
222
+ // What Auto found after the run: its own test check, the best-of-2 pick, a hand-over.
223
+ function autoPill(s) {
224
+ const a = s.auto;
225
+ if (!a || s.pending) return '';
226
+ const out = [];
227
+ if (a.pick?.winner) out.push(`<span class="pill auto-pick" title="${esc(`wisp's pick of the two runs: ${a.pick.why}`)}">${icon('spark')}Pick</span>`);
228
+ if (a.escalatedTo) {
229
+ const to = S.sessions.get(a.escalatedTo);
230
+ const toName = to ? `${agentName(to.agent)}${to.model ? ` · ${to.model}` : ''}` : 'another pick';
231
+ const why = a.reason === 'limit' ? `${agentName(s.agent)} was at its usage limit, so wisp moved the task to ${toName}` : a.reason === 'error' ? `The run failed, so wisp handed the task to ${toName}` : `Tests still failed, so wisp handed the task to ${toName}`;
232
+ out.push(`<span class="pill auto-handed" title="${esc(why)}">${a.reason === 'limit' ? 'Moved on' : 'Handed on'}</span>`);
233
+ }
234
+ const c = a.check;
235
+ if (c && !a.busy && c.verdict !== 'skip') {
236
+ const n = c.newFailures || c.failed;
237
+ const tip = c.verdict === 'pass' ? `wisp ran ${c.command}: no new failures${c.note ? ` (${c.note})` : ''}` : c.verdict === 'fail' ? `wisp ran ${c.command}: ${n} test${n === 1 ? '' : 's'} that passed before now fail${a.repairs ? `, after ${a.repairs} repair round${a.repairs > 1 ? 's' : ''}` : ''}` : `wisp ran ${c.command}: ${c.note || "couldn't tell"}`;
238
+ const label = c.verdict === 'pass' ? 'Tests' : c.verdict === 'fail' ? `${n} failing` : 'Tests ?';
239
+ out.push(`<span class="pill auto-ck ck-${c.verdict}" title="${esc(tip)}">${icon(c.verdict === 'pass' ? 'check' : c.verdict === 'fail' ? 'x' : 'info')}${label}</span>`);
240
+ }
241
+ return out.join('');
242
+ }
243
+
221
244
  // One run inside a compare task: the harness and model stand in for the title the task shows.
222
245
  function renderRunHead(n, s, { open, live, pill }) {
223
246
  const a = S.agents.find((x) => x.id === s.agent);
@@ -233,6 +256,7 @@ function renderRunHead(n, s, { open, live, pill }) {
233
256
  ${runsOf(s).some((r) => (r.machine?.id || '') !== (s.machine?.id || '')) ? `${s.machine ? machinePill(s.machine) : `<span class="q-machine" title="Runs on this computer">${icon('laptop')}<span>This computer</span></span>`}<span class="q-proj">${esc(s.project)}</span>` : ''}
234
257
  ${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
235
258
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
259
+ ${autoPill(s)}
236
260
  ${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
237
261
  <span class="q-time">${ago(s.updatedAt)}</span>
238
262
  ${icon('chevron', 'chev')}
@@ -257,7 +281,13 @@ function taskItem(t) {
257
281
  g.lead = t.id;
258
282
  const finished = t.runs.filter((r) => r.state === 'review' || r.state === 'done').length;
259
283
  const places = new Set(t.runs.map((r) => r.machine?.id || '')).size;
260
- const status = t.state === 'waiting' ? 'A run is asking you' : t.state === 'running' ? `${finished} of ${t.runs.length} finished${places > 1 ? ` · on ${places} machines` : ''}` : t.state === 'done' ? 'Done' : places > 1 ? `All finished on ${places} machines` : 'All finished · pick one to keep';
284
+ // Auto's groups say what Auto did: which run it picked, or who it handed the task to.
285
+ const picked = t.runs.find((r) => r.auto?.pick?.winner);
286
+ const handed = t.runs.find((r) => r.auto?.escalatedFrom);
287
+ const runName = (r) => `${agentName(r.agent)}${r.model ? ` · ${r.model}` : ''}`;
288
+ const how = handed?.auto?.reason === 'limit' ? 'The first harness was at its usage limit, so wisp moved it to' : handed?.auto?.reason === 'error' ? 'The first run failed, so wisp handed it to' : 'Tests failed, so wisp handed it to';
289
+ const autoLine = picked ? `wisp's pick: ${picked.auto.pick.why}` : handed ? `${how} ${runName(handed)}${handed.auto?.check?.verdict === 'pass' ? ' · tests pass now' : handed.auto?.check?.verdict === 'fail' ? ' · still failing' : ''}` : '';
290
+ const status = t.state === 'waiting' ? 'A run is asking you' : t.state === 'running' ? `${finished} of ${t.runs.length} finished${places > 1 ? ` · on ${places} machines` : ''}` : t.state === 'done' ? 'Done' : autoLine || (places > 1 ? `All finished on ${places} machines` : 'All finished · pick one to keep');
261
291
  g.card.className = `task-item c-${t.color} st-${t.state} ${t.unread ? 'unread' : ''}`;
262
292
  g.head.innerHTML = `
263
293
  <span class="q-dot ${t.state === 'running' ? 'live' : ''} ${t.state === 'waiting' ? 'waiting' : ''}"></span>
@@ -7,7 +7,7 @@ import { $ } from '../core/dom.js';
7
7
  import { S, saveOpen, setAgents } from '../core/state.js';
8
8
  import { toast } from '../ui/toast.js';
9
9
  import { paintAutoBtn } from './automations.js';
10
- import { collapse, renderFeed, scheduleRefetch, sectionOf } from './feed.js';
10
+ import { renderFeed, scheduleRefetch, sectionOf } from './feed.js';
11
11
  import { dropPending, renderLaunchChips } from './launcher.js';
12
12
  import { loadMachines, setMachines } from './machines.js';
13
13
  import { notifyFinished } from './notify.js';
@@ -34,12 +34,7 @@ export function connect() {
34
34
  // list and focus goes to the next one instead.
35
35
  const moved = prev && was !== sectionOf(s);
36
36
  const follow = moved && s.state !== 'done' && !s.archived && watching(s.id);
37
- keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id), () => {
38
- // A card left open while it worked shouldn't land in "Needs you" expanded —
39
- // fold it unless the user is actually watching it.
40
- if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(watching(s.id) && document.hasFocus())) collapse(s.id);
41
- renderFeed();
42
- });
37
+ keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id), renderFeed);
43
38
  if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
44
39
  if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state, prev?.shipping]) !== JSON.stringify([s.pr, s.toShip, s.state, s.shipping])) renderPrStrip(s.id);
45
40
  });
@@ -6,7 +6,7 @@ import * as bus from '../core/bus.js';
6
6
  import { $, $$, el, icon } from '../core/dom.js';
7
7
  import { launch, S, save, saveLaunch } from '../core/state.js';
8
8
  import { present } from '../ui/overlay.js';
9
- import { popover } from '../ui/popover.js';
9
+ import { clampToViewport, popover } from '../ui/popover.js';
10
10
  import { toast, toastError, tryApi } from '../ui/toast.js';
11
11
  import { clones, isRepo, repoLabel, repoName, startClone } from './clones.js';
12
12
  import { feedToTop, renderFeed } from './feed.js';
@@ -146,10 +146,7 @@ function projectMenu(id, e) {
146
146
  }
147
147
  };
148
148
  const close = popover(menu);
149
- // Keep the menu on-screen when right-clicking near the window edge.
150
- const r = menu.getBoundingClientRect();
151
- if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
152
- if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
149
+ clampToViewport(menu, e.clientX, e.clientY);
153
150
  }
154
151
 
155
152
  const COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
@@ -17,14 +17,15 @@ export const DEFAULT_MODE = MODES[2];
17
17
 
18
18
  export const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'auto' ? 'Auto' : id === 'wisp' ? 'wisp' : id);
19
19
 
20
- // Auto: not a CLI but wisp's router (server/router.js), which picks harness and model per task.
21
- // Its "models" are the goal it optimises for; there are no thinking levels to pick.
20
+ // Auto: not a CLI but wisp's router (server/router.js), which picks harness, model and thinking
21
+ // level per task. Its "models" are the goal it optimises for, which also sets the level band, so
22
+ // there are no thinking levels to pick here.
22
23
  export const AUTO_GOALS = [
23
- { id: 'balanced', label: 'Balanced', hint: 'Best result per dollar' },
24
- { id: 'cheap', label: 'Cheap', hint: 'Lowest cost that still gets it done' },
25
- { id: 'best', label: 'Best', hint: 'Best result; cost only breaks ties' },
24
+ { id: 'balanced', label: 'Balanced', hint: 'Best result per dollar · default or medium thinking' },
25
+ { id: 'cheap', label: 'Cheap', hint: 'Lowest cost that still gets it done · low thinking' },
26
+ { id: 'best', label: 'Best', hint: 'Best result; cost only breaks ties · high thinking' },
26
27
  ];
27
- export const AUTO = { id: 'auto', name: 'Auto', available: true, delegates: true, models: AUTO_GOALS, efforts: [], hint: 'wisp picks the harness and model from what worked before' };
28
+ export const AUTO = { id: 'auto', name: 'Auto', available: true, delegates: true, models: AUTO_GOALS, efforts: [], hint: 'wisp picks the harness, model and thinking level from what worked before' };
28
29
 
29
30
  // Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
30
31
  export const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
@@ -78,7 +78,7 @@ export function openSettings(opts) {
78
78
  </div>
79
79
  <div class="sx-group">
80
80
  ${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
81
- ${row('Fix PR conflicts', 'When a shipped PR conflicts with its base, the task merges the base in, resolves it and pushes', '<button class="switch" id="fixConflicts" role="switch"></button>')}
81
+ ${row('Auto-fix PR conflicts', 'Without waiting for a click on Fix conflicts: when a shipped PR conflicts with its base, the task merges the base in, resolves it and pushes', '<button class="switch" id="fixConflicts" role="switch"></button>')}
82
82
  </div>
83
83
  </section>
84
84
  <section class="sx-pane" data-pane="notifications">
@@ -101,6 +101,13 @@ export function openSettings(opts) {
101
101
  ${row('Try new models', 'A new model or harness gets a few tasks that aren’t marked important before Auto relies on it', '<button class="switch" id="roExplore" role="switch"></button>')}
102
102
  ${row('Harnesses it may use', 'Leave all off to let Auto use every one you have', '<span class="ro-allowed"></span>')}
103
103
  </div>
104
+ <div class="sx-group">
105
+ ${row('Check with the tests', 'When the agent says it’s done, wisp runs the project’s tests itself. Only failures that are new count', '<button class="switch" id="roCheck" role="switch"></button>')}
106
+ ${row('Repair rounds', 'Failing tests go back to the same agent this many times', `<span class="pk-seg ro-repairs">${[0, 1, 2].map((n) => `<button data-n="${n}">${n}</button>`).join('')}</span>`)}
107
+ ${row('Hand over when it still fails', 'The task runs again on a stronger pick, next to the first run', '<button class="switch" id="roEscalate" role="switch"></button>')}
108
+ ${row('Best of two', 'Off by default. With the Best goal, two picks work side by side and wisp marks the better one', '<button class="switch" id="roBest2" role="switch"></button>')}
109
+ ${row('Scout first', 'A cheap model looks around the repo and tells the agent where to start', `<span class="pk-seg ro-scout">${[['off', 'Off'], ['big', 'Bigger tasks'], ['all', 'Every task']].map(([v, l]) => `<button data-v="${v}">${l}</button>`).join('')}</span>`)}
110
+ </div>
104
111
  <div class="sx-group">
105
112
  ${row('What it learned', '<span class="ro-hint"></span>', '<button class="ghost sm" id="openRouter">Open</button>')}
106
113
  </div>
@@ -302,10 +309,10 @@ export function openSettings(opts) {
302
309
  if (!saved) return;
303
310
  paintWt();
304
311
  const fc = $('#fixConflicts', node);
305
- const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts !== 'off');
312
+ const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts === 'on');
306
313
  fc.onclick = async () => {
307
314
  const was = S.config.defaults.fixConflicts;
308
- S.config.defaults.fixConflicts = was === 'off' ? 'on' : 'off';
315
+ S.config.defaults.fixConflicts = was === 'on' ? 'off' : 'on';
309
316
  paintFc();
310
317
  const saved = await tryApi(() => api('/config', { method: 'PATCH', body: { defaults: { fixConflicts: S.config.defaults.fixConflicts } } }));
311
318
  if (!saved) {
@@ -323,13 +330,18 @@ export function openSettings(opts) {
323
330
  if (!r || !node.isConnected) return;
324
331
  $$('.ro-goal button', node).forEach((b) => b.classList.toggle('on', b.dataset.goal === r.goal));
325
332
  $('#roExplore', node).setAttribute('aria-checked', r.settings.explore);
333
+ $('#roCheck', node).setAttribute('aria-checked', r.settings.check);
334
+ $('#roEscalate', node).setAttribute('aria-checked', r.settings.escalate);
335
+ $('#roBest2', node).setAttribute('aria-checked', r.settings.best2);
336
+ $$('.ro-repairs button', node).forEach((b) => b.classList.toggle('on', Number(b.dataset.n) === r.settings.repairs));
337
+ $$('.ro-scout button', node).forEach((b) => b.classList.toggle('on', b.dataset.v === r.settings.scout));
326
338
  const allowed = r.settings.allowed || [];
327
339
  $('.ro-allowed', node).innerHTML = S.agents
328
340
  .filter((a) => a.available)
329
341
  .map((a) => `<button class="chip-tog${allowed.includes(a.id) ? ' on' : ''}" data-id="${esc(a.id)}" aria-pressed="${allowed.includes(a.id)}" title="${esc(a.name)}"><span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}</button>`)
330
342
  .join('');
331
343
  $('.ro-hint', node).textContent = r.learned
332
- ? `${r.learned} task${r.learned === 1 ? '' : 's'} learned from · ${r.arms.filter((a) => a.state !== 'gone').length} harness · model pairs`
344
+ ? `${r.learned} task${r.learned === 1 ? '' : 's'} learned from · ${r.arms.filter((a) => a.state !== 'gone').length} harness · model · level options`
333
345
  : `Nothing of yours yet · starting from wisp-bench (${r.priors.version})`;
334
346
  };
335
347
  const patchAuto = async (body) => {
@@ -349,10 +361,24 @@ export function openSettings(opts) {
349
361
  patchAuto({ goal: b.dataset.goal });
350
362
  }
351
363
  };
352
- $('#roExplore', node).onclick = (e) => {
353
- const on = e.currentTarget.getAttribute('aria-checked') !== 'true';
354
- e.currentTarget.setAttribute('aria-checked', on);
355
- patchAuto({ explore: on });
364
+ for (const [id, key] of [['#roExplore', 'explore'], ['#roCheck', 'check'], ['#roEscalate', 'escalate'], ['#roBest2', 'best2']]) {
365
+ $(id, node).onclick = (e) => {
366
+ const on = e.currentTarget.getAttribute('aria-checked') !== 'true';
367
+ e.currentTarget.setAttribute('aria-checked', on);
368
+ patchAuto({ [key]: on });
369
+ };
370
+ }
371
+ $('.ro-repairs', node).onclick = (e) => {
372
+ const b = e.target.closest('[data-n]');
373
+ if (!b) return;
374
+ $$('.ro-repairs button', node).forEach((x) => x.classList.toggle('on', x === b));
375
+ patchAuto({ repairs: Number(b.dataset.n) });
376
+ };
377
+ $('.ro-scout', node).onclick = (e) => {
378
+ const b = e.target.closest('[data-v]');
379
+ if (!b) return;
380
+ $$('.ro-scout button', node).forEach((x) => x.classList.toggle('on', x === b));
381
+ patchAuto({ scout: b.dataset.v });
356
382
  };
357
383
  $('.ro-allowed', node).onclick = (e) => {
358
384
  const b = e.target.closest('[data-id]');
@@ -69,18 +69,45 @@ export function renderPrStrip(id) {
69
69
  <a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
70
70
  <span class="pr-line">${esc(prLine(pr, s.conflictFix))}</span>
71
71
  <span class="grow"></span>
72
- ${pr.state === 'OPEN' && pr.conflict && s.conflictFix?.step === 'failed' ? `<button class="ghost xs" data-pr="fix" title="Ask the agent to merge the base in and resolve the conflicts again">${icon('replay')}<span>Fix conflicts</span></button>` : ''}
72
+ ${pr.state === 'OPEN' && pr.conflict && !fixing(s.conflictFix) && s.state !== 'running' ? `<button class="ghost xs pr-fix" data-pr="fix" title="Send the task a follow-up to merge the base in and resolve the conflicts; wisp pushes the fix to the PR">${icon('merge')}<span>Fix conflicts</span></button>` : ''}
73
73
  ${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
74
74
  <a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
75
75
  ${pr.state === 'OPEN' && !pr.conflict ? `<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>` : ''}`;
76
76
  node.onclick = (e) => {
77
77
  const a = e.target.closest('[data-pr]')?.dataset.pr;
78
- if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch(toastError);
78
+ if (a === 'refresh') refreshPr(id, e.target.closest('[data-pr]'));
79
79
  if (a === 'merge') mergePr(id);
80
- if (a === 'fix') api(`/sessions/${id}/fix-conflicts`, { method: 'POST', body: {} }).catch(toastError);
80
+ if (a === 'fix') fixPr(id);
81
81
  };
82
82
  }
83
83
 
84
+ // Check now: the button spins at once and stops when the answer (pushed as a summary) is in.
85
+ function refreshPr(id, btn) {
86
+ btn.classList.add('busy');
87
+ btn.disabled = true;
88
+ api(`/sessions/${id}/pr`, { method: 'POST', body: {} })
89
+ .catch(toastError)
90
+ .finally(() => {
91
+ btn.classList.remove('busy');
92
+ btn.disabled = false;
93
+ });
94
+ }
95
+
96
+ // Fix conflicts: the row flips to "Fixing merge conflicts" at once; a refusal puts it back.
97
+ function fixPr(id) {
98
+ const s = S.sessions.get(id);
99
+ if (!s) return;
100
+ const was = s.conflictFix;
101
+ s.conflictFix = { ...was, step: 'fixing', why: '' };
102
+ renderPrStrip(id);
103
+ api(`/sessions/${id}/fix-conflicts`, { method: 'POST', body: {} }).catch((err) => {
104
+ const now = S.sessions.get(id);
105
+ if (now?.conflictFix?.step === 'fixing') now.conflictFix = was;
106
+ renderPrStrip(id);
107
+ toastError(err);
108
+ });
109
+ }
110
+
84
111
  // The in-flight row: the stages as a track that fills in as the server reports them. Built once
85
112
  // and updated in place, so its entry animation and shimmer don't restart with every summary.
86
113
  function renderShipRun(node, id, run) {
@@ -19,7 +19,7 @@ import { scrollByPx, watching } from './scroll.js';
19
19
  import { renderPrStrip, shipDialog } from './ship.js';
20
20
  import { copiesOf, memberLabel, renderSubs, subKey, teamLiveHtml, teamSize } from './team.js';
21
21
 
22
- const KIND_ICON = { read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask', background: 'clock' };
22
+ const KIND_ICON = { check: 'check', read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask', background: 'clock' };
23
23
 
24
24
  export function ctxFor(s, turn, isLatest) {
25
25
  return {
@@ -169,7 +169,7 @@ function renderFoldedTurn(t, unfold) {
169
169
  <article class="turn folded" data-turn="${t.id}">
170
170
  <button class="t-fold" title="Show this exchange">
171
171
  ${icon('chevron', 'chev')}
172
- <span class="t-prompt">${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
172
+ <span class="t-prompt">${t.by === 'wisp' ? '<b class="t-by">wisp</b> ' : ''}${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
173
173
  ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
174
174
  ${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
175
175
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
@@ -194,7 +194,8 @@ function renderTurn(s, t, isLatest, fold) {
194
194
  const cp = t.checkpoint;
195
195
  const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
196
196
  const ask = el(`
197
- <div class="ask">
197
+ <div class="ask${t.by === 'wisp' ? ' by-wisp' : ''}">
198
+ ${t.by === 'wisp' ? `<div class="ask-by">${icon('spark')}Sent by wisp after its test run</div>` : ''}
198
199
  <div class="ask-text ${long ? 'clamped' : ''}">${mentionHtml(esc(t.prompt))}</div>
199
200
  ${long ? '<button class="link xs more-btn">Show all</button>' : ''}
200
201
  ${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
@@ -50,3 +50,13 @@ export function popover(node, { anchor, keep, capture = false, onClose } = {}) {
50
50
  document.body.append(node);
51
51
  return close;
52
52
  }
53
+
54
+ // Keep a right-click menu at the pointer but on-screen near the window edges. Reads offset
55
+ // sizes: the menu's pop-in animation starts scaled down, so its bounding box is too small
56
+ // on the first frame and the last items would end up below the fold.
57
+ export function clampToViewport(node, x, y) {
58
+ const w = node.offsetWidth;
59
+ const h = node.offsetHeight;
60
+ node.style.left = `${Math.max(8, Math.min(x, innerWidth - w - 8))}px`;
61
+ node.style.top = `${Math.max(8, Math.min(y, innerHeight - h - 8))}px`;
62
+ }