wisp-agents 0.20.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.
@@ -13,7 +13,7 @@ import { feedToTop, renderFeed } from './feed.js';
13
13
  import { renderLaunchChips } from './launcher.js';
14
14
  import { loadConfig } from './live.js';
15
15
  import { connectMachine, openMachines } from './machines.js';
16
- import { MACHINE_STATE, machineById, placeOf, projectById, projectTitle, recentFirst } from './places.js';
16
+ import { inProject, MACHINE_STATE, machineById, placeOf, projectById, projectTitle, recentFirst } from './places.js';
17
17
  import { projectList } from './project-list.js';
18
18
 
19
19
  // Project filter chips in the top bar before the rest fold into "+N".
@@ -23,7 +23,7 @@ let fitBar; // refits the chips when the bar's width changes
23
23
  export function renderProjects() {
24
24
  const all = [...S.sessions.values()];
25
25
  const stats = (pid) => {
26
- const l = pid === 'all' ? all : all.filter((s) => s.projectId === pid);
26
+ const l = pid === 'all' ? all : all.filter((s) => inProject(s, pid));
27
27
  return { run: l.filter((s) => s.state === 'running').length, need: l.filter((s) => s.state === 'waiting' || (s.state === 'review' && s.unread)).length };
28
28
  };
29
29
  const badges = (id) => {
@@ -259,6 +259,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
259
259
  const picked = async (proj) => {
260
260
  await loadConfig();
261
261
  launch.picks[placeOf(proj)] = proj.id;
262
+ delete launch.also[placeOf(proj)];
262
263
  if (!launch.where.includes(placeOf(proj))) launch.where = [placeOf(proj)];
263
264
  if (placeOf(proj) === launch.where[0]) launch.project = proj.id;
264
265
  saveLaunch();
@@ -343,7 +344,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
343
344
  ? pending +
344
345
  projects
345
346
  .map((p) => {
346
- const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
347
+ const n = [...S.sessions.values()].filter((s) => inProject(s, p.id)).length;
347
348
  return `<div class="prow c-${p.color}" data-id="${p.id}">
348
349
  <button class="swatch" title="Change colour"><i class="p-dot"></i></button>
349
350
  <div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(tilde(p.path))}</span></div>
@@ -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,29 +59,41 @@ 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 && !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
- ${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
99
  if (a === 'refresh') refreshPr(id, e.target.closest('[data-pr]'));
@@ -135,7 +156,8 @@ function renderShipRun(node, id, run) {
135
156
  node.className = `pr-strip ship-run${failed ? ' failed pr-fail' : ''}`;
136
157
  node.title = failed ? run.error : '';
137
158
  $$('.ship-track li', node).forEach((li, i) => (li.className = i < at ? 'done' : i === at ? (failed ? 'bad' : 'now') : ''));
138
- 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>`;
139
161
  if ($('.ship-what', node).innerHTML !== what) $('.ship-what', node).innerHTML = what;
140
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>` : '';
141
163
  if ($('.ship-acts', node).innerHTML !== acts) $('.ship-acts', node).innerHTML = acts;
@@ -151,19 +173,23 @@ export async function shipDialog(id) {
151
173
  } catch (err) {
152
174
  return toastError(err);
153
175
  }
154
- 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');
155
179
  const typed = ships.get(id)?.values; // a failed ship's message, back for Retry
156
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>`;
157
184
  const node = el(`
158
185
  <div class="modal ship-modal">
159
- <header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
160
- <p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
161
- <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('')}
162
188
  ${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
163
189
  ${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
164
190
  <label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
165
- ${!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>' : ''}
166
- <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>
167
193
  </div>`);
168
194
  const close = overlay(node);
169
195
  $('.close', node).onclick = close;
@@ -188,10 +214,12 @@ export async function shipDialog(id) {
188
214
  (pr) => {
189
215
  const s = S.sessions.get(id);
190
216
  // Everything just went up: no Push to #n until the server's recheck finds something new.
191
- 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 }) } });
192
219
  ships.delete(id);
193
220
  renderPrStrip(id);
194
- 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 });
195
223
  },
196
224
  (err) => {
197
225
  if (ships.has(id)) ships.get(id).error = err.message;
@@ -210,15 +238,24 @@ export async function shipDialog(id) {
210
238
 
211
239
  export async function mergePr(id) {
212
240
  const s = S.sessions.get(id);
213
- const pr = s?.pr;
214
- if (!pr || pr.state !== 'OPEN') return;
215
- const c = pr.checks || {};
216
- 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.` : '';
217
- 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;
218
- 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}…`);
219
255
  try {
220
256
  const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
221
- 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 });
222
259
  } catch (err) {
223
260
  toastError(err, { timeout: 12_000 });
224
261
  }
@@ -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';