wisp-agents 0.20.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +17 -8
  3. package/bin/wisp.js +14 -6
  4. package/package.json +4 -3
  5. package/server/adapters/claude.js +12 -7
  6. package/server/adapters/codex.js +13 -7
  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 +22 -2
  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/mcp-wisp.js +2 -1
  18. package/server/openapi.js +28 -2
  19. package/server/remote.js +4 -2
  20. package/server/routes/machines.js +1 -1
  21. package/server/routes/proxy.js +6 -0
  22. package/server/routes/sessions.js +47 -10
  23. package/server/routes/settings.js +24 -38
  24. package/server/routes/ship.js +19 -24
  25. package/server/routes/uploads.js +2 -0
  26. package/server/runner.js +178 -12
  27. package/server/services/auto.js +10 -2
  28. package/server/services/launch.js +67 -7
  29. package/server/services/move.js +130 -0
  30. package/server/services/phone.js +20 -1
  31. package/server/services/places.js +150 -0
  32. package/server/services/ship.js +97 -10
  33. package/server/services/uploads.js +23 -4
  34. package/server/store.js +39 -6
  35. package/skills/wisp/SKILL.md +3 -1
  36. package/web/css/blocks.css +2 -0
  37. package/web/css/composer.css +23 -1
  38. package/web/css/feed.css +52 -1
  39. package/web/css/launcher.css +12 -0
  40. package/web/css/machines.css +4 -0
  41. package/web/css/modals.css +19 -0
  42. package/web/css/palette.css +6 -0
  43. package/web/css/ship.css +14 -0
  44. package/web/css/transcript.css +115 -0
  45. package/web/index.html +2 -0
  46. package/web/js/core/state.js +5 -2
  47. package/web/js/features/actions.js +42 -10
  48. package/web/js/features/btw.js +145 -0
  49. package/web/js/features/clones.js +7 -6
  50. package/web/js/features/commands.js +3 -1
  51. package/web/js/features/composer.js +162 -18
  52. package/web/js/features/feed.js +10 -10
  53. package/web/js/features/keys.js +2 -1
  54. package/web/js/features/launcher.js +121 -67
  55. package/web/js/features/place-menu.js +44 -0
  56. package/web/js/features/places.js +25 -5
  57. package/web/js/features/project-list.js +20 -5
  58. package/web/js/features/projects.js +16 -13
  59. package/web/js/features/settings.js +24 -3
  60. package/web/js/features/ship.js +66 -29
  61. package/web/js/features/slash.js +63 -0
  62. package/web/js/features/transcript.js +290 -0
  63. package/web/js/features/turns.js +101 -29
  64. package/web/js/slash-text.js +22 -0
  65. 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,7 +31,17 @@ 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
+ // 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());
44
+ const MOD = combo('').replace(/\+$/, ''); // ⌘ or Ctrl, for "⌘-click"
34
45
  // Project chips in the "in" row, at most, before the rest fold into "+N".
35
46
  const CHIPS = 5;
36
47
 
@@ -40,15 +51,25 @@ let filterSeen = null;
40
51
  function syncWhere() {
41
52
  launch.where = launch.where.filter((k, i) => (!k || machineById(k)) && launch.where.indexOf(k) === i);
42
53
  if (!launch.where.length) launch.where = [''];
43
- // Filtering the feed to a project points the launcher at it (its machine joins if it wasn't picked).
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.
44
61
  const f = S.filter !== 'all' && S.filter !== filterSeen && projectById(S.filter);
45
62
  filterSeen = S.filter;
46
- if (f) {
47
- if (!launch.where.includes(placeOf(f))) launch.where = [placeOf(f)];
48
- launch.picks[placeOf(f)] = f.id;
49
- } else if (projectById(launch.project) && !Object.values(launch.picks).includes(launch.project)) launch.picks[placeOf(projectById(launch.project))] ??= launch.project;
50
- 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;
51
- 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;
52
73
  }
53
74
 
54
75
  export function renderLauncher() {
@@ -249,10 +270,10 @@ export function renderLauncher() {
249
270
  ta.dispatchEvent(new Event('input'));
250
271
  return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
251
272
  }
252
- // One run per place picked under "Run on", each in the project picked for it.
253
- const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectOf(k), clone: cloneById(launch.picks[k]) }));
254
- const unset = targets.find((t) => !t.project && !t.clone);
255
- 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 });
256
277
  const off = targets.find((t) => t.m && t.m.state !== 'online');
257
278
  if (off) return offlineToast(off.k);
