wisp-agents 0.18.0 → 0.21.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 (52) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +23 -7
  3. package/bin/wisp.js +4 -3
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +2 -1
  6. package/server/adapters/claude.js +7 -5
  7. package/server/adapters/codex.js +7 -3
  8. package/server/adapters/commands.js +808 -0
  9. package/server/adapters/cursor.js +2 -1
  10. package/server/adapters/grok.js +2 -1
  11. package/server/adapters/opencode.js +2 -1
  12. package/server/check.js +205 -0
  13. package/server/client.js +3 -2
  14. package/server/index.js +2 -1
  15. package/server/judge.js +78 -0
  16. package/server/mcp-wisp.js +2 -1
  17. package/server/openapi.js +4 -3
  18. package/server/pipeline.js +217 -0
  19. package/server/quick.js +76 -0
  20. package/server/router.js +201 -28
  21. package/server/routes/auto.js +8 -1
  22. package/server/routes/sessions.js +15 -8
  23. package/server/routes/settings.js +2 -0
  24. package/server/routes/ship.js +27 -27
  25. package/server/runner.js +83 -17
  26. package/server/scout.js +71 -0
  27. package/server/services/auto.js +111 -14
  28. package/server/services/launch.js +45 -8
  29. package/server/services/ship.js +132 -23
  30. package/skills/wisp/SKILL.md +2 -1
  31. package/web/css/auto.css +22 -0
  32. package/web/css/feed.css +3 -0
  33. package/web/css/launcher.css +12 -0
  34. package/web/css/palette.css +6 -0
  35. package/web/css/ship.css +17 -1
  36. package/web/index.html +1 -0
  37. package/web/js/blocks.js +1 -1
  38. package/web/js/core/state.js +4 -2
  39. package/web/js/features/actions.js +3 -5
  40. package/web/js/features/auto.js +38 -7
  41. package/web/js/features/composer.js +2 -4
  42. package/web/js/features/feed.js +40 -10
  43. package/web/js/features/launcher.js +62 -18
  44. package/web/js/features/live.js +2 -7
  45. package/web/js/features/places.js +10 -0
  46. package/web/js/features/project-list.js +20 -5
  47. package/web/js/features/projects.js +6 -8
  48. package/web/js/features/runners.js +7 -6
  49. package/web/js/features/settings.js +34 -8
  50. package/web/js/features/ship.js +95 -31
  51. package/web/js/features/turns.js +6 -4
  52. package/web/js/ui/popover.js +10 -0
@@ -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]');
@@ -31,16 +31,25 @@ const STEP_LABEL = { commit: 'Commit', push: 'Push', pr: 'Open PR' };
31
31
  const STEP_DOING = { commit: 'Committing', push: 'Pushing', pr: 'Opening the PR' };
32
32
  const shipping = (s) => !!(s?.shipping || (ships.has(s?.id) && !ships.get(s.id).error));
33
33
 
34
+ // A task in several repos (also) has a PR per repo: its own first, then the others', each { repo, pr }.
35
+ export const prsOf = (s) => [...(s?.pr ? [{ repo: s.project, pr: s.pr }] : []), ...(s?.also || []).filter((a) => a.pr).map((a) => ({ repo: a.project, pr: a.pr }))];
36
+ export const prOf = (s) => prsOf(s)[0]?.pr || null; // the one the card's pill shows
37
+ const branches = (s) => [s?.branch, ...(s?.also || []).map((a) => a.branch)].filter(Boolean);
38
+ const severalRepos = (s) => branches(s).length > 1;
39
+
34
40
  // Ship it only makes sense on a finished worktree task with something to ship: uncommitted
35
41
  // changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
42
+ // Several repos: the server counts the ones with something new (toShip.repos).
36
43
  export function shippable(s) {
37
- if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED' || shipping(s)) return false;
44
+ if (!branches(s).length || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || shipping(s)) return false;
38
45
  const t = s.toShip;
46
+ if (t?.repos !== undefined) return t.repos > 0;
47
+ if (s.pr?.state === 'MERGED') return false;
39
48
  return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
40
49
  }
