wisp-agents 0.21.0 → 0.22.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 +24 -1
- package/README.md +13 -8
- package/bin/wisp.js +10 -3
- package/package.json +4 -3
- package/server/adapters/claude.js +8 -4
- package/server/adapters/codex.js +9 -6
- package/server/adapters/cursor.js +4 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/index.js +7 -2
- package/server/adapters/opencode.js +5 -2
- package/server/btw.js +76 -0
- package/server/client.js +19 -0
- package/server/git.js +80 -4
- package/server/handoff.js +13 -7
- package/server/index.js +4 -0
- package/server/machines.js +15 -4
- package/server/openapi.js +27 -2
- package/server/remote.js +4 -2
- package/server/routes/machines.js +1 -1
- package/server/routes/proxy.js +6 -0
- package/server/routes/sessions.js +32 -2
- package/server/routes/settings.js +24 -38
- package/server/routes/uploads.js +2 -0
- package/server/runner.js +154 -8
- package/server/services/launch.js +32 -4
- package/server/services/move.js +130 -0
- package/server/services/phone.js +20 -1
- package/server/services/places.js +150 -0
- package/server/services/uploads.js +23 -4
- package/server/store.js +39 -6
- package/skills/wisp/SKILL.md +1 -0
- package/web/css/blocks.css +2 -0
- package/web/css/composer.css +23 -1
- package/web/css/feed.css +49 -1
- package/web/css/machines.css +4 -0
- package/web/css/modals.css +19 -0
- package/web/css/ship.css +2 -0
- package/web/css/transcript.css +115 -0
- package/web/index.html +2 -0
- package/web/js/core/state.js +3 -2
- package/web/js/features/actions.js +42 -10
- package/web/js/features/btw.js +145 -0
- package/web/js/features/clones.js +7 -6
- package/web/js/features/commands.js +3 -1
- package/web/js/features/composer.js +162 -18
- package/web/js/features/feed.js +1 -1
- package/web/js/features/keys.js +2 -1
- package/web/js/features/launcher.js +104 -94
- package/web/js/features/place-menu.js +44 -0
- package/web/js/features/places.js +15 -5
- package/web/js/features/projects.js +14 -12
- package/web/js/features/settings.js +24 -3
- package/web/js/features/slash.js +63 -0
- package/web/js/features/transcript.js +290 -0
- package/web/js/features/turns.js +99 -28
- package/web/js/slash-text.js +22 -0
- package/web/js/ui/kbd.js +1 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The launcher: the box on top where tasks start. It picks where they run (this computer and/or
|
|
2
|
-
// machines,
|
|
2
|
+
// machines), in which project (any of them runs anywhere), on what, and shows a placeholder card
|
|
3
|
+
// until the server answers.
|
|
3
4
|
|
|
4
5
|
import { esc } from '../md.js';
|
|
5
6
|
import { plain } from '../mention-text.js';
|
|
@@ -18,7 +19,7 @@ import { feedbackCommand, isFeedback } from './feedback.js';
|
|
|
18
19
|
import { connectMachine, offlineToast, openMachines } from './machines.js';
|
|
19
20
|
import { mentionBox } from './mentions.js';
|
|
20
21
|
import { askNotify } from './notify.js';
|
|
21
|
-
import { MACHINE_STATE, machineById, placeName, placeOf, projectById,
|
|
22
|
+
import { MACHINE_STATE, machineById, pathAt, placeName, placeOf, placesOf, projectById, projects, projectTitle } from './places.js';
|
|
22
23
|
import { projectList } from './project-list.js';
|
|
23
24
|
import { openProjects } from './projects.js';
|
|
24
25
|
import { runnerPicker } from './runner-picker.js';
|
|
@@ -30,11 +31,16 @@ import { canLead, paintTeamBtn, teamEditor } from './team.js';
|
|
|
30
31
|
// "No folder": a general ask with no project. The server gives it an empty scratch directory.
|
|
31
32
|
const NO_FOLDER = 'none';
|
|
32
33
|
const GENERAL = { id: null, name: 'General', path: '', color: 'slate', scratch: true };
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
34
|
+
// One pick for every place the task runs on (launch.picks[''], see core/state.js): the project,
|
|
35
|
+
// or No folder, or a repo still cloning; and the task's other folders (launch.also['']).
|
|
36
|
+
// Nothing to pick (no projects yet) runs in No folder; the stored pick stays null, so the first
|
|
37
|
+
// project to show up takes over (syncWhere).
|
|
38
|
+
const storedPick = () => launch.picks[''];
|
|
39
|
+
const pickId = () => storedPick() || NO_FOLDER;
|
|
40
|
+
const projectOf = () => (pickId() === NO_FOLDER ? GENERAL : projectById(pickId()));
|
|
41
|
+
const alsoIds = () => launch.also[''] || [];
|
|
42
|
+
const chosen = (id) => id === pickId() || alsoIds().includes(id);
|
|
43
|
+
const ownPick = () => !!projectById(pickId());
|
|
38
44
|
const MOD = combo('').replace(/\+$/, ''); // ⌘ or Ctrl, for "⌘-click"
|
|
39
45
|
// Project chips in the "in" row, at most, before the rest fold into "+N".
|
|
40
46
|
const CHIPS = 5;
|
|
@@ -45,25 +51,25 @@ let filterSeen = null;
|
|
|
45
51
|
function syncWhere() {
|
|
46
52
|
launch.where = launch.where.filter((k, i) => (!k || machineById(k)) && launch.where.indexOf(k) === i);
|
|
47
53
|
if (!launch.where.length) launch.where = [''];
|
|
48
|
-
//
|
|
49
|
-
|
|
54
|
+
// Picks saved per place, from before every project ran everywhere: the first place's carries on.
|
|
55
|
+
if (!('' in launch.picks) && launch.picks[launch.where[0]]) launch.picks[''] = launch.picks[launch.where[0]];
|
|
56
|
+
if (!launch.also[''] && launch.also[launch.where[0]]) launch.also[''] = launch.also[launch.where[0]];
|
|
57
|
+
for (const k of Object.keys(launch.picks)) if (k) delete launch.picks[k];
|
|
58
|
+
for (const k of Object.keys(launch.also)) if (k) delete launch.also[k];
|
|
59
|
+
// Filtering the feed to a project points the launcher at it. One that's already in the task
|
|
60
|
+
// (as its first folder or another) leaves the selection as it is.
|
|
50
61
|
const f = S.filter !== 'all' && S.filter !== filterSeen && projectById(S.filter);
|
|
51
62
|
filterSeen = S.filter;
|
|
52
|
-
if (f) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const ids = ownPick(k) ? launch.also[k].filter((id, i, a) => id !== launch.picks[k] && a.indexOf(id) === i && projectsAt(k).some((p) => p.id === id)) : [];
|
|
63
|
-
if (ids.length) launch.also[k] = ids;
|
|
64
|
-
else delete launch.also[k];
|
|
65
|
-
}
|
|
66
|
-
launch.project = launch.picks[launch.where[0]] || null;
|
|
63
|
+
if (f && !chosen(f.id)) {
|
|
64
|
+
launch.picks[''] = f.id;
|
|
65
|
+
delete launch.also[''];
|
|
66
|
+
} else if (!storedPick() && projectById(launch.project)) launch.picks[''] = launch.project;
|
|
67
|
+
if (storedPick() !== NO_FOLDER && !cloneById(storedPick()) && !projectById(storedPick())) launch.picks[''] = projects()[0]?.id || null;
|
|
68
|
+
// Other folders only go with a project (not No folder or a clone).
|
|
69
|
+
const ids = ownPick() ? alsoIds().filter((id, i, a) => id !== pickId() && a.indexOf(id) === i && projectById(id)) : [];
|
|
70
|
+
if (ids.length) launch.also[''] = ids;
|
|
71
|
+
else delete launch.also[''];
|
|
72
|
+
launch.project = storedPick() || null;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
export function renderLauncher() {
|
|
@@ -264,10 +270,10 @@ export function renderLauncher() {
|
|
|
264
270
|
ta.dispatchEvent(new Event('input'));
|
|
265
271
|
return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
|
|
266
272
|
}
|
|
267
|
-
// One run per place picked under "Run on",
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
if (
|
|
273
|
+
// One run per place picked under "Run on", all in the picked project: a place that has no
|
|
274
|
+
// checkout of it yet gets one first (the server clones it there).
|
|
275
|
+
const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectOf(), clone: cloneById(pickId()), also: ownPick() ? alsoIds().map(projectById).filter(Boolean) : [] }));
|
|
276
|
+
if (!targets[0].project && !targets[0].clone) return openProjects({ focusAdd: true });
|
|
271
277
|
const off = targets.find((t) => t.m && t.m.state !== 'online');
|
|
272
278
|
if (off) return offlineToast(off.k);
|
|
273
279
|
if (needsSetup()) return nudgeSetup();
|
|
@@ -286,7 +292,8 @@ export function renderLauncher() {
|
|
|
286
292
|
const cmp = compare;
|
|
287
293
|
const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
|
|
288
294
|
// A repo still cloning gets a stand-in project; its card says so until the clone lands.
|
|
289
|
-
const
|
|
295
|
+
const fetching = (t) => t.k && [t.project, ...t.also].some((p) => p && !p.scratch && !pathAt(p, t.k)); // not on that machine yet
|
|
296
|
+
const pending = targets.map((t) => addPending(prompt, t.project ? t : { ...t, project: { id: t.clone.id, name: t.clone.name, path: '', color: 'slate' } }, runner, t.clone ? `Cloning ${t.clone.label}` : fetching(t) ? `Cloning ${t.project.name} on ${placeName(t.k)}` : undefined));
|
|
290
297
|
if (cmp.length) (compare = []), paintCompare();
|
|
291
298
|
ta.value = '';
|
|
292
299
|
S.drafts.delete('home');
|
|
@@ -313,7 +320,7 @@ export function renderLauncher() {
|
|
|
313
320
|
targets.map((t) =>
|
|
314
321
|
api('/sessions', {
|
|
315
322
|
method: 'POST',
|
|
316
|
-
body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, also: t.also.length ? t.also.map((p) => p.path) : undefined, scratch: t.project.scratch || undefined, machine: t.k
|
|
323
|
+
body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, also: t.also.length ? t.also.map((p) => p.path) : undefined, scratch: t.project.scratch || undefined, machine: t.k, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
|
|
317
324
|
}),
|
|
318
325
|
),
|
|
319
326
|
);
|
|
@@ -367,52 +374,56 @@ export function renderLauncher() {
|
|
|
367
374
|
});
|
|
368
375
|
}
|
|
369
376
|
|
|
370
|
-
// No folder
|
|
377
|
+
// No folder: no git repo, so no worktree either; the toggle would only mislead.
|
|
371
378
|
function paintScratch() {
|
|
372
379
|
const wt = $('#launcher #wtBtn');
|
|
373
|
-
if (wt) wt.hidden =
|
|
380
|
+
if (wt) wt.hidden = pickId() === NO_FOLDER;
|
|
374
381
|
}
|
|
375
382
|
|
|
376
383
|
export function renderLaunchChips() {
|
|
377
384
|
const box = $('#launcher .l-projects');
|
|
378
385
|
if (!box) return;
|
|
379
386
|
syncWhere();
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
? `<button class="lp-chip add" data-add="${k}" title="Another folder${k ? ` on ${esc(placeName(k))}` : ''}">${icon('folder')}${multi || list.length > max ? '' : 'Other folder…'}</button>`
|
|
411
|
-
: `<button class="lp-chip add" data-add="${k}">${icon('folder')}${k ? `Pick a project on ${esc(placeName(k))}…` : 'Choose a folder…'}</button>`;
|
|
412
|
-
const none = `<button class="lp-chip none ${launch.picks[k] === NO_FOLDER ? 'on' : ''}" data-id="${NO_FOLDER}" data-k="${k}" title="No folder: a general ask, with no project and no worktree. The agent gets an empty scratch directory.">${icon('spark')}<span>No folder</span></button>`;
|
|
413
|
-
return `<span class="lp-group">${label}${chips}${more}${none}${add}</span>`;
|
|
387
|
+
const list = projects();
|
|
388
|
+
const pick = pickId();
|
|
389
|
+
// A machine picked under "Run on" but not connected: it says so, with a way to connect.
|
|
390
|
+
const offline = launch.where
|
|
391
|
+
.map(machineById)
|
|
392
|
+
.filter((m) => m && m.state !== 'online')
|
|
393
|
+
.map((m) => `<span class="lp-gl off" title="${esc(`${m.target} · ${MACHINE_STATE[m.state] || ''}`)}">${icon('server')}${esc(m.name)}<button class="link xs" data-connect="${m.id}">${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : 'Connect'}</button></span>`)
|
|
394
|
+
.join('');
|
|
395
|
+
// A repo still cloning: picked, with a spinner, until the real project replaces it.
|
|
396
|
+
const cloning = clones
|
|
397
|
+
.map((c) => `<button class="lp-chip cloning ${c.id === pick ? 'on' : ''}" data-id="${c.id}" title="Cloning ${esc(c.label)}${c.where ? ` on ${esc(placeName(c.where))}` : ''}…"><span class="orb live"></span>${esc(c.name)}<span class="lp-note">cloning…</span></button>`)
|
|
398
|
+
.join('');
|
|
399
|
+
// Tens of projects: the most recent few as chips (the picks always among them), the rest one
|
|
400
|
+
// search away behind "+N". fitChips() then folds more into it if the row would wrap.
|
|
401
|
+
const picked = list.filter((p) => chosen(p.id));
|
|
402
|
+
const rest = list.filter((p) => !chosen(p.id)).slice(0, Math.max(0, CHIPS - picked.length));
|
|
403
|
+
const shown = list.filter((p) => picked.includes(p) || rest.includes(p));
|
|
404
|
+
// A task can span several projects: ⌘-click a chip, or its + badge, to add it; the × on a
|
|
405
|
+
// picked one leaves it out again.
|
|
406
|
+
const own = ownPick();
|
|
407
|
+
const several = own && alsoIds().length > 0;
|
|
408
|
+
const badge = (p) =>
|
|
409
|
+
chosen(p.id)
|
|
410
|
+
? several ? `<span class="lp-also x" data-also="${p.id}" title="Leave ${esc(p.name)} out of this task">${icon('x')}</span>` : ''
|
|
411
|
+
: own ? `<span class="lp-also" data-also="${p.id}" title="Also work in ${esc(p.name)} (${MOD}-click)">${icon('plus')}</span>` : '';
|
|
412
|
+
// Machines picked that have no checkout of it yet: the first run there clones it.
|
|
413
|
+
const missing = (p) => launch.where.filter((k) => k && !pathAt(p, k)).map(placeName);
|
|
414
|
+
const tip = (p) => {
|
|
415
|
+
const m = missing(p);
|
|
416
|
+
return `${projectTitle(p)}${m.length ? `\nwisp clones it on ${m.join(', ')} for this task` : ''}${several && p.id === pick ? '\nThe agent starts here' : ''}${own && !chosen(p.id) ? `\n${MOD}-click to work in it too` : ''}`;
|
|
414
417
|
};
|
|
415
|
-
|
|
418
|
+
const chips = cloning + shown.map((p) => `<button class="lp-chip c-${p.color} ${chosen(p.id) ? 'on' : ''}" data-id="${p.id}" title="${esc(tip(p))}"><i class="p-dot"></i>${esc(p.name)}${badge(p)}</button>`).join('');
|
|
419
|
+
const more = list.length > 1 ? `<button class="lp-chip more" data-more="1" data-total="${list.length}" title="All ${list.length} projects" aria-haspopup="dialog"><span class="lp-more-n"></span>${icon('search')}</button>` : '';
|
|
420
|
+
// Another folder goes on the one machine picked, if that's all it runs on.
|
|
421
|
+
const addOn = launch.where.length === 1 ? launch.where[0] : '';
|
|
422
|
+
const add = list.length || cloning
|
|
423
|
+
? `<button class="lp-chip add" data-add="${addOn}" title="Another folder${addOn ? ` on ${esc(placeName(addOn))}` : ''}">${icon('folder')}${list.length > CHIPS ? '' : 'Other folder…'}</button>`
|
|
424
|
+
: `<button class="lp-chip add" data-add="${addOn}">${icon('folder')}${addOn ? `Pick a project on ${esc(placeName(addOn))}…` : 'Choose a folder…'}</button>`;
|
|
425
|
+
const none = `<button class="lp-chip none ${pick === NO_FOLDER ? 'on' : ''}" data-id="${NO_FOLDER}" title="No folder: a general ask, with no project and no worktree. The agent gets an empty scratch directory.">${icon('spark')}<span>No folder</span></button>`;
|
|
426
|
+
box.innerHTML = `<span class="l-in">in</span><span class="lp-group">${chips}${more}${none}${add}${offline}</span>`;
|
|
416
427
|
fitChips(box);
|
|
417
428
|
fitRow ??= new ResizeObserver(([e]) => {
|
|
418
429
|
if (e.contentRect.width === fitRow.w) return; // its own folding only changes the height
|
|
@@ -423,19 +434,19 @@ export function renderLaunchChips() {
|
|
|
423
434
|
paintScratch();
|
|
424
435
|
// A plain pick makes it the task's only folder; `add` (⌘-click, the + badge) adds it to the
|
|
425
436
|
// folders or takes it out. Leaving out the first folder hands its place to the next one.
|
|
426
|
-
const
|
|
427
|
-
const also =
|
|
428
|
-
if (add && ownPick(
|
|
429
|
-
if (id !==
|
|
437
|
+
const choose = (id, { add = false } = {}) => {
|
|
438
|
+
const also = alsoIds();
|
|
439
|
+
if (add && ownPick() && projectById(id)) {
|
|
440
|
+
if (id !== pickId()) launch.also[''] = also.includes(id) ? also.filter((x) => x !== id) : [...also, id];
|
|
430
441
|
else if (also.length) {
|
|
431
|
-
launch.picks[
|
|
432
|
-
launch.also[
|
|
442
|
+
launch.picks[''] = also[0];
|
|
443
|
+
launch.also[''] = also.slice(1);
|
|
433
444
|
}
|
|
434
445
|
} else {
|
|
435
|
-
launch.picks[
|
|
436
|
-
delete launch.also[
|
|
446
|
+
launch.picks[''] = id;
|
|
447
|
+
delete launch.also[''];
|
|
437
448
|
}
|
|
438
|
-
|
|
449
|
+
launch.project = storedPick();
|
|
439
450
|
saveLaunch();
|
|
440
451
|
paintScratch();
|
|
441
452
|
renderLaunchRecipes();
|
|
@@ -443,33 +454,31 @@ export function renderLaunchChips() {
|
|
|
443
454
|
box.onclick = (e) => {
|
|
444
455
|
const chip = e.target.closest('.lp-chip[data-id]');
|
|
445
456
|
if (chip) {
|
|
446
|
-
|
|
447
|
-
pick(k, chip.dataset.id, { add: !!e.target.closest('[data-also]') || e.metaKey || e.ctrlKey || e.shiftKey });
|
|
457
|
+
choose(chip.dataset.id, { add: !!e.target.closest('[data-also]') || e.metaKey || e.ctrlKey || e.shiftKey });
|
|
448
458
|
renderLaunchChips(); // the + and × badges follow the picks
|
|
449
459
|
return;
|
|
450
460
|
}
|
|
451
461
|
const more = e.target.closest('[data-more]');
|
|
452
462
|
if (more) {
|
|
453
|
-
const k = more.dataset.more;
|
|
454
463
|
return projectList(more, {
|
|
455
|
-
projects:
|
|
456
|
-
current:
|
|
464
|
+
projects: projects(),
|
|
465
|
+
current: pickId(),
|
|
457
466
|
// Each row's + adds it to the task (or takes it out) and leaves the list open.
|
|
458
|
-
also: ownPick(
|
|
459
|
-
has: (id) => chosen(
|
|
467
|
+
also: ownPick() && {
|
|
468
|
+
has: (id) => chosen(id),
|
|
460
469
|
toggle: (id) => {
|
|
461
|
-
|
|
470
|
+
choose(id, { add: true });
|
|
462
471
|
renderLaunchChips();
|
|
463
472
|
},
|
|
464
473
|
},
|
|
465
|
-
label:
|
|
474
|
+
label: 'Projects',
|
|
466
475
|
extras: [
|
|
467
476
|
{ id: NO_FOLDER, label: 'No folder', icon: 'spark' },
|
|
468
|
-
{ id: '+add', label:
|
|
477
|
+
{ id: '+add', label: addOn ? `Another folder on ${placeName(addOn)}…` : 'Other folder…', icon: 'folder' },
|
|
469
478
|
],
|
|
470
479
|
onPick: (id) => {
|
|
471
|
-
if (id === '+add') return openProjects({ focusAdd: true, machine:
|
|
472
|
-
|
|
480
|
+
if (id === '+add') return openProjects({ focusAdd: true, machine: addOn || null });
|
|
481
|
+
choose(id);
|
|
473
482
|
renderLaunchChips(); // the pick joins the chips
|
|
474
483
|
$('#prompt')?.focus();
|
|
475
484
|
},
|
|
@@ -499,7 +508,6 @@ function fitChips(box) {
|
|
|
499
508
|
$('.lp-more-n', more).textContent = `+${n}`;
|
|
500
509
|
};
|
|
501
510
|
paint();
|
|
502
|
-
if (launch.where.length > 1) continue;
|
|
503
511
|
const wraps = () => {
|
|
504
512
|
const kids = [...g.children].filter((x) => !x.hidden && x.offsetParent);
|
|
505
513
|
if (!kids.length) return false; // not laid out (a launcher painted over, or folded into the dock)
|
|
@@ -543,12 +551,12 @@ function whereMenu(anchor) {
|
|
|
543
551
|
return `<button class="wm-row ${on ? 'on' : ''}" data-k="${k}" role="menuitemcheckbox" aria-checked="${on}">
|
|
544
552
|
<span class="wm-check">${icon('check')}</span>
|
|
545
553
|
<span class="wm-icon">${icon(k ? 'server' : 'laptop')}${m ? `<i class="dot ${st === 'online' ? 'ok' : st === 'error' ? 'bad' : ''}"></i>` : ''}</span>
|
|
546
|
-
<span class="wm-name"><b>${esc(placeName(k))}</b><small>${m ? esc(st === 'online' ? m.target : `${m.target} · ${MACHINE_STATE[st]}`) :
|
|
554
|
+
<span class="wm-name"><b>${esc(placeName(k))}</b><small>${m ? esc(st === 'online' ? m.target : `${m.target} · ${MACHINE_STATE[st]}`) : 'Where wisp runs'}</small></span>
|
|
547
555
|
${m && st === 'online' && ready.length ? `<span class="wm-agents">${ready.map((a) => `<span class="model-logo">${harnessLogo(a.id)}</span>`).join('')}</span>` : ''}
|
|
548
556
|
<span class="wm-only" data-only="${k}" title="Only here">only</span>
|
|
549
557
|
</button>`;
|
|
550
558
|
};
|
|
551
|
-
box.innerHTML = `<div class="wm-h">Run on <small>pick several to run in parallel</small></div>
|
|
559
|
+
box.innerHTML = `<div class="wm-h">Run on <small>pick several to run in parallel · any project runs anywhere</small></div>
|
|
552
560
|
${row('', null)}${S.machines.map((m) => row(m.id, m)).join('')}
|
|
553
561
|
<div class="wm-sep"></div>
|
|
554
562
|
<button data-a="add">${icon('plus')}${S.machines.length ? 'Add a machine…' : 'Add a machine to run on another computer…'}</button>
|
|
@@ -676,10 +684,12 @@ function addPending(prompt, { project: p, m, also = [] }, { agent, model, effort
|
|
|
676
684
|
|
|
677
685
|
// A launch from a dialog (rerun, recipes): the dialog closes and a placeholder shows in Working
|
|
678
686
|
// at once; the real session replaces it, or it disappears and the error is thrown on.
|
|
679
|
-
|
|
680
|
-
|
|
687
|
+
// machine: where it runs ('' = this computer), else where the folder is.
|
|
688
|
+
export async function launchBehind({ prompt, cwd, runner, machine }, request) {
|
|
689
|
+
const sum = S.config.projects.find((p) => placesOf(p).some((k) => pathAt(p, k) === cwd));
|
|
681
690
|
const project = sum || { path: cwd, name: cwd.split('/').filter(Boolean).pop() || cwd, id: null, color: 'slate' };
|
|
682
|
-
const
|
|
691
|
+
const k = machine ?? placeOf(project);
|
|
692
|
+
const ghost = addPending(prompt, { project, m: k ? machineById(k) : null }, runner, k && sum && !pathAt(sum, k) ? `Cloning ${sum.name} on ${placeName(k)}` : undefined);
|
|
683
693
|
if (S.filter !== 'all' && S.filter !== project.id) {
|
|
684
694
|
S.filter = 'all';
|
|
685
695
|
save('wisp.filter', S.filter);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// "Runs on" for one task: pick this computer or a machine for a follow-up or a rerun. The
|
|
2
|
+
// launcher's own menu (several places at once) is whereMenu in launcher.js.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import * as bus from '../core/bus.js';
|
|
6
|
+
import { $, el, icon } from '../core/dom.js';
|
|
7
|
+
import { S } from '../core/state.js';
|
|
8
|
+
import { popover } from '../ui/popover.js';
|
|
9
|
+
import { MACHINE_STATE, machineById, placeName } from './places.js';
|
|
10
|
+
|
|
11
|
+
// One place for a follow-up or a rerun: this computer or a machine. from: where the task is now.
|
|
12
|
+
// locked: why it can't move right now (a running task), shown instead of a choice.
|
|
13
|
+
export function placeMenu(anchor, { title, current, from, note = '', locked = '' }, pick) {
|
|
14
|
+
if ($('.menu.place-menu')) return $('.menu.place-menu').close();
|
|
15
|
+
const box = el(`<div class="menu where-menu place-menu" role="menu"></div>`);
|
|
16
|
+
const paint = () => {
|
|
17
|
+
const row = (k) => {
|
|
18
|
+
const m = k ? machineById(k) : null;
|
|
19
|
+
const st = m ? m.state : 'online';
|
|
20
|
+
const on = k === current;
|
|
21
|
+
const sub = k === from ? 'Where it is now' : m ? (st === 'online' ? m.target : `${m.target} · ${MACHINE_STATE[st]}`) : 'Where wisp runs';
|
|
22
|
+
return `<button class="wm-row ${on ? 'on' : ''}" data-k="${k}" role="menuitemradio" aria-checked="${on}" ${locked && k !== from ? 'disabled' : ''}>
|
|
23
|
+
<span class="wm-check">${icon('check')}</span>
|
|
24
|
+
<span class="wm-icon">${icon(k ? 'server' : 'laptop')}${m ? `<i class="dot ${st === 'online' ? 'ok' : st === 'error' ? 'bad' : ''}"></i>` : ''}</span>
|
|
25
|
+
<span class="wm-name"><b>${esc(placeName(k))}</b><small>${esc(sub)}</small></span>
|
|
26
|
+
</button>`;
|
|
27
|
+
};
|
|
28
|
+
box.innerHTML = `<div class="wm-h">${esc(title)}</div>${locked || note ? `<p class="wm-note ${locked ? 'warn' : ''}">${esc(locked || note)}</p>` : ''}${row('')}${S.machines.map((m) => row(m.id)).join('')}`;
|
|
29
|
+
};
|
|
30
|
+
box.onclick = (e) => {
|
|
31
|
+
const r = e.target.closest('.wm-row:not([disabled])');
|
|
32
|
+
if (!r) return;
|
|
33
|
+
shut();
|
|
34
|
+
pick(r.dataset.k);
|
|
35
|
+
};
|
|
36
|
+
paint();
|
|
37
|
+
const shut = popover(box, { anchor, onClose: () => anchor.setAttribute('aria-expanded', 'false') });
|
|
38
|
+
bus.on('machines', paint, { node: box });
|
|
39
|
+
anchor.setAttribute('aria-expanded', 'true');
|
|
40
|
+
const r = anchor.getBoundingClientRect();
|
|
41
|
+
const h = box.offsetHeight;
|
|
42
|
+
box.style.left = `${Math.max(8, Math.min(r.left, innerWidth - box.offsetWidth - 8))}px`;
|
|
43
|
+
box.style.top = `${r.bottom + 6 + h > innerHeight - 8 ? Math.max(8, r.top - h - 6) : r.bottom + 6}px`;
|
|
44
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Projects and machines: the places a task can run. A project is
|
|
2
|
-
//
|
|
1
|
+
// Projects and machines: the places a task can run. A project is one repo, and runs on every place:
|
|
2
|
+
// it was added on one (path, machine), and wisp clones it onto the others the first time a task
|
|
3
|
+
// runs there (copies: { [machine id | 'here']: path }; server/services/places.js).
|
|
3
4
|
|
|
4
5
|
import { esc } from '../md.js';
|
|
5
6
|
import { icon } from '../core/dom.js';
|
|
@@ -20,17 +21,26 @@ export function projectLabel(s) {
|
|
|
20
21
|
}
|
|
21
22
|
export const placeName = (k) => (k ? machineById(k)?.name || 'machine' : 'This computer');
|
|
22
23
|
export const recentFirst = (a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt);
|
|
23
|
-
|
|
24
|
+
// Every project, on every place: one that isn't on a place yet gets cloned there on first use.
|
|
25
|
+
export const projects = () => [...S.config.projects].sort(recentFirst);
|
|
26
|
+
// The project's folder on place k, or '' when wisp still has to put it there.
|
|
27
|
+
export const pathAt = (p, k) => (placeOf(p) === k ? p.path : p?.copies?.[k || 'here'] || '');
|
|
28
|
+
// The places that have a checkout of it: where it was added first.
|
|
29
|
+
export const placesOf = (p) => (p ? [placeOf(p), ...Object.keys(p.copies || {}).map((k) => (k === 'here' ? '' : k))].filter((k, i, a) => a.indexOf(k) === i && (!k || machineById(k))) : []);
|
|
24
30
|
|
|
25
31
|
// A machine runs its own wisp; this one signs in over SSH, tunnels to it and shows its sessions
|
|
26
|
-
// as <id>@<machine>.
|
|
32
|
+
// as <id>@<machine>. Any project runs there; the launch clones it there first if it has to.
|
|
27
33
|
export const machineById = (id) => S.machines.find((m) => m.id === id);
|
|
28
34
|
const machineOnline = (id) => machineById(id)?.state === 'online';
|
|
29
35
|
export const MACHINE_STATE = { online: 'Online', connecting: 'Connecting…', reconnecting: 'Reconnecting…', error: 'Couldn’t connect', off: 'Not connected' };
|
|
36
|
+
// Where it is: "~/code/app", "on devbox: /srv/app", plus the other places that have it.
|
|
30
37
|
export const projectTitle = (p) => {
|
|
31
38
|
if (!p) return '';
|
|
39
|
+
const tilde = (x) => (S.config.home && x.startsWith(S.config.home) ? `~${x.slice(S.config.home.length)}` : x);
|
|
32
40
|
const m = p.machine && machineById(p.machine);
|
|
33
|
-
|
|
41
|
+
const first = m ? `${p.path} on ${m.name}${m.state === 'online' ? '' : ' (not connected)'}` : tilde(p.path);
|
|
42
|
+
const also = placesOf(p).slice(1).map(placeName);
|
|
43
|
+
return also.length ? `${first}\nAlso checked out on ${also.join(', ')}` : first;
|
|
34
44
|
};
|
|
35
45
|
|
|
36
46
|
export function machinePill(mt) {
|
|
@@ -13,7 +13,7 @@ import { feedToTop, renderFeed } from './feed.js';
|
|
|
13
13
|
import { renderLaunchChips } from './launcher.js';
|
|
14
14
|
import { loadConfig } from './live.js';
|
|
15
15
|
import { connectMachine, openMachines } from './machines.js';
|
|
16
|
-
import { inProject, MACHINE_STATE, machineById,
|
|
16
|
+
import { inProject, MACHINE_STATE, machineById, pathAt, placeName, placesOf, projectById, projectTitle, recentFirst } from './places.js';
|
|
17
17
|
import { projectList } from './project-list.js';
|
|
18
18
|
|
|
19
19
|
// Project filter chips in the top bar before the rest fold into "+N".
|
|
@@ -203,8 +203,8 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
203
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
204
|
else
|
|
205
205
|
hint.innerHTML = m
|
|
206
|
-
? `Folders on <b>${esc(m.name)}</b>, or a repo URL to clone there.
|
|
207
|
-
:
|
|
206
|
+
? `Folders on <b>${esc(m.name)}</b>, or a repo URL to clone there. You don’t need to add a project here that you already have: every project runs on ${esc(m.name)}, and wisp clones it there the first time.`
|
|
207
|
+
: `Paste a GitHub URL and wisp clones it for you. Or run <code>wisp add</code> inside any folder: it is added automatically.${S.machines.length ? ' Projects run on your machines too: wisp clones them there when a task needs it.' : ''}`;
|
|
208
208
|
};
|
|
209
209
|
// Change the clone folder in place: the hint turns into a small path field.
|
|
210
210
|
$('.hint', node).addEventListener('click', (e) => {
|
|
@@ -255,13 +255,13 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
255
255
|
input.focus();
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
-
// A new project becomes the launcher's pick
|
|
258
|
+
// A new project becomes the launcher's pick; added on a machine, the task runs there.
|
|
259
259
|
const picked = async (proj) => {
|
|
260
260
|
await loadConfig();
|
|
261
|
-
launch.picks[
|
|
262
|
-
delete launch.also[
|
|
263
|
-
|
|
264
|
-
if (
|
|
261
|
+
launch.picks[''] = proj.id;
|
|
262
|
+
delete launch.also[''];
|
|
263
|
+
launch.project = proj.id;
|
|
264
|
+
if (!launch.where.includes(where || '')) launch.where = [where || ''];
|
|
265
265
|
saveLaunch();
|
|
266
266
|
renderLaunchChips();
|
|
267
267
|
};
|
|
@@ -332,9 +332,9 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
332
332
|
|
|
333
333
|
const list = () => {
|
|
334
334
|
const box = $('#plist', node);
|
|
335
|
-
|
|
335
|
+
// Every project, wherever it was added: each runs on every place. Its row says where it's checked out.
|
|
336
|
+
const projects = [...S.config.projects].sort(recentFirst);
|
|
336
337
|
const pending = clones
|
|
337
|
-
.filter((j) => j.where === where)
|
|
338
338
|
.map(
|
|
339
339
|
(j) => `<div class="prow cloning"><span class="swatch"><span class="orb live"></span></span>
|
|
340
340
|
<div class="prow-main"><b class="pname">${esc(repoName(j.url))}</b><span class="ppath">Cloning ${esc(repoLabel(j.url))}…</span></div></div>`,
|
|
@@ -345,15 +345,17 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
345
345
|
projects
|
|
346
346
|
.map((p) => {
|
|
347
347
|
const n = [...S.sessions.values()].filter((s) => inProject(s, p.id)).length;
|
|
348
|
+
const at = placesOf(p);
|
|
349
|
+
const spots = at.map((k) => `<span class="pplace" title="${esc(k ? `${pathAt(p, k)} on ${placeName(k)}` : pathAt(p, k))}">${icon(k ? 'server' : 'laptop')}${esc(placeName(k))}</span>`).join('');
|
|
348
350
|
return `<div class="prow c-${p.color}" data-id="${p.id}">
|
|
349
351
|
<button class="swatch" title="Change colour"><i class="p-dot"></i></button>
|
|
350
|
-
<div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(tilde(p.path))}</span></div>
|
|
352
|
+
<div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${S.machines.length ? spots : esc(tilde(p.path))}</span></div>
|
|
351
353
|
<span class="pcount">${n} session${n === 1 ? '' : 's'}</span>
|
|
352
354
|
<button class="icon-btn premove" title="Remove from wisp (files untouched)">${icon('trash')}</button>
|
|
353
355
|
</div>`;
|
|
354
356
|
})
|
|
355
357
|
.join('')
|
|
356
|
-
: `<p class="muted">No projects
|
|
358
|
+
: `<p class="muted">No projects yet. Add a folder above, or pick one below.</p>`;
|
|
357
359
|
$$('.prow[data-id]', box).forEach((row) => {
|
|
358
360
|
const id = row.dataset.id;
|
|
359
361
|
const p = projectById(id);
|
|
@@ -20,6 +20,8 @@ import { openPhone, phoneSummary } from './phone.js';
|
|
|
20
20
|
import { machinesSummary } from './places.js';
|
|
21
21
|
import { AUTO_GOALS, harnessLogo } from './runners.js';
|
|
22
22
|
import { INSTALLABLE, installHarness, isInstalling } from './setup.js';
|
|
23
|
+
import { clearPicks } from './transcript.js';
|
|
24
|
+
import { repaintViews } from './turns.js';
|
|
23
25
|
import { openUsage } from './usage.js';
|
|
24
26
|
|
|
25
27
|
// A sidebar of sections and one pane at a time, like every other app's settings. Big lists
|
|
@@ -80,6 +82,9 @@ export function openSettings(opts) {
|
|
|
80
82
|
${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
83
|
${row('Auto-fix PR conflicts', 'Without waiting for a click on Fix conflicts: when a shipped PR conflicts with its base, the task merges the base in, resolves it and pushes', '<button class="switch" id="fixConflicts" role="switch"></button>')}
|
|
82
84
|
</div>
|
|
85
|
+
<div class="sx-group">
|
|
86
|
+
${row('Full transcript', 'Every message and step the agent took, with its presentation at the end. Any one card switches with its Transcript button or <kbd>V</kbd>', '<button class="switch" id="txDefault" role="switch"></button>')}
|
|
87
|
+
</div>
|
|
83
88
|
</section>
|
|
84
89
|
<section class="sx-pane" data-pane="notifications">
|
|
85
90
|
<div class="sx-group">
|
|
@@ -116,7 +121,7 @@ export function openSettings(opts) {
|
|
|
116
121
|
<div class="sx-group">
|
|
117
122
|
${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
|
|
118
123
|
</div>
|
|
119
|
-
<p class="hint">Also in the top bar
|
|
124
|
+
<p class="hint">Also in the top bar. Every project runs on every machine: pick one under Run on and wisp clones the project there the first time. A follow-up or a rerun can move a task to another one.</p>
|
|
120
125
|
</section>
|
|
121
126
|
<section class="sx-pane" data-pane="memory">
|
|
122
127
|
<div class="sx-group">
|
|
@@ -308,6 +313,9 @@ export function openSettings(opts) {
|
|
|
308
313
|
});
|
|
309
314
|
if (!saved) return;
|
|
310
315
|
paintWt();
|
|
316
|
+
renderLauncher();
|
|
317
|
+
};
|
|
318
|
+
paintWt();
|
|
311
319
|
const fc = $('#fixConflicts', node);
|
|
312
320
|
const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts === 'on');
|
|
313
321
|
fc.onclick = async () => {
|
|
@@ -321,9 +329,22 @@ export function openSettings(opts) {
|
|
|
321
329
|
}
|
|
322
330
|
};
|
|
323
331
|
paintFc();
|
|
324
|
-
|
|
332
|
+
// The transcript switch flips every open card at once; a refused save flips them back.
|
|
333
|
+
const tx = $('#txDefault', node);
|
|
334
|
+
const paintTx = () => tx.setAttribute('aria-checked', S.config.defaults.transcript === 'on');
|
|
335
|
+
const setTx = (v) => {
|
|
336
|
+
S.config.defaults.transcript = v;
|
|
337
|
+
clearPicks();
|
|
338
|
+
paintTx();
|
|
339
|
+
repaintViews();
|
|
325
340
|
};
|
|
326
|
-
|
|
341
|
+
tx.onclick = async () => {
|
|
342
|
+
const was = S.config.defaults.transcript || 'off';
|
|
343
|
+
setTx(was === 'on' ? 'off' : 'on');
|
|
344
|
+
const saved = await tryApi(() => api('/config', { method: 'PATCH', body: { defaults: { transcript: S.config.defaults.transcript } } }));
|
|
345
|
+
if (!saved) setTx(was);
|
|
346
|
+
};
|
|
347
|
+
paintTx();
|
|
327
348
|
// Auto: goal, exploring and allowed harnesses here; the full record in its own view.
|
|
328
349
|
const paintAuto = async () => {
|
|
329
350
|
const r = await api('/router').catch(() => null);
|