258
279
  if (needsSetup()) return nudgeSetup();
@@ -271,12 +292,13 @@ export function renderLauncher() {
271
292
  const cmp = compare;
272
293
  const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
273
294
  // A repo still cloning gets a stand-in project; its card says so until the clone lands.
274
- 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));
275
297
  if (cmp.length) (compare = []), paintCompare();
276
298
  ta.value = '';
277
299
  S.drafts.delete('home');
278
300
  ta.dispatchEvent(new Event('input'));
279
- if (S.filter !== 'all' && targets.some((t) => t.project?.scratch || t.project?.id !== S.filter)) {
301
+ if (S.filter !== 'all' && targets.some((t) => t.project?.scratch || (t.project?.id !== S.filter && !t.also.some((p) => p.id === S.filter)))) {
280
302
  S.filter = 'all';
281
303
  save('wisp.filter', S.filter);
282
304
  }
@@ -298,7 +320,7 @@ export function renderLauncher() {
298
320
  targets.map((t) =>
299
321
  api('/sessions', {
300
322
  method: 'POST',
301
- body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, 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 },
302
324
  }),
303
325
  ),
304
326
  );
@@ -352,46 +374,56 @@ export function renderLauncher() {
352
374
  });
353
375
  }
354
376
 
355
- // 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.
356
378
  function paintScratch() {
357
379
  const wt = $('#launcher #wtBtn');
358
- if (wt) wt.hidden = launch.where.every((k) => launch.picks[k] === NO_FOLDER);
380
+ if (wt) wt.hidden = pickId() === NO_FOLDER;
359
381
  }
360
382
 
361
383
  export function renderLaunchChips() {
362
384
  const box = $('#launcher .l-projects');
363
385
  if (!box) return;
364
386
  syncWhere();
365
- const multi = launch.where.length > 1;
366
- // One group per place: its name (unless it's only this computer), its projects, a way to add one.
367
- const group = (k) => {
368
- const m = k && machineById(k);
369
- const list = projectsAt(k);
370
- const off = m && m.state !== 'online';
371
- 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>` : '';
372
- // A repo still cloning: picked, with a spinner, until the real project replaces it.
373
- const cloning = clones
374
- .filter((c) => (c.where || '') === k)
375
- .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>`)
376
- .join('');
377
- // Tens of projects: the most recent few as chips (the pick always among them), the rest one
378
- // search away behind "+N". fitChips() then folds more into it if the row would wrap.
379
- const max = multi ? 3 : CHIPS;
380
- let shown = list;
381
- if (list.length > max) {
382
- const top = list.slice(0, max);
383
- const picked = list.find((p) => p.id === launch.picks[k]);
384
- shown = !picked || top.includes(picked) ? top : [...top.slice(0, -1), picked].sort((a, b) => list.indexOf(a) - list.indexOf(b));
385
- }
386
- const chips = cloning + shown.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launch.picks[k] ? 'on' : ''}" data-id="${p.id}" data-k="${k}" title="${esc(projectTitle(p))}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('');
387
- 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>` : '';
388
- const add = list.length || cloning
389
- ? `<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>`
390
- : `<button class="lp-chip add" data-add="${k}">${icon('folder')}${k ? `Pick a project on ${esc(placeName(k))}…` : 'Choose a folder…'}</button>`;
391
- 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>`;
392
- 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` : ''}`;
393
417
  };
