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
@@ -0,0 +1,99 @@
1
+ // The project list: every project in a searchable list, for when there are too many to show as
2
+ // chips. The launcher's "in" row and the top bar's filter both open it from a "+N" chip.
3
+ //
4
+ // projects the list, in the order to show it (recent first)
5
+ // current the picked id, checked and scrolled to
6
+ // meta(p) extra html after a row's name (status badges)
7
+ // extras [{ id, label, icon }] fixed rows under the list (No folder, Other folder…)
8
+ // onPick (id) => …, a project's id or an extra's
9
+
10
+ import { esc } from '../md.js';
11
+ import { $, el, icon } from '../core/dom.js';
12
+ import { fuzzy, highlight } from '../fuzzy.js';
13
+ import { popover } from '../ui/popover.js';
14
+ import { projectTitle } from './places.js';
15
+
16
+ export function projectList(anchor, { projects, current, meta = () => '', extras = [], onPick, label = 'Projects' }) {
17
+ if ($('.menu.pm')) return $('.menu.pm').close();
18
+ const node = el(`
19
+ <div class="menu pm" role="dialog" aria-label="${esc(label)}">
20
+ <label class="field pm-search">${icon('search')}<input spellcheck="false" autocomplete="off" placeholder="Search ${projects.length} projects" aria-label="Search projects"></label>
21
+ <div class="pm-list" role="listbox"></div>
22
+ ${extras.length ? `<div class="pm-extras">${extras.map((x) => `<button class="pm-row pm-x" data-id="${esc(x.id)}">${icon(x.icon)}<span class="pm-name">${esc(x.label)}</span></button>`).join('')}</div>` : ''}
23
+ </div>`);
24
+ const input = $('input', node);
25
+ const list = $('.pm-list', node);
26
+ let act = 0;
27
+ const rows = () => [...node.querySelectorAll('.pm-row')];
28
+ const mark = (i, scroll = true) => {
29
+ const r = rows();
30
+ if (!r.length) return;
31
+ act = (i + r.length) % r.length;
32
+ r.forEach((x, k) => x.classList.toggle('act', k === act));
33
+ if (scroll) r[act].scrollIntoView({ block: 'nearest' });
34
+ };
35
+ const paint = () => {
36
+ const q = input.value.trim();
37
+ const hits = q
38
+ ? projects
39
+ .map((p) => ({ p, m: fuzzy(q, [p.name, p.path]) }))
40
+ .filter((x) => x.m)
41
+ .sort((a, b) => b.m.score - a.m.score)
42
+ : projects.map((p) => ({ p, m: null }));
43
+ list.innerHTML = hits.length
44
+ ? hits
45
+ .map(
46
+ ({ p, m }) => `<button class="pm-row c-${p.color} ${p.id === current ? 'on' : ''}" data-id="${esc(p.id)}" role="option" aria-selected="${p.id === current}" title="${esc(projectTitle(p))}">
47
+ <i class="p-dot"></i><span class="pm-name">${highlight(p.name, m?.idx, esc)}</span>${p.machine ? icon('server', 'pm-where') : ''}${meta(p)}<small class="pm-path">${esc(p.path.replace(/^\/(Users|home)\/[^/]+/, '~'))}</small>${icon('check', 'pm-check')}
48
+ </button>`,
49
+ )
50
+ .join('')
51
+ : `<div class="pm-empty">No project matches “${esc(q)}”</div>`;
52
+ // Searching starts on the best hit; the full list opens on the current pick.
53
+ const at = q ? 0 : Math.max(0, hits.findIndex((h) => h.p.id === current));
54
+ mark(at, !q);
55
+ };
56
+ const pick = (id) => {
57
+ close();
58
+ onPick(id);
59
+ };
60
+ node.onclick = (e) => {
61
+ const r = e.target.closest('.pm-row');
62
+ if (r) pick(r.dataset.id);
63
+ };
64
+ node.onmousemove = (e) => {
65
+ const r = e.target.closest('.pm-row');
66
+ if (r && !r.classList.contains('act')) mark(rows().indexOf(r), false);
67
+ };
68
+ input.oninput = paint;
69
+ input.onkeydown = (e) => {
70
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
71
+ e.preventDefault();
72
+ mark(act + (e.key === 'ArrowDown' ? 1 : -1));
73
+ } else if (e.key === 'Enter' && !e.isComposing) {
74
+ e.preventDefault();
75
+ const r = rows()[act];
76
+ if (r) pick(r.dataset.id);
77
+ } else if (e.key === 'Tab') close();
78
+ };
79
+
80
+ const close = popover(node, {
81
+ anchor,
82
+ onClose: () => {
83
+ anchor.classList.remove('open');
84
+ anchor.setAttribute('aria-expanded', 'false');
85
+ },
86
+ });
87
+ anchor.classList.add('open');
88
+ anchor.setAttribute('aria-expanded', 'true');
89
+ paint();
90
+ // Under the chip, or over it when the page has no room below; never off the side.
91
+ const r = anchor.getBoundingClientRect();
92
+ const w = node.offsetWidth;
93
+ const h = node.offsetHeight;
94
+ node.style.left = `${Math.max(8, Math.min(r.left, innerWidth - w - 8))}px`;
95
+ node.style.top = `${r.bottom + 6 + h > innerHeight - 8 && r.top - h - 6 > 8 ? r.top - h - 6 : r.bottom + 6}px`;
96
+ rows()[act]?.scrollIntoView({ block: 'nearest' }); // placed: now bring the current pick into view
97
+ input.focus({ preventScroll: true });
98
+ return close;
99
+ }
@@ -6,13 +6,19 @@ 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
+ import { clones, isRepo, repoLabel, repoName, startClone } from './clones.js';
11
12
  import { feedToTop, renderFeed } from './feed.js';
