wisp-agents 0.17.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +20 -7
  3. package/bin/wisp.js +4 -2
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +2 -1
  6. package/server/adapters/claude.js +2 -1
  7. package/server/adapters/codex.js +2 -1
  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/git.js +114 -2
  14. package/server/index.js +2 -1
  15. package/server/judge.js +78 -0
  16. package/server/machines.js +2 -2
  17. package/server/openapi.js +19 -2
  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/meta.js +1 -1
  23. package/server/routes/settings.js +53 -4
  24. package/server/routes/ship.js +29 -2
  25. package/server/runner.js +65 -15
  26. package/server/scout.js +71 -0
  27. package/server/services/auto.js +115 -15
  28. package/server/services/launch.js +22 -13
  29. package/server/services/ship.js +107 -6
  30. package/server/store.js +15 -0
  31. package/web/css/auto.css +22 -0
  32. package/web/css/launcher.css +17 -0
  33. package/web/css/layout.css +10 -2
  34. package/web/css/modals.css +8 -0
  35. package/web/css/palette.css +22 -0
  36. package/web/css/ship.css +44 -0
  37. package/web/index.html +16 -5
  38. package/web/js/app.js +14 -3
  39. package/web/js/blocks.js +1 -1
  40. package/web/js/core/state.js +2 -1
  41. package/web/js/features/actions.js +2 -4
  42. package/web/js/features/auto.js +38 -7
  43. package/web/js/features/clones.js +79 -0
  44. package/web/js/features/composer.js +2 -4
  45. package/web/js/features/feed.js +34 -4
  46. package/web/js/features/launcher.js +139 -19
  47. package/web/js/features/live.js +5 -10
  48. package/web/js/features/project-list.js +99 -0
  49. package/web/js/features/projects.js +220 -39
  50. package/web/js/features/runners.js +7 -6
  51. package/web/js/features/settings.js +47 -7
  52. package/web/js/features/setup.js +3 -3
  53. package/web/js/features/ship.js +128 -25
  54. package/web/js/features/turns.js +4 -3
  55. package/web/js/ui/popover.js +10 -0
@@ -7,26 +7,42 @@ import { S } from '../core/state.js';
7
7
  import { overlay } from '../ui/overlay.js';
8
8
  import { toast, toastError } from '../ui/toast.js';
9
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';
10
+ // fix: the session's conflictFix ({ step, why }) while wisp resolves the PR's merge conflicts itself.
11
+ const fixing = (fix) => fix?.step === 'fixing' || fix?.step === 'pushing';
12
+ const prTone = (pr, fix) =>
13
+ pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.conflict ? (fixing(fix) ? 'pending' : 'fail') : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
12
14
  const REVIEW = { APPROVED: 'Approved', CHANGES_REQUESTED: 'Changes requested', REVIEW_REQUIRED: 'Review required' };