394
- 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>`;
395
427
  fitChips(box);
396
428
  fitRow ??= new ResizeObserver(([e]) => {
397
429
  if (e.contentRect.width === fitRow.w) return; // its own folding only changes the height
@@ -400,9 +432,21 @@ export function renderLaunchChips() {
400
432
  });
401
433
  fitRow.observe(box);
402
434
  paintScratch();
403
- const pick = (k, id) => {
404
- launch.picks[k] = id;
405
- if (k === launch.where[0]) launch.project = id;
435
+ // A plain pick makes it the task's only folder; `add` (⌘-click, the + badge) adds it to the
436
+ // folders or takes it out. Leaving out the first folder hands its place to the next one.
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];
441
+ else if (also.length) {
442
+ launch.picks[''] = also[0];
443
+ launch.also[''] = also.slice(1);
444
+ }
445
+ } else {
446
+ launch.picks[''] = id;
447
+ delete launch.also[''];
448
+ }
449
+ launch.project = storedPick();
406
450
  saveLaunch();
407
451
  paintScratch();
408
452
  renderLaunchRecipes();
@@ -410,24 +454,31 @@ export function renderLaunchChips() {
410
454
  box.onclick = (e) => {
411
455
  const chip = e.target.closest('.lp-chip[data-id]');
412
456
  if (chip) {
413
- pick(chip.dataset.k, chip.dataset.id);
414
- $$(`.lp-chip[data-k="${chip.dataset.k}"]`, box).forEach((x) => x.classList.toggle('on', x === chip));
457
+ choose(chip.dataset.id, { add: !!e.target.closest('[data-also]') || e.metaKey || e.ctrlKey || e.shiftKey });
458
+ renderLaunchChips(); // the + and × badges follow the picks
415
459
  return;
416
460
  }
417
461
  const more = e.target.closest('[data-more]');
418
462
  if (more) {
419
- const k = more.dataset.more;
420
463
  return projectList(more, {
421
- projects: projectsAt(k),
422
- current: launch.picks[k],
423
- label: `Projects on ${placeName(k)}`,
464
+ projects: projects(),
465
+ current: pickId(),
466
+ // Each row's + adds it to the task (or takes it out) and leaves the list open.
467
+ also: ownPick() && {
468
+ has: (id) => chosen(id),
469
+ toggle: (id) => {
470
+ choose(id, { add: true });
471
+ renderLaunchChips();
472
+ },
473
+ },
474
+ label: 'Projects',
424
475
  extras: [
425
476
  { id: NO_FOLDER, label: 'No folder', icon: 'spark' },
426
- { 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' },
427
478
  ],
428
479
  onPick: (id) => {
429
- if (id === '+add') return openProjects({ focusAdd: true, machine: k || null });
430
- pick(k, id);
480
+ if (id === '+add') return openProjects({ focusAdd: true, machine: addOn || null });
481
+ choose(id);
431
482
  renderLaunchChips(); // the pick joins the chips
432
483
  $('#prompt')?.focus();
433
484
  },
@@ -457,9 +508,9 @@ function fitChips(box) {
457
508
  $('.lp-more-n', more).textContent = `+${n}`;
458
509
  };
459
510
  paint();
460
- if (launch.where.length > 1) continue;
461
511
  const wraps = () => {
462
512
  const kids = [...g.children].filter((x) => !x.hidden && x.offsetParent);
513
+ if (!kids.length) return false; // not laid out (a launcher painted over, or folded into the dock)
463
514
  const lin = $('.l-in', box); // "in" left alone on a line counts too
464
515
  return kids.some((x) => x.offsetTop >= kids[0].offsetTop + kids[0].offsetHeight) || (lin && kids[0].offsetTop >= lin.offsetTop + lin.offsetHeight);
465
516
  };
@@ -500,12 +551,12 @@ function whereMenu(anchor) {
500
551
  return `<button class="wm-row ${on ? 'on' : ''}" data-k="${k}" role="menuitemcheckbox" aria-checked="${on}">
501
552
  <span class="wm-check">${icon('check')}</span>
502
553
  <span class="wm-icon">${icon(k ? 'server' : 'laptop')}${m ? `<i class="dot ${st === 'online' ? 'ok' : st === 'error' ? 'bad' : ''}"></i>` : ''}</span>
503
- <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>
504
555
  ${m && st === 'online' && ready.length ? `<span class="wm-agents">${ready.map((a) => `<span class="model-logo">${harnessLogo(a.id)}</span>`).join('')}</span>` : ''}
505
556
  <span class="wm-only" data-only="${k}" title="Only here">only</span>