12
13
  import { renderLaunchChips } from './launcher.js';
13
14
  import { loadConfig } from './live.js';
14
15
  import { connectMachine, openMachines } from './machines.js';
15
- import { MACHINE_STATE, machineById, placeOf, projectById, projectTitle } from './places.js';
16
+ import { MACHINE_STATE, machineById, placeOf, projectById, projectTitle, recentFirst } from './places.js';
17
+ import { projectList } from './project-list.js';
18
+
19
+ // Project filter chips in the top bar before the rest fold into "+N".
20
+ const TOP_CHIPS = 6;
21
+ let fitBar; // refits the chips when the bar's width changes
16
22
 
17
23
  export function renderProjects() {
18
24
  const all = [...S.sessions.values()];
@@ -20,26 +26,89 @@ export function renderProjects() {
20
26
  const l = pid === 'all' ? all : all.filter((s) => s.projectId === pid);
21
27
  return { run: l.filter((s) => s.state === 'running').length, need: l.filter((s) => s.state === 'waiting' || (s.state === 'review' && s.unread)).length };
22
28
  };
23
- const chip = (id, name, color) => {
29
+ const badges = (id) => {
24
30
  const st = stats(id);
25
- return `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectTitle(projectById(id)))}">
26
- ${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${projectById(id)?.machine ? icon('server', 'p-where') : ''}${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}
31
+ return `${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}`;
32
+ };
33
+ const chip = (id, name, color) => `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectTitle(projectById(id)))}">
34
+ ${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${projectById(id)?.machine ? icon('server', 'p-where') : ''}${badges(id)}
27
35
  </button>`;
36
+ const list = S.config.projects.slice().sort(recentFirst);
37
+ // Tens of projects: the filtered one and those that need you first, then the most recent, up to
38
+ // TOP_CHIPS, kept in recent order so chips don't shuffle; the rest sit behind "+N".
39
+ const keep = new Set([
40
+ ...list.filter((p) => p.id === S.filter),
41
+ ...list.filter((p) => stats(p.id).need),
42
+ ...list,
43
+ ].slice(0, TOP_CHIPS));
44
+ const shown = list.filter((p) => keep.has(p));
45
+ $('#projects').innerHTML = `<div class="p-scroll">${chip('all', 'All', 'slate')}${shown.map((p) => chip(p.id, p.name, p.color)).join('')}</div><button class="p-chip more" id="moreProjects" title="All ${list.length} projects" aria-haspopup="dialog"><i class="p-dot" hidden></i><span class="p-more-n"></span><span class="p-need" hidden></span>${icon('chevron', 'chev')}</button><button class="p-chip add" id="manageProjects" title="Manage projects">${icon('plus')}</button>`;
46
+ // Keep chips whole: drop the least important ones (recent last, the filtered one never) until
47
+ // the rest fit, and count what's left out on "+N" with what needs you in there.
48
+ const fit = () => {
49
+ const box = $('#projects .p-scroll');
50
+ if (!box) return;
51
+ const chips = $$('.p-chip[data-id]:not([data-id="all"])', box);
52
+ chips.forEach((c) => (c.hidden = false));
53
+ const drop = [...chips].sort((a, b) => (a.dataset.id === S.filter) - (b.dataset.id === S.filter) || stats(a.dataset.id).need - stats(b.dataset.id).need || shown.findIndex((p) => p.id === b.dataset.id) - shown.findIndex((p) => p.id === a.dataset.id));
54
+ const more = $('#moreProjects');
55
+ box.hidden = false;
56
+ $('#manageProjects').hidden = false;
57
+ $('.p-dot', more).hidden = true;
58
+ more.classList.remove('solo', ...[...more.classList].filter((c) => c.startsWith('c-')));
59
+ const paint = () => {
60
+ const out = list.filter((p) => !chips.some((c) => !c.hidden && c.dataset.id === p.id));
61
+ more.hidden = !out.length;
62
+ $('.p-more-n', more).textContent = `+${out.length}`;
63
+ const need = out.reduce((n, p) => n + stats(p.id).need, 0);
64
+ $('.p-need', more).hidden = !need;
65
+ $('.p-need', more).textContent = need;
66
+ };
67
+ paint();
68
+ for (const c of drop) {
69
+ if (box.scrollWidth <= box.clientWidth) break;
70
+ c.hidden = true;
71
+ paint();
72
+ }
73
+ // Too narrow for even "All" (a phone): one chip naming the filter opens the whole list, which
74
+ // also has Manage projects, so + steps aside for it.
75
+ if (box.scrollWidth > box.clientWidth) {
76
+ const p = projectById(S.filter);
77
+ box.hidden = true;
78
+ $('#manageProjects').hidden = true;
79
+ more.hidden = false;
80
+ more.classList.add('solo', ...(p ? [`c-${p.color}`] : []));
81
+ $('.p-dot', more).hidden = !p;
82
+ $('.p-more-n', more).textContent = p ? p.name : 'All';
83
+ const need = stats(p ? p.id : 'all').need;
84
+ $('.p-need', more).hidden = !need;
85
+ $('.p-need', more).textContent = need;
86
+ }
28
87
  };
29
- $('#projects').innerHTML = `${chip('all', 'All', 'slate')}${S.config.projects
30
- .slice()
31
- .sort((a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt))
32
- .map((p) => chip(p.id, p.name, p.color))
33
- .join('')}<button class="p-chip add" id="manageProjects" title="Manage projects">${icon('plus')}</button>`;
34
- $$('.p-chip[data-id]').forEach((b) =>
35
- b.addEventListener('click', () => {
36
- S.filter = S.filter === b.dataset.id ? 'all' : b.dataset.id;
37
- save('wisp.filter', S.filter);
38
- renderFeed();
39
- feedToTop();
40
- renderLaunchChips();
41
- }),
42
- );
88
+ fit();
89
+ fitBar ??= new ResizeObserver(() => fitBar.fit?.());
90
+ fitBar.fit = fit;
91
+ fitBar.observe($('#projects'));
92
+ const setFilter = (id) => {
93
+ S.filter = id;
94
+ save('wisp.filter', S.filter);
95
+ renderFeed(); // repaints these chips too
96
+ feedToTop();
97
+ renderLaunchChips();
98
+ };
99
+ $$('.p-chip[data-id]').forEach((b) => b.addEventListener('click', () => setFilter(S.filter === b.dataset.id ? 'all' : b.dataset.id)));
100
+ $('#moreProjects').onclick = (e) =>
101
+ projectList(e.currentTarget, {
102
+ projects: list,
103
+ current: S.filter,
104
+ label: 'Filter by project',
105
+ meta: (p) => badges(p.id),
106
+ extras: [
107
+ ...(S.filter === 'all' ? [] : [{ id: 'all', label: 'All projects', icon: 'x' }]),
108
+ { id: '+manage', label: 'Manage projects…', icon: 'settings' },
109
+ ],
110
+ onPick: (id) => (id === '+manage' ? openProjects() : setFilter(id)),
111
+ });
43
112
  $$('.p-chip[data-id]:not([data-id="all"])').forEach((b) =>
44
113
  b.addEventListener('contextmenu', (e) => {
45
114
  e.preventDefault();
@@ -77,10 +146,7 @@ function projectMenu(id, e) {
77
146
  }
78
147
  };
79
148
  const close = popover(menu);
80
- // Keep the menu on-screen when right-clicking near the window edge.
81
- const r = menu.getBoundingClientRect();
82
- if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
83
- if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
149
+ clampToViewport(menu, e.clientX, e.clientY);
84
150
  }
85
151
 
86
152
  const COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
@@ -94,7 +160,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
94
160
  <div class="add-row">
95
161
  <div class="field lg mono path-input">
96
162
  ${window.wispDesktop ? `<button class="browse" title="Browse…">${icon('folder')}</button>` : icon('folder')}
97
- <input id="ppath" placeholder="~/code/my-app" spellcheck="false" autocomplete="off">
163
+ <input id="ppath" placeholder="~/code/my-app or a GitHub URL" spellcheck="false" autocomplete="off">
98
164
  <div class="suggest" hidden></div>
99
165
  </div>
100
166
  ${window.wispDesktop ? `<button class="ghost" id="pbrowse">${icon('folder')}Browse…</button>` : ''}
@@ -102,15 +168,17 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
102
168
  </div>
103
169
  <p class="hint"></p>
104
170
  <div class="plist" id="plist"></div>
171
+ <div class="discover" id="ghrepos"></div>
105
172
  <div class="discover" id="discover"><h4>Found on this machine</h4><p class="muted">Looking for git repos…</p></div>
106
173
  </div>`);
107
- present(node, host);
174
+ const close = present(node, host);
108
175
  const input = $('#ppath', node);
109
176
  const sug = $('.suggest', node);
110
177
  let where = machine && machineById(machine) ? machine : null;
111
178
  const here = () => machineById(where);
112
179
  const home = () => (where ? here()?.remote?.home : S.config.home) || '';
113
180
  const tilde = (p) => (home() && p.startsWith(home()) ? `~${p.slice(home().length)}` : p);
181
+ let editingCloneDir = false;
114
182
 
115
183
  const paintWhere = () => {
116
184
  const tabs = $('.where', node);
@@ -118,12 +186,59 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
118
186
  .map((m) => `<button data-w="${m.id}" class="${where === m.id ? 'on' : ''}" title="${esc(m.target)} · ${esc(MACHINE_STATE[m.state] || m.state)}">${icon('server')}${esc(m.name)}<i class="dot ${m.state === 'online' ? 'ok' : m.state === 'error' ? 'bad' : ''}"></i></button>`)
119
187
  .join('')}<i></i>${host ? '' : `<button data-w="+" title="Run agents on another computer over SSH">${icon('plus')}Machine</button>`}`;
120
188
  const m = here();
121
- input.placeholder = m ? `~/code/my-app on ${m.name}` : '~/code/my-app';
189
+ input.placeholder = m ? `~/code/my-app or a GitHub URL, on ${m.name}` : '~/code/my-app or a GitHub URL';
122
190
  $$('#pbrowse, .browse', node).forEach((b) => (b.hidden = !!m));
123
- $('.hint', node).innerHTML = m
124
- ? `Folders on <b>${esc(m.name)}</b>. Agents run there, in its own worktrees; you follow along here.`
125
- : 'Tip: run <code>wisp add</code> inside any folder, or launch from a terminal with <code>wisp "task"</code> — the folder is added automatically.';
191
+ paintHint();
126
192
  };
193
+ // The hint under the input follows what's typed: a repo URL says where the clone goes.
194
+ const paintHint = () => {
195
+ const m = here();
196
+ const hint = $('.hint', node);
197
+ const v = input.value.trim();
198
+ $('#padd', node).textContent = isRepo(v) ? 'Clone' : 'Add';
199
+ if (editingCloneDir) return;
200
+ if (isRepo(v))
201
+ hint.innerHTML = m
202
+ ? `Clones <b>${esc(repoLabel(v))}</b> on <b>${esc(m.name)}</b>. You can write the task while it clones.`
203
+ : `Clones <b>${esc(repoLabel(v))}</b> into <button class="link" data-a="clonedir" title="Change where repos are cloned">${esc(tilde(S.config.cloneDir || '~/code'))}/${esc(repoName(v))}</button>. You can write the task while it clones.`;
204
+ else
205
+ hint.innerHTML = m
206
+ ? `Folders on <b>${esc(m.name)}</b>, or a repo URL to clone there. Agents run there, in its own worktrees; you follow along here.`
207
+ : 'Paste a GitHub URL and wisp clones it for you. Or run <code>wisp add</code> inside any folder: it is added automatically.';
208
+ };
209
+ // Change the clone folder in place: the hint turns into a small path field.
210
+ $('.hint', node).addEventListener('click', (e) => {
211
+ if (!e.target.closest('[data-a="clonedir"]')) return;
212
+ editingCloneDir = true;
213
+ const hint = $('.hint', node);
214
+ hint.innerHTML = `Clone repos into <input class="clonedir mono" spellcheck="false" value="${esc(tilde(S.config.cloneDir || '~/code'))}"> <button class="link" data-a="savedir">Save</button>`;
215
+ const f = $('.clonedir', hint);
216
+ const done = async (keep) => {
217
+ if (!editingCloneDir) return;
218
+ editingCloneDir = false;
219
+ if (keep && f.value.trim() !== tilde(S.config.cloneDir || '')) {
220
+ S.config.cloneDir = f.value.trim(); // optimistic; the server answers with the resolved folder
221
+ await tryApi(async () => {
222
+ await api('/config', { method: 'PATCH', body: { defaults: { cloneDir: f.value.trim() } } });
223
+ await loadConfig();
224
+ });
225
+ }
226
+ paintHint();
227
+ input.focus();
228
+ };
229
+ f.onkeydown = (ev) => {
230
+ if (ev.key === 'Enter' || ev.key === 'Escape') {
231
+ ev.preventDefault();
232
+ ev.stopPropagation(); // Escape cancels the edit, not the dialog
233
+ done(ev.key === 'Enter');
234
+ }
235
+ };
236
+ $('[data-a="savedir"]', hint).onclick = () => {
237
+ done(true);
238
+ };
239
+ f.focus();
240
+ f.select();
241
+ });
127
242
  $('.where', node).addEventListener('click', (e) => {
128
243
  const b = e.target.closest('[data-w]');
129
244
  if (!b) return;
@@ -135,28 +250,42 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
135
250
  sug.hidden = true;
136
251
  paintWhere();
137
252
  list();
253
+ github();
138
254
  discover();
139
255
  input.focus();
140
256
  });
141
257
 
258
+ // A new project becomes the launcher's pick for its place.
259
+ const picked = async (proj) => {
260
+ await loadConfig();
261
+ launch.picks[placeOf(proj)] = proj.id;
262
+ if (!launch.where.includes(placeOf(proj))) launch.where = [placeOf(proj)];
263
+ if (placeOf(proj) === launch.where[0]) launch.project = proj.id;
264
+ saveLaunch();
265
+ renderLaunchChips();
266
+ };
142
267
  const add = async (p) => {
268
+ if (isRepo(p)) return clone(p);
143
269
  try {
144
270
  const proj = await api('/projects', { method: 'POST', body: { path: p, ...(where && { machine: where }) } });
145
- await loadConfig();
146
- launch.picks[placeOf(proj)] = proj.id;
147
- if (!launch.where.includes(placeOf(proj))) launch.where = [placeOf(proj)];
148
- if (placeOf(proj) === launch.where[0]) launch.project = proj.id;
149
- saveLaunch();
150
- renderLaunchChips();
271
+ await picked(proj);
151
272
  toast(`Added <b>${esc(proj.name)}</b>${where ? ` on ${esc(here()?.name || '')}` : ''}`);
152
273
  input.value = '';
153
274
  sug.hidden = true;
275
+ paintHint();
154
276
  list();
155
277
  discover();
156
278
  } catch (err) {
157
279
  toastError(err);
158
280
  }
159
281
  };
282
+ // Clone a repo: the launcher picks it right away (cloning in the background) and the dialog
283
+ // closes, so the next thing is writing the task. A launch into it waits for the clone.
284
+ const clone = (url) => {
285
+ startClone(url, where);
286
+ close();
287
+ setTimeout(() => $('#prompt')?.focus(), 60);
288
+ };
160
289
  $('#padd', node).onclick = () => {
161
290
  if (input.value.trim()) add(input.value.trim());
162
291
  };
@@ -169,10 +298,11 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
169
298
  let sel = -1;
170
299
  let t;
171
300
  input.addEventListener('input', () => {
301
+ paintHint();
172
302
  clearTimeout(t);
173
303
  t = setTimeout(async () => {
174
304
  const q = input.value;
175
- if (!q) return (sug.hidden = true);
305
+ if (!q || isRepo(q)) return (sug.hidden = true);
176
306
  const dirs = await api(`${where ? `/machines/${where}/fs` : '/fs'}?q=${encodeURIComponent(q)}`).catch(() => []);
177
307
  sel = -1;
178
308
  sug.hidden = !dirs.length;
@@ -202,8 +332,16 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
202
332
  const list = () => {
203
333
  const box = $('#plist', node);
204
334
  const projects = S.config.projects.filter((p) => (p.machine || null) === where);
205
- box.innerHTML = projects.length
206
- ? projects
335
+ const pending = clones
336
+ .filter((j) => j.where === where)
337
+ .map(
338
+ (j) => `<div class="prow cloning"><span class="swatch"><span class="orb live"></span></span>
339
+ <div class="prow-main"><b class="pname">${esc(repoName(j.url))}</b><span class="ppath">Cloning ${esc(repoLabel(j.url))}…</span></div></div>`,
340
+ )
341
+ .join('');
342
+ box.innerHTML = projects.length || pending
343
+ ? pending +
344
+ projects
207
345
  .map((p) => {
208
346
  const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
209
347
  return `<div class="prow c-${p.color}" data-id="${p.id}">
@@ -215,7 +353,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
215
353
  })
