wisp-agents 0.21.0 → 0.23.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 (60) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +15 -10
  3. package/bin/wisp.js +10 -3
  4. package/package.json +15 -4
  5. package/server/adapters/claude.js +8 -4
  6. package/server/adapters/codex.js +9 -6
  7. package/server/adapters/cursor.js +4 -1
  8. package/server/adapters/grok.js +2 -1
  9. package/server/adapters/index.js +7 -2
  10. package/server/adapters/opencode.js +5 -2
  11. package/server/btw.js +76 -0
  12. package/server/client.js +19 -0
  13. package/server/git.js +80 -4
  14. package/server/handoff.js +13 -7
  15. package/server/index.js +4 -0
  16. package/server/machines.js +15 -4
  17. package/server/openapi.js +27 -2
  18. package/server/remote.js +4 -2
  19. package/server/router.js +35 -20
  20. package/server/routes/machines.js +1 -1
  21. package/server/routes/proxy.js +6 -0
  22. package/server/routes/sessions.js +32 -2
  23. package/server/routes/settings.js +24 -38
  24. package/server/routes/uploads.js +2 -0
  25. package/server/runner.js +154 -8
  26. package/server/services/launch.js +32 -4
  27. package/server/services/move.js +130 -0
  28. package/server/services/phone.js +20 -1
  29. package/server/services/places.js +150 -0
  30. package/server/services/uploads.js +23 -4
  31. package/server/store.js +39 -6
  32. package/skills/wisp/SKILL.md +1 -0
  33. package/web/css/blocks.css +2 -0
  34. package/web/css/composer.css +23 -1
  35. package/web/css/feed.css +49 -1
  36. package/web/css/machines.css +4 -0
  37. package/web/css/modals.css +19 -0
  38. package/web/css/ship.css +2 -0
  39. package/web/css/transcript.css +115 -0
  40. package/web/index.html +2 -0
  41. package/web/js/core/state.js +3 -2
  42. package/web/js/features/actions.js +42 -10
  43. package/web/js/features/auto.js +1 -1
  44. package/web/js/features/btw.js +145 -0
  45. package/web/js/features/clones.js +7 -6
  46. package/web/js/features/commands.js +3 -1
  47. package/web/js/features/composer.js +162 -18
  48. package/web/js/features/feed.js +1 -1
  49. package/web/js/features/keys.js +2 -1
  50. package/web/js/features/launcher.js +104 -94
  51. package/web/js/features/place-menu.js +44 -0
  52. package/web/js/features/places.js +15 -5
  53. package/web/js/features/projects.js +14 -12
  54. package/web/js/features/runners.js +2 -2
  55. package/web/js/features/settings.js +24 -3
  56. package/web/js/features/slash.js +63 -0
  57. package/web/js/features/transcript.js +290 -0
  58. package/web/js/features/turns.js +99 -28
  59. package/web/js/slash-text.js +22 -0
  60. 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, a project in each), on what, and shows a placeholder card until the server answers.
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, projectsAt, projectTitle } from './places.js';
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
- const projectOf = (k) => (launch.picks[k] === NO_FOLDER ? GENERAL : projectById(launch.picks[k]));
34
- // The place's other folders for the task (launch.also), and whether a project is in it at all.
35
- const alsoAt = (k) => launch.also[k] || [];
36
- const chosen = (k, id) => id === launch.picks[k] || alsoAt(k).includes(id);
37
- const ownPick = (k) => projectsAt(k).some((p) => p.id === launch.picks[k]);
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
- // Filtering the feed to a project points the launcher at it (its machine joins if it wasn't picked).
49
- // One that's already in the task (as its first folder or another) leaves the selection as it is.
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
- if (!launch.where.includes(placeOf(f))) launch.where = [placeOf(f)];
54
- if (!chosen(placeOf(f), f.id)) {
55
- launch.picks[placeOf(f)] = f.id;
56
- delete launch.also[placeOf(f)];
57
- }
58
- } else if (projectById(launch.project) && !Object.values(launch.picks).includes(launch.project)) launch.picks[placeOf(projectById(launch.project))] ??= launch.project;
59
- for (const k of launch.where) if (launch.picks[k] !== NO_FOLDER && !cloneById(launch.picks[k]) && !projectsAt(k).some((p) => p.id === launch.picks[k])) launch.picks[k] = projectsAt(k)[0]?.id || null;
60
- // Other folders only go with a project (not No folder or a clone), and only that place's projects.
61
- for (const k of Object.keys(launch.also)) {
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", each in the project picked for it.
268
- const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectOf(k), clone: cloneById(launch.picks[k]), also: ownPick(k) ? alsoAt(k).map(projectById).filter(Boolean) : [] }));
269
- const unset = targets.find((t) => !t.project && !t.clone);
270
- if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
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 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}`));
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 || undefined, 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 },
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 everywhere picked: no git repo, so no worktree either; the toggle would only mislead.
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 = launch.where.every((k) => launch.picks[k] === NO_FOLDER);
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 multi = launch.where.length > 1;
381
- // One group per place: its name (unless it's only this computer), its projects, a way to add one.
382
- const group = (k) => {
383
- const m = k && machineById(k);
384
- const list = projectsAt(k);
385
- const off = m && m.state !== 'online';
386
- const label = k || multi ? `<span class="lp-gl ${off ? 'off' : ''}" title="${esc(k ? `${m?.target} · ${MACHINE_STATE[m?.state] || ''}` : 'This computer')}">${icon(k ? 'server' : 'laptop')}${esc(placeName(k))}${off ? `<button class="link xs" data-connect="${k}">${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : 'Connect'}</button>` : ''}</span>` : '';
387
- // A repo still cloning: picked, with a spinner, until the real project replaces it.
388
- const cloning = clones
389
- .filter((c) => (c.where || '') === k)
390
- .map((c) => `<button class="lp-chip cloning ${c.id === launch.picks[k] ? 'on' : ''}" data-id="${c.id}" data-k="${k}" title="Cloning ${esc(c.label)}…"><span class="orb live"></span>${esc(c.name)}<span class="lp-note">cloning…</span></button>`)
391
- .join('');
392
- // Tens of projects: the most recent few as chips (the picks always among them), the rest one
393
- // search away behind "+N". fitChips() then folds more into it if the row would wrap.
394
- const max = multi ? 3 : CHIPS;
395
- const picked = list.filter((p) => chosen(k, p.id));
396
- const rest = list.filter((p) => !chosen(k, p.id)).slice(0, Math.max(0, max - picked.length));
397
- const shown = list.filter((p) => picked.includes(p) || rest.includes(p));
398
- // A task can span several of the place's projects: ⌘-click a chip, or its + badge, to add it;
399
- // the × on a picked one leaves it out again.
400
- const own = ownPick(k);
401
- const several = own && alsoAt(k).length > 0;
402
- const badge = (p) =>
403
- chosen(k, p.id)
404
- ? several ? `<span class="lp-also x" data-also="${p.id}" title="Leave ${esc(p.name)} out of this task">${icon('x')}</span>` : ''
405
- : own ? `<span class="lp-also" data-also="${p.id}" title="Also work in ${esc(p.name)} (${MOD}-click)">${icon('plus')}</span>` : '';
406
- const tip = (p) => `${projectTitle(p)}${several && p.id === launch.picks[k] ? '\nThe agent starts here' : ''}${own && !chosen(k, p.id) ? `\n${MOD}-click to work in it too` : ''}`;
407
- const chips = cloning + shown.map((p) => `<button class="lp-chip c-${p.color} ${chosen(k, p.id) ? 'on' : ''}" data-id="${p.id}" data-k="${k}" title="${esc(tip(p))}"><i class="p-dot"></i>${esc(p.name)}${badge(p)}</button>`).join('');
408
- const more = list.length > 1 ? `<button class="lp-chip more" data-more="${k}" data-total="${list.length}" title="All ${list.length} projects${k ? ` on ${esc(placeName(k))}` : ''}" aria-haspopup="dialog"><span class="lp-more-n"></span>${icon('search')}</button>` : '';
409
- const add = list.length || cloning
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
- box.innerHTML = `<span class="l-in">in</span>${launch.where.map(group).join('')}`;
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 pick = (k, id, { add = false } = {}) => {
427
- const also = alsoAt(k);
428
- if (add && ownPick(k) && projectById(id)) {
429
- if (id !== launch.picks[k]) launch.also[k] = also.includes(id) ? also.filter((x) => x !== id) : [...also, 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[k] = also[0];
432
- launch.also[k] = also.slice(1);
442
+ launch.picks[''] = also[0];
443
+ launch.also[''] = also.slice(1);
433
444
  }
434
445
  } else {
435
- launch.picks[k] = id;
436
- delete launch.also[k];
446
+ launch.picks[''] = id;
447
+ delete launch.also[''];
437
448
  }
438
- if (k === launch.where[0]) launch.project = launch.picks[k];
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
- const k = chip.dataset.k;
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: projectsAt(k),
456
- current: launch.picks[k],
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(k) && {
459
- has: (id) => chosen(k, id),
467
+ also: ownPick() && {
468
+ has: (id) => chosen(id),
460
469
  toggle: (id) => {
461
- pick(k, id, { add: true });
470
+ choose(id, { add: true });
462
471
  renderLaunchChips();
463
472
  },
464
473
  },
465
- label: `Projects on ${placeName(k)}`,
474
+ label: 'Projects',
466
475
  extras: [
467
476
  { id: NO_FOLDER, label: 'No folder', icon: 'spark' },
468
- { id: '+add', label: k ? `Another folder on ${placeName(k)}…` : 'Other folder…', icon: 'folder' },
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: k || null });
472
- pick(k, id);
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]}`) : `${projectsAt('').length} project${projectsAt('').length === 1 ? '' : 's'}`}</small></span>
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
- export async function launchBehind({ prompt, cwd, runner }, request) {
680
- const sum = S.config.projects.find((p) => p.path === cwd);
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 ghost = addPending(prompt, { project, m: project.machine ? machineById(project.machine) : null }, runner);
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 a folder, on this computer or on a
2
- // machine (another computer running its own wisp, reached over SSH).
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
- export const projectsAt = (k) => S.config.projects.filter((p) => placeOf(p) === k).sort(recentFirst);
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>. Projects can live on a machine; launching in one runs the agent there.
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
- return m ? `${p.path} on ${m.name}${m.state === 'online' ? '' : ' (not connected)'}` : p.path;
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, placeOf, projectById, projectTitle, recentFirst } from './places.js';
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. 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.';
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 for its place.
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[placeOf(proj)] = proj.id;
262
- delete launch.also[placeOf(proj)];
263
- if (!launch.where.includes(placeOf(proj))) launch.where = [placeOf(proj)];
264
- if (placeOf(proj) === launch.where[0]) launch.project = proj.id;
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
- const projects = S.config.projects.filter((p) => (p.machine || null) === where);
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 ${where ? `on ${esc(here()?.name || '')} ` : ''}yet. Add a folder above, or pick one below.</p>`;
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);
@@ -21,9 +21,9 @@ export const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id
21
21
  // level per task. Its "models" are the goal it optimises for, which also sets the level band, so
22
22
  // there are no thinking levels to pick here.
23
23
  export const AUTO_GOALS = [
24
- { id: 'balanced', label: 'Balanced', hint: 'Best result per dollar · default or medium thinking' },
24
+ { id: 'balanced', label: 'Balanced', hint: 'Frontier models · default or medium thinking' },
25
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
+ { id: 'best', label: 'Best', hint: 'Frontier models · high thinking; cost only breaks ties' },
27
27
  ];
28
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' };
29
29
 
@@ -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, and in Projects: add folders on a machine, then launch there like anywhere else.</p>
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
- renderLauncher();
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
- paintWt();
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);