506
557
  </button>`;
507
558
  };
508
- 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>
509
560
  ${row('', null)}${S.machines.map((m) => row(m.id, m)).join('')}
510
561
  <div class="wm-sep"></div>
511
562
  <button data-a="add">${icon('plus')}${S.machines.length ? 'Add a machine…' : 'Add a machine to run on another computer…'}</button>
@@ -605,7 +656,7 @@ function launchedFx(from, to) {
605
656
  // A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
606
657
  // "Starting up" card. It can't be opened; the real card takes its place.
607
658
  let pendingSeq = 0;
608
- function addPending(prompt, { project: p, m }, { agent, model, effort }, live = 'Starting up') {
659
+ function addPending(prompt, { project: p, m, also = [] }, { agent, model, effort }, live = 'Starting up') {
609
660
  const id = `pending-${++pendingSeq}`;
610
661
  const line = plain(prompt).split('\n')[0].replace(/\s+/g, ' ');
611
662
  const now = Date.now();
@@ -621,6 +672,7 @@ function addPending(prompt, { project: p, m }, { agent, model, effort }, live =
621
672
  project: p.name,
622
673
  projectId: p.id,
623
674
  color: p.color || 'slate',
675
+ ...(also.length && { also: also.map((x) => ({ cwd: x.path, project: x.name, projectId: x.id, color: x.color, branch: '', pr: null })) }),
624
676
  state: 'running',
625
677
  live,
626
678
  createdAt: now,
@@ -632,10 +684,12 @@ function addPending(prompt, { project: p, m }, { agent, model, effort }, live =
632
684
 
633
685
  // A launch from a dialog (rerun, recipes): the dialog closes and a placeholder shows in Working
634
686
  // at once; the real session replaces it, or it disappears and the error is thrown on.
635
- export async function launchBehind({ prompt, cwd, runner }, request) {
636
- 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));
637
690
  const project = sum || { path: cwd, name: cwd.split('/').filter(Boolean).pop() || cwd, id: null, color: 'slate' };
638
- 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);
639
693
  if (S.filter !== 'all' && S.filter !== project.id) {
640
694
  S.filter = 'all';
641
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';
@@ -8,19 +9,38 @@ import { S } from '../core/state.js';
8
9
  export const projectById = (id) => S.config.projects.find((p) => p.id === id);
9
10
 
10
11
  export const placeOf = (p) => p?.machine || '';
12
+ // A task's projects: its own, then those of the other folders it works in (also).
13
+ export const inProject = (s, id) => s.projectId === id || !!s.also?.some((a) => a.projectId === id);
14
+
15
+ // The project on a card: "api + web", or "api +2" past that, each one in the tooltip.
16
+ export function projectLabel(s) {
17
+ const also = s.also || [];
18
+ if (!also.length) return `<span class="q-proj">${esc(s.project)}</span>`;
19
+ const names = [s.project, ...also.map((a) => a.project)];
20
+ return `<span class="q-proj multi" title="${esc(`One task in ${names.length} folders: ${names.join(', ')}`)}">${esc(s.project)}<span class="q-also ${also.length === 1 ? `c-${also[0].color}` : ''}">${also.length === 1 ? `+ ${esc(also[0].project)}` : `+${also.length}`}</span></span>`;
21
+ }
11
22
  export const placeName = (k) => (k ? machineById(k)?.name || 'machine' : 'This computer');
12
23
  export const recentFirst = (a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt);
13
- 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))) : []);
14
30
 
15
31
  // A machine runs its own wisp; this one signs in over SSH, tunnels to it and shows its sessions
16
- // 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.
17
33
  export const machineById = (id) => S.machines.find((m) => m.id === id);
18
34
  const machineOnline = (id) => machineById(id)?.state === 'online';
19
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.
20
37
  export const projectTitle = (p) => {
21
38
  if (!p) return '';
39
+ const tilde = (x) => (S.config.home && x.startsWith(S.config.home) ? `~${x.slice(S.config.home.length)}` : x);
22
40
  const m = p.machine && machineById(p.machine);
23
- 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;
24
44
  };
25
45
 
26
46
  export function machinePill(mt) {
@@ -6,6 +6,8 @@
6
6
  // meta(p) extra html after a row's name (status badges)
7
7
  // extras [{ id, label, icon }] fixed rows under the list (No folder, Other folder…)
8
8
  // onPick (id) => …, a project's id or an extra's
9
+ // also { has(id), toggle(id) }: each row gets a + that adds the project to the task (✓ once
10
+ // it's in) without closing the list; ⌘/Ctrl/Shift+Enter does the same from the keyboard
9
11
 
10
12
  import { esc } from '../md.js';
11
13
  import { $, el, icon } from '../core/dom.js';
@@ -13,7 +15,7 @@ import { fuzzy, highlight } from '../fuzzy.js';
13
15
  import { popover } from '../ui/popover.js';
14
16
  import { projectTitle } from './places.js';
15
17
 
16
- export function projectList(anchor, { projects, current, meta = () => '', extras = [], onPick, label = 'Projects' }) {
18
+ export function projectList(anchor, { projects, current, meta = () => '', extras = [], onPick, also = null, label = 'Projects' }) {
17
19
  if ($('.menu.pm')) return $('.menu.pm').close();
18
20
  const node = el(`
19
21
  <div class="menu pm" role="dialog" aria-label="${esc(label)}">