13
- function prLine(pr) {
15
+ function prLine(pr, fix) {
16
+ if (pr.state === 'OPEN' && (pr.conflict || fixing(fix))) {
17
+ const what = fix?.step === 'pushing' ? 'Pushing the conflict fix' : fixing(fix) ? 'Fixing merge conflicts' : fix?.step === 'failed' ? `Conflicts not fixed: ${fix.why}` : 'Merge conflicts';
18
+ return `Open · ${what}`;
19
+ }
14
20
  const c = pr.checks || {};
15
21
  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
22
  const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
17
23
  return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
18
24
  }
19
25
 
26
+ // A ship in flight, per session: { steps, step, update, route, error, values }. The dialog closes
27
+ // on confirm and the card follows along here (the server sends the live stage as `shipping`);
28
+ // a failure stays on the card with Retry, which reopens the dialog with what was typed.
29
+ const ships = new Map();
30
+ const STEP_LABEL = { commit: 'Commit', push: 'Push', pr: 'Open PR' };
31
+ const STEP_DOING = { commit: 'Committing', push: 'Pushing', pr: 'Opening the PR' };
32
+ const shipping = (s) => !!(s?.shipping || (ships.has(s?.id) && !ships.get(s.id).error));
33
+
20
34
  // Ship it only makes sense on a finished worktree task with something to ship: uncommitted
21
35
  // changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
22
36
  export function shippable(s) {
23
- if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED') return false;
37
+ if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED' || shipping(s)) return false;
24
38
  const t = s.toShip;
25
39
  return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
26
40
  }
27
41
  export const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
28
42
 
29
- export const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
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>` : '');
44
+
45
+ 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>`;
30
46
 
31
47
  // The PR row under a card's bar; reads the live summary, so polled check results show up.
32
48
  export function renderPrStrip(id) {
@@ -39,25 +55,92 @@ export function renderPrStrip(id) {
39
55
  b.hidden = !shippable(s);
40
56
  $('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
41
57
  });
58
+ // Started here, or elsewhere (another tab, the API): the server's stage moves the track along.
59
+ 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;
61
+ if (run) return renderShipRun(node, id, run);
62
+ const landed = node.classList.contains('ship-run') && !node.classList.contains('failed');
42
63
  node.hidden = !pr;
43
64
  if (!pr) return;
44
- const tone = prTone(pr);
45
- node.className = `pr-strip pr-${tone}`;
65
+ const tone = prTone(pr, s.conflictFix);
66
+ node.className = `pr-strip pr-${tone}${landed ? ' landed' : ''}`;
46
67
  node.innerHTML = `
47
68
  <span class="pr-dot"></span>
48
69
  <a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
49
- <span class="pr-line">${esc(prLine(pr))}</span>
70
+ <span class="pr-line">${esc(prLine(pr, s.conflictFix))}</span>
50
71
  <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>` : ''}
51
73
  ${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
52
74
  <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>` : ''}`;
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>` : ''}`;
54
76
  node.onclick = (e) => {
55
77
  const a = e.target.closest('[data-pr]')?.dataset.pr;
56
- if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch(toastError);
78
+ if (a === 'refresh') refreshPr(id, e.target.closest('[data-pr]'));
57
79
  if (a === 'merge') mergePr(id);
80
+ if (a === 'fix') fixPr(id);
58
81
  };
59
82
  }
60
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
+
111
+ // The in-flight row: the stages as a track that fills in as the server reports them. Built once
112
+ // and updated in place, so its entry animation and shimmer don't restart with every summary.
113
+ function renderShipRun(node, id, run) {
114
+ const at = Math.max(run.steps.indexOf(run.step), 0);
115
+ if (!node.classList.contains('ship-run') || node.dataset.steps !== run.steps.join()) {
116
+ node.dataset.steps = run.steps.join();
117
+ node.innerHTML = `
118
+ <span class="ship-icon">${icon('send')}</span>
119
+ <span class="ship-what"></span>
120
+ <ol class="ship-track">${run.steps.map((k) => `<li data-step="${k}"><i>${icon('check')}</i><span>${STEP_LABEL[k]}</span></li>`).join('')}</ol>
121
+ <span class="grow"></span>
122
+ <span class="ship-acts"></span>
123
+ <span class="ship-bar"></span>`;
124
+ node.onclick = (e) => {
125
+ const a = e.target.closest('[data-ship]')?.dataset.ship;
126
+ if (a === 'retry') shipDialog(id);
127
+ if (a === 'dismiss') {
128
+ ships.delete(id);
129
+ renderPrStrip(id);
130
+ }
131
+ };
132
+ }
133
+ const failed = !!run.error;
134
+ node.hidden = false;
135
+ node.className = `pr-strip ship-run${failed ? ' failed pr-fail' : ''}`;
136
+ node.title = failed ? run.error : '';
137
+ $$('.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>`;
139
+ if ($('.ship-what', node).innerHTML !== what) $('.ship-what', node).innerHTML = what;
140
+ 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
+ if ($('.ship-acts', node).innerHTML !== acts) $('.ship-acts', node).innerHTML = acts;
142
+ }
143
+
61
144
  export async function shipDialog(id) {
62
145
  const s = S.sessions.get(id);
63
146
  if (!s) return;
@@ -69,6 +152,7 @@ export async function shipDialog(id) {
69
152
  return toastError(err);
70
153
  }
71
154
  const open = info.pr?.state === 'OPEN';
155
+ const typed = ships.get(id)?.values; // a failed ship's message, back for Retry
72
156
  const nothing = !info.dirty && !info.ahead;
73
157
  const node = el(`
74
158
  <div class="modal ship-modal">
@@ -85,23 +169,42 @@ export async function shipDialog(id) {
85
169
  $('.close', node).onclick = close;
86
170
  const title = $('[name="title"]', node);
87
171
  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 });
172
+ if (title) title.value = typed?.title || info.title;
173
+ if (body) body.value = typed?.body ?? info.body;
174
+ if (typed?.draft && $('[name="draft"]', node)) $('[name="draft"]', node).checked = true;
175
+ // Confirm closes the dialog at once; the card's PR row shows the stages, then the PR itself.
176
+ const go = () => {
177
+ if (title && !title.value.trim()) {
178
+ title.focus();
179
+ return toast('A commit message is required.', { tone: 'bad' });
102
180
  }
181
+ const values = { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked };
182
+ close();
183
+ ships.set(id, { steps: [...(info.dirty ? ['commit'] : []), 'push', 'pr'], step: info.dirty ? 'commit' : 'push', update: open, values });
184
+ renderPrStrip(id);
185
+ const strip = S.nodes.get(id)?.body?.querySelector('.pr-strip');
186
+ if (strip && !strip.hidden) strip.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
187
+ api(`/sessions/${id}/ship`, { method: 'POST', body: values }).then(
188
+ (pr) => {
189
+ const s = S.sessions.get(id);
190
+ // 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 } });
192
+ ships.delete(id);
193
+ 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 });
195
+ },
196
+ (err) => {
197
+ if (ships.has(id)) ships.get(id).error = err.message;
198
+ const s = S.sessions.get(id);
199
+ if (s) S.sessions.set(id, { ...s, shipping: null });
200
+ renderPrStrip(id);
201
+ toastError(err, { timeout: 10_000 });
202
+ },
203
+ );
204
+ };
205
+ $('[data-a="go"]', node).onclick = () => {
206
+ go();
103
207
  };
104
- $('[data-a="go"]', node).onclick = go;
105
208
  requestAnimationFrame(() => (title || $('[data-a="go"]', node)).focus());
106
209
  }
107
210
 
@@ -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
+ }