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.
- package/CHANGELOG.md +72 -1
- package/README.md +20 -7
- package/bin/wisp.js +4 -2
- package/package.json +2 -2
- package/server/adapters/antigravity.js +2 -1
- package/server/adapters/claude.js +2 -1
- package/server/adapters/codex.js +2 -1
- package/server/adapters/commands.js +808 -0
- package/server/adapters/cursor.js +2 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/opencode.js +2 -1
- package/server/check.js +205 -0
- package/server/git.js +114 -2
- package/server/index.js +2 -1
- package/server/judge.js +78 -0
- package/server/machines.js +2 -2
- package/server/openapi.js +19 -2
- package/server/pipeline.js +217 -0
- package/server/quick.js +76 -0
- package/server/router.js +201 -28
- package/server/routes/auto.js +8 -1
- package/server/routes/meta.js +1 -1
- package/server/routes/settings.js +53 -4
- package/server/routes/ship.js +29 -2
- package/server/runner.js +65 -15
- package/server/scout.js +71 -0
- package/server/services/auto.js +115 -15
- package/server/services/launch.js +22 -13
- package/server/services/ship.js +107 -6
- package/server/store.js +15 -0
- package/web/css/auto.css +22 -0
- package/web/css/launcher.css +17 -0
- package/web/css/layout.css +10 -2
- package/web/css/modals.css +8 -0
- package/web/css/palette.css +22 -0
- package/web/css/ship.css +44 -0
- package/web/index.html +16 -5
- package/web/js/app.js +14 -3
- package/web/js/blocks.js +1 -1
- package/web/js/core/state.js +2 -1
- package/web/js/features/actions.js +2 -4
- package/web/js/features/auto.js +38 -7
- package/web/js/features/clones.js +79 -0
- package/web/js/features/composer.js +2 -4
- package/web/js/features/feed.js +34 -4
- package/web/js/features/launcher.js +139 -19
- package/web/js/features/live.js +5 -10
- package/web/js/features/project-list.js +99 -0
- package/web/js/features/projects.js +220 -39
- package/web/js/features/runners.js +7 -6
- package/web/js/features/settings.js +47 -7
- package/web/js/features/setup.js +3 -3
- package/web/js/features/ship.js +128 -25
- package/web/js/features/turns.js +4 -3
- 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
|
|
29
|
+
const badges = (id) => {
|
|
24
30
|
const st = stats(id);
|
|
25
|
-
return `<
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
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
|
|
21
|
-
// Its "models" are the goal it optimises for
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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]');
|
package/web/js/features/setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.');
|