@@ -43,8 +45,10 @@ export function projectList(anchor, { projects, current, meta = () => '', extras
43
45
  list.innerHTML = hits.length
44
46
  ? hits
45
47
  .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
+ ({ p, m }) => `<button class="pm-row c-${p.color} ${p.id === current ? 'on' : ''} ${also?.has(p.id) ? 'in' : ''}" data-id="${esc(p.id)}" role="option" aria-selected="${p.id === current}" title="${esc(projectTitle(p))}">
49
+ <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>${
50
+ also ? `<span class="pm-also" data-also title="${also.has(p.id) ? `Leave ${esc(p.name)} out of this task` : `Also work in ${esc(p.name)}`}">${icon(also.has(p.id) ? 'check' : 'plus')}</span>` : icon('check', 'pm-check')
51
+ }
48
52
  </button>`,
49
53
  )
50
54
  .join('')
@@ -57,9 +61,19 @@ export function projectList(anchor, { projects, current, meta = () => '', extras
57
61
  close();
58
62
  onPick(id);
59
63
  };
64
+ // Adding to the task keeps the list open, the row where it was and the search as typed.
65
+ const toggle = (r) => {
66
+ also.toggle(r.dataset.id);
67
+ const at = act;
68
+ paint();
69
+ mark(at, false);
70
+ input.focus({ preventScroll: true });
71
+ };
60
72
  node.onclick = (e) => {
61
73
  const r = e.target.closest('.pm-row');
62
- if (r) pick(r.dataset.id);
74
+ if (!r) return;
75
+ if (also && !r.classList.contains('pm-x') && (e.target.closest('[data-also]') || e.metaKey || e.ctrlKey || e.shiftKey)) return toggle(r);
76
+ pick(r.dataset.id);
63
77
  };
64
78
  node.onmousemove = (e) => {
65
79
  const r = e.target.closest('.pm-row');
@@ -73,7 +87,8 @@ export function projectList(anchor, { projects, current, meta = () => '', extras
73
87
  } else if (e.key === 'Enter' && !e.isComposing) {
74
88
  e.preventDefault();
75
89
  const r = rows()[act];
76
- if (r) pick(r.dataset.id);
90
+ if (r && also && !r.classList.contains('pm-x') && (e.metaKey || e.ctrlKey || e.shiftKey)) toggle(r);
91
+ else if (r) pick(r.dataset.id);
77
92
  } else if (e.key === 'Tab') close();
78
93
  };
79
94
 
@@ -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 { 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".
@@ -23,7 +23,7 @@ let fitBar; // refits the chips when the bar's width changes
23
23
  export function renderProjects() {
24
24
  const all = [...S.sessions.values()];
25
25
  const stats = (pid) => {
26
- const l = pid === 'all' ? all : all.filter((s) => s.projectId === pid);
26
+ const l = pid === 'all' ? all : all.filter((s) => inProject(s, pid));
27
27
  return { run: l.filter((s) => s.state === 'running').length, need: l.filter((s) => s.state === 'waiting' || (s.state === 'review' && s.unread)).length };
28
28
  };
29
29
  const badges = (id) => {
@@ -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,12 +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
- if (!launch.where.includes(placeOf(proj))) launch.where = [placeOf(proj)];
263
- 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 || ''];
264
265
  saveLaunch();
265
266
  renderLaunchChips();
266
267
  };
@@ -331,9 +332,9 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
331
332
 
332
333
  const list = () => {
333
334
  const box = $('#plist', node);
334
- 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);
335
337
  const pending = clones
336
- .filter((j) => j.where === where)
337
338
  .map(
338
339
  (j) => `<div class="prow cloning"><span class="swatch"><span class="orb live"></span></span>
339
340
  <div class="prow-main"><b class="pname">${esc(repoName(j.url))}</b><span class="ppath">Cloning ${esc(repoLabel(j.url))}…</span></div></div>`,
@@ -343,16 +344,18 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
343
344
  ? pending +
344
345
  projects
345
346
  .map((p) => {
346
- const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
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('');
347
350
  return `<div class="prow c-${p.color}" data-id="${p.id}">
348
351
  <button class="swatch" title="Change colour"><i class="p-dot"></i></button>
349
- <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>
350
353
  <span class="pcount">${n} session${n === 1 ? '' : 's'}</span>
351
354
  <button class="icon-btn premove" title="Remove from wisp (files untouched)">${icon('trash')}</button>
352
355
  </div>`;
353
356
  })
354
357
  .join('')
355
- : `<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>`;
356
359
  $$('.prow[data-id]', box).forEach((row) => {
357
360
  const id = row.dataset.id;
358
361
  const p = projectById(id);