216
354
  .join('')
217
355
  : `<p class="muted">No projects ${where ? `on ${esc(here()?.name || '')} ` : ''}yet. Add a folder above, or pick one below.</p>`;
218
- $$('.prow', box).forEach((row) => {
356
+ $$('.prow[data-id]', box).forEach((row) => {
219
357
  const id = row.dataset.id;
220
358
  const p = projectById(id);
221
359
  $('.pname', row).onchange = (e) => {
@@ -243,6 +381,40 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
243
381
  });
244
382
  };
245
383
 
384
+ // The user's own GitHub repos (through gh), one click to clone. Only on this computer, and
385
+ // hidden when gh isn't installed or signed in.
386
+ let ghRepos = null;
387
+ let ghAll = false;
388
+ const github = async () => {
389
+ const box = $('#ghrepos', node);
390
+ if (where) return (box.innerHTML = '');
391
+ box.onclick = (e) => {
392
+ const b = e.target.closest('[data-repo]');
393
+ if (b) clone(b.dataset.repo);
394
+ if (e.target.closest('[data-a="ghall"]')) {
395
+ ghAll = !ghAll;
396
+ github();
397
+ }
398
+ };
399
+ if (!ghRepos) {
400
+ box.innerHTML = `<h4>${icon('github')}Your GitHub repos</h4><p class="muted">Asking gh…</p>`;
401
+ ghRepos = await api('/projects/github').catch(() => []);
402
+ if (where) return (box.innerHTML = '');
403
+ }
404
+ const busy = new Set(clones.map((j) => j.label.toLowerCase()));
405
+ const repos = ghRepos.filter((r) => !busy.has(r.repo.toLowerCase()));
406
+ const more = repos.length > 6 ? `<button class="link xs" data-a="ghall">${ghAll ? 'Show fewer' : `Show all ${repos.length}`}</button>` : '';
407
+ box.innerHTML = repos.length
408
+ ? `<h4>${icon('github')}Your GitHub repos <span class="muted">· click to clone</span>${more}</h4><div class="found">${repos
409
+ .slice(0, ghAll ? undefined : 6)
410
+ .map(
411
+ (r) =>
412
+ `<button class="found-item" data-repo="${esc(r.url)}" title="${esc(r.description || r.repo)}">${icon('plus')}<b>${esc(r.repo.split('/')[1])}</b><span>${esc(r.repo.split('/')[0])}${r.private ? ' · private' : ''}${r.fork ? ' · fork' : ''}</span></button>`,
413
+ )
414
+ .join('')}</div>`
415
+ : '';
416
+ };
417
+
246
418
  const discover = async () => {
247
419
  const box = $('#discover', node);
248
420
  const m = here();
@@ -265,6 +437,14 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
265
437
  .join('')}</div>`
266
438
  : '';
267
439
  };
440
+ bus.on(
441
+ 'clones',
442
+ () => {
443
+ list();
444
+ github();
445
+ },
446
+ { node },
447
+ );
268
448
  let wasOnline = here()?.state === 'online';
269
449
  bus.on(
270
450
  'machines',
@@ -279,6 +459,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
279
459
 
280
460
  paintWhere();
281
461
  list();
462
+ github();
282
463
  discover();
283
464
  if (focusAdd) setTimeout(() => input.focus(), 60);
284
465
  }
@@ -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);
@@ -6,7 +6,7 @@ import { api } from '../core/api.js';
6
6
  import * as bus from '../core/bus.js';
7
7
  import { $, $$, el, icon } from '../core/dom.js';
8
8
  import { money, pct } from '../core/format.js';
9
- import { S } from '../core/state.js';
9
+ import { S, setAgents } from '../core/state.js';
10
10
  import { overlay } from '../ui/overlay.js';
11
11
  import { toast, toastError, tryApi } from '../ui/toast.js';
12
12
  import { openRouter } from './auto.js';
@@ -78,6 +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('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>')}
81
82
  </div>
82
83
  </section>
83
84
  <section class="sx-pane" data-pane="notifications">
@@ -100,6 +101,13 @@ export function openSettings(opts) {
100
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>')}
101
102
  ${row('Harnesses it may use', 'Leave all off to let Auto use every one you have', '<span class="ro-allowed"></span>')}
102
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>
103
111
  <div class="sx-group">
104
112
  ${row('What it learned', '<span class="ro-hint"></span>', '<button class="ghost sm" id="openRouter">Open</button>')}
105
113
  </div>
@@ -170,7 +178,7 @@ export function openSettings(opts) {
170
178
  btn.classList.add('busy');
171
179
  const before = new Map(S.agents.map((a) => [a.id, a]));
172
180
  try {
173
- S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
181
+ setAgents(await api('/agents/recheck', { method: 'POST', body: {} }));
174
182
  if (!node.isConnected) return;
175
183
  paintAgents();
176
184
  $('#launcher')?.placesChanged?.();
@@ -300,6 +308,19 @@ export function openSettings(opts) {
300
308
  });
301
309
  if (!saved) return;
302
310
  paintWt();
311
+ const fc = $('#fixConflicts', node);
312
+ const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts === 'on');
313
+ fc.onclick = async () => {
314
+ const was = S.config.defaults.fixConflicts;
315
+ S.config.defaults.fixConflicts = was === 'on' ? 'off' : 'on';
316
+ paintFc();
317
+ const saved = await tryApi(() => api('/config', { method: 'PATCH', body: { defaults: { fixConflicts: S.config.defaults.fixConflicts } } }));
318
+ if (!saved) {
319
+ S.config.defaults.fixConflicts = was;
320
+ paintFc();
321
+ }
322
+ };
323
+ paintFc();
303
324
  renderLauncher();
304
325
  };
305
326
  paintWt();
@@ -309,13 +330,18 @@ export function openSettings(opts) {
309
330
  if (!r || !node.isConnected) return;
310
331
  $$('.ro-goal button', node).forEach((b) => b.classList.toggle('on', b.dataset.goal === r.goal));
311
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));
312
338
  const allowed = r.settings.allowed || [];
313
339
  $('.ro-allowed', node).innerHTML = S.agents
314
340
  .filter((a) => a.available)
315
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>`)
316
342
  .join('');
317
343
  $('.ro-hint', node).textContent = r.learned
318
- ? `${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`
319
345
  : `Nothing of yours yet · starting from wisp-bench (${r.priors.version})`;
320
346
  };