41
- export const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
50
+ export const shipLabel = (s) => (severalRepos(s) ? (prsOf(s).some((x) => x.pr.state === 'OPEN') ? 'Push to the PRs…' : 'Ship it…') : s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
42
51
 
43
- export const shipPill = (s) => (s.shipping ? `<span class="pill ship-pill" title="Shipping: ${esc(STEP_DOING[s.shipping.step] || 'Starting')}…"><i></i>Shipping</span>` : '');
52
+ export const shipPill = (s) => (s.shipping ? `<span class="pill ship-pill" title="Shipping: ${esc(STEP_DOING[s.shipping.step] || 'Starting')}${s.shipping.repo ? ` ${esc(s.shipping.repo)}` : ''}…"><i></i>Shipping</span>` : '');
44
53
 
45
54
  export const prPill = (pr, fix) => `<span class="pill pr pr-${prTone(pr, fix)}" title="PR #${pr.number}: ${esc(prLine(pr, fix))}"><i></i>#${pr.number}</span>`;
46
55
 
@@ -50,37 +59,76 @@ export function renderPrStrip(id) {
50
59
  const node = n?.body && $('.pr-strip', n.body);
51
60
  const s = S.sessions.get(id);
52
61
  if (!node || !s) return;
53
- const pr = s.pr;
62
+ const prs = prsOf(s);
63
+ const pr = prs[0]?.pr;
64
+ const several = prs.length > 1;
54
65
  $$('[data-a="ship"]', n.body).forEach((b) => {
55
66
  b.hidden = !shippable(s);
56
- $('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
67
+ $('span', b).textContent = severalRepos(s) ? (prs.some((x) => x.pr.state === 'OPEN') ? 'Push to PRs' : prs.length ? 'Ship again' : 'Ship it') : pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
57
68
  });
58
69
  // Started here, or elsewhere (another tab, the API): the server's stage moves the track along.
59
70
  const run = ships.get(id) || (s.shipping && { steps: s.shipping.update ? ['push', 'pr'] : ['commit', 'push', 'pr'], step: 'push' });
60
- if (run && STEP_LABEL[s.shipping?.step] && !run.error) run.step = s.shipping.step;
71
+ if (run && STEP_LABEL[s.shipping?.step] && !run.error) Object.assign(run, { step: s.shipping.step, repo: s.shipping.repo, at: s.shipping.at, of: s.shipping.of });
61
72
  if (run) return renderShipRun(node, id, run);
62
73
  const landed = node.classList.contains('ship-run') && !node.classList.contains('failed');
63
74
  node.hidden = !pr;
64
75
  if (!pr) return;
65
- const tone = prTone(pr, s.conflictFix);
76
+ // Several repos: the first PR leads the row and the others follow as small links, each in its
77
+ // own tone; the row takes the worst of them, and Merge merges them all.
78
+ const fix = s.pr ? s.conflictFix : null; // wisp fixes conflicts on the task's own PR only
79
+ const TONES = ['fail', 'pending', 'pass', 'merged', 'closed'];
80
+ const tone = prs.map((x, i) => prTone(x.pr, i ? null : fix)).sort((a, b) => TONES.indexOf(a) - TONES.indexOf(b))[0];
81
+ const open = prs.filter((x) => x.pr.state === 'OPEN');
82
+ const conflict = open.some((x) => x.pr.conflict);
66
83
  node.className = `pr-strip pr-${tone}${landed ? ' landed' : ''}`;
67
84
  node.innerHTML = `
68
85
  <span class="pr-dot"></span>
69
- <a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
70
- <span class="pr-line">${esc(prLine(pr, s.conflictFix))}</span>
86
+ <a href="${esc(pr.url)}" target="_blank" class="pr-num">${several ? `${esc(prs[0].repo)} #${pr.number}` : `PR #${pr.number}`}</a>
87
+ ${prs
88
+ .slice(1)
89
+ .map((x) => `<a href="${esc(x.pr.url)}" target="_blank" class="pr-also pr-${prTone(x.pr)}" title="${esc(`${x.repo}: PR #${x.pr.number} · ${prLine(x.pr)}`)}"><i></i>${esc(x.repo)} #${x.pr.number}</a>`)
90
+ .join('')}
91
+ <span class="pr-line">${esc(prLine(pr, fix))}</span>
71
92
  <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>` : ''}
73
- ${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
74
- <a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
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>` : ''}`;
93
+ ${pr.state === 'OPEN' && pr.conflict && s.pr && !fixing(fix) && 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>` : ''}
94
+ ${open.length ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
95
+ ${several ? '' : `<a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>`}
96
+ ${open.length && !conflict ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge${open.length > 1 ? ` all ${open.length} PRs` : ''}, then fast-forward your local base branch${open.length > 1 ? 'es' : ''}">${icon('check')}<span>Merge${open.length > 1 ? ' all' : ''}</span></button>` : ''}`;
76
97
  node.onclick = (e) => {
77
98
  const a = e.target.closest('[data-pr]')?.dataset.pr;
78
- if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch(toastError);
99
+ if (a === 'refresh') refreshPr(id, e.target.closest('[data-pr]'));
79
100
  if (a === 'merge') mergePr(id);
80
- if (a === 'fix') api(`/sessions/${id}/fix-conflicts`, { method: 'POST', body: {} }).catch(toastError);
101
+ if (a === 'fix') fixPr(id);
81
102
  };
82
103
  }
83
104
 
105
+ // Check now: the button spins at once and stops when the answer (pushed as a summary) is in.
106
+ function refreshPr(id, btn) {
107
+ btn.classList.add('busy');
108
+ btn.disabled = true;
109
+ api(`/sessions/${id}/pr`, { method: 'POST', body: {} })
110
+ .catch(toastError)
111
+ .finally(() => {
112
+ btn.classList.remove('busy');
113
+ btn.disabled = false;
114
+ });
115
+ }
116
+
117
+ // Fix conflicts: the row flips to "Fixing merge conflicts" at once; a refusal puts it back.
118
+ function fixPr(id) {
119
+ const s = S.sessions.get(id);
120
+ if (!s) return;
121
+ const was = s.conflictFix;
122
+ s.conflictFix = { ...was, step: 'fixing', why: '' };
123
+ renderPrStrip(id);
124
+ api(`/sessions/${id}/fix-conflicts`, { method: 'POST', body: {} }).catch((err) => {
125
+ const now = S.sessions.get(id);
126
+ if (now?.conflictFix?.step === 'fixing') now.conflictFix = was;
127
+ renderPrStrip(id);
128
+ toastError(err);
129
+ });
130
+ }
131
+
84
132
  // The in-flight row: the stages as a track that fills in as the server reports them. Built once
85
133
  // and updated in place, so its entry animation and shimmer don't restart with every summary.
86
134
  function renderShipRun(node, id, run) {
@@ -108,7 +156,8 @@ function renderShipRun(node, id, run) {
108
156
  node.className = `pr-strip ship-run${failed ? ' failed pr-fail' : ''}`;
109
157
  node.title = failed ? run.error : '';
110
158
  $$('.ship-track li', node).forEach((li, i) => (li.className = i < at ? 'done' : i === at ? (failed ? 'bad' : 'now') : ''));
111
- const what = failed ? `Ship failed at ${STEP_LABEL[run.steps[at]] || 'start'}: <span class="ship-err">${esc(run.error)}</span>` : `${STEP_DOING[run.steps[at]]}<span class="dots"></span>`;
159
+ const repo = !failed && run.repo ? ` ${esc(run.repo)}${run.of > 1 ? ` <span class="muted">(${run.at} of ${run.of})</span>` : ''}` : '';
160
+ const what = failed ? `Ship failed at ${STEP_LABEL[run.steps[at]] || 'start'}: <span class="ship-err">${esc(run.error)}</span>` : `${STEP_DOING[run.steps[at]]}${repo}<span class="dots"></span>`;
112
161
  if ($('.ship-what', node).innerHTML !== what) $('.ship-what', node).innerHTML = what;
113
162
  const acts = failed ? `<button class="ghost xs" data-ship="retry">${icon('replay')}<span>Retry</span></button><button class="ghost xs" data-ship="dismiss" title="Dismiss">${icon('x')}</button>` : '';
114
163
  if ($('.ship-acts', node).innerHTML !== acts) $('.ship-acts', node).innerHTML = acts;
@@ -124,19 +173,23 @@ export async function shipDialog(id) {
124
173
  } catch (err) {
125
174
  return toastError(err);
126
175
  }
127
- const open = info.pr?.state === 'OPEN';
176
+ // Several repos: a route line each, and the PRs open or update together.
177
+ const repos = info.repos || [info];
178
+ const open = repos.every((r) => r.pr?.state === 'OPEN');
128
179
  const typed = ships.get(id)?.values; // a failed ship's message, back for Retry
129
180
  const nothing = !info.dirty && !info.ahead;
181
+ const noRemote = repos.every((r) => !r.remote);
182
+ const route = (r) => `<p class="ship-route">${icon('branch')}${info.repos ? `<b class="ship-repo">${esc(r.repo)}</b>` : ''}<code>${esc(r.branch)}</code>${icon('arrow')}<code>${esc(r.remote || 'no remote')}/${esc(r.base)}</code>
183
+ <span class="muted">${r.dirty ? `${r.dirty} uncommitted change${r.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${r.ahead} commit${r.ahead === 1 ? '' : 's'} on the branch${info.repos && r.pr ? ` · PR #${r.pr.number}` : ''}</span></p>`;
130
184
  const node = el(`
131
185
  <div class="modal ship-modal">
132
- <header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
133
- <p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
134
- <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>
186
+ <header><h2>${open ? (info.repos ? `Push to ${repos.length} PRs` : `Push to PR #${info.pr.number}`) : info.repos ? `Ship it to ${repos.length} repos` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
187
+ ${repos.map(route).join('')}
135
188
  ${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
136
189
  ${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
137
190
  <label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
138
- ${!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>' : ''}
139
- <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>
191
+ ${noRemote ? `<p class="hint bad">${info.repos ? 'These repos have' : '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>' : ''}
192
+ <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 ${info.repos ? 'each worktree with changes, one PR per repo' : 'the worktree'}.</span><button class="primary sm" data-a="go" ${nothing || noRemote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : info.repos ? 'Commit, push & open PRs' : 'Commit, push & open PR'}</button></div>
140
193
  </div>`);
141
194
  const close = overlay(node);
142
195
  $('.close', node).onclick = close;
@@ -161,10 +214,12 @@ export async function shipDialog(id) {
161
214
  (pr) => {
162
215
  const s = S.sessions.get(id);
163
216
  // Everything just went up: no Push to #n until the server's recheck finds something new.
164
- if (s) S.sessions.set(id, { ...s, pr: { ...s.pr, ...pr }, shipping: null, toShip: s.toShip && { ...s.toShip, dirty: 0, unpushed: 0 } });
217
+ // Several repos: their PRs come with the server's next summary.
218
+ if (s) S.sessions.set(id, { ...s, ...(!pr.prs && { pr: { ...s.pr, ...pr } }), shipping: null, toShip: s.toShip && { ...s.toShip, dirty: 0, unpushed: 0, ...(s.toShip.repos !== undefined && { repos: 0 }) } });
165
219
  ships.delete(id);
166
220
  renderPrStrip(id);
167
- toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
221
+ const what = pr.prs ? pr.prs.map((x) => `<b>${esc(x.repo)} #${x.number}</b>`).join(', ') : `<b>PR #${pr.number}</b>`;
222
+ toast(`${icon('branch')} ${open ? 'Pushed to' : pr.prs ? 'PRs:' : 'Opened'} ${what}`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
168
223
  },
169
224
  (err) => {
170
225
  if (ships.has(id)) ships.get(id).error = err.message;
@@ -183,15 +238,24 @@ export async function shipDialog(id) {
183
238
 
184
239
  export async function mergePr(id) {
185
240
  const s = S.sessions.get(id);
186
- const pr = s?.pr;
187
- if (!pr || pr.state !== 'OPEN') return;
188
- const c = pr.checks || {};
189
- 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.` : '';
190
- 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;
191
- toast(`Merging PR #${pr.number}…`);
241
+ const open = prsOf(s).filter((x) => x.pr.state === 'OPEN');
242
+ if (!open.length) return;
243
+ const several = open.length > 1;
244
+ const warn = open
245
+ .map(({ repo, pr }) => {
246
+ const c = pr.checks || {};
247
+ const where = several ? ` in ${repo}` : '';
248
+ return c.fail ? `⚠ ${c.fail} check${c.fail > 1 ? 's are' : ' is'} failing${where}.` : c.pending ? `⚠ ${c.pending} check${c.pending > 1 ? 's are' : ' is'} still running${where}.` : '';
249
+ })
250
+ .filter(Boolean)
251
+ .join('\n');
252
+ const what = several ? `these ${open.length} PRs (${open.map((x) => `${x.repo} #${x.pr.number}`).join(', ')})` : `PR #${open[0].pr.number}${open[0].pr.draft ? ' (marks it ready first)' : ''}`;
253
+ if (!confirm(`Squash-merge ${what}, then fast-forward your local base branch${several ? 'es' : ''} and remove the worktree${several ? 's' : ''}?${warn ? `\n\n${warn}` : ''}`)) return;
254
+ toast(several ? `Merging ${open.length} PRs…` : `Merging PR #${open[0].pr.number}…`);
192
255
  try {
193
256
  const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
194
- toast(`${icon('check')} Merged <b>PR #${r.pr.number}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
257
+ const merged = r.prs ? r.prs.filter((x) => x.state === 'MERGED').map((x) => `${esc(x.repo)} #${x.number}`).join(', ') : `PR #${r.pr.number}`;
258
+ toast(`${icon('check')} Merged <b>${merged}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
195
259
  } catch (err) {
196
260
  toastError(err, { timeout: 12_000 });
197
261
  }
@@ -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 {
@@ -97,13 +97,14 @@ export function renderBody(id, { fresh }) {
97
97
  const proj = projectById(summary.projectId);
98
98
  // The head already shows the harness, model and project, and the task head has Compare. This bar
99
99
  // says how the run was set up: a mode worth noticing (Plan or Edits, as on the launcher), the
100
- // branch, and what launched it.
100
+ // branch, the other folders it works in, and what launched it.
101
101
  const mode = MODES.find((m) => m.id === s.mode);
102
102
  const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
103
103
  $('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
104
104
  $('.it-meta', body).innerHTML = `
105
105
  ${mode && mode !== DEFAULT_MODE ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
106
106
  ${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
107
+ ${(s.also || []).map((a, i) => `<span class="m also c-${summary.also?.[i]?.color || 'slate'}" title="${esc(`Also works in ${a.cwd}${a.worktree ? `\nWorktree: ${a.worktree.path}\nBranch ${a.worktree.branch}, from ${a.worktree.baseRef}` : ''}`)}"><i class="p-dot"></i>${esc(summary.also?.[i]?.project || a.cwd.split(/[\\/]/).pop())}</span>`).join('')}
107
108
  ${s.subagents?.length ? `<span class="m team-m" title="${esc(s.subagents.map((m) => `@${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n'))}">${icon('agent')}${teamSize(s.subagents)} subagent${teamSize(s.subagents) > 1 ? 's' : ''}</span>` : ''}
108
109
  ${s.origin ? `<span class="m" title="${s.origin.kind === 'telegram' ? 'Started from Telegram' : `Launched by a ${esc(s.origin.kind)}`}">${icon({ trigger: 'replay', telegram: 'send' }[s.origin.kind] || 'spark')}${esc(s.origin.name)}</span>` : ''}`;
109
110
  const live = s.state === 'running' || s.state === 'waiting';
@@ -169,7 +170,7 @@ function renderFoldedTurn(t, unfold) {
169
170
  <article class="turn folded" data-turn="${t.id}">
170
171
  <button class="t-fold" title="Show this exchange">
171
172
  ${icon('chevron', 'chev')}
172
- <span class="t-prompt">${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
173
+ <span class="t-prompt">${t.by === 'wisp' ? '<b class="t-by">wisp</b> ' : ''}${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
173
174
  ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
174
175
  ${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
175
176
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
@@ -194,7 +195,8 @@ function renderTurn(s, t, isLatest, fold) {
194
195
  const cp = t.checkpoint;
195
196
  const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
196
197
  const ask = el(`
197
- <div class="ask">
198
+ <div class="ask${t.by === 'wisp' ? ' by-wisp' : ''}">
199
+ ${t.by === 'wisp' ? `<div class="ask-by">${icon('spark')}Sent by wisp after its test run</div>` : ''}
198
200
  <div class="ask-text ${long ? 'clamped' : ''}">${mentionHtml(esc(t.prompt))}</div>
199
201
  ${long ? '<button class="link xs more-btn">Show all</button>' : ''}
200
202
  ${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
+ }