321
347
  const patchAuto = async (body) => {
@@ -335,10 +361,24 @@ export function openSettings(opts) {
335
361
  patchAuto({ goal: b.dataset.goal });
336
362
  }
337
363
  };
338
- $('#roExplore', node).onclick = (e) => {
339
- const on = e.currentTarget.getAttribute('aria-checked') !== 'true';
340
- e.currentTarget.setAttribute('aria-checked', on);
341
- 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 });
342
382
  };
343
383
  $('.ro-allowed', node).onclick = (e) => {
344
384
  const b = e.target.closest('[data-id]');
@@ -6,7 +6,7 @@ import { esc } from '../md.js';
6
6
  import { api } from '../core/api.js';
7
7
  import * as bus from '../core/bus.js';
8
8
  import { $, icon } from '../core/dom.js';
9
- import { launch, S } from '../core/state.js';
9
+ import { launch, S, setAgents } from '../core/state.js';
10
10
  import { toast, toastError } from '../ui/toast.js';
11
11
  import { harnessLogo } from './runners.js';
12
12
 
@@ -25,7 +25,7 @@ export async function installHarness(id) {
25
25
  failed = '';
26
26
  bus.emit('install');
27
27
  try {
28
- S.agents = await api('/agents/install', { method: 'POST', body: { id } });
28
+ setAgents(await api('/agents/install', { method: 'POST', body: { id } }));
29
29
  $('#launcher')?.placesChanged?.();
30
30
  toast(`<b>${esc(S.agents.find((a) => a.id === id)?.name || id)}</b> is ready: free models, no account needed. Type a task and press Enter.`, { timeout: 7000 });
31
31
  $('#prompt')?.focus();
@@ -68,7 +68,7 @@ export function renderSetup() {
68
68
  btn.disabled = true;
69
69
  btn.classList.add('busy');
70
70
  try {
71
- S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
71
+ setAgents(await api('/agents/recheck', { method: 'POST', body: {} }));
72
72
  $('#launcher')?.placesChanged?.();
73
73
  bus.emit('agents');
74
74
  if (needsSetup()) toast('Still no harness found. Install opencode here, or Claude Code, Codex, Cursor… yourself.');