wisp-agents 0.7.0 → 0.9.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/web/js/app.js CHANGED
@@ -12,9 +12,11 @@ const S = {
12
12
  full: new Map(), // id -> full session, for expanded items
13
13
  open: new Set(load('wisp.open', [])), // expanded item ids
14
14
  nodes: new Map(), // id -> { card, head, body, turnNodes, tray, showAll, unfolded, latestTurn }
15
+ tasks: new Map(), // compare task key -> { card, head, runs, lead }
15
16
  focus: null, // id of the keyboard-focused item
16
17
  config: { defaults: {}, projects: [] },
17
18
  agents: [],
19
+ machines: [], // other computers over SSH: GET /api/machines, kept fresh by the 'machines' event
18
20
  filter: load('wisp.filter', 'all'),
19
21
  showDone: load('wisp.showDone', false),
20
22
  showArchived: false,
@@ -24,6 +26,9 @@ const S = {
24
26
  const $ = (s, root = document) => root.querySelector(s);
25
27
  const $$ = (s, root = document) => [...root.querySelectorAll(s)];
26
28
  const stage = $('#stage');
29
+ // How much of the feed the sticky top bar and docked launcher cover: the stage's scroll-padding-top,
30
+ // which scrollIntoView already honours, so hand-placed cards line up with scrolled-to ones.
31
+ const topInset = () => parseFloat(getComputedStyle(stage).scrollPaddingTop) || 0;
27
32
 
28
33
  function load(k, d) {
29
34
  try {
@@ -68,6 +73,7 @@ const HARNESS_LOGO = {
68
73
  opencode: { file: 'opencode', mono: true },
69
74
  cursor: { file: 'cursor', mono: true },
70
75
  antigravity: { file: 'antigravity-color' },
76
+ grok: { file: 'grok', mono: true },
71
77
  };
72
78
  function harnessLogo(agent) {
73
79
  const l = HARNESS_LOGO[agent];
@@ -121,11 +127,23 @@ function overlay(node, { onClose } = {}) {
121
127
  setTimeout(() => o.firstChild === node && ((o.hidden = true), (o.innerHTML = '')), 200);
122
128
  onClose?.();
123
129
  };
124
- o.onclick = (e) => e.target === o && close();
130
+ // A block body: an on* handler that returns false cancels the click, which would stop every
131
+ // submit button inside the dialog from submitting its form.
132
+ o.onclick = (e) => {
133
+ if (e.target === o) close();
134
+ };
125
135
  o.close = close;
126
136
  return close;
127
137
  }
128
138
  const closeOverlay = () => !$('#overlay').hidden && $('#overlay').close?.();
139
+ // A view opens as its own dialog, or inside another one (Settings) that hands it a host.
140
+ // Returns what closes it for good: the whole overlay either way.
141
+ function present(node, host) {
142
+ if (host) return host(node);
143
+ const close = overlay(node);
144
+ $('.close', node).onclick = close;
145
+ return close;
146
+ }
129
147
 
130
148
  // --- feed ----------------------------------------------------------------------
131
149
 
@@ -133,8 +151,39 @@ function visible(list) {
133
151
  return S.filter === 'all' ? list : list.filter((s) => s.projectId === S.filter);
134
152
  }
135
153
 
154
+ // Compare runs (one launch on several harnesses, sharing a groupId) are one task in the feed
155
+ // while two or more of them are on the same side of archived: a card with a row per run.
156
+ // Keeping one archives the rest, and what's left is an ordinary card again.
157
+ function runsOf(s) {
158
+ if (!s.groupId) return [s];
159
+ const runs = [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived === !s.archived);
160
+ return runs.length > 1 ? runs.sort((a, b) => a.createdAt - b.createdAt) : [s];
161
+ }
162
+ // The task as a whole: asking if any run asks, working while any works, done once all are.
163
+ function taskOf(s) {
164
+ const runs = runsOf(s);
165
+ if (runs.length < 2) return s;
166
+ const state = runs.some((r) => r.state === 'waiting') ? 'waiting' : runs.some((r) => r.state === 'running') ? 'running' : runs.every((r) => r.state === 'done') ? 'done' : 'review';
167
+ return {
168
+ ...runs[0],
169
+ key: `g:${s.groupId}${s.archived ? ':a' : ''}`,
170
+ runs,
171
+ state,
172
+ unread: runs.some((r) => r.unread),
173
+ createdAt: Math.min(...runs.map((r) => r.createdAt)),
174
+ updatedAt: Math.max(...runs.map((r) => r.updatedAt)),
175
+ };
176
+ }
177
+
136
178
  function groups() {
137
- const every = visible([...S.sessions.values()]);
179
+ const every = [];
180
+ const seen = new Set();
181
+ for (const s of visible([...S.sessions.values()])) {
182
+ const t = taskOf(s);
183
+ if (t.runs && seen.has(t.key)) continue;
184
+ if (t.runs) seen.add(t.key);
185
+ every.push(t);
186
+ }
138
187
  const all = every.filter((s) => !s.archived);
139
188
  const needs = all
140
189
  .filter((s) => s.state === 'review' || s.state === 'waiting')
@@ -154,7 +203,7 @@ function renderFeed() {
154
203
  $('h4 .n', sec).textContent = list.length || '';
155
204
  const want = show ? list : [];
156
205
  want.forEach((s, i) => {
157
- const { card } = item(s);
206
+ const { card } = s.runs ? taskItem(s) : item(s);
158
207
  // Only touch the DOM when the order actually changed: moving a node
159
208
  // would blur a composer the user is typing in.
160
209
  if (box.children[i] !== card) box.insertBefore(card, box.children[i] || null);
@@ -169,8 +218,11 @@ function renderFeed() {
169
218
  $('#secArchived').classList.toggle('collapsed', !S.showArchived);
170
219
 
171
220
  // Drop nodes for sessions that are gone or filtered out of the feed entirely.
172
- const shown = new Set([...needs, ...working, ...done, ...archived].map((s) => s.id));
221
+ const tasks = [...needs, ...working, ...done, ...archived];
222
+ const shown = new Set(tasks.flatMap((s) => (s.runs || [s]).map((r) => r.id)));
173
223
  for (const [id, n] of S.nodes) if (!shown.has(id)) (n.card.remove(), S.nodes.delete(id), S.full.delete(id));
224
+ const keys = new Set(tasks.map((t) => t.key));
225
+ for (const [key, g] of S.tasks) if (!keys.has(key)) (g.card.remove(), S.tasks.delete(key));
174
226
 
175
227
  const empty = !needs.length && !working.length && !done.length && !archived.length;
176
228
  $('#feedEmpty').hidden = !empty;
@@ -183,6 +235,13 @@ function renderFeed() {
183
235
  renderProjects();
184
236
  }
185
237
 
238
+ // A new project filter swaps the whole list; staying at the old offset would drop the user
239
+ // somewhere arbitrary in it, so bring the top of the feed back into view.
240
+ function feedToTop() {
241
+ const top = $('#feed').getBoundingClientRect().top;
242
+ if (top < topInset()) scrollByPx(top - topInset());
243
+ }
244
+
186
245
  function renderFeedHead(needs, working) {
187
246
  const parts = [];
188
247
  if (working.length) parts.push(`<span class="hs working"><i></i>${working.length} working</span>`);
@@ -262,6 +321,7 @@ function renderHead(n, s) {
262
321
  : s.state === 'review'
263
322
  ? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
264
323
  : '';
324
+ if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
265
325
  n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
266
326
  n.head.innerHTML = `
267
327
  <span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
@@ -272,7 +332,9 @@ function renderHead(n, s) {
272
332
  <span class="it-side">
273
333
  ${pill}
274
334
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
275
- ${modelBadge(s.agent, s.model, s.effort)}
335
+ ${s.pr ? prPill(s.pr) : ''}
336
+ ${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort)}
337
+ ${s.machine ? machinePill(s.machine) : ''}
276
338
  <span class="q-proj">${esc(s.project)}</span>
277
339
  <span class="q-time">${ago(s.updatedAt)}</span>
278
340
  ${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
@@ -280,13 +342,76 @@ function renderHead(n, s) {
280
342
  </span>`;
281
343
  }
282
344
 
345
+ // One run inside a compare task: the harness and model stand in for the title the task shows.
346
+ function renderRunHead(n, s, { open, live, pill }) {
347
+ const a = S.agents.find((x) => x.id === s.agent);
348
+ const model = (s.model && a?.models?.find((m) => m.id === s.model)?.label) || s.model || 'Default model';
349
+ n.card.className = `item run c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
350
+ n.head.innerHTML = `
351
+ <span class="run-logo">${harnessLogo(s.agent)}</span>
352
+ <span class="it-main">
353
+ <span class="it-title"><b>${esc(a?.name || agentName(s.agent))}</b><span class="run-model">${esc(model)}${s.effort ? ` · ${esc(effortLabel(s.effort))}` : ''}</span></span>
354
+ ${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
355
+ </span>
356
+ <span class="it-side">
357
+ ${runsOf(s).some((r) => (r.machine?.id || '') !== (s.machine?.id || '')) ? `${s.machine ? machinePill(s.machine) : `<span class="q-machine" title="Runs on this computer">${icon('laptop')}<span>This computer</span></span>`}<span class="q-proj">${esc(s.project)}</span>` : ''}
358
+ ${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
359
+ ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
360
+ ${s.pr ? prPill(s.pr) : ''}
361
+ <span class="q-time">${ago(s.updatedAt)}</span>
362
+ ${icon('chevron', 'chev')}
363
+ </span>`;
364
+ }
365
+
366
+ // The card for a compare task: the shared prompt on top, one row per run (each an ordinary,
367
+ // expandable item), and the side-by-side view one click away.
368
+ function taskItem(t) {
369
+ let g = S.tasks.get(t.key);
370
+ if (!g) {
371
+ const card = el(`<section class="task-item"><header class="task-head"></header><div class="task-runs"></div></section>`);
372
+ g = { card, head: $('.task-head', card), runs: $('.task-runs', card) };
373
+ S.tasks.set(t.key, g);
374
+ g.head.addEventListener('click', (e) => {
375
+ const a = e.target.closest('[data-a]')?.dataset.a;
376
+ const ids = (taskOf(S.sessions.get(g.lead) || {}).runs || []).map((r) => r.id);
377
+ if (a === 'compare') return compareView(g.lead);
378
+ if (a === 'quick-done') return taskDone(ids.filter((id) => S.sessions.get(id)?.state === 'review'));
379
+ });
380
+ }
381
+ g.lead = t.id;
382
+ const finished = t.runs.filter((r) => r.state === 'review' || r.state === 'done').length;
383
+ const places = new Set(t.runs.map((r) => r.machine?.id || '')).size;
384
+ const status = t.state === 'waiting' ? 'A run is asking you' : t.state === 'running' ? `${finished} of ${t.runs.length} finished${places > 1 ? ` · on ${places} machines` : ''}` : t.state === 'done' ? 'Done' : places > 1 ? `All finished on ${places} machines` : 'All finished · pick one to keep';
385
+ g.card.className = `task-item c-${t.color} st-${t.state} ${t.unread ? 'unread' : ''}`;
386
+ g.head.innerHTML = `
387
+ <span class="q-dot ${t.state === 'running' ? 'live' : ''} ${t.state === 'waiting' ? 'waiting' : ''}"></span>
388
+ <span class="it-main">
389
+ <span class="it-title">${esc(t.title)}</span>
390
+ <span class="it-sub">${esc(status)}</span>
391
+ </span>
392
+ <span class="it-side">
393
+ <button class="ghost xs" data-a="compare" title="Side by side: time, cost, changes, tests and each presentation"><b class="vs-mark">vs</b><span>Compare</span></button>
394
+ ${places > 1 ? '' : `<span class="q-proj">${esc(t.project)}</span>`}
395
+ <span class="q-time">${ago(t.updatedAt)}</span>
396
+ ${t.state === 'review' && !t.archived ? `<button class="it-quick" data-a="quick-done" title="Mark every run done">${icon('done')}</button>` : ''}
397
+ </span>`;
398
+ t.runs.forEach((r, i) => {
399
+ const { card } = item(r);
400
+ if (g.runs.children[i] !== card) g.runs.insertBefore(card, g.runs.children[i] || null);
401
+ });
402
+ while (g.runs.children.length > t.runs.length) g.runs.lastElementChild.remove();
403
+ return g;
404
+ }
405
+
283
406
  function setFocus(id, { scroll = true } = {}) {
284
407
  if (S.focus === id) return;
285
408
  S.nodes.get(S.focus)?.card.classList.remove('focused');
286
409
  S.focus = id;
287
410
  const n = S.nodes.get(id);
288
411
  n?.card.classList.add('focused');
289
- if (scroll) n?.card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
412
+ // A card taller than the screen would land with its header out of view ('nearest' aligns
413
+ // its far edge when moving up), so bring its top in instead.
414
+ if (scroll && n) n.card.scrollIntoView({ block: n.card.offsetHeight > stage.clientHeight - topInset() ? 'start' : 'nearest', behavior: 'smooth' });
290
415
  }
291
416
 
292
417
  function toggle(id) {
@@ -454,7 +579,28 @@ function autosize(ta, max = 340) {
454
579
 
455
580
  // --- launcher ----------------------------------------------------------------------
456
581
 
457
- let launchProject = load('wisp.launchProject', null);
582
+ let launchProject = load('wisp.launchProject', null); // the project picked in the first place below
583
+ // Where the launcher sends a prompt: '' = this computer, else machine ids. Several run in parallel,
584
+ // each in the project picked for it (launchPicks[place]), and show up as one task.
585
+ let launchWhere = load('wisp.launchWhere', ['']);
586
+ let launchPicks = load('wisp.launchPicks', {});
587
+ const placeOf = (p) => p?.machine || '';
588
+ const placeName = (k) => (k ? machineById(k)?.name || 'machine' : 'This computer');
589
+ const recentFirst = (a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt);
590
+ const projectsAt = (k) => S.config.projects.filter((p) => placeOf(p) === k).sort(recentFirst);
591
+ const saveWhere = () => (save('wisp.launchWhere', launchWhere), save('wisp.launchPicks', launchPicks), save('wisp.launchProject', launchProject));
592
+ function syncWhere() {
593
+ launchWhere = launchWhere.filter((k, i) => (!k || machineById(k)) && launchWhere.indexOf(k) === i);
594
+ if (!launchWhere.length) launchWhere = [''];
595
+ // Filtering the feed to a project points the launcher at it (its machine joins if it wasn't picked).
596
+ const f = S.filter !== 'all' && projectById(S.filter);
597
+ if (f) {
598
+ if (!launchWhere.includes(placeOf(f))) launchWhere = [placeOf(f)];
599
+ launchPicks[placeOf(f)] = f.id;
600
+ } else if (projectById(launchProject) && !Object.values(launchPicks).includes(launchProject)) launchPicks[placeOf(projectById(launchProject))] ??= launchProject;
601
+ for (const k of launchWhere) if (!projectsAt(k).some((p) => p.id === launchPicks[k])) launchPicks[k] = projectsAt(k)[0]?.id || null;
602
+ launchProject = launchPicks[launchWhere[0]] || null;
603
+ }
458
604
 
459
605
  // The model is remembered per harness (ids aren't portable between them).
460
606
  function savedModel(id) {
@@ -482,10 +628,18 @@ function renderLauncher() {
482
628
  <div class="l-bar">
483
629
  <button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
484
630
  <button class="mode-btn" id="mode" aria-haspopup="menu"></button>
485
- <button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
631
+ <div class="runners">
632
+ <button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
633
+ <button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
634
+ <button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
635
+ </div>
636
+ <button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
637
+ <button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
486
638
  <span class="grow"></span>
639
+ <button class="tog-btn" id="wtBtn"></button>
487
640
  <button class="send" id="launch" title="Launch (Enter · Shift+Enter for newline)">${icon('enter')}</button>
488
- </div>`;
641
+ </div>
642
+ <div class="l-recipes"></div>`;
489
643
  renderLaunchChips();
490
644
 
491
645
  const ta = $('#prompt', box);
@@ -494,7 +648,7 @@ function renderLauncher() {
494
648
  acceptFiles(box, ta, tray);
495
649
  autosize(ta);
496
650
  ta.value = S.drafts.get('home') || '';
497
- ta.addEventListener('input', () => S.drafts.set('home', ta.value));
651
+ ta.addEventListener('input', () => (S.drafts.set('home', ta.value), paintSaveRecipe()));
498
652
  // While focus is elsewhere, the placeholder advertises the shortcut that brings you back.
499
653
  const hint = () => (ta.placeholder = document.activeElement === ta
500
654
  ? 'What should I go do? Paste screenshots, drop logs or videos…'
@@ -502,7 +656,15 @@ function renderLauncher() {
502
656
  ta.addEventListener('focus', hint);
503
657
  ta.addEventListener('blur', hint);
504
658
  hint();
659
+ // Docked and folded to one line, a click anywhere on it opens it up for typing.
660
+ box.addEventListener('mousedown', (e) => {
661
+ if (!box.matches('.docked:not(:focus-within)')) return;
662
+ e.preventDefault();
663
+ ta.focus();
664
+ });
505
665
  $('#attach', box).onclick = tray.pick;
666
+ $('#whereBtn', box).onclick = (e) => whereMenu(e.currentTarget);
667
+ paintWhereBtn();
506
668
 
507
669
  // Mode is set-and-forget, so it's one small button: just an icon on the default (Edits),
508
670
  // spelled out when it's Plan or Full auto so you can't miss it.
@@ -522,21 +684,82 @@ function renderLauncher() {
522
684
  };
523
685
  paintMode();
524
686
  modeBtn.onclick = () => modeMenu(modeBtn, curMode, setMode);
687
+ box.mode = () => curMode;
525
688
  let curModel = savedModel(agent?.id);
526
689
  let curEffort = savedEffort(agent?.id, curModel);
527
690
  const runnerBtn = $('#runner', box);
528
691
  const paintRunner = () => {
529
- const m = (agent?.models || []).find((x) => x.id === curModel);
530
- runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent?.id)}</span><span class="rb-h">${esc(agent?.name || 'No agent')}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${curEffort ? `<span class="rb-e">${esc(effortLabel(curEffort))}</span>` : ''}${icon('chevron', 'chev')}`;
692
+ paintRunnerBtn(runnerBtn, agent?.id, curModel, curEffort);
531
693
  runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
532
694
  };
533
695
  paintRunner();
696
+
697
+ // Worktree: each launch gets a fresh branch from the remote's default branch (git repos only).
698
+ const wtBtn = $('#wtBtn', box);
699
+ const paintWt = () => {
700
+ const on = S.config.defaults.worktree !== 'off';
701
+ wtBtn.classList.toggle('on', on);
702
+ wtBtn.innerHTML = `${icon('branch')}<span>${on ? 'Worktree' : 'In place'}</span>`;
703
+ wtBtn.title = on
704
+ ? 'Worktree: runs on its own branch from origin/main in a separate checkout, so parallel agents never collide (git repos; not in Plan mode). Click to work in the project folder instead.'
705
+ : 'In place: edits the project folder directly. Click to give each task its own worktree and branch.';
706
+ };
707
+ paintWt();
708
+ wtBtn.onclick = () => {
709
+ const worktree = S.config.defaults.worktree === 'off' ? 'on' : 'off';
710
+ S.config.defaults.worktree = worktree;
711
+ paintWt();
712
+ api('/config', { method: 'PATCH', body: { defaults: { worktree } } }).catch(() => {});
713
+ };
714
+
715
+ // Subagents: remembered like the other picks, so a team stays set until you clear it.
716
+ let team = (S.config.defaults.subagents || []).filter((m) => S.agents.some((a) => a.id === m.agent));
717
+ let teamPreset = S.config.defaults.teamPreset || '';
718
+ const teamBtn = $('#teamBtn', box);
719
+ const paintTeam = () => paintTeamBtn(teamBtn, team, teamPreset, agent?.id);
720
+ paintTeam();
721
+ teamBtn.onclick = () =>
722
+ teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset }, (members, preset) => {
723
+ if (JSON.stringify([members, preset]) === JSON.stringify([team, teamPreset])) return;
724
+ team = members;
725
+ teamPreset = preset;
726
+ Object.assign(S.config.defaults, { subagents: team, teamPreset });
727
+ paintTeam();
728
+ api('/config', { method: 'PATCH', body: { defaults: { subagents: team, teamPreset } } }).catch(() => {});
729
+ });
730
+
731
+ // Compare: a second harness gets the same task; picks here don't touch the launcher defaults.
732
+ let compare = null;
733
+ const vsBtn = $('#compareBtn', box);
734
+ const runner2 = $('#runner2', box);
735
+ const paintCompare = () => {
736
+ vsBtn.parentElement.classList.toggle('cmp', !!compare);
737
+ vsBtn.setAttribute('aria-pressed', String(!!compare));
738
+ vsBtn.title = compare ? 'Stop comparing' : 'Compare: run the same task on a second harness, side by side';
739
+ runner2.hidden = !compare;
740
+ if (compare) paintRunnerBtn(runner2, compare.agent, compare.model, compare.effort);
741
+ };
742
+ paintCompare();
743
+ vsBtn.onclick = () => {
744
+ if (compare) compare = null;
745
+ else {
746
+ const other = S.agents.find((a) => a.available && a.id !== agent?.id) || agent;
747
+ compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
748
+ }
749
+ paintCompare();
750
+ };
751
+ runner2.onclick = () =>
752
+ runnerPicker(runner2, { ...compare, savedModel, savedEffort }, (id, model, effort) => {
753
+ compare = { agent: id, model, effort };
754
+ paintCompare();
755
+ });
534
756
  runnerBtn.onclick = () =>
535
757
  runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
536
758
  agent = S.agents.find((a) => a.id === id);
537
759
  curModel = model;
538
760
  curEffort = effort;
539
761
  paintRunner();
762
+ paintTeam();
540
763
  const d = S.config.defaults;
541
764
  const models = { ...d.models, [id]: model };
542
765
  // A model without levels leaves the harness's saved level alone, so switching back restores it.
@@ -548,29 +771,57 @@ function renderLauncher() {
548
771
  const launch = async () => {
549
772
  const prompt = ta.value.trim();
550
773
  if (!prompt) return ta.focus();
551
- const project = projectById(launchProject);
552
- if (!project) return openProjects({ focusAdd: true });
774
+ // One run per place picked under "Run on", each in the project picked for it.
775
+ const targets = launchWhere.map((k) => ({ k, m: k ? machineById(k) : null, project: projectById(launchPicks[k]) }));
776
+ const unset = targets.find((t) => !t.project);
777
+ if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
778
+ const off = targets.find((t) => t.m && t.m.state !== 'online');
779
+ if (off) return offlineToast(off.k);
780
+ const wanted = [agent?.id, ...(compare ? [compare.agent] : [])];
781
+ for (const t of targets) {
782
+ const missing = t.m?.remote?.agents && wanted.find((id) => !t.m.remote.agents.some((x) => x.id === id && x.available));
783
+ if (missing) return toast(`${esc(agentName(missing))} isn’t installed on <b>${esc(t.m.name)}</b>`, { tone: 'bad' });
784
+ }
553
785
  const btn = $('#launch', box);
554
786
  btn.disabled = true;
787
+ btn.classList.add('busy'); // a fetch and fresh worktrees can take a second or two
555
788
  try {
556
789
  const attachments = await tray.ids();
557
- const s = await api('/sessions', { method: 'POST', body: { prompt, cwd: project.path, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments } });
558
- S.sessions.set(s.id, s);
790
+ // Several places: one shared groupId, so the runs land as one task with a row each.
791
+ const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
792
+ const results = await Promise.allSettled(
793
+ targets.map((t) =>
794
+ api('/sessions', {
795
+ method: 'POST',
796
+ body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments, worktree: S.config.defaults.worktree === 'off' && !compare ? false : undefined, compare: compare ? [compare] : undefined, subagents: team.length && canLead(agent?.id) ? team : undefined },
797
+ }),
798
+ ),
799
+ );
800
+ results.forEach((r, i) => {
801
+ if (r.status === 'rejected') toast(`${esc(placeName(targets[i].k))}: ${esc(r.reason.message)}`, { tone: 'bad' });
802
+ });
803
+ const started = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
804
+ if (!started.length) return;
805
+ for (const x of started.flatMap((y) => y.group || [y])) S.sessions.set(x.id, x);
806
+ const s = started[0];
807
+ if (compare) (compare = null), paintCompare();
559
808
  ta.value = '';
560
809
  S.drafts.delete('home');
561
810
  tray.clear();
562
811
  ta.dispatchEvent(new Event('input'));
563
- if (S.filter !== 'all' && S.filter !== s.projectId) {
812
+ if (S.filter !== 'all' && (started.length > 1 || S.filter !== s.projectId)) {
564
813
  S.filter = 'all';
565
814
  save('wisp.filter', S.filter);
566
815
  }
567
816
  renderFeed();
568
- launchedFx(btn, S.nodes.get(s.id)?.card);
817
+ const card = S.nodes.get(s.id)?.card;
818
+ showLaunched(btn, card?.closest('.task-item') || card);
569
819
  askNotify();
570
820
  } catch (err) {
571
821
  toast(esc(err.message), { tone: 'bad' });
572
822
  } finally {
573
823
  btn.disabled = false;
824
+ btn.classList.remove('busy');
574
825
  ta.focus();
575
826
  }
576
827
  };
@@ -587,6 +838,403 @@ function renderLauncher() {
587
838
  });
588
839
  }
589
840
 
841
+ // The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
842
+ function paintRunnerBtn(btn, agentId, model, effort) {
843
+ const a = S.agents.find((x) => x.id === agentId);
844
+ const m = (a?.models || []).find((x) => x.id === model);
845
+ btn.innerHTML = agentId
846
+ ? `<span class="rb-logo">${harnessLogo(agentId)}</span><span class="rb-h">${esc(a?.name || agentName(agentId))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`
847
+ : `<span class="rb-logo">${icon('spark')}</span><span class="rb-h">Launcher default</span>${icon('chevron', 'chev')}`;
848
+ }
849
+
850
+ // --- subagents -------------------------------------------------------------------
851
+ // A team the lead can delegate to: any harness, model and thinking level per member, plus the
852
+ // user's "use it for" note. Picked in the launcher, saved as presets, shown on cards and turns.
853
+
854
+ const MAX_TEAM = 6; // rows
855
+ const MAX_COPIES = 5; // parallel copies of one row
856
+ const MAX_INSTANCES = 12; // copies across the team
857
+ const copiesOf = (m) => Math.max(1, m.count || 1);
858
+ const teamSize = (team) => team.reduce((n, m) => n + copiesOf(m), 0);
859
+ const canLead = (id) => S.agents.find((a) => a.id === id)?.delegates !== false;
860
+ const memberLabel = (m) => {
861
+ const a = S.agents.find((x) => x.id === m.agent);
862
+ const model = (m.model && a?.models?.find((x) => x.id === m.model)?.label) || m.model || 'Default model';
863
+ return `${a?.name || agentName(m.agent)} · ${model}${m.effort ? ` · ${effortLabel(m.effort)}` : ''}`;
864
+ };
865
+ const slugName = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
866
+ // Same rule as the server: blank names become the harness id, repeats get -2, -3…
867
+ function uniqueNames(members) {
868
+ const taken = new Set();
869
+ return members.map((m) => {
870
+ const base = slugName(m.name) || m.agent;
871
+ let name = base;
872
+ for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
873
+ taken.add(name);
874
+ return { ...m, name };
875
+ });
876
+ }
877
+
878
+ // The lead's logo with its subagents' tucked behind it; they fan out on hover. A member that
879
+ // is working pulses; one the lead never used is dimmed.
880
+ function teamStack(lead, team) {
881
+ const shown = team.slice(0, 4);
882
+ const tip = `${agentName(lead)} with ${team.map((m) => `${m.name}${m.busy ? ' (working)' : m.used === false ? ' (unused)' : ''}`).join(', ')}`;
883
+ return `<span class="team-stack" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">
884
+ <span class="ts-l">${harnessLogo(lead)}</span>
885
+ ${shown.map((m, i) => `<span class="ts-m${m.used === false ? ' idle' : ''}${m.busy ? ' busy' : ''}" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}
886
+ ${team.length > shown.length ? `<span class="ts-m ts-more" style="--i:${shown.length}">+${team.length - shown.length}</span>` : ''}
887
+ </span>`;
888
+ }
889
+
890
+ // Launcher button: a stack of the members' logos and the preset's name, or just "Subagents".
891
+ function paintTeamBtn(btn, team, presetId, lead) {
892
+ const ok = canLead(lead);
893
+ const flat = team.flatMap((m) => Array(copiesOf(m)).fill(m));
894
+ const n = flat.length;
895
+ const p = (S.config.teams || []).find((x) => x.id === presetId);
896
+ btn.classList.toggle('on', team.length > 0 && ok);
897
+ btn.classList.toggle('off', team.length > 0 && !ok);
898
+ btn.innerHTML = team.length
899
+ ? `<span class="tb-stack">${flat.slice(0, 5).map((m, i) => `<span class="ts-m" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}${n > 5 ? `<span class="ts-m ts-more" style="--i:5">+${n - 5}</span>` : ''}</span><span class="tb-label">${esc(p?.name || `${n} subagent${n > 1 ? 's' : ''}`)}</span>`
900
+ : `${icon('agent')}<span class="tb-label">Subagents</span>`;
901
+ btn.title = !team.length
902
+ ? 'Subagents: give the lead a team on other harnesses to delegate to'
903
+ : ok
904
+ ? `Subagents the lead can delegate to:\n${team.map((m) => `• ${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n')}`
905
+ : `${agentName(lead)} can't delegate (wisp can't give it tools), so this launch runs without subagents`;
906
+ }
907
+
908
+ function teamEditor(anchor, { lead, team, presetId }, onChange) {
909
+ const open = $('.team-pop');
910
+ if (open) return open.close();
911
+ let members = team.map((m) => ({ ...m }));
912
+ let preset = presetId;
913
+ const r = anchor.getBoundingClientRect();
914
+ const width = Math.min(540, innerWidth - 24);
915
+ const left = Math.max(12, Math.min(r.left, innerWidth - 12 - width));
916
+ const below = r.bottom + 440 < innerHeight;
917
+ const node = el(`
918
+ <div class="team-pop" role="dialog" aria-label="Subagents" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
919
+ <header class="tp-head">
920
+ <div class="tp-title"><b>Subagents</b><small>The lead hands tasks to these, each on its own harness. Tell it what each one is for.</small></div>
921
+ <button class="ghost xs tp-presets" aria-haspopup="menu">${icon('agent')}<span>Presets</span>${icon('chevron', 'chev')}</button>
922
+ </header>
923
+ <div class="tp-warn" hidden></div>
924
+ <div class="tp-list"></div>
925
+ <footer class="tp-foot"></footer>
926
+ </div>`);
927
+ const list = $('.tp-list', node);
928
+ const foot = $('.tp-foot', node);
929
+ const commit = () => onChange(uniqueNames(members), preset);
930
+ let typing;
931
+ const commitSoon = () => (clearTimeout(typing), (typing = setTimeout(commit, 300)));
932
+
933
+ const paintWarn = () => {
934
+ const w = $('.tp-warn', node);
935
+ w.hidden = canLead(lead);
936
+ w.innerHTML = `${icon('warn')}<span>${esc(agentName(lead))} can't delegate: wisp can't give it tools. Pick Claude Code, Codex or opencode as the lead to use these.</span>`;
937
+ };
938
+ const paintFoot = () => {
939
+ foot.innerHTML = `
940
+ <button class="ghost sm tp-add" ${members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}Add subagent</button>
941
+ <span class="tp-count" title="Agents in all, counting copies (max ${MAX_INSTANCES})">${teamSize(members)} agent${teamSize(members) === 1 ? '' : 's'}</span>
942
+ <span class="grow"></span>
943
+ ${members.length ? `<button class="link xs tp-clear">Clear</button><button class="ghost sm tp-save">${icon('check')}Save as preset</button>` : ''}`;
944
+ };
945
+ const paintList = () => {
946
+ const named = uniqueNames(members);
947
+ list.innerHTML = members.length
948
+ ? members
949
+ .map(
950
+ (m, i) => `<div class="tp-row" data-i="${i}">
951
+ <div class="tp-top">
952
+ <button class="runner-btn tp-runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
953
+ <span class="tp-copies${copiesOf(m) > 1 ? ' on' : ''}" title="Copies: identical agents that take tasks in parallel">
954
+ <button class="tp-dec" aria-label="One copy fewer" ${copiesOf(m) <= 1 ? 'disabled' : ''}>${icon('minus')}</button>
955
+ <b>×${copiesOf(m)}</b>
956
+ <button class="tp-inc" aria-label="One copy more" ${copiesOf(m) >= MAX_COPIES || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}</button>
957
+ </span>
958
+ <label class="field sm mono tp-name" title="What the lead calls it"><span>@</span><input value="${esc(m.name || '')}" placeholder="${esc(named[i].name)}" maxlength="24" spellcheck="false" autocomplete="off"></label>
959
+ <button class="icon-btn tp-del" title="Remove">${icon('x')}</button>
960
+ </div>
961
+ <label class="field sm tp-use"><span>Use for</span><input value="${esc(m.use || '')}" placeholder="e.g. image and UI work · research · writing tests" maxlength="300" spellcheck="false" autocomplete="off"></label>
962
+ </div>`,
963
+ )
964
+ .join('')
965
+ : `<div class="tp-empty">${icon('agent')}<p>No subagents yet. Add one per harness you want the lead to use, like Codex for tests or opencode for research.</p></div>`;
966
+ $$('.tp-row', list).forEach((row, i) => paintRunnerBtn($('.tp-runner', row), members[i].agent, members[i].model, members[i].effort));
967
+ paintFoot();
968
+ };
969
+ const add = () => {
970
+ if (members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES) return;
971
+ const used = new Set([lead, ...members.map((m) => m.agent)]);
972
+ const a = S.agents.find((x) => x.available && !used.has(x.id)) || S.agents.find((x) => x.available && x.id !== lead) || S.agents.find((x) => x.available);
973
+ if (!a) return toast('No harness is installed.', { tone: 'bad' });
974
+ const model = savedModel(a.id);
975
+ members.push({ name: '', agent: a.id, model, effort: savedEffort(a.id, model), use: '', count: 1 });
976
+ paintList();
977
+ commit();
978
+ $(`.tp-row[data-i="${members.length - 1}"] .tp-use input`, list)?.focus();
979
+ };
980
+
981
+ // Save: name it; an existing preset's name updates that preset.
982
+ const askName = () => {
983
+ const cur = (S.config.teams || []).find((x) => x.id === preset);
984
+ foot.innerHTML = `<form class="tp-saveform"><input class="input sm" placeholder="Preset name, e.g. Review crew" value="${esc(cur?.name || '')}" maxlength="60"><button class="primary sm">Save</button><button type="button" class="link xs tp-cancel">Cancel</button></form>`;
985
+ const input = $('input', foot);
986
+ input.focus();
987
+ input.select();
988
+ $('.tp-cancel', foot).onclick = paintFoot;
989
+ $('form', foot).onsubmit = async (e) => {
990
+ e.preventDefault();
991
+ const name = input.value.trim();
992
+ if (!name) return input.focus();
993
+ const same = (S.config.teams || []).find((x) => x.name.toLowerCase() === name.toLowerCase());
994
+ try {
995
+ const p = await api(same ? `/teams/${same.id}` : '/teams', { method: same ? 'PATCH' : 'POST', body: { name, members: uniqueNames(members) } });
996
+ S.config.teams = [...(S.config.teams || []).filter((x) => x.id !== p.id), p];
997
+ preset = p.id;
998
+ commit();
999
+ paintFoot();
1000
+ toast(`${same ? 'Updated' : 'Saved'} preset <b>${esc(p.name)}</b>`);
1001
+ } catch (err) {
1002
+ toast(esc(err.message), { tone: 'bad' });
1003
+ }
1004
+ };
1005
+ };
1006
+
1007
+ const presetMenu = (btn) => {
1008
+ const prev = $('.menu.tp-menu');
1009
+ if (prev) return prev.close();
1010
+ const teams = S.config.teams || [];
1011
+ const b = btn.getBoundingClientRect();
1012
+ const menu = el(`
1013
+ <div class="menu tp-menu" role="menu" style="top:${b.bottom + 4}px;right:${innerWidth - b.right}px">
1014
+ ${teams.length
1015
+ ? teams
1016
+ .map(
1017
+ (t) => `<button role="menuitem" data-id="${esc(t.id)}" class="${t.id === preset ? 'on' : ''}">
1018
+ <span class="tb-stack">${t.members.slice(0, 4).map((m, i) => `<span class="ts-m" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}</span>
1019
+ <span class="mm-text"><b>${esc(t.name)}</b><small>${esc(t.members.map((m) => m.name).join(', '))}</small></span>
1020
+ <span class="tp-mdel" data-del="${esc(t.id)}" title="Delete preset">${icon('trash')}</span>
1021
+ </button>`,
1022
+ )
1023
+ .join('')
1024
+ : `<div class="tp-mempty">No presets yet. Set up a team, then <b>Save as preset</b>.</div>`}
1025
+ </div>`);
1026
+ const close = () => (menu.remove(), document.removeEventListener('mousedown', outsideMenu, true));
1027
+ const outsideMenu = (ev) => !menu.contains(ev.target) && !btn.contains(ev.target) && close();
1028
+ menu.close = close;
1029
+ setTimeout(() => document.addEventListener('mousedown', outsideMenu, true));
1030
+ menu.onclick = async (ev) => {
1031
+ const del = ev.target.closest('[data-del]');
1032
+ if (del) {
1033
+ ev.stopPropagation();
1034
+ const t = teams.find((x) => x.id === del.dataset.del);
1035
+ await api(`/teams/${t.id}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
1036
+ S.config.teams = (S.config.teams || []).filter((x) => x.id !== t.id);
1037
+ if (preset === t.id) (preset = ''), commit();
1038
+ close();
1039
+ return toast(`Deleted preset <b>${esc(t.name)}</b>`);
1040
+ }
1041
+ const item = ev.target.closest('button[data-id]');
1042
+ if (!item) return;
1043
+ const t = teams.find((x) => x.id === item.dataset.id);
1044
+ members = t.members.map((m) => ({ ...m }));
1045
+ preset = t.id;
1046
+ close();
1047
+ paintList();
1048
+ commit();
1049
+ };
1050
+ document.body.append(menu);
1051
+ };
1052
+
1053
+ list.addEventListener('click', (e) => {
1054
+ const row = e.target.closest('.tp-row');
1055
+ if (!row) return;
1056
+ const i = Number(row.dataset.i);
1057
+ if (e.target.closest('.tp-del')) {
1058
+ members.splice(i, 1);
1059
+ paintList();
1060
+ return commit();
1061
+ }
1062
+ const step = e.target.closest('.tp-inc') ? 1 : e.target.closest('.tp-dec') ? -1 : 0;
1063
+ if (step) {
1064
+ const m = members[i];
1065
+ const next = copiesOf(m) + step;
1066
+ if (next < 1 || next > MAX_COPIES || (step > 0 && teamSize(members) >= MAX_INSTANCES)) return;
1067
+ m.count = next;
1068
+ paintList();
1069
+ $(`.tp-row[data-i="${i}"] ${step > 0 ? '.tp-inc' : '.tp-dec'}`, list)?.focus();
1070
+ return commit();
1071
+ }
1072
+ const rb = e.target.closest('.tp-runner');
1073
+ if (rb) {
1074
+ const m = members[i];
1075
+ runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort }, (agent, model, effort) => {
1076
+ Object.assign(m, { agent, model, effort });
1077
+ paintList();
1078
+ commit();
1079
+ });
1080
+ }
1081
+ });
1082
+ list.addEventListener('input', (e) => {
1083
+ const row = e.target.closest('.tp-row');
1084
+ if (!row) return;
1085
+ const m = members[Number(row.dataset.i)];
1086
+ if (e.target.closest('.tp-name')) m.name = e.target.value;
1087
+ else m.use = e.target.value;
1088
+ commitSoon();
1089
+ });
1090
+ foot.addEventListener('click', (e) => {
1091
+ if (e.target.closest('.tp-add')) add();
1092
+ if (e.target.closest('.tp-save')) askName();
1093
+ if (e.target.closest('.tp-clear')) {
1094
+ members = [];
1095
+ preset = '';
1096
+ paintList();
1097
+ commit();
1098
+ }
1099
+ });
1100
+ $('.tp-presets', node).onclick = (e) => presetMenu(e.currentTarget);
1101
+
1102
+ const close = () => {
1103
+ clearTimeout(typing);
1104
+ commit();
1105
+ node.remove();
1106
+ $('.menu.tp-menu')?.close();
1107
+ anchor.classList.remove('open');
1108
+ document.removeEventListener('mousedown', outside);
1109
+ };
1110
+ node.close = close;
1111
+ // The harness picker and preset menu float outside the popover; clicks there keep it open.
1112
+ const outside = (e) => !node.contains(e.target) && !anchor.contains(e.target) && !e.target.closest('.picker, .tp-menu') && close();
1113
+ node.addEventListener('keydown', (e) => e.key === 'Escape' && (e.stopPropagation(), close(), anchor.focus()));
1114
+ setTimeout(() => document.addEventListener('mousedown', outside));
1115
+ paintWarn();
1116
+ paintList();
1117
+ document.body.append(node);
1118
+ anchor.classList.add('open');
1119
+ if (!members.length) $('.tp-add', node)?.focus();
1120
+ }
1121
+
1122
+ // One delegation with the child session and turn it ran as (from the lead's childSessions).
1123
+ function delegationsOf(s, t) {
1124
+ return (t.delegations || []).map((d) => {
1125
+ const child = s.childSessions?.[d.sessionId];
1126
+ return { d, child, ct: child?.turns.find((x) => x.id === d.turnId) };
1127
+ });
1128
+ }
1129
+ const subKey = (s, t) => delegationsOf(s, t).map(({ ct }) => ct?.status || '?').join(',');
1130
+ const subStatus = (ct) => {
1131
+ if (!ct || ct.status === 'running') return { cls: 'waiting', label: 'Working' };
1132
+ if (ct.status === 'stopped') return { cls: 'partial', label: 'Stopped' };
1133
+ if (ct.status === 'error') return { cls: 'blocked', label: 'Failed' };
1134
+ const st = ct.presentation?.blocks?.find((b) => b.type === 'hero')?.status || 'done';
1135
+ return { cls: st, label: STATUS_LABEL[st] || 'Done' };
1136
+ };
1137
+
1138
+ // While the lead works: one live line per subagent it's waiting on.
1139
+ function teamLiveHtml(s, t) {
1140
+ const list = delegationsOf(s, t);
1141
+ if (!list.length) return '';
1142
+ return `<div class="team-live">${list
1143
+ .map(({ d, ct }) => {
1144
+ const running = !ct || ct.status === 'running';
1145
+ const hero = ct?.presentation?.blocks?.find((b) => b.type === 'hero');
1146
+ const last = ct?.activity?.findLast((a) => a.kind !== 'text')?.label;
1147
+ const st = subStatus(ct);
1148
+ return `<div class="tl-row ${running ? 'live' : `st-${st.cls}`}" data-sub="${esc(d.sessionId)}">
1149
+ <span class="tl-logo">${harnessLogo(d.agent)}</span>
1150
+ <b class="tl-name">@${esc(d.name)}</b>
1151
+ <span class="tl-live">${esc(running ? last || 'Starting up' : hero?.title || st.label)}</span>
1152
+ ${running ? `<span class="elapsed" data-start="${ct?.startedAt || d.startedAt}">${fmtDuration(Date.now() - (ct?.startedAt || d.startedAt))}</span>` : `<span class="pill st-${st.cls}"><i></i>${st.label}</span>`}
1153
+ </div>`;
1154
+ })
1155
+ .join('')}</div>`;
1156
+ }
1157
+
1158
+ // After the run: the team's reports, one card each, opening into the subagent's own presentation.
1159
+ const subOpen = new Set(); // delegation ids the user unfolded
1160
+ function renderSubs(s, t) {
1161
+ const list = delegationsOf(s, t);
1162
+ if (!list.length) return null;
1163
+ const names = [...new Set(list.map(({ d }) => `@${d.name}`))];
1164
+ const node = el(`
1165
+ <section class="subs">
1166
+ <div class="subs-head">
1167
+ ${teamStack(s.agent, list.map(({ d, ct }) => ({ name: d.name, agent: d.agent, busy: ct?.status === 'running' })))}
1168
+ <b>${list.length} delegated task${list.length > 1 ? 's' : ''}</b>
1169
+ <span class="subs-to">${esc(names.join(', '))}</span>
1170
+ </div>
1171
+ <div class="subs-list"></div>
1172
+ </section>`);
1173
+ const box = $('.subs-list', node);
1174
+ for (const { d, child, ct } of list) {
1175
+ const st = subStatus(ct);
1176
+ const hero = ct?.presentation?.blocks?.find((b) => b.type === 'hero');
1177
+ const files = ct?.changes?.length || 0;
1178
+ const card = el(`
1179
+ <article class="sub st-${st.cls} ${subOpen.has(d.id) ? 'open' : ''}">
1180
+ <button class="sub-head" aria-expanded="${subOpen.has(d.id)}">
1181
+ <span class="sub-logo">${harnessLogo(d.agent)}</span>
1182
+ <span class="sub-main">
1183
+ <span class="sub-top"><b>@${esc(d.name)}</b><span class="sub-model">${esc(memberLabel(d))}</span></span>
1184
+ <span class="sub-title">${esc(hero?.title || (ct?.status === 'running' ? ct.activity?.findLast((a) => a.kind !== 'text')?.label || 'Working' : d.prompt))}</span>
1185
+ </span>
1186
+ <span class="sub-side">
1187
+ ${files ? `<span class="sub-files">${files} file${files > 1 ? 's' : ''}</span>` : ''}
1188
+ ${ct?.endedAt ? `<span class="sub-dur">${fmtDuration(ct.endedAt - ct.startedAt)}</span>` : ''}
1189
+ <span class="pill st-${st.cls}"><i></i>${st.label}</span>
1190
+ ${icon('chevron', 'chev')}
1191
+ </span>
1192
+ </button>
1193
+ <div class="sub-body" ${subOpen.has(d.id) ? '' : 'hidden'}></div>
1194
+ </article>`);
1195
+ const body = $('.sub-body', card);
1196
+ const fill = () => {
1197
+ body.innerHTML = `<div class="sub-task"><span>Task from ${esc(agentName(s.agent))}</span><p>${esc(d.prompt)}</p></div>`;
1198
+ if (ct?.presentation && child) {
1199
+ // Its "next" and question blocks draft into the lead's composer: the lead stays in charge.
1200
+ const ctx = { ...ctxFor(child, ct, false), reply: (text) => draftInto(s.id, `Ask @${d.name} to: ${text}`), draft: (text) => draftInto(s.id, `Ask @${d.name} to: ${text}`) };
1201
+ body.append(renderPresentation(ct.presentation, ctx));
1202
+ } else if (ct?.status === 'running') body.append(el(`<p class="muted sub-wait">Still working…</p>`));
1203
+ };
1204
+ if (subOpen.has(d.id)) fill();
1205
+ $('.sub-head', card).onclick = () => {
1206
+ const opening = body.hidden;
1207
+ opening ? subOpen.add(d.id) : subOpen.delete(d.id);
1208
+ card.classList.toggle('open', opening);
1209
+ $('.sub-head', card).setAttribute('aria-expanded', String(opening));
1210
+ if (opening) fill();
1211
+ body.hidden = !opening;
1212
+ };
1213
+ box.append(card);
1214
+ }
1215
+ return node;
1216
+ }
1217
+
1218
+ function draftInto(id, text) {
1219
+ const ta = $('.reply', S.nodes.get(id)?.card);
1220
+ if (!ta) return;
1221
+ ta.value = text;
1222
+ ta.dispatchEvent(new Event('input'));
1223
+ ta.focus();
1224
+ ta.setSelectionRange(text.length, text.length);
1225
+ }
1226
+
1227
+ // A subagent's summary: its lead's card shows it, so update the live line or re-fetch the lead.
1228
+ const childState = new Map();
1229
+ function onChildSummary(c) {
1230
+ const row = $(`.tl-row[data-sub="${CSS.escape(c.id)}"] .tl-live`);
1231
+ if (row && c.state === 'running' && c.live) row.textContent = c.live;
1232
+ if (childState.get(c.id) !== c.state) {
1233
+ childState.set(c.id, c.state);
1234
+ scheduleRefetch(c.parentId);
1235
+ }
1236
+ }
1237
+
590
1238
  function modeMenu(anchor, current, pick) {
591
1239
  const open = $('.menu.mode');
592
1240
  if (open) return open.close();
@@ -628,21 +1276,172 @@ function modeMenu(anchor, current, pick) {
628
1276
  function renderLaunchChips() {
629
1277
  const box = $('#launcher .l-projects');
630
1278
  if (!box) return;
631
- const projects = S.config.projects.slice().sort((a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt));
632
- if (S.filter !== 'all' && projectById(S.filter)) launchProject = S.filter;
633
- if (!projectById(launchProject)) launchProject = projects[0]?.id || null;
634
- box.innerHTML = `
635
- <span class="l-in">in</span>
636
- ${projects.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launchProject ? 'on' : ''}" data-id="${p.id}" title="${esc(p.path)}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('')}
637
- <button class="lp-chip add" id="lpAdd">${icon('folder')}${projects.length ? 'Other folder…' : 'Choose a project folder…'}</button>`;
638
- $$('.lp-chip[data-id]', box).forEach((b) =>
639
- b.addEventListener('click', () => {
640
- launchProject = b.dataset.id;
641
- save('wisp.launchProject', launchProject);
642
- $$('.lp-chip', box).forEach((x) => x.classList.toggle('on', x === b));
643
- }),
644
- );
645
- $('#lpAdd', box).onclick = () => openProjects({ focusAdd: true });
1279
+ syncWhere();
1280
+ const multi = launchWhere.length > 1;
1281
+ // One group per place: its name (unless it's only this computer), its projects, a way to add one.
1282
+ const group = (k) => {
1283
+ const m = k && machineById(k);
1284
+ const list = projectsAt(k);
1285
+ const off = m && m.state !== 'online';
1286
+ 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>` : '';
1287
+ const chips = list.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launchPicks[k] ? 'on' : ''}" data-id="${p.id}" data-k="${k}" title="${esc(projectTitle(p))}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('');
1288
+ const add = list.length
1289
+ ? `<button class="lp-chip add" data-add="${k}" title="Another folder${k ? ` on ${esc(placeName(k))}` : ''}">${icon('folder')}${multi ? '' : 'Other folder…'}</button>`
1290
+ : `<button class="lp-chip add" data-add="${k}">${icon('folder')}${k ? `Pick a project on ${esc(placeName(k))}…` : 'Choose a project folder…'}</button>`;
1291
+ return `<span class="lp-group">${label}${chips}${add}</span>`;
1292
+ };
1293
+ box.innerHTML = `<span class="l-in">in</span>${launchWhere.map(group).join('')}`;
1294
+ box.onclick = (e) => {
1295
+ const chip = e.target.closest('.lp-chip[data-id]');
1296
+ if (chip) {
1297
+ launchPicks[chip.dataset.k] = chip.dataset.id;
1298
+ if (chip.dataset.k === launchWhere[0]) launchProject = chip.dataset.id;
1299
+ saveWhere();
1300
+ $$(`.lp-chip[data-k="${chip.dataset.k}"]`, box).forEach((x) => x.classList.toggle('on', x === chip));
1301
+ renderLaunchRecipes();
1302
+ return;
1303
+ }
1304
+ const add = e.target.closest('[data-add]');
1305
+ if (add) openProjects({ focusAdd: true, machine: add.dataset.add || null });
1306
+ const conn = e.target.closest('[data-connect]');
1307
+ if (conn) connectMachine(conn.dataset.connect);
1308
+ };
1309
+ renderLaunchRecipes();
1310
+ paintWhereBtn();
1311
+ }
1312
+
1313
+ // "Run on": this computer and/or machines. The button says where; its menu picks one or several.
1314
+ function paintWhereBtn() {
1315
+ const b = $('#whereBtn');
1316
+ if (!b) return;
1317
+ const n = launchWhere.length;
1318
+ const remote = n > 1 || !!launchWhere[0];
1319
+ b.classList.toggle('on', remote);
1320
+ b.classList.toggle('bare', !S.machines.length && !remote);
1321
+ b.innerHTML = `${icon(remote ? 'server' : 'laptop')}<span>${esc(n > 1 ? `${n} places` : placeName(launchWhere[0]))}</span>${icon('chevron', 'chev')}`;
1322
+ b.title = n > 1 ? `Runs in parallel on ${launchWhere.map(placeName).join(', ')}` : S.machines.length ? `Runs on ${placeName(launchWhere[0])}. Pick several to run in parallel.` : 'Runs on this computer. Add a machine to run on other computers too.';
1323
+ }
1324
+
1325
+ function whereMenu(anchor) {
1326
+ if ($('.menu.where-menu')) return $('.menu.where-menu').close();
1327
+ const box = el(`<div class="menu where-menu" role="menu"></div>`);
1328
+ const paint = () => {
1329
+ const row = (k, m) => {
1330
+ const on = launchWhere.includes(k);
1331
+ const st = m ? m.state : 'online';
1332
+ const ready = m ? (m.remote?.agents || []).filter((a) => a.available) : [];
1333
+ return `<button class="wm-row ${on ? 'on' : ''}" data-k="${k}" role="menuitemcheckbox" aria-checked="${on}">
1334
+ <span class="wm-check">${icon('check')}</span>
1335
+ <span class="wm-icon">${icon(k ? 'server' : 'laptop')}${m ? `<i class="dot ${st === 'online' ? 'ok' : st === 'error' ? 'bad' : ''}"></i>` : ''}</span>
1336
+ <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>
1337
+ ${m && st === 'online' && ready.length ? `<span class="wm-agents">${ready.map((a) => `<span class="model-logo">${harnessLogo(a.id)}</span>`).join('')}</span>` : ''}
1338
+ <span class="wm-only" data-only="${k}" title="Only here">only</span>
1339
+ </button>`;
1340
+ };
1341
+ box.innerHTML = `<div class="wm-h">Run on <small>pick several to run in parallel</small></div>
1342
+ ${row('', null)}${S.machines.map((m) => row(m.id, m)).join('')}
1343
+ <div class="wm-sep"></div>
1344
+ <button data-a="add">${icon('plus')}${S.machines.length ? 'Add a machine…' : 'Add a machine to run on another computer…'}</button>
1345
+ ${S.machines.length ? `<button data-a="manage">${icon('settings')}Manage machines</button>` : ''}`;
1346
+ };
1347
+ const set = (list) => {
1348
+ launchWhere = list.length ? list : [''];
1349
+ // A machine picked while disconnected starts connecting; you asked to run there.
1350
+ for (const k of launchWhere) if (k && machineById(k)?.state === 'off') connectMachine(k);
1351
+ renderLaunchChips();
1352
+ paint();
1353
+ saveWhere();
1354
+ };
1355
+ box.onclick = (e) => {
1356
+ const only = e.target.closest('[data-only]');
1357
+ if (only) return set([only.dataset.only]);
1358
+ const r = e.target.closest('.wm-row');
1359
+ if (r) {
1360
+ const k = r.dataset.k;
1361
+ return set(launchWhere.includes(k) ? launchWhere.filter((x) => x !== k) : [...launchWhere, k]);
1362
+ }
1363
+ const a = e.target.closest('[data-a]')?.dataset.a;
1364
+ if (!a) return;
1365
+ shut();
1366
+ openMachines({ focusAdd: a === 'add' });
1367
+ };
1368
+ const shut = () => {
1369
+ box.remove();
1370
+ anchor.setAttribute('aria-expanded', 'false');
1371
+ document.removeEventListener('mousedown', outside);
1372
+ document.removeEventListener('keydown', esc_, true);
1373
+ };
1374
+ const outside = (ev) => {
1375
+ if (!box.contains(ev.target) && !anchor.contains(ev.target)) shut();
1376
+ };
1377
+ const esc_ = (ev) => {
1378
+ if (ev.key === 'Escape') (ev.stopPropagation(), shut());
1379
+ };
1380
+ box.close = shut;
1381
+ box.refresh = paint;
1382
+ paint();
1383
+ document.body.append(box);
1384
+ anchor.setAttribute('aria-expanded', 'true');
1385
+ const r = anchor.getBoundingClientRect();
1386
+ const h = box.offsetHeight;
1387
+ box.style.left = `${Math.min(r.left, innerWidth - box.offsetWidth - 8)}px`;
1388
+ box.style.top = `${r.bottom + 6 + h > innerHeight - 8 ? Math.max(8, r.top - h - 6) : r.bottom + 6}px`;
1389
+ setTimeout(() => document.addEventListener('mousedown', outside));
1390
+ document.addEventListener('keydown', esc_, true);
1391
+ }
1392
+
1393
+ // Recipes for the picked project (and the ones tied to none), one click from the launcher.
1394
+ // With text in the box, "Save as recipe" turns it into one.
1395
+ function renderLaunchRecipes() {
1396
+ const row = $('#launcher .l-recipes');
1397
+ if (!row) return;
1398
+ const path = projectById(launchProject)?.path;
1399
+ const all = S.config.recipes || [];
1400
+ const list = all.filter((r) => !r.cwd || r.cwd === path).sort((a, b) => (b.lastRunAt || 0) - (a.lastRunAt || 0));
1401
+ const hidden = all.length - list.length;
1402
+ row.innerHTML = `
1403
+ <span class="l-in">${icon('spark')}Recipes</span>
1404
+ ${list.map((r) => `<button class="lp-chip rc" data-recipe="${esc(r.id)}" title="${esc(r.prompt.replace(/\s+/g, ' ').slice(0, 200))}">${esc(r.name)}${recipeParams(r.prompt).length ? '<i class="rc-f">…</i>' : ''}</button>`).join('')}
1405
+ ${list.length ? '' : `<span class="muted xs lr-empty">${hidden ? `None for this project · ${hidden} elsewhere` : 'Prompts you send again and again, one click away'}</span>`}
1406
+ <button class="lp-chip add" id="lrSave" hidden>${icon('plus')}Save as recipe</button>
1407
+ <button class="lp-chip add" id="lrNew">${icon('plus')}New</button>`;
1408
+ row.onclick = (e) => {
1409
+ const b = e.target.closest('button');
1410
+ if (!b) return;
1411
+ if (b.dataset.recipe) {
1412
+ const r = all.find((x) => x.id === b.dataset.recipe);
1413
+ return r && runRecipe({ ...r, params: recipeParams(r.prompt) });
1414
+ }
1415
+ const draft = { name: '', prompt: '', cwd: path || '', mode: $('#launcher').mode?.() };
1416
+ if (b.id === 'lrSave') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft: { ...draft, prompt: $('#prompt').value.trim() } } });
1417
+ if (b.id === 'lrNew') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft } });
1418
+ };
1419
+ paintSaveRecipe();
1420
+ }
1421
+ function paintSaveRecipe() {
1422
+ const has = !!$('#prompt')?.value.trim();
1423
+ const save = $('#lrSave');
1424
+ if (!save) return;
1425
+ save.hidden = !has;
1426
+ $('#lrNew').hidden = has;
1427
+ }
1428
+
1429
+ // Top bar: always one click to recipes and triggers. The badge counts triggers that are on;
1430
+ // the tooltip says what fires next.
1431
+ async function paintAutoBtn() {
1432
+ const btn = $('#autoBtn');
1433
+ const on = (S.config.triggers || []).filter((t) => t.enabled);
1434
+ const n = $('.ab-n', btn);
1435
+ n.hidden = !on.length;
1436
+ n.textContent = on.length;
1437
+ btn.classList.toggle('live', !!on.length);
1438
+ const r = S.config.recipes?.length || 0;
1439
+ let title = `Automations: ${r} recipe${r === 1 ? '' : 's'} · ${on.length} trigger${on.length === 1 ? '' : 's'} on`;
1440
+ if (on.length) {
1441
+ const next = (await api('/triggers').catch(() => [])).filter((t) => t.enabled && t.nextAt).sort((a, b) => a.nextAt - b.nextAt)[0];
1442
+ if (next) title += `\nNext: ${next.name} · ${whenLabel(next.nextAt)}`;
1443
+ }
1444
+ btn.title = title;
646
1445
  }
647
1446
 
648
1447
  // A little wisp flies from the launch button to the new item in the feed.
@@ -668,6 +1467,18 @@ function launchedFx(from, to) {
668
1467
  ).onfinish = () => w.remove();
669
1468
  }
670
1469
 
1470
+ // A new card lands in Working, below everything in "Needs you", so it's often off-screen. Scroll
1471
+ // it into view (just enough) and flash it; the flying wisp only plays when the card is already
1472
+ // on screen, since the launcher it starts from may scroll away or fold into the dock.
1473
+ function showLaunched(btn, card) {
1474
+ if (!card) return;
1475
+ const r = card.getBoundingClientRect();
1476
+ if (r.top >= topInset() && r.bottom <= innerHeight) return launchedFx(btn, card);
1477
+ card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1478
+ card.classList.add('arrived');
1479
+ setTimeout(() => card.classList.remove('arrived'), 1600);
1480
+ }
1481
+
671
1482
  // --- project filter (top bar) ---------------------------------------------------
672
1483
 
673
1484
  function renderProjects() {
@@ -678,8 +1489,8 @@ function renderProjects() {
678
1489
  };
679
1490
  const chip = (id, name, color) => {
680
1491
  const st = stats(id);
681
- return `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectById(id)?.path || '')}">
682
- ${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}
1492
+ return `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectTitle(projectById(id)))}">
1493
+ ${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${projectById(id)?.machine ? icon('server', 'p-where') : ''}${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}
683
1494
  </button>`;
684
1495
  };
685
1496
  $('#projects').innerHTML = `${chip('all', 'All', 'slate')}${S.config.projects
@@ -692,6 +1503,7 @@ function renderProjects() {
692
1503
  S.filter = S.filter === b.dataset.id ? 'all' : b.dataset.id;
693
1504
  save('wisp.filter', S.filter);
694
1505
  renderFeed();
1506
+ feedToTop();
695
1507
  renderLaunchChips();
696
1508
  }),
697
1509
  );
@@ -761,6 +1573,8 @@ function ctxFor(s, turn, isLatest) {
761
1573
  openFile: (p, line) => openInEditor(s, p, line),
762
1574
  fileUrl: (p) => `/api/sessions/${s.id}/file?path=${encodeURIComponent(p)}`,
763
1575
  zoom: (src) => overlay(el(`<img class="lightbox" src="${esc(src)}">`)),
1576
+ revertFile: (file, restore) => revertFile(s.id, turn.id, file, restore),
1577
+ exec: (command, key) => execAction(s.id, turn.id, command, key),
764
1578
  };
765
1579
  }
766
1580
 
@@ -781,11 +1595,12 @@ function renderBody(id, { fresh }) {
781
1595
  <div class="it-meta"></div>
782
1596
  <div class="it-actions">
783
1597
  <button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
784
- <button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}<span>Take over</span></button>
785
- <button class="ghost xs" data-a="done" title="Mark done (E)">${icon('done')}<span>Done</span></button>
1598
+ <button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}</button>
1599
+ <button class="ghost xs" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
786
1600
  <button class="ghost xs" data-a="more" title="More">${icon('dots')}</button>
787
1601
  </div>
788
1602
  </div>
1603
+ <div class="pr-strip" hidden></div>
789
1604
  <button class="link xs earlier" hidden></button>
790
1605
  <div class="turns"></div>
791
1606
  <div class="it-dock"></div>
@@ -796,7 +1611,7 @@ function renderBody(id, { fresh }) {
796
1611
  const a = e.target.closest('[data-a]')?.dataset.a;
797
1612
  if (a === 'replay') replay(id);
798
1613
  if (a === 'takeover') takeOver(id);
799
- if (a === 'done') toggleDone(id);
1614
+ if (a === 'ship') shipDialog(id);
800
1615
  if (a === 'more') moreMenu(id, e.target.closest('button'));
801
1616
  });
802
1617
  $('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
@@ -805,17 +1620,25 @@ function renderBody(id, { fresh }) {
805
1620
 
806
1621
  const body = n.body;
807
1622
  const proj = projectById(summary.projectId);
1623
+ // The head already shows the harness, model and project, the task head has Compare, and Done
1624
+ // sits by the composer. This bar only says how the run was set up: a mode worth noticing
1625
+ // (Plan or Full auto, as on the launcher), the branch, and what launched it.
1626
+ const mode = MODES.find((m) => m.id === s.mode);
1627
+ const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
1628
+ $('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
808
1629
  $('.it-meta', body).innerHTML = `
809
- <span class="m">${esc(agentName(s.agent))}</span>
810
- <span class="m">${esc(MODES.find((m) => m.id === s.mode)?.label || s.mode)}</span>
811
- ${s.model ? `<span class="m">${esc(s.model)}</span>` : ''}
812
- ${s.effort ? `<span class="m">${esc(effortLabel(s.effort))} thinking</span>` : ''}
813
- <span class="m path" title="${esc(s.cwd)}">${esc((proj?.path || s.cwd).replace(S.config.home || '', '~'))}</span>`;
814
- // Done lives both in the top bar and next to the composer, so it's reachable after reading.
1630
+ ${mode && mode.id !== 'edits' ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
1631
+ ${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
1632
+ ${s.subagents?.length ? `<span class="m team-m" title="${esc(s.subagents.map((m) => `@${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n'))}">${icon('agent')}${teamSize(s.subagents)} subagent${teamSize(s.subagents) > 1 ? 's' : ''}</span>` : ''}
1633
+ ${s.origin ? `<span class="m" title="Launched by a ${esc(s.origin.kind)}">${icon(s.origin.kind === 'trigger' ? 'replay' : 'spark')}${esc(s.origin.name)}</span>` : ''}`;
1634
+ const live = s.state === 'running' || s.state === 'waiting';
1635
+ // Ship sits in the top bar and again next to the composer, so it's there after reading the result.
1636
+ $$('[data-a="ship"]', body).forEach((b) => (b.hidden = !s.worktree || live || s.agent === 'wisp' || s.pr?.state === 'MERGED'));
1637
+ renderPrStrip(id);
815
1638
  $$('[data-a="done"]', body).forEach((b) => {
816
1639
  b.classList.toggle('on', s.state === 'done');
817
1640
  b.hidden = s.state === 'running' || s.state === 'waiting';
818
- $('span', b).textContent = s.state === 'done' ? 'Reopen' : b.closest('.c-bar') ? 'Mark done' : 'Done';
1641
+ $('span', b).textContent = s.state === 'done' ? 'Reopen' : 'Mark done';
819
1642
  });
820
1643
  $('[data-a="takeover"]', body).hidden = !s.agentSessionId;
821
1644
 
@@ -834,7 +1657,8 @@ function renderBody(id, { fresh }) {
834
1657
  turns.forEach((t) => {
835
1658
  const isLatest = t.id === latestId;
836
1659
  const folded = !isLatest && !n.unfolded.has(t.id);
837
- const key = `${t.status}|${isLatest}|${folded}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}`;
1660
+ const revKey = `${t.rolledBackAt || 0}:${(t.changes || []).map((c) => (c.reverted ? 1 : 0)).join('')}`;
1661
+ const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
838
1662
  const prev = n.turnNodes.get(t.id);
839
1663
  let node = prev?.node;
840
1664
  if (!prev || prev.key !== key) {
@@ -851,12 +1675,54 @@ function renderBody(id, { fresh }) {
851
1675
  for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
852
1676
 
853
1677
  updateDock(id);
854
- if (firstNew && isInView(n.card)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
1678
+ if (firstNew && watching(id)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
1679
+ }
1680
+
1681
+ // Whether the user is reading a card, not just catching its edge at the top or bottom of the
1682
+ // screen: it crosses the middle of the viewport, or it's focused and a fair share of it shows.
1683
+ // Anything that moves the page on a card's behalf asks this first.
1684
+ function watching(id) {
1685
+ const card = S.nodes.get(id)?.card;
1686
+ if (!card?.isConnected) return false;
1687
+ const r = card.getBoundingClientRect();
1688
+ const mid = innerHeight / 2;
1689
+ if (r.top <= mid && r.bottom >= mid) return true;
1690
+ const shown = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0);
1691
+ return S.focus === id && shown >= Math.min(r.height, innerHeight) / 3;
855
1692
  }
856
1693
 
857
- function isInView(node) {
858
- const r = node.getBoundingClientRect();
859
- return r.bottom > 0 && r.top < innerHeight;
1694
+ const sectionOf = (s) => {
1695
+ const t = taskOf(s);
1696
+ return t.archived ? 'archived' : t.state === 'running' ? 'working' : t.state === 'done' ? 'done' : 'needs';
1697
+ };
1698
+
1699
+ // Run a DOM change without moving `card` on screen. The browser's own scroll anchoring misses
1700
+ // cards that are moved between lists, so feed re-renders do this explicitly.
1701
+ function keepInPlace(card, change) {
1702
+ const top = card?.isConnected ? card.getBoundingClientRect().top : null;
1703
+ change();
1704
+ if (top !== null && card.isConnected) scrollByPx(card.getBoundingClientRect().top - top);
1705
+ }
1706
+
1707
+ // Any write to scrollTop cancels a smooth scroll in flight (j/k, Open, a launch), so only
1708
+ // write when there's an actual shift to undo.
1709
+ function scrollByPx(dy) {
1710
+ if (Math.abs(dy) >= 1) stage.scrollTop += dy;
1711
+ }
1712
+
1713
+ // The card to hold still while the feed changes around it: the first one showing under the top
1714
+ // bar and docked launcher, skipping `id` (the card that's changing). None at the very top of the page, where new
1715
+ // cards should push the list down, or when `id` itself is the top card on screen: then the next
1716
+ // one should slide up into its place.
1717
+ function feedAnchor(id) {
1718
+ if (stage.scrollTop < 1) return null;
1719
+ const moving = id && S.nodes.get(id)?.card;
1720
+ const inset = topInset();
1721
+ for (const card of $$('#feed .item')) {
1722
+ const r = card.getBoundingClientRect();
1723
+ if (r.bottom > inset) return card === moving ? null : card;
1724
+ }
1725
+ return null;
860
1726
  }
861
1727
 
862
1728
  function renderFoldedTurn(t, unfold) {
@@ -866,6 +1732,7 @@ function renderFoldedTurn(t, unfold) {
866
1732
  <button class="t-fold" title="Show this exchange">
867
1733
  ${icon('chevron', 'chev')}
868
1734
  <span class="t-prompt">${esc(t.prompt.replace(/\s+/g, ' '))}</span>
1735
+ ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
869
1736
  ${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
870
1737
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
871
1738
  </button>
@@ -876,14 +1743,21 @@ function renderFoldedTurn(t, unfold) {
876
1743
 
877
1744
  // `fold` is set for older turns the user unfolded, so they can tuck them away again.
878
1745
  function renderTurn(s, t, isLatest, fold) {
879
- const node = el(`<article class="turn" data-turn="${t.id}"></article>`);
1746
+ const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
880
1747
  const long = t.prompt.length > 360 || t.prompt.split('\n').length > 5;
1748
+ const cp = t.checkpoint;
1749
+ const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
881
1750
  const ask = el(`
882
1751
  <div class="ask">
883
1752
  <div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
884
1753
  ${long ? '<button class="link xs more-btn">Show all</button>' : ''}
885
1754
  ${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
886
- <div class="ask-foot">${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time></div>
1755
+ <div class="ask-foot">
1756
+ ${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}
1757
+ <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
1758
+ ${cpLabel ? `<span class="cp-sha" title="Git checkpoint before this turn (${esc(cp.head)}${cp.dirty ? ' + uncommitted changes' : ''})">${icon('branch')}${esc(cpLabel)}</span>` : ''}
1759
+ ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
1760
+ </div>
887
1761
  ${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
888
1762
  </div>`);
889
1763
  if (fold) ask.querySelector('.t-unfold').onclick = fold;
@@ -898,6 +1772,8 @@ function renderTurn(s, t, isLatest, fold) {
898
1772
  node.append(renderWorking(s, t));
899
1773
  } else {
900
1774
  node.append(renderPresentation(t.presentation, ctxFor(s, t, isLatest)));
1775
+ const subs = renderSubs(s, t);
1776
+ if (subs) node.append(subs);
901
1777
  node.append(renderTrail(s, t));
902
1778
  }
903
1779
  return node;
@@ -915,6 +1791,7 @@ function renderWorking(s, t) {
915
1791
  <span class="w-count">${t.activity.filter((a) => a.kind !== 'text').length} actions</span>
916
1792
  </div>
917
1793
  <div class="ask-slot"></div>
1794
+ ${teamLiveHtml(s, t)}
918
1795
  <ul class="ticker"></ul>
919
1796
  <div class="w-actions">
920
1797
  <button class="link xs peek">Show all activity</button>
@@ -955,7 +1832,7 @@ function renderAskCard(s) {
955
1832
  <p class="q-text">${esc(a.question)}</p>
956
1833
  ${a.context ? `<div class="prose q-context">${md(a.context)}</div>` : ''}
957
1834
  ${a.options?.length ? `<div class="q-opts">${a.options.map((o, i) => `<button class="opt" data-i="${i}"><span class="k">${i + 1}</span>${esc(o)}</button>`).join('')}</div>` : ''}
958
- <form class="q-own"><input placeholder="${a.options?.length ? 'Or answer in your own words…' : 'Your answer…'}"><button class="primary sm">Answer</button></form>
1835
+ <form class="q-own"><input class="input" placeholder="${a.options?.length ? 'Or answer in your own words…' : 'Your answer…'}"><button class="primary sm">Answer</button></form>
959
1836
  </div>`);
960
1837
  const answer = async (text) => {
961
1838
  if (!text.trim()) return;
@@ -985,6 +1862,8 @@ function renderTrail(s, t) {
985
1862
  return { add: acc.add + n.add, del: acc.del + n.del };
986
1863
  }, { add: 0, del: 0 });
987
1864
  const st = t.stats || {};
1865
+ const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (t.changes?.length || t.checkpoint?.commit);
1866
+ const rolledBack = !!t.rolledBackAt;
988
1867
  const node = el(`
989
1868
  <div class="trail">
990
1869
  <div class="trail-bar">
@@ -992,6 +1871,7 @@ function renderTrail(s, t) {
992
1871
  ${t.endedAt ? `<span class="tb">${fmtDuration(t.endedAt - t.startedAt)}</span>` : ''}
993
1872
  ${st.costUsd ? `<span class="tb">$${st.costUsd.toFixed(2)}</span>` : ''}
994
1873
  <span class="grow"></span>
1874
+ ${canRollback ? `<button class="link xs cp-rollback ${rolledBack ? 'rolled-back' : ''}" data-a="rollback" title="${rolledBack ? 'Re-apply changes from this turn' : `Restore working tree to before this turn${t.checkpoint?.head ? ` (${t.checkpoint.head.slice(0, 7)})` : ''}`}">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply turn' : 'Restore to before this turn'}</button>` : ''}
995
1875
  ${t.changes?.length ? `<button class="link xs" data-tab="changes">${t.changes.length} file${t.changes.length > 1 ? 's' : ''} <span class="plus">+${totals.add}</span> <span class="minus">−${totals.del}</span></button>` : ''}
996
1876
  ${acts.length ? `<button class="link xs" data-tab="activity">Activity</button>` : ''}
997
1877
  ${t.presentation?.notes ? `<button class="link xs" data-tab="notes">Agent notes</button>` : ''}
@@ -1001,6 +1881,9 @@ function renderTrail(s, t) {
1001
1881
  const body = $('.trail-body', node);
1002
1882
  let open = null;
1003
1883
  node.addEventListener('click', (e) => {
1884
+ if (e.target.closest('[data-a="rollback"]')) {
1885
+ return rollbackTurn(s.id, t.id, rolledBack);
1886
+ }
1004
1887
  const b = e.target.closest('[data-tab]');
1005
1888
  if (!b) return;
1006
1889
  const tab = b.dataset.tab;
@@ -1055,6 +1938,7 @@ function renderDock(id) {
1055
1938
  <div class="c-bar">
1056
1939
  <button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
1057
1940
  <span class="c-hint"></span>
1941
+ <button class="ghost sm c-ship" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
1058
1942
  <button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
1059
1943
  <button class="ghost xs c-queue" hidden>${icon('queue')}<span>Queue</span></button>
1060
1944
  <button class="send" title="Send (Enter · Shift+Enter for newline)">${icon('enter')}</button>
@@ -1068,6 +1952,7 @@ function renderDock(id) {
1068
1952
  acceptFiles(comp, ta, n.tray);
1069
1953
  $('.c-attach', comp).onclick = n.tray.pick;
1070
1954
  $('.c-done', comp).onclick = () => toggleDone(id);
1955
+ $('.c-ship', comp).onclick = () => shipDialog(id);
1071
1956
  $('.c-queue', comp).onclick = toggleQueue;
1072
1957
  ta.value = S.drafts.get(id) || '';
1073
1958
  ta.addEventListener('input', () => S.drafts.set(id, ta.value));
@@ -1201,15 +2086,66 @@ async function toggleDone(id) {
1201
2086
  if (!s) return;
1202
2087
  if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
1203
2088
  const next = s.state === 'done' ? 'review' : 'done';
2089
+ const leave = next === 'done' && handOff(id); // measured before the live update moves the card
1204
2090
  await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
1205
- if (next === 'done') {
1206
- const nextUp = groups().needs.find((x) => x.id !== id);
1207
- collapse(id);
1208
- if (nextUp) setFocus(nextUp.id);
2091
+ if (leave) {
2092
+ leave(() => S.sessions.set(id, { ...S.sessions.get(id), state: 'done' }));
1209
2093
  toast(`Marked done`, { action: { label: 'Undo', run: () => api(`/sessions/${id}`, { method: 'PATCH', body: { state: 'review' } }) } });
1210
2094
  }
1211
2095
  }
1212
2096
 
2097
+ // Every finished run of a compare task at once, with one toast to undo them all.
2098
+ async function taskDone(ids) {
2099
+ if (!ids.length) return;
2100
+ const card = S.nodes.get(ids[0])?.card.closest('.task-item');
2101
+ const next = card?.nextElementSibling?.querySelector('.item')?.dataset.id || card?.nextElementSibling?.dataset.id;
2102
+ const set = (state) => Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } })));
2103
+ await set('done').catch((err) => toast(esc(err.message), { tone: 'bad' }));
2104
+ for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state: 'done' });
2105
+ renderFeed();
2106
+ if (next && S.nodes.has(next)) setFocus(next);
2107
+ toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
2108
+ }
2109
+
2110
+ async function revertFile(id, turnId, file, restore = false) {
2111
+ try {
2112
+ await api(`/sessions/${id}/revert`, { method: 'POST', body: { turnId, file, restore } });
2113
+ toast(restore ? `Re-applied ${esc(file)}` : `Reverted ${esc(file)}`, {
2114
+ action: { label: 'Undo', run: () => revertFile(id, turnId, file, !restore) },
2115
+ });
2116
+ } catch (err) {
2117
+ toast(esc(err.message), { tone: 'bad' });
2118
+ }
2119
+ }
2120
+
2121
+ async function rollbackTurn(id, turnId, restore = false) {
2122
+ const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
2123
+ const turn = s?.turns?.find((t) => t.id === turnId) || s?.turns?.at(-1);
2124
+ if (!turn) return;
2125
+ try {
2126
+ const r = await api(`/sessions/${id}/rollback`, { method: 'POST', body: { turnId: turn.id, restore } });
2127
+ toast(
2128
+ restore
2129
+ ? `Re-applied turn changes`
2130
+ : `Restored working tree to before this turn${r.head ? ` (${esc(r.head.slice(0, 7))})` : ''}`,
2131
+ { action: { label: 'Undo', run: () => rollbackTurn(id, turn.id, !restore) } },
2132
+ );
2133
+ } catch (err) {
2134
+ toast(esc(err.message), { tone: 'bad' });
2135
+ }
2136
+ }
2137
+
2138
+ async function execAction(id, turnId, command, key) {
2139
+ try {
2140
+ const r = await api(`/sessions/${id}/exec`, { method: 'POST', body: { command, turnId } });
2141
+ if (key) S.actionRuns.set(key, r);
2142
+ return r;
2143
+ } catch (err) {
2144
+ toast(esc(err.message), { tone: 'bad' });
2145
+ return null;
2146
+ }
2147
+ }
2148
+
1213
2149
  async function takeOver(id) {
1214
2150
  const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
1215
2151
  if (!s?.agentSessionId) return;
@@ -1274,7 +2210,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
1274
2210
  .join('')}
1275
2211
  </div>
1276
2212
  <div class="pk-main">
1277
- <label class="pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
2213
+ <label class="field pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
1278
2214
  <div class="pk-list" role="listbox" aria-label="Model"></div>
1279
2215
  <div class="pk-effort" role="radiogroup" aria-label="Thinking level"></div>
1280
2216
  </div>
@@ -1458,12 +2394,16 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
1458
2394
  function moreMenu(id, anchor) {
1459
2395
  const s = S.full.get(id);
1460
2396
  if (!s) return;
2397
+ const lastTurn = s.turns?.at(-1);
2398
+ const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit);
2399
+ const rolledBack = !!lastTurn?.rolledBackAt;
1461
2400
  const r = anchor.getBoundingClientRect();
1462
2401
  const menu = el(`
1463
2402
  <div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
1464
2403
  <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
1465
2404
  ${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
1466
2405
  <button data-a="folder">${icon('folder')}Open project in editor</button>
2406
+ ${canRollback ? `<button data-a="rollback">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn'}</button>` : ''}
1467
2407
  ${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : s.state !== 'running' && s.state !== 'waiting' ? `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>` : ''}
1468
2408
  <button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
1469
2409
  </div>`);
@@ -1480,6 +2420,7 @@ function moreMenu(id, anchor) {
1480
2420
  toast('Copied resume command');
1481
2421
  }
1482
2422
  if (a === 'folder') openInEditor(s, '.');
2423
+ if (a === 'rollback') rollbackTurn(id, lastTurn?.id, rolledBack);
1483
2424
  if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
1484
2425
  if (a === 'delete') deleteSession(id);
1485
2426
  };
@@ -1497,6 +2438,10 @@ function taskMenu(id, e) {
1497
2438
  ${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
1498
2439
  <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
1499
2440
  <button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
2441
+ ${s.branch && !busy && s.pr?.state !== 'MERGED' ? `<button data-a="ship">${icon('send')}${s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…'}${keyHint('S')}</button>` : ''}
2442
+ ${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
2443
+ ${s.groupId ? `<button data-a="compare"><b class="vs-mark">vs</b>Compare runs</button>` : ''}
2444
+ <button data-a="recipe">${icon('spark')}Save as recipe…</button>
1500
2445
  ${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
1501
2446
  ${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
1502
2447
  <button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
@@ -1512,6 +2457,10 @@ function taskMenu(id, e) {
1512
2457
  if (a === 'collapse') collapse(id);
1513
2458
  if (a === 'rename') rename(id);
1514
2459
  if (a === 'rerun') rerun(id);
2460
+ if (a === 'ship') shipDialog(id);
2461
+ if (a === 'merge') mergePr(id);
2462
+ if (a === 'compare') compareView(id);
2463
+ if (a === 'recipe') saveAsRecipe(id);
1515
2464
  if (a === 'done') toggleDone(id);
1516
2465
  if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
1517
2466
  if (a === 'delete') deleteSession(id);
@@ -1528,7 +2477,9 @@ const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
1528
2477
 
1529
2478
  async function deleteSession(id) {
1530
2479
  if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
1531
- await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
2480
+ const leave = handOff(id);
2481
+ const ok = await api(`/sessions/${id}`, { method: 'DELETE' }).then(() => true, (err) => toast(esc(err.message), { tone: 'bad' }));
2482
+ if (ok) leave(() => S.sessions.delete(id));
1532
2483
  }
1533
2484
 
1534
2485
  // Launch the task again as a new session in the same folder: same prompt and attachments,
@@ -1573,11 +2524,7 @@ async function rerun(id) {
1573
2524
  modeBtn.title = m.hint;
1574
2525
  };
1575
2526
  const runnerBtn = $('[data-a="runner"]', node);
1576
- const paintRunner = () => {
1577
- const a = S.agents.find((x) => x.id === agent);
1578
- const m = (a?.models || []).find((x) => x.id === model);
1579
- runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent)}</span><span class="rb-h">${esc(a?.name || agentName(agent))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`;
1580
- };
2527
+ const paintRunner = () => paintRunnerBtn(runnerBtn, agent, model, effort);
1581
2528
  paintMode();
1582
2529
  paintRunner();
1583
2530
  modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
@@ -1622,13 +2569,14 @@ async function rerun(id) {
1622
2569
  }
1623
2570
 
1624
2571
  async function setArchived(id, archived) {
2572
+ const leave = archived && handOff(id);
1625
2573
  try {
1626
2574
  await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
1627
2575
  } catch (err) {
1628
2576
  return toast(esc(err.message), { tone: 'bad' });
1629
2577
  }
1630
- if (archived) {
1631
- if (S.open.has(id)) collapse(id);
2578
+ if (leave) {
2579
+ leave(() => S.sessions.set(id, { ...S.sessions.get(id), archived: true }));
1632
2580
  toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
1633
2581
  }
1634
2582
  }
@@ -1650,9 +2598,20 @@ const EDITORS = {
1650
2598
  idea: (f, l) => `idea://open?file=${encodeURIComponent(f)}${l ? `&line=${l}` : ''}`,
1651
2599
  };
1652
2600
 
2601
+ // VS Code and its forks open a file on a machine through their Remote - SSH extension.
2602
+ const REMOTE_EDITORS = new Set(['vscode', 'cursor', 'windsurf']);
2603
+
1653
2604
  function openInEditor(s, p, line) {
1654
2605
  const editor = S.config.defaults.editor || 'vscode';
1655
2606
  const abs = p.startsWith('/') ? p : `${s.cwd}/${p}`.replace(/\/\.$/, '');
2607
+ if (s.machine) {
2608
+ if (!REMOTE_EDITORS.has(editor)) {
2609
+ copy(abs);
2610
+ return toast(`Copied path ${esc(abs)} (on ${esc(s.machine.name)})`);
2611
+ }
2612
+ location.href = `${editor}://vscode-remote/ssh-remote+${encodeURIComponent(s.machine.target)}${abs}${line ? `:${line}` : ''}`;
2613
+ return;
2614
+ }
1656
2615
  if (editor === 'none' || !EDITORS[editor]) {
1657
2616
  copy(abs);
1658
2617
  return toast(`Copied path ${esc(abs)}`);
@@ -1684,12 +2643,14 @@ function reveal(id) {
1684
2643
 
1685
2644
  const COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
1686
2645
 
1687
- async function openProjects({ focusAdd = false } = {}) {
2646
+ // where: a machine id to add folders on that computer (server/machines.js), else this one.
2647
+ async function openProjects({ focusAdd = false, machine = null, host } = {}) {
1688
2648
  const node = el(`
1689
2649
  <div class="modal projects-modal">
1690
2650
  <header><h2>Projects</h2><button class="icon-btn close">${icon('x')}</button></header>
2651
+ <div class="mem-tabs where"></div>
1691
2652
  <div class="add-row">
1692
- <div class="path-input">
2653
+ <div class="field lg mono path-input">
1693
2654
  ${window.wispDesktop ? `<button class="browse" title="Browse…">${icon('folder')}</button>` : icon('folder')}
1694
2655
  <input id="ppath" placeholder="~/code/my-app" spellcheck="false" autocomplete="off">
1695
2656
  <div class="suggest" hidden></div>
@@ -1697,23 +2658,55 @@ async function openProjects({ focusAdd = false } = {}) {
1697
2658
  ${window.wispDesktop ? `<button class="ghost" id="pbrowse">${icon('folder')}Browse…</button>` : ''}
1698
2659
  <button class="primary" id="padd">Add</button>
1699
2660
  </div>
1700
- <p class="hint">Tip: run <code>wisp add</code> inside any folder, or launch from a terminal with <code>wisp "task"</code> — the folder is added automatically.</p>
2661
+ <p class="hint"></p>
1701
2662
  <div class="plist" id="plist"></div>
1702
2663
  <div class="discover" id="discover"><h4>Found on this machine</h4><p class="muted">Looking for git repos…</p></div>
1703
2664
  </div>`);
1704
- const close = overlay(node);
1705
- $('.close', node).onclick = close;
2665
+ present(node, host);
1706
2666
  const input = $('#ppath', node);
1707
2667
  const sug = $('.suggest', node);
2668
+ let where = machine && machineById(machine) ? machine : null;
2669
+ const here = () => machineById(where);
2670
+ const home = () => (where ? here()?.remote?.home : S.config.home) || '';
2671
+ const tilde = (p) => (home() && p.startsWith(home()) ? `~${p.slice(home().length)}` : p);
2672
+
2673
+ const paintWhere = () => {
2674
+ const tabs = $('.where', node);
2675
+ tabs.innerHTML = `<button data-w="" class="${where ? '' : 'on'}">${icon('folder')}This computer</button>${S.machines
2676
+ .map((m) => `<button data-w="${m.id}" class="${where === m.id ? 'on' : ''}" title="${esc(m.target)} · ${esc(MACHINE_STATE[m.state] || m.state)}">${icon('server')}${esc(m.name)}<i class="dot ${m.state === 'online' ? 'ok' : m.state === 'error' ? 'bad' : ''}"></i></button>`)
2677
+ .join('')}<i></i>${host ? '' : `<button data-w="+" title="Run agents on another computer over SSH">${icon('plus')}Machine</button>`}`;
2678
+ const m = here();
2679
+ input.placeholder = m ? `~/code/my-app on ${m.name}` : '~/code/my-app';
2680
+ $$('#pbrowse, .browse', node).forEach((b) => (b.hidden = !!m));
2681
+ $('.hint', node).innerHTML = m
2682
+ ? `Folders on <b>${esc(m.name)}</b>. Agents run there, in its own worktrees; you follow along here.`
2683
+ : 'Tip: run <code>wisp add</code> inside any folder, or launch from a terminal with <code>wisp "task"</code> — the folder is added automatically.';
2684
+ };
2685
+ $('.where', node).addEventListener('click', (e) => {
2686
+ const b = e.target.closest('[data-w]');
2687
+ if (!b) return;
2688
+ if (b.dataset.w === '+') {
2689
+ openMachines({ focusAdd: true });
2690
+ return;
2691
+ }
2692
+ where = b.dataset.w || null;
2693
+ sug.hidden = true;
2694
+ paintWhere();
2695
+ list();
2696
+ discover();
2697
+ input.focus();
2698
+ });
1708
2699
 
1709
2700
  const add = async (p) => {
1710
2701
  try {
1711
- const proj = await api('/projects', { method: 'POST', body: { path: p } });
2702
+ const proj = await api('/projects', { method: 'POST', body: { path: p, ...(where && { machine: where }) } });
1712
2703
  await loadConfig();
1713
- launchProject = proj.id;
1714
- save('wisp.launchProject', launchProject);
2704
+ launchPicks[placeOf(proj)] = proj.id;
2705
+ if (!launchWhere.includes(placeOf(proj))) launchWhere = [placeOf(proj)];
2706
+ if (placeOf(proj) === launchWhere[0]) launchProject = proj.id;
2707
+ saveWhere();
1715
2708
  renderLaunchChips();
1716
- toast(`Added <b>${esc(proj.name)}</b>`);
2709
+ toast(`Added <b>${esc(proj.name)}</b>${where ? ` on ${esc(here()?.name || '')}` : ''}`);
1717
2710
  input.value = '';
1718
2711
  sug.hidden = true;
1719
2712
  list();
@@ -1722,7 +2715,9 @@ async function openProjects({ focusAdd = false } = {}) {
1722
2715
  toast(esc(err.message), { tone: 'bad' });
1723
2716
  }
1724
2717
  };
1725
- $('#padd', node).onclick = () => input.value.trim() && add(input.value.trim());
2718
+ $('#padd', node).onclick = () => {
2719
+ if (input.value.trim()) add(input.value.trim());
2720
+ };
1726
2721
  const browse = async () => {
1727
2722
  const dir = await window.wispDesktop.pickFolder();
1728
2723
  if (dir) add(dir);
@@ -1736,10 +2731,10 @@ async function openProjects({ focusAdd = false } = {}) {
1736
2731
  t = setTimeout(async () => {
1737
2732
  const q = input.value;
1738
2733
  if (!q) return (sug.hidden = true);
1739
- const dirs = await api(`/fs?q=${encodeURIComponent(q)}`).catch(() => []);
2734
+ const dirs = await api(`${where ? `/machines/${where}/fs` : '/fs'}?q=${encodeURIComponent(q)}`).catch(() => []);
1740
2735
  sel = -1;
1741
2736
  sug.hidden = !dirs.length;
1742
- sug.innerHTML = dirs.map((d) => `<button data-p="${esc(d)}">${esc(d.replace(S.config.home, '~'))}</button>`).join('');
2737
+ sug.innerHTML = dirs.map((d) => `<button data-p="${esc(d)}">${esc(tilde(d))}</button>`).join('');
1743
2738
  }, 120);
1744
2739
  });
1745
2740
  input.addEventListener('keydown', (e) => {
@@ -1750,7 +2745,7 @@ async function openProjects({ focusAdd = false } = {}) {
1750
2745
  opts.forEach((o, i) => o.classList.toggle('on', i === sel));
1751
2746
  } else if (e.key === 'Tab' && opts.length) {
1752
2747
  e.preventDefault();
1753
- input.value = (opts[Math.max(0, sel)].dataset.p + '/').replace(S.config.home, '~');
2748
+ input.value = tilde(opts[Math.max(0, sel)].dataset.p + '/');
1754
2749
  input.dispatchEvent(new Event('input'));
1755
2750
  } else if (e.key === 'Enter') {
1756
2751
  e.preventDefault();
@@ -1764,20 +2759,20 @@ async function openProjects({ focusAdd = false } = {}) {
1764
2759
 
1765
2760
  const list = () => {
1766
2761
  const box = $('#plist', node);
1767
- const projects = S.config.projects;
2762
+ const projects = S.config.projects.filter((p) => (p.machine || null) === where);
1768
2763
  box.innerHTML = projects.length
1769
2764
  ? projects
1770
2765
  .map((p) => {
1771
2766
  const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
1772
2767
  return `<div class="prow c-${p.color}" data-id="${p.id}">
1773
2768
  <button class="swatch" title="Change colour"><i class="p-dot"></i></button>
1774
- <div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(p.path.replace(S.config.home, '~'))}</span></div>
2769
+ <div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(tilde(p.path))}</span></div>
1775
2770
  <span class="pcount">${n} session${n === 1 ? '' : 's'}</span>
1776
2771
  <button class="icon-btn premove" title="Remove from wisp (files untouched)">${icon('trash')}</button>
1777
2772
  </div>`;
1778
2773
  })
1779
2774
  .join('')
1780
- : '<p class="muted">No projects yet. Add a folder above, or pick one below.</p>';
2775
+ : `<p class="muted">No projects ${where ? `on ${esc(here()?.name || '')} ` : ''}yet. Add a folder above, or pick one below.</p>`;
1781
2776
  $$('.prow', box).forEach((row) => {
1782
2777
  const id = row.dataset.id;
1783
2778
  const p = projectById(id);
@@ -1797,50 +2792,1111 @@ async function openProjects({ focusAdd = false } = {}) {
1797
2792
 
1798
2793
  const discover = async () => {
1799
2794
  const box = $('#discover', node);
1800
- const found = await api('/projects/discover').catch(() => []);
1801
- box.innerHTML = found.length
1802
- ? `<h4>Found on this machine <span class="muted">· git repos</span></h4><div class="found">${found
1803
- .map((f) => `<button class="found-item" data-p="${esc(f.path)}">${icon('plus')}<b>${esc(f.name)}</b><span>${esc(f.path.replace(S.config.home, '~').replace(/\/[^/]+$/, ''))}</span></button>`)
1804
- .join('')}</div>`
1805
- : '';
2795
+ const m = here();
2796
+ const at = where;
1806
2797
  box.onclick = (e) => {
1807
2798
  const b = e.target.closest('.found-item');
1808
2799
  if (b) add(b.dataset.p);
2800
+ if (e.target.closest('[data-a="connect"]')) connectMachine(at);
1809
2801
  };
2802
+ if (m && m.state !== 'online') {
2803
+ box.innerHTML = `<p class="muted">${esc(m.name)} is ${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : `offline. <button class="link" data-a="connect">Connect</button>`}</p>`;
2804
+ return;
2805
+ }
2806
+ box.innerHTML = `<h4>Found on ${m ? esc(m.name) : 'this machine'}</h4><p class="muted">Looking for git repos…</p>`;
2807
+ const found = await api(m ? `/machines/${m.id}/discover` : '/projects/discover').catch(() => []);
2808
+ if (where !== at) return; // switched tabs meanwhile
2809
+ box.innerHTML = found.length
2810
+ ? `<h4>Found on ${m ? esc(m.name) : 'this machine'} <span class="muted">· git repos</span></h4><div class="found">${found
2811
+ .map((f) => `<button class="found-item" data-p="${esc(f.path)}">${icon('plus')}<b>${esc(f.name)}</b><span>${esc(tilde(f.path).replace(/\/[^/]+$/, ''))}</span></button>`)
2812
+ .join('')}</div>`
2813
+ : '';
2814
+ };
2815
+ let wasOnline = here()?.state === 'online';
2816
+ node.refresh = () => {
2817
+ paintWhere();
2818
+ const on = here()?.state === 'online';
2819
+ if (on !== wasOnline) discover();
2820
+ wasOnline = on;
1810
2821
  };
1811
2822
 
2823
+ paintWhere();
1812
2824
  list();
1813
2825
  discover();
1814
2826
  if (focusAdd) setTimeout(() => input.focus(), 60);
1815
2827
  }
1816
2828
 
2829
+ // --- ship it: worktree → commit → PR → checks → merge ---------------------------------------
2830
+
2831
+ const prTone = (pr) =>
2832
+ pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
2833
+ const REVIEW = { APPROVED: 'Approved', CHANGES_REQUESTED: 'Changes requested', REVIEW_REQUIRED: 'Review required' };
2834
+ function prLine(pr) {
2835
+ const c = pr.checks || {};
2836
+ const checks = c.fail ? `${c.fail} check${c.fail > 1 ? 's' : ''} failing` : c.pending ? `${c.pending} check${c.pending > 1 ? 's' : ''} running` : c.pass ? `${c.pass} check${c.pass > 1 ? 's' : ''} passed` : 'No checks';
2837
+ const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
2838
+ return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
2839
+ }
2840
+ const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
2841
+
2842
+ // The PR row under a card's bar; reads the live summary, so polled check results show up.
2843
+ function renderPrStrip(id) {
2844
+ const n = S.nodes.get(id);
2845
+ const node = n?.body && $('.pr-strip', n.body);
2846
+ const s = S.sessions.get(id);
2847
+ if (!node || !s) return;
2848
+ const pr = s.pr;
2849
+ $$('[data-a="ship"] span', n.body).forEach((b) => (b.textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it'));
2850
+ node.hidden = !pr;
2851
+ if (!pr) return;
2852
+ const tone = prTone(pr);
2853
+ node.className = `pr-strip pr-${tone}`;
2854
+ node.innerHTML = `
2855
+ <span class="pr-dot"></span>
2856
+ <a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
2857
+ <span class="pr-line">${esc(prLine(pr))}</span>
2858
+ <span class="grow"></span>
2859
+ ${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
2860
+ <a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
2861
+ ${pr.state === 'OPEN' ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge, then fast-forward your local ${esc(s.branch ? 'base branch' : 'checkout')}">${icon('check')}<span>Merge</span></button>` : ''}`;
2862
+ node.onclick = (e) => {
2863
+ const a = e.target.closest('[data-pr]')?.dataset.pr;
2864
+ if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
2865
+ if (a === 'merge') mergePr(id);
2866
+ };
2867
+ }
2868
+
2869
+ async function shipDialog(id) {
2870
+ const s = S.sessions.get(id);
2871
+ if (!s) return;
2872
+ if (s.state === 'running' || s.state === 'waiting') return toast('Still working: let it finish first.');
2873
+ let info;
2874
+ try {
2875
+ info = await api(`/sessions/${id}/ship`);
2876
+ } catch (err) {
2877
+ return toast(esc(err.message), { tone: 'bad' });
2878
+ }
2879
+ const open = info.pr?.state === 'OPEN';
2880
+ const nothing = !info.dirty && !info.ahead;
2881
+ const node = el(`
2882
+ <div class="modal ship-modal">
2883
+ <header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
2884
+ <p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
2885
+ <span class="muted">${info.dirty ? `${info.dirty} uncommitted change${info.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${info.ahead} commit${info.ahead === 1 ? '' : 's'} on the branch</span></p>
2886
+ ${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
2887
+ ${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
2888
+ <label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
2889
+ ${!info.remote ? '<p class="hint bad">This repo has no <code>origin</code> remote, so there is nowhere to push.</p>' : nothing ? '<p class="hint">Nothing to ship yet: the branch has no changes.</p>' : ''}
2890
+ <div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in the worktree.</span><button class="primary sm" data-a="go" ${nothing || !info.remote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : 'Commit, push & open PR'}</button></div>
2891
+ </div>`);
2892
+ const close = overlay(node);
2893
+ $('.close', node).onclick = close;
2894
+ const title = $('[name="title"]', node);
2895
+ const body = $('[name="body"]', node);
2896
+ if (title) title.value = info.title;
2897
+ if (body) body.value = info.body;
2898
+ const go = async () => {
2899
+ const btn = $('[data-a="go"]', node);
2900
+ btn.disabled = true;
2901
+ btn.lastChild.textContent = 'Shipping…';
2902
+ try {
2903
+ const pr = await api(`/sessions/${id}/ship`, { method: 'POST', body: { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked } });
2904
+ close();
2905
+ toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
2906
+ } catch (err) {
2907
+ btn.disabled = false;
2908
+ btn.lastChild.textContent = open ? 'Commit & push' : 'Commit, push & open PR';
2909
+ toast(esc(err.message), { tone: 'bad', timeout: 10_000 });
2910
+ }
2911
+ };
2912
+ $('[data-a="go"]', node).onclick = go;
2913
+ requestAnimationFrame(() => (title || $('[data-a="go"]', node)).focus());
2914
+ }
2915
+
2916
+ async function mergePr(id) {
2917
+ const s = S.sessions.get(id);
2918
+ const pr = s?.pr;
2919
+ if (!pr || pr.state !== 'OPEN') return;
2920
+ const c = pr.checks || {};
2921
+ const warn = c.fail ? `\n\n⚠ ${c.fail} check${c.fail > 1 ? 's are' : ' is'} failing.` : c.pending ? `\n\n⚠ ${c.pending} check${c.pending > 1 ? 's are' : ' is'} still running.` : '';
2922
+ if (!confirm(`Squash-merge PR #${pr.number}${pr.draft ? ' (marks it ready first)' : ''}, then fast-forward your local base branch and remove the worktree?${warn}`)) return;
2923
+ toast(`Merging PR #${pr.number}…`);
2924
+ try {
2925
+ const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
2926
+ toast(`${icon('check')} Merged <b>PR #${r.pr.number}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
2927
+ } catch (err) {
2928
+ toast(esc(err.message), { tone: 'bad', timeout: 12_000 });
2929
+ }
2930
+ }
2931
+
2932
+ // --- compare: one task, several harnesses, side by side -------------------------------------------
2933
+
2934
+ function turnMetrics(t) {
2935
+ const lines = (t?.changes || []).reduce((acc, c) => {
2936
+ const n = countChanges(c.edits);
2937
+ return { add: acc.add + n.add, del: acc.del + n.del };
2938
+ }, { add: 0, del: 0 });
2939
+ const tests = (t?.presentation?.blocks || []).filter((b) => b?.type === 'tests');
2940
+ return {
2941
+ time: t?.endedAt ? t.endedAt - t.startedAt : null,
2942
+ cost: t?.stats?.costUsd,
2943
+ files: t?.changes?.length || 0,
2944
+ ...lines,
2945
+ tests: tests.length ? { passed: tests.reduce((a, b) => a + (Number(b.passed) || 0), 0), failed: tests.reduce((a, b) => a + (Number(b.failed) || 0), 0) } : null,
2946
+ asks: (t?.activity || []).filter((a) => a.kind === 'ask').length,
2947
+ };
2948
+ }
2949
+
2950
+ async function compareView(id) {
2951
+ const s0 = S.sessions.get(id);
2952
+ if (!s0?.groupId) return;
2953
+ const group = [...S.sessions.values()].filter((x) => x.groupId === s0.groupId).sort((a, b) => a.createdAt - b.createdAt);
2954
+ const fulls = (await Promise.all(group.map((g) => api(`/sessions/${g.id}`).catch(() => null)))).filter(Boolean);
2955
+ const node = el(`
2956
+ <div class="modal compare-modal">
2957
+ <header><h2>Compare</h2><button class="icon-btn close">${icon('x')}</button></header>
2958
+ <p class="cmp-prompt">${esc(fulls[0]?.turns[0]?.prompt || '')}</p>
2959
+ <div class="cmp-grid" style="--n:${fulls.length}"></div>
2960
+ </div>`);
2961
+ const close = overlay(node);
2962
+ $('.close', node).onclick = close;
2963
+ const grid = $('.cmp-grid', node);
2964
+ for (const f of fulls) {
2965
+ const sum = S.sessions.get(f.id) || {};
2966
+ const t = f.turns.at(-1);
2967
+ const mx = turnMetrics(t);
2968
+ const live = f.state === 'running' || f.state === 'waiting';
2969
+ const col = el(`
2970
+ <section class="cmp-col" data-id="${f.id}">
2971
+ <div class="cmp-head">
2972
+ <span class="rb-logo">${harnessLogo(f.agent)}</span>
2973
+ <span class="cmp-who"><b>${esc(agentName(f.agent))}</b><small>${esc(f.model || 'Default model')}${f.effort ? ` · ${esc(effortLabel(f.effort))}` : ''}</small></span>
2974
+ ${live ? `<span class="pill st-waiting"><i></i>${f.state === 'waiting' ? 'Asking' : 'Working'}</span>` : `<span class="pill st-${sum.status || 'info'}"><i></i>${STATUS_LABEL[sum.status] || 'Done'}</span>`}
2975
+ </div>
2976
+ <dl class="cmp-stats">
2977
+ <div><dt>Time</dt><dd>${mx.time !== null ? fmtDuration(mx.time) : '—'}</dd></div>
2978
+ <div><dt>Cost</dt><dd>${typeof mx.cost === 'number' ? `$${mx.cost.toFixed(2)}` : '—'}</dd></div>
2979
+ <div><dt>Files</dt><dd>${mx.files} <span class="plus">+${mx.add}</span> <span class="minus">−${mx.del}</span></dd></div>
2980
+ <div><dt>Tests</dt><dd>${mx.tests ? `<span class="${mx.tests.failed ? 'minus' : 'plus'}">${mx.tests.passed} ✓ ${mx.tests.failed ? `${mx.tests.failed} ✗` : ''}</span>` : '—'}</dd></div>
2981
+ <div><dt>Asked</dt><dd>${mx.asks}</dd></div>
2982
+ </dl>
2983
+ <div class="cmp-body"></div>
2984
+ <div class="cmp-foot">
2985
+ <button class="ghost xs" data-a="open">${icon('chevron')}Open card</button>
2986
+ <span class="grow"></span>
2987
+ <button class="primary sm" data-a="keep" ${live ? 'disabled title="Still working"' : ''}>${icon('check')}Keep this</button>
2988
+ </div>
2989
+ </section>`);
2990
+ $('.cmp-body', col).append(live ? el(`<p class="muted xs">${esc(sum.live || 'Working…')}</p>`) : renderPresentation(t?.presentation, ctxFor(f, t, false)));
2991
+ grid.append(col);
2992
+ }
2993
+ grid.onclick = async (e) => {
2994
+ const a = e.target.closest('[data-a]')?.dataset.a;
2995
+ const keep = e.target.closest('.cmp-col')?.dataset.id;
2996
+ if (!a || !keep) return;
2997
+ if (a === 'open') return close(), reveal(keep);
2998
+ const others = group.filter((g) => g.id !== keep);
2999
+ if (!confirm(`Keep ${agentName(S.sessions.get(keep)?.agent)}'s result?\n\nThe other ${others.length === 1 ? 'run is' : `${others.length} runs are`} archived, and ${others.length === 1 ? 'its worktree is' : 'their worktrees are'} deleted with their changes.`)) return;
3000
+ for (const o of others) {
3001
+ if (o.state === 'running' || o.state === 'waiting') {
3002
+ await api(`/sessions/${o.id}/stop`, { method: 'POST', body: {} }).catch(() => {});
3003
+ await until(() => S.sessions.get(o.id)?.state !== 'running', 5000);
3004
+ }
3005
+ await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
3006
+ }
3007
+ close();
3008
+ reveal(keep);
3009
+ toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
3010
+ };
3011
+ }
3012
+
3013
+ // Poll a condition for up to `ms` (used after asking the server to stop something).
3014
+ async function until(fn, ms) {
3015
+ const end = Date.now() + ms;
3016
+ while (Date.now() < end && !fn()) await new Promise((r) => setTimeout(r, 150));
3017
+ }
3018
+
3019
+ // --- automations: recipes (saved prompts) and triggers (schedules, GitHub watchers) -----------
3020
+
3021
+ const TRIGGER_KINDS = [
3022
+ { kind: 'schedule', label: 'Schedule', icon: 'clock', hint: 'On a cron schedule, local time' },
3023
+ { kind: 'ci', label: 'CI failed', icon: 'warn', hint: 'A GitHub Actions run fails (polled with gh)' },
3024
+ { kind: 'issues', label: 'Issue labelled', icon: 'ask', hint: 'An open issue gets a label (polled with gh)' },
3025
+ ];
3026
+ const CRON_PRESETS = [
3027
+ ['Weekdays 9:00', '0 9 * * 1-5'],
3028
+ ['Every hour', '0 * * * *'],
3029
+ ['Daily 18:00', '0 18 * * *'],
3030
+ ['Mondays 8:00', '0 8 * * 1'],
3031
+ ];
3032
+ const recipeParams = (prompt) => [...new Set([...String(prompt || '').matchAll(/\{([a-zA-Z][\w-]{0,30})\}/g)].map((m) => m[1]))];
3033
+ const whenLabel = (t) => {
3034
+ if (!t) return '';
3035
+ const d = new Date(t);
3036
+ const today = new Date().toDateString() === d.toDateString();
3037
+ return d.toLocaleString([], today ? { hour: '2-digit', minute: '2-digit' } : { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
3038
+ };
3039
+ const projectOptions = (selected, { any } = {}) =>
3040
+ (any ? `<option value="">${esc(any)}</option>` : '') +
3041
+ S.config.projects.map((p) => `<option value="${esc(p.path)}" ${p.path === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('');
3042
+ const modeOptions = (selected, { inherit } = {}) =>
3043
+ (inherit ? `<option value="">${esc(inherit)}</option>` : '') + MODES.map((m) => `<option value="${m.id}" ${m.id === selected ? 'selected' : ''}>${m.label}</option>`).join('');
3044
+
3045
+ function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
3046
+ const node = el(`<div class="modal auto-modal">
3047
+ <header><h2>Automations</h2><button class="icon-btn close">${icon('x')}</button></header>
3048
+ <p class="mem-lede">Recipes are prompts you send again and again; <code>{name}</code> in one becomes a field to fill in. Triggers start sessions on their own.</p>
3049
+ <div class="mem-tabs" role="tablist"></div>
3050
+ <div class="auto-body"></div>
3051
+ </div>`);
3052
+ const close = present(node, host);
3053
+ let recipes = [];
3054
+ let triggers = [];
3055
+ let editing = edit; // { type: 'recipe' | 'trigger', draft }
3056
+
3057
+ const render = async () => {
3058
+ [recipes, triggers] = await Promise.all([api('/recipes').catch(() => recipes), api('/triggers').catch(() => triggers)]);
3059
+ if (!node.isConnected) return;
3060
+ $('.mem-tabs', node).innerHTML = [
3061
+ ['recipes', 'Recipes', recipes.length],
3062
+ ['triggers', 'Triggers', triggers.length],
3063
+ ].map(([k, l, n]) => `<button role="tab" data-tab="${k}" class="${tab === k ? 'on' : ''}">${l}<span>${n}</span></button>`).join('');
3064
+ const body = $('.auto-body', node);
3065
+ body.innerHTML = '';
3066
+ if (editing?.type === 'recipe') body.append(recipeForm(editing.draft));
3067
+ else if (editing?.type === 'trigger') body.append(triggerForm(editing.draft));
3068
+ else body.innerHTML = tab === 'recipes' ? recipesView() : triggersView();
3069
+ };
3070
+
3071
+ const recipesView = () =>
3072
+ `<div class="au-list">${
3073
+ recipes
3074
+ .map((r) => {
3075
+ const proj = S.config.projects.find((p) => p.path === r.cwd);
3076
+ const tags = [r.params.length ? r.params.map((p) => `{${p}}`).join(' ') : '', proj ? proj.name : 'Any project', r.agent ? agentName(r.agent) : '', r.runs ? `${r.runs} run${r.runs > 1 ? 's' : ''}` : ''].filter(Boolean);
3077
+ return `<div class="au-item" data-id="${r.id}">
3078
+ <span class="mem-ico">${icon('spark')}</span>
3079
+ <div class="au-main"><b>${esc(r.name)}</b><p>${esc(r.prompt.replace(/\s+/g, ' ').slice(0, 140))}</p><span class="au-tags">${tags.map((t) => `<i>${esc(t)}</i>`).join('')}</span></div>
3080
+ <span class="au-acts"><button class="primary sm" data-act="run">Run</button><button class="icon-btn" data-act="schedule" title="Run it on a schedule">${icon('clock')}</button><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="delete" title="Delete">${icon('trash')}</button></span>
3081
+ </div>`;
3082
+ })
3083
+ .join('') || '<p class="mem-empty">No recipes yet. Save a task you repeat, like a release checklist, a PR from a commit, or a dependency bump.</p>'
3084
+ }</div><button class="mem-add" data-act="new">${icon('plus')}New recipe</button>
3085
+ <p class="hint">Also from the terminal: <code>wisp recipes</code>, <code>wisp run &lt;name&gt; [values…]</code>.</p>`;
3086
+
3087
+ const describe = (t) => {
3088
+ const proj = S.config.projects.find((p) => p.path === t.cwd)?.name || t.cwd.split(/[\\/]/).pop();
3089
+ if (t.kind === 'schedule') return `<code>${esc(t.cron)}</code> in ${esc(proj)}${t.nextAt ? ` · next ${esc(whenLabel(t.nextAt))}` : ''}`;
3090
+ const what = t.kind === 'ci' ? `failed CI${t.branch ? ` on <code>${esc(t.branch)}</code>` : ''}` : `issues labelled <code>${esc(t.label)}</code>`;
3091
+ return `Watches ${what} in ${esc(proj)} every ${t.every} min${t.seeded ? '' : ' · first check records what is already there'}`;
3092
+ };
3093
+ const triggersView = () =>
3094
+ `<div class="au-list">${
3095
+ triggers
3096
+ .map((t) => {
3097
+ const k = TRIGGER_KINDS.find((x) => x.kind === t.kind);
3098
+ const last = t.lastSessionId && S.sessions.has(t.lastSessionId) ? `<a href="#" data-session="${esc(t.lastSessionId)}">last run ${ago(t.lastRunAt)} ago</a>` : t.lastRunAt ? `last run ${ago(t.lastRunAt)} ago` : 'not run yet';
3099
+ return `<div class="au-item ${t.enabled ? '' : 'off'}" data-id="${t.id}">
3100
+ <span class="mem-ico">${icon(k.icon)}</span>
3101
+ <div class="au-main"><b>${esc(t.name)}</b><p>${describe(t)}</p><span class="au-tags"><i>${esc(MODES.find((m) => m.id === t.mode)?.label || t.mode)}</i>${t.agent ? `<i>${esc(agentName(t.agent))}</i>` : ''}<i>${last}</i></span>${t.lastError ? `<p class="au-err">${icon('warn')}${esc(t.lastError)}</p>` : ''}</div>
3102
+ <span class="au-acts"><button class="ghost xs" data-act="fire" title="${t.kind === 'schedule' ? 'Run it now' : 'Check GitHub now'}">${icon('replay')}${t.kind === 'schedule' ? 'Run now' : 'Check now'}</button><button class="switch" data-act="toggle" role="switch" aria-checked="${t.enabled}" title="On / off"></button><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="delete" title="Delete">${icon('trash')}</button></span>
3103
+ </div>`;
3104
+ })
3105
+ .join('') || '<p class="mem-empty">No triggers yet. Run a recipe every weekday morning, or send an agent after every red CI run.</p>'
3106
+ }</div><button class="mem-add" data-act="new">${icon('plus')}New trigger</button>
3107
+ <p class="hint">Triggers run while wisp is open. GitHub watchers use your <code>gh</code> login and poll, so nothing has to reach this machine.</p>`;
3108
+
3109
+ // Harness · model button for a form, with "Launcher default" when no agent is set.
3110
+ const runnerField = (draft) => {
3111
+ const wrap = el(`<div class="au-runner"><button type="button" class="runner-btn" aria-haspopup="dialog"></button><button type="button" class="link xs" ${draft.agent ? '' : 'hidden'}>Use launcher default</button></div>`);
3112
+ const [btn, reset] = wrap.children;
3113
+ const paint = () => (paintRunnerBtn(btn, draft.agent, draft.model, draft.effort), (reset.hidden = !draft.agent));
3114
+ btn.onclick = () =>
3115
+ runnerPicker(btn, { agent: draft.agent || S.config.defaults.agent, model: draft.model, effort: draft.effort, savedModel, savedEffort }, (a, m, e) => {
3116
+ Object.assign(draft, { agent: a, model: m, effort: e });
3117
+ paint();
3118
+ });
3119
+ reset.onclick = () => (Object.assign(draft, { agent: '', model: '', effort: '' }), paint());
3120
+ paint();
3121
+ return wrap;
3122
+ };
3123
+
3124
+ const recipeForm = (d) => {
3125
+ const f = el(`<form class="au-form">
3126
+ <label><span>Name</span><input class="input" name="name" maxlength="60" placeholder="Release" spellcheck="false"></label>
3127
+ <label class="stack"><span>Prompt <small class="muted">· <code>{version}</code> becomes a field</small></span><textarea class="textarea mono" name="prompt" rows="7" spellcheck="false" placeholder="Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}…"></textarea></label>
3128
+ <p class="au-params muted xs"></p>
3129
+ <label><span>Project</span><select name="cwd">${projectOptions(d.cwd, { any: 'Any project (picked when run)' })}</select></label>
3130
+ <label><span>Mode</span><select name="mode">${modeOptions(d.mode, { inherit: 'Launcher default' })}</select></label>
3131
+ <div class="au-row"><span>Harness</span></div>
3132
+ <div class="au-foot"><button type="button" class="ghost sm" data-act="cancel">Cancel</button><button class="primary sm">${d.id ? 'Save' : 'Create recipe'}</button></div>
3133
+ </form>`);
3134
+ $('[name="name"]', f).value = d.name || '';
3135
+ $('[name="prompt"]', f).value = d.prompt || '';
3136
+ $('.au-row', f).append(runnerField(d));
3137
+ const params = () => {
3138
+ const p = recipeParams($('[name="prompt"]', f).value);
3139
+ $('.au-params', f).innerHTML = p.length ? `Fields when run: ${p.map((x) => `<code>{${esc(x)}}</code>`).join(' ')}` : 'No fields: it runs as written.';
3140
+ };
3141
+ $('[name="prompt"]', f).oninput = params;
3142
+ params();
3143
+ f.onsubmit = async (e) => {
3144
+ e.preventDefault();
3145
+ const body = { name: $('[name="name"]', f).value, prompt: $('[name="prompt"]', f).value, cwd: $('[name="cwd"]', f).value, mode: $('[name="mode"]', f).value, agent: d.agent || '', model: d.model || '', effort: d.effort || '' };
3146
+ try {
3147
+ await api(d.id ? `/recipes/${d.id}` : '/recipes', { method: d.id ? 'PATCH' : 'POST', body });
3148
+ editing = null;
3149
+ tab = 'recipes';
3150
+ render();
3151
+ } catch (err) {
3152
+ toast(esc(err.message), { tone: 'bad' });
3153
+ }
3154
+ };
3155
+ requestAnimationFrame(() => $(d.name ? '[name="prompt"]' : '[name="name"]', f)?.focus());
3156
+ return f;
3157
+ };
3158
+
3159
+ const triggerForm = (d) => {
3160
+ d.kind ||= 'schedule';
3161
+ const f = el(`<form class="au-form">
3162
+ <div class="au-kinds">${TRIGGER_KINDS.map((k) => `<button type="button" data-kind="${k.kind}" class="${k.kind === d.kind ? 'on' : ''}">${icon(k.icon)}<b>${k.label}</b><small>${k.hint}</small></button>`).join('')}</div>
3163
+ <label><span>Name</span><input class="input" name="name" maxlength="60" spellcheck="false"></label>
3164
+ <label><span>Project</span><select name="cwd">${projectOptions(d.cwd || projectById(launchProject)?.path)}</select></label>
3165
+ <div class="k-schedule"><label><span>When</span><input class="input mono" name="cron" spellcheck="false" placeholder="0 9 * * 1-5"></label>
3166
+ <p class="au-presets">${CRON_PRESETS.map(([l, c]) => `<button type="button" class="lp-chip" data-cron="${c}">${l}</button>`).join('')}<span class="muted xs">minute hour day month weekday</span></p></div>
3167
+ <div class="k-ci"><label><span>Branch</span><input class="input mono" name="branch" spellcheck="false" placeholder="Any branch (e.g. main)"></label></div>
3168
+ <div class="k-issues"><label><span>Label</span><input class="input" name="label" spellcheck="false" placeholder="wisp"></label></div>
3169
+ <div class="k-gh"><label><span>Check every</span><select name="every">${[2, 5, 15, 60].map((m) => `<option value="${m}" ${Number(d.every || 5) === m ? 'selected' : ''}>${m} min</option>`).join('')}</select></label></div>
3170
+ <label><span>Run</span><select name="recipeId"><option value="">This prompt</option>${recipes.map((r) => `<option value="${r.id}" ${r.id === d.recipeId ? 'selected' : ''}>Recipe: ${esc(r.name)}</option>`).join('')}</select></label>
3171
+ <label class="stack k-prompt"><span>Prompt <small class="muted k-vars"></small></span><textarea class="textarea mono" name="prompt" rows="6" spellcheck="false"></textarea></label>
3172
+ <label><span>Mode</span><select name="mode">${modeOptions(d.mode || (d.kind === 'issues' ? 'plan' : 'edits'))}</select></label>
3173
+ <div class="au-row"><span>Harness</span></div>
3174
+ <p class="au-warn" hidden>${icon('warn')}<span>Issue text comes from GitHub: anyone who can open and label issues can steer this agent. Plan mode only reads; Full auto runs shell commands without asking.</span></p>
3175
+ <div class="au-foot"><button type="button" class="ghost sm" data-act="cancel">Cancel</button><button class="primary sm">${d.id ? 'Save' : 'Create trigger'}</button></div>
3176
+ </form>`);
3177
+ for (const k of ['name', 'cron', 'branch', 'label']) $(`[name="${k}"]`, f).value = d[k] || '';
3178
+ $('[name="prompt"]', f).value = d.prompt || '';
3179
+ $('.au-row', f).append(runnerField(d));
3180
+ const sync = () => {
3181
+ for (const k of ['schedule', 'ci', 'issues']) $(`.k-${k}`, f).hidden = d.kind !== k;
3182
+ $('.k-gh', f).hidden = d.kind === 'schedule';
3183
+ $('.k-prompt', f).hidden = !!$('[name="recipeId"]', f).value;
3184
+ $('.k-vars', f).innerHTML = d.kind === 'ci' ? '· <code>{name} {branch} {url} {id} {sha}</code> · empty = built-in CI prompt' : d.kind === 'issues' ? '· <code>{number} {title} {body} {url} {author}</code> · empty = built-in issue prompt' : '';
3185
+ $('.au-warn', f).hidden = !(d.kind === 'issues' && $('[name="mode"]', f).value !== 'plan');
3186
+ $$('.au-kinds button', f).forEach((b) => b.classList.toggle('on', b.dataset.kind === d.kind));
3187
+ };
3188
+ f.addEventListener('click', (e) => {
3189
+ const k = e.target.closest('[data-kind]')?.dataset.kind;
3190
+ if (k) {
3191
+ if (k === 'issues' && !d.id) $('[name="mode"]', f).value = 'plan';
3192
+ d.kind = k;
3193
+ sync();
3194
+ }
3195
+ const c = e.target.closest('[data-cron]')?.dataset.cron;
3196
+ if (c) $('[name="cron"]', f).value = c;
3197
+ });
3198
+ f.addEventListener('change', sync);
3199
+ sync();
3200
+ f.onsubmit = async (e) => {
3201
+ e.preventDefault();
3202
+ const v = (k) => $(`[name="${k}"]`, f).value;
3203
+ const body = { kind: d.kind, name: v('name'), cwd: v('cwd'), cron: v('cron'), branch: v('branch'), label: v('label') || 'wisp', every: Number(v('every')), recipeId: v('recipeId'), prompt: v('recipeId') ? '' : v('prompt'), mode: v('mode'), agent: d.agent || '', model: d.model || '', effort: d.effort || '' };
3204
+ try {
3205
+ await api(d.id ? `/triggers/${d.id}` : '/triggers', { method: d.id ? 'PATCH' : 'POST', body });
3206
+ editing = null;
3207
+ tab = 'triggers';
3208
+ render();
3209
+ } catch (err) {
3210
+ toast(esc(err.message), { tone: 'bad' });
3211
+ }
3212
+ };
3213
+ return f;
3214
+ };
3215
+
3216
+ node.addEventListener('click', async (e) => {
3217
+ const t = e.target.closest('[data-tab],[data-act],[data-session]');
3218
+ if (!t || t.closest('.au-form') && !t.dataset.act) return;
3219
+ if (t.dataset.tab) return e.preventDefault(), (tab = t.dataset.tab), (editing = null), render();
3220
+ if (t.dataset.session) return e.preventDefault(), close(), reveal(t.dataset.session);
3221
+ const act = t.dataset.act;
3222
+ if (act === 'cancel') return (editing = null), render();
3223
+ const itemId = t.closest('.au-item')?.dataset.id;
3224
+ if (tab === 'recipes') {
3225
+ const r = recipes.find((x) => x.id === itemId);
3226
+ if (act === 'new') return (editing = { type: 'recipe', draft: { cwd: projectById(launchProject)?.path || '' } }), render();
3227
+ if (act === 'edit') return (editing = { type: 'recipe', draft: { ...r } }), render();
3228
+ if (act === 'run') return close(), runRecipe(r);
3229
+ if (act === 'schedule') return (tab = 'triggers'), (editing = { type: 'trigger', draft: { kind: 'schedule', name: r.name, recipeId: r.id, cwd: r.cwd || projectById(launchProject)?.path, mode: r.mode || undefined } }), render();
3230
+ if (act === 'delete' && confirm(`Delete the recipe “${r.name}”?`)) return api(`/recipes/${r.id}`, { method: 'DELETE' }).then(render);
3231
+ } else {
3232
+ const tr = triggers.find((x) => x.id === itemId);
3233
+ if (act === 'new') return (editing = { type: 'trigger', draft: {} }), render();
3234
+ if (act === 'edit') return (editing = { type: 'trigger', draft: { ...tr } }), render();
3235
+ if (act === 'toggle') return api(`/triggers/${tr.id}`, { method: 'PATCH', body: { enabled: !tr.enabled } }).then(render).catch((err) => toast(esc(err.message), { tone: 'bad' }));
3236
+ if (act === 'delete' && confirm(`Delete the trigger “${tr.name}”?`)) return api(`/triggers/${tr.id}`, { method: 'DELETE' }).then(render);
3237
+ if (act === 'fire') {
3238
+ t.disabled = true;
3239
+ try {
3240
+ const r = await api(`/triggers/${tr.id}/run`, { method: 'POST', body: {} });
3241
+ for (const x of r.started) S.sessions.set(x.id, x);
3242
+ renderFeed();
3243
+ toast(r.started.length ? `Started ${r.started.length} session${r.started.length > 1 ? 's' : ''}` : 'Nothing new to act on.');
3244
+ } catch (err) {
3245
+ toast(esc(err.message), { tone: 'bad' });
3246
+ }
3247
+ render();
3248
+ }
3249
+ }
3250
+ });
3251
+ node.refresh = () => !editing && render();
3252
+ render();
3253
+ }
3254
+
3255
+ // Run a recipe: show it, ask for its fields (and a project, if it isn't tied to one), launch on Run.
3256
+ function runRecipe(r) {
3257
+ if (!r) return;
3258
+ const needsProject = !r.cwd;
3259
+ const node = el(`
3260
+ <form class="modal run-modal">
3261
+ <header><h2>${esc(r.name)}</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
3262
+ <p class="cmp-prompt">${esc(r.prompt)}</p>
3263
+ ${r.params.map((p) => `<label class="ship-field"><span>${esc(p)}</span><input class="input" name="p:${esc(p)}" spellcheck="false" required></label>`).join('')}
3264
+ ${needsProject ? `<label class="ship-field"><span>Project</span><select name="cwd">${projectOptions(projectById(launchProject)?.path)}</select></label>` : ''}
3265
+ <div class="ship-foot"><span class="muted xs">${r.agent ? `${esc(agentName(r.agent))}${r.model ? ` · ${esc(r.model)}` : ''}` : 'Launcher harness'} · ${esc(MODES.find((m) => m.id === r.mode)?.label || 'launcher mode')}</span><button class="primary sm">${icon('enter')}Run</button></div>
3266
+ </form>`);
3267
+ const close = overlay(node);
3268
+ $('.close', node).onclick = close;
3269
+ node.onsubmit = async (e) => {
3270
+ e.preventDefault();
3271
+ const params = Object.fromEntries($$('input[name^="p:"]', node).map((i) => [i.name.slice(2), i.value]));
3272
+ const d = S.config.defaults;
3273
+ const agent = r.agent || d.agent;
3274
+ try {
3275
+ const s = await api(`/recipes/${r.id}/run`, {
3276
+ method: 'POST',
3277
+ body: { params, cwd: needsProject ? $('[name="cwd"]', node).value : undefined, agent, model: r.agent ? undefined : savedModel(agent), effort: r.agent ? undefined : savedEffort(agent, savedModel(agent)), mode: r.mode || d.mode },
3278
+ });
3279
+ S.sessions.set(s.id, s);
3280
+ close();
3281
+ renderFeed();
3282
+ setFocus(s.id);
3283
+ toast(`Running “${esc(r.name)}”`);
3284
+ } catch (err) {
3285
+ toast(esc(err.message), { tone: 'bad' });
3286
+ }
3287
+ };
3288
+ // Always wait for Run, even with nothing to fill in, so a click on a recipe never launches it outright.
3289
+ requestAnimationFrame(() => ($('input, select', node) || $('button.primary', node))?.focus());
3290
+ }
3291
+
3292
+ // A task worth repeating becomes a recipe, starting from its first prompt.
3293
+ async function saveAsRecipe(id) {
3294
+ const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
3295
+ if (!s?.turns?.[0]) return;
3296
+ openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft: { name: s.title, prompt: s.turns[0].prompt, cwd: '', mode: s.mode, agent: s.agent, model: s.model, effort: s.effort } } });
3297
+ }
3298
+
3299
+ // --- usage: spend and outcomes --------------------------------------------------------------------
3300
+
3301
+ const money = (v) => (v >= 100 ? `$${Math.round(v)}` : `$${(v || 0).toFixed(2)}`);
3302
+ const pct = (v) => (v === null || v === undefined ? '—' : `${Math.round(v * 100)}%`);
3303
+
3304
+ async function openUsage({ days = 30, host } = {}) {
3305
+ const node = el(`<div class="modal usage-modal">
3306
+ <header><h2>Usage</h2><div class="mem-tabs us-range">${[7, 30, 90].map((d) => `<button data-days="${d}">${d} days</button>`).join('')}</div><button class="icon-btn close">${icon('x')}</button></header>
3307
+ <div class="us-body"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
3308
+ </div>`);
3309
+ const close = present(node, host);
3310
+ const render = async () => {
3311
+ $$('.us-range button', node).forEach((b) => b.classList.toggle('on', Number(b.dataset.days) === days));
3312
+ const u = await api(`/usage?days=${days}`).catch((err) => (toast(esc(err.message), { tone: 'bad' }), null));
3313
+ if (!u || !node.isConnected) return;
3314
+ const t = u.total;
3315
+ const unpriced = t.turns - t.priced;
3316
+ // Cost when any harness reports it, else turns: one measure per chart, never two axes.
3317
+ const byCost = t.priced > 0;
3318
+ const val = (d) => (byCost ? d.cost : d.turns);
3319
+ const max = Math.max(...u.daily.map(val), 0) || 1;
3320
+ const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString([], { month: 'short', day: 'numeric' });
3321
+ const table = (rows, label) =>
3322
+ rows.length
3323
+ ? `<table class="us-table"><thead><tr><th></th><th>Spend</th><th>Turns</th><th>Worked</th><th>Asked</th><th>Avg time</th></tr></thead><tbody>${rows
3324
+ .map((r) => `<tr><td>${label(r)}</td><td>${r.priced ? money(r.cost) : '—'}</td><td>${r.turns}</td><td>${pct(r.successRate)}</td><td>${r.questions}</td><td>${fmtDuration(r.avgMs)}</td></tr>`)
3325
+ .join('')}</tbody></table>`
3326
+ : '<p class="mem-empty">Nothing in this range.</p>';
3327
+ $('.us-body', node).innerHTML = `
3328
+ <div class="us-tiles">
3329
+ <div><span>Spend</span><b>${byCost ? money(t.cost) : '—'}</b><small>${unpriced ? `${unpriced} turn${unpriced > 1 ? 's' : ''} without a price` : 'all turns priced'}</small></div>
3330
+ <div><span>Turns</span><b>${t.turns}</b><small>in ${t.sessions} session${t.sessions === 1 ? '' : 's'}</small></div>
3331
+ <div><span>Worked</span><b>${pct(t.successRate)}</b><small>finished, not partial or blocked</small></div>
3332
+ <div><span>Questions</span><b>${t.questions}</b><small>times an agent needed you</small></div>
3333
+ </div>
3334
+ <h4>${byCost ? 'Spend per day' : 'Turns per day'}</h4>
3335
+ <div class="us-chart" role="img" aria-label="${byCost ? 'Spend' : 'Turns'} per day">${u.daily
3336
+ .map((d) => `<div class="us-bar" data-tip="${esc(fmtDay(d.date))}: ${byCost ? money(d.cost) : ''}${byCost ? ' · ' : ''}${d.turns} turn${d.turns === 1 ? '' : 's'}"><i style="height:${val(d) ? Math.max(2, (val(d) / max) * 100) : 0}%"></i></div>`)
3337
+ .join('')}</div>
3338
+ <div class="us-axis"><span>${esc(fmtDay(u.daily[0].date))}</span><span>${byCost ? `max ${money(max)}/day` : `max ${max}/day`}</span><span>today</span></div>
3339
+ <h4>By project</h4>${table(u.projects, (r) => `<span class="us-proj c-${esc(r.color)}"><i class="p-dot"></i>${esc(r.name)}</span>`)}
3340
+ <h4>By harness · model</h4>${table(u.runners, (r) => `<span class="us-runner"><span class="model-logo">${harnessLogo(r.agent)}</span>${esc(agentName(r.agent))} · ${esc(r.model || 'default')}</span>`)}
3341
+ ${unpriced ? '<p class="hint">Codex, Cursor and Antigravity report tokens but not cost, so their turns count in turns and outcomes but not in spend.</p>' : ''}`;
3342
+ };
3343
+ // One floating tooltip for the bars (they are too thin to label).
3344
+ const tip = el('<div class="us-tip" hidden></div>');
3345
+ node.append(tip);
3346
+ node.addEventListener('mousemove', (e) => {
3347
+ const b = e.target.closest('.us-bar');
3348
+ tip.hidden = !b;
3349
+ if (!b) return;
3350
+ tip.textContent = b.dataset.tip;
3351
+ const r = b.getBoundingClientRect();
3352
+ const m = node.getBoundingClientRect();
3353
+ tip.style.left = `${Math.min(r.left - m.left + r.width / 2, m.width - 90)}px`;
3354
+ tip.style.top = `${r.top - m.top + node.scrollTop - 6}px`;
3355
+ });
3356
+ node.addEventListener('mouseleave', () => (tip.hidden = true));
3357
+ $('.us-range', node).onclick = (e) => {
3358
+ const d = Number(e.target.closest('[data-days]')?.dataset.days);
3359
+ if (d) (days = d), render();
3360
+ };
3361
+ render();
3362
+ }
3363
+
3364
+ function usageSummary(u) {
3365
+ if (!u) return '';
3366
+ return u.total.turns ? `${u.total.priced ? `${money(u.total.cost)} · ` : ''}${u.total.turns} turns · ${pct(u.total.successRate)} worked, last 7 days` : 'Nothing in the last 7 days';
3367
+ }
3368
+
3369
+ // --- phone: ntfy / Telegram -----------------------------------------------------------------------
3370
+
3371
+ function phoneSummary(r) {
3372
+ if (!r) return '';
3373
+ const on = [r.ntfy.enabled && r.ntfy.topic && 'ntfy', r.telegram.enabled && r.telegram.linked && 'Telegram'].filter(Boolean);
3374
+ return on.length ? `${on.join(' + ')} · ${r.events.map((e) => (e === 'waiting' ? 'questions' : 'finished')).join(' & ')}` : 'Off';
3375
+ }
3376
+
3377
+ async function openPhone({ host } = {}) {
3378
+ const node = el(`<div class="modal phone-modal">
3379
+ <header><h2>Phone</h2><button class="icon-btn close">${icon('x')}</button></header>
3380
+ <p class="mem-lede">Get questions and finished tasks on your phone, and answer from there. wisp polls both services, so nothing needs to reach this machine. They receive titles, summaries and questions (with their context), never code or diffs.</p>
3381
+ <div class="ph-body"></div>
3382
+ </div>`);
3383
+ const close = present(node, host);
3384
+ let r;
3385
+ const put = async (patch) => {
3386
+ try {
3387
+ r = await api('/remote', { method: 'PUT', body: patch });
3388
+ paint();
3389
+ } catch (err) {
3390
+ toast(esc(err.message), { tone: 'bad' });
3391
+ }
3392
+ };
3393
+ const status = (st) => (!st ? '' : st.ok ? `<span class="ph-ok">${icon('check')}Working</span>` : `<span class="ph-bad">${icon('warn')}${esc(st.error || 'Failed')}</span>`);
3394
+ const paint = () => {
3395
+ const tg = r.telegram;
3396
+ $('.ph-body', node).innerHTML = `
3397
+ <div class="settings-modal ph-sec">
3398
+ <div class="setting"><span>Send me</span>
3399
+ <label class="check"><input type="checkbox" data-ev="waiting" ${r.events.includes('waiting') ? 'checked' : ''}> Questions</label>
3400
+ <label class="check"><input type="checkbox" data-ev="finished" ${r.events.includes('finished') ? 'checked' : ''}> Finished tasks</label></div>
3401
+ </div>
3402
+ <section class="ph-sec">
3403
+ <h4>ntfy <span class="muted xs">· tap a button on the notification to answer</span>${status(r.status.ntfy)}<button class="switch" data-sw="ntfy" role="switch" aria-checked="${r.ntfy.enabled}"></button></h4>
3404
+ <div class="settings-modal">
3405
+ <label><span>Topic</span><input class="input mono" data-ntfy="topic" value="${esc(r.ntfy.topic)}" placeholder="wisp-…" spellcheck="false"></label>
3406
+ <label><span>Server</span><input class="input mono" data-ntfy="server" value="${esc(r.ntfy.server)}" spellcheck="false"></label>
3407
+ <label><span>Access token</span><input class="input mono" data-ntfy="token" value="${esc(r.ntfy.token)}" placeholder="Only for protected topics" spellcheck="false" type="password"></label>
3408
+ </div>
3409
+ <p class="hint">Install the ntfy app and subscribe to the topic. Anyone who knows the topic name can read it, so keep it long and random; answers are signed, so nobody else can send them.</p>
3410
+ </section>
3411
+ <section class="ph-sec">
3412
+ <h4>Telegram <span class="muted xs">· buttons, or reply to a message in your own words</span>${status(r.status.telegram)}<button class="switch" data-sw="telegram" role="switch" aria-checked="${tg.enabled}"></button></h4>
3413
+ <div class="settings-modal">
3414
+ <label><span>Bot token</span><input class="input mono" data-tg="token" value="${esc(tg.token)}" placeholder="123456:ABC… from @BotFather" spellcheck="false" type="password"></label>
3415
+ </div>
3416
+ <p class="hint">${
3417
+ tg.linked
3418
+ ? `${icon('check')} Linked to <b>${esc(tg.chatName || 'your chat')}</b>. <button class="link xs" data-act="unlink">Unlink</button>`
3419
+ : tg.token && tg.enabled
3420
+ ? `Now send <code>/link ${esc(tg.linkCode)}</code> to your bot. Only the chat that sends this code gets linked.`
3421
+ : 'Create a bot with @BotFather, paste its token here and switch Telegram on.'
3422
+ }</p>
3423
+ </section>
3424
+ <div class="ship-foot"><span class="grow"></span><button class="ghost sm" data-act="test">${icon('send')}Send a test</button></div>`;
3425
+ };
3426
+ node.addEventListener('change', (e) => {
3427
+ const t = e.target;
3428
+ if (t.dataset.ev) put({ events: $$('[data-ev]', node).filter((x) => x.checked).map((x) => x.dataset.ev) });
3429
+ if (t.dataset.ntfy) put({ ntfy: { ...r.ntfy, [t.dataset.ntfy]: t.value.trim() } });
3430
+ if (t.dataset.tg) put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
3431
+ });
3432
+ node.addEventListener('click', async (e) => {
3433
+ const sw = e.target.closest('[data-sw]')?.dataset.sw;
3434
+ if (sw === 'ntfy') {
3435
+ // A fresh random topic the first time, so it isn't guessable.
3436
+ const topic = r.ntfy.topic || `wisp-${crypto.getRandomValues(new Uint32Array(2)).join('').slice(0, 14)}`;
3437
+ return put({ ntfy: { ...r.ntfy, enabled: !r.ntfy.enabled, topic } });
3438
+ }
3439
+ if (sw === 'telegram') return put({ telegram: { enabled: !r.telegram.enabled, token: r.telegram.token } });
3440
+ const act = e.target.closest('[data-act]')?.dataset.act;
3441
+ if (act === 'unlink') return put({ telegram: { enabled: r.telegram.enabled, token: r.telegram.token, unlink: true } });
3442
+ if (act === 'test') {
3443
+ try {
3444
+ r = await api('/remote/test', { method: 'POST', body: {} });
3445
+ paint();
3446
+ toast('Test sent');
3447
+ } catch (err) {
3448
+ toast(esc(err.message), { tone: 'bad' });
3449
+ }
3450
+ }
3451
+ });
3452
+ node.refresh = async () => {
3453
+ if (node.contains(document.activeElement) && document.activeElement.matches('input')) return; // don't clobber typing
3454
+ r = await api('/remote').catch(() => r);
3455
+ if (r && node.isConnected) paint();
3456
+ };
3457
+ r = await api('/remote').catch((err) => (toast(esc(err.message), { tone: 'bad' }), null));
3458
+ if (r) paint();
3459
+ }
3460
+
3461
+ // --- machines: other computers, over SSH ---------------------------------------------------------
3462
+
3463
+ // A machine runs its own wisp; this one signs in over SSH, tunnels to it and shows its sessions
3464
+ // as <id>@<machine>. Projects can live on a machine; launching in one runs the agent there.
3465
+ const machineById = (id) => S.machines.find((m) => m.id === id);
3466
+ const machineOnline = (id) => machineById(id)?.state === 'online';
3467
+ const MACHINE_STATE = { online: 'Online', connecting: 'Connecting…', reconnecting: 'Reconnecting…', error: 'Couldn’t connect', off: 'Not connected' };
3468
+ const projectTitle = (p) => {
3469
+ if (!p) return '';
3470
+ const m = p.machine && machineById(p.machine);
3471
+ return m ? `${p.path} on ${m.name}${m.state === 'online' ? '' : ' (not connected)'}` : p.path;
3472
+ };
3473
+
3474
+ function machinePill(mt) {
3475
+ const on = machineOnline(mt.id);
3476
+ return `<span class="q-machine ${on ? '' : 'off'}" title="Runs on ${esc(mt.name)} (${esc(mt.target)})${on ? '' : ' · not connected'}">${icon('server')}<span>${esc(mt.name)}</span></span>`;
3477
+ }
3478
+
3479
+ function machinesSummary() {
3480
+ const n = S.machines.length;
3481
+ if (!n) return 'None yet · run agents on another computer over SSH';
3482
+ const on = S.machines.filter((m) => m.state === 'online').length;
3483
+ return `${n} machine${n === 1 ? '' : 's'} · ${on ? `${on} online` : 'none online'}`;
3484
+ }
3485
+
3486
+ const loadMachines = () => api('/machines').then(setMachines).catch(() => {});
3487
+ function setMachines(list) {
3488
+ const was = new Map(S.machines.map((m) => [m.id, m.state === 'online']));
3489
+ S.machines = list;
3490
+ paintMachinesBtn();
3491
+ renderLaunchChips();
3492
+ renderProjects();
3493
+ $('.machines-modal')?.refresh();
3494
+ $('.projects-modal')?.refresh?.();
3495
+ $('.menu.where-menu')?.refresh();
3496
+ $('.settings-modal .machines-hint') && ($('.settings-modal .machines-hint').textContent = machinesSummary());
3497
+ // Coming online lists its sessions; going offline greys their cards.
3498
+ if (list.some((m) => (was.get(m.id) || false) !== (m.state === 'online'))) loadSessions();
3499
+ }
3500
+
3501
+ function paintMachinesBtn() {
3502
+ const b = $('#machinesBtn');
3503
+ if (!b) return;
3504
+ const on = S.machines.filter((m) => m.state === 'online').length;
3505
+ const trouble = S.machines.some((m) => m.state === 'error' || m.state === 'reconnecting');
3506
+ b.classList.toggle('live', on > 0);
3507
+ b.classList.toggle('trouble', trouble);
3508
+ const n = $('.ab-n', b);
3509
+ n.hidden = !on;
3510
+ n.textContent = on;
3511
+ b.title = S.machines.length ? `Machines: ${machinesSummary()}` : 'Machines: run agents on another computer over SSH';
3512
+ }
3513
+
3514
+ async function connectMachine(id) {
3515
+ try {
3516
+ await api(`/machines/${id}/connect`, { method: 'POST', body: {} });
3517
+ } catch (err) {
3518
+ toast(esc(err.message), { tone: 'bad' });
3519
+ }
3520
+ }
3521
+
3522
+ function offlineToast(id) {
3523
+ const m = machineById(id);
3524
+ if (m?.state === 'connecting' || m?.state === 'reconnecting') return toast(`Still connecting to <b>${esc(m.name)}</b>…`);
3525
+ toast(`<b>${esc(m?.name || 'That machine')}</b> isn’t connected`, { tone: 'bad', action: { label: 'Connect', run: () => connectMachine(id) } });
3526
+ }
3527
+
3528
+ const STEP_ICON = { done: 'check', fail: 'x', warn: 'warn', run: '', pending: '' };
3529
+
3530
+ function openMachines({ host, focusAdd = false } = {}) {
3531
+ const node = el(`<div class="modal machines-modal">
3532
+ <header><h2>Machines</h2><button class="icon-btn close">${icon('x')}</button></header>
3533
+ <div class="mc-body">
3534
+ <p class="mem-lede">Run agents on your other computers: a dev box, a GPU server, a cloud VM. wisp signs in over SSH, starts itself there and tunnels back, so worktrees, diffs and Ship it work as usual, and agents keep going when this computer sleeps.</p>
3535
+ <div class="mc-list"></div>
3536
+ <section class="mc-add"></section>
3537
+ </div>
3538
+ </div>`);
3539
+ const close = present(node, host);
3540
+ const body = $('.mc-body', node);
3541
+ let editing = null; // machine id whose form is open, '' = a new one, null = closed
3542
+ let hosts = [];
3543
+ api('/machines/ssh-hosts')
3544
+ .then((h) => {
3545
+ hosts = h;
3546
+ const dl = $('#sshHosts', node);
3547
+ if (dl) dl.innerHTML = hosts.map((x) => `<option value="${esc(x.host)}">${esc([x.user, x.hostname].filter(Boolean).join('@'))}</option>`).join('');
3548
+ })
3549
+ .catch(() => {});
3550
+
3551
+ // Projects on a machine open here, with a way back.
3552
+ const sub = (view) => {
3553
+ const head = $(':scope > header', node);
3554
+ body.hidden = true;
3555
+ head.hidden = true;
3556
+ view.classList.add('embedded');
3557
+ const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
3558
+ back.onclick = () => {
3559
+ view.remove();
3560
+ body.hidden = false;
3561
+ head.hidden = false;
3562
+ paint();
3563
+ };
3564
+ $('header', view).prepend(back);
3565
+ $('.close', view).onclick = close;
3566
+ node.append(view);
3567
+ return close;
3568
+ };
3569
+
3570
+ const steps = (m) => `<ol class="mc-steps">${m.steps
3571
+ .map((st) => `<li class="s-${st.status}"><span class="mc-si">${STEP_ICON[st.status] ? icon(STEP_ICON[st.status]) : ''}</span><b>${esc(st.label)}</b>${st.detail ? `<small>${esc(st.detail)}</small>` : ''}</li>`)
3572
+ .join('')}</ol>`;
3573
+ const trouble = (m) => `<div class="mc-hint ${m.state === 'reconnecting' ? 'soft' : ''}">
3574
+ <p><b>${esc(m.state === 'reconnecting' ? `${m.error || 'Connection lost.'} Trying again${m.attempt > 1 ? ` (attempt ${m.attempt})` : ''}…` : m.error)}</b></p>
3575
+ ${m.hint?.text ? `<p>${esc(m.hint.text)}</p>` : ''}
3576
+ ${m.hint?.command ? `<div class="mc-cmd"><code>${esc(m.hint.command)}</code><button class="ghost xs" data-a="copy" data-cmd="${esc(m.hint.command)}" title="Copy">${icon('copy')}</button></div>` : ''}
3577
+ ${m.stale ? `<button class="ghost sm" data-a="restart">${icon('rerun')}Restart wisp there</button>` : ''}
3578
+ ${m.log.length ? `<details class="mc-log"><summary>Log</summary><pre>${esc(m.log.join('\n'))}</pre></details>` : ''}
3579
+ </div>`;
3580
+ const projects = (m) => {
3581
+ const list = S.config.projects.filter((p) => p.machine === m.id);
3582
+ return `<div class="mc-projects">${list.map((p) => `<span class="lp-chip c-${p.color}" title="${esc(p.path)}"><i class="p-dot"></i>${esc(p.name)}</span>`).join('')}
3583
+ <button class="lp-chip add" data-a="projects">${icon(list.length ? 'plus' : 'folder')}${list.length ? 'Add' : 'Pick projects on it…'}</button></div>`;
3584
+ };
3585
+ const card = (m) => {
3586
+ const st = m.state;
3587
+ const busy = st === 'connecting' || st === 'reconnecting';
3588
+ const ready = (m.remote?.agents || []).filter((a) => a.available);
3589
+ return `<div class="mc st-${st}" data-id="${m.id}">
3590
+ <div class="mc-head">
3591
+ <span class="mc-icon">${icon('server')}<i class="mc-dot"></i></span>
3592
+ <div class="mc-title"><b>${esc(m.name)}</b><span>${esc(m.target)}${m.port !== 4477 ? ` · port ${m.port}` : ''}</span></div>
3593
+ <span class="mc-state">${MACHINE_STATE[st] || st}</span>
3594
+ ${st === 'online' || busy ? `<button class="ghost sm" data-a="disconnect">${busy ? 'Cancel' : 'Disconnect'}</button>` : `<button class="primary sm" data-a="connect">${st === 'error' ? 'Try again' : 'Connect'}</button>`}
3595
+ <button class="icon-btn" data-a="menu" title="More">${icon('dots')}</button>
3596
+ </div>
3597
+ ${
3598
+ st === 'online'
3599
+ ? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
3600
+ : ''
3601
+ }
3602
+ ${busy || st === 'error' ? steps(m) : ''}
3603
+ ${(st === 'error' || st === 'reconnecting') && m.error ? trouble(m) : ''}
3604
+ ${st === 'online' || m.projects ? projects(m) : ''}
3605
+ ${editing === m.id ? `<div class="mc-edit">${form(m)}</div>` : ''}
3606
+ </div>`;
3607
+ };
3608
+ const form = (m) => `<form class="mc-form" data-id="${m?.id || ''}">
3609
+ <div class="mc-row">
3610
+ <label class="mc-field grow"><span>SSH host</span><input class="input mono" name="target" list="sshHosts" placeholder="me@devbox, 10.0.0.5, or a Host from ~/.ssh/config" spellcheck="false" autocomplete="off" autocapitalize="off" value="${esc(m?.target || '')}" required></label>
3611
+ <label class="mc-field"><span>Name</span><input class="input" name="name" placeholder="${esc(m?.name || 'devbox')}" value="${esc(m?.name || '')}" spellcheck="false"></label>
3612
+ </div>
3613
+ <details class="mc-adv" ${m && (m.port !== 4477 || m.command) ? 'open' : ''}><summary>Advanced</summary>
3614
+ <div class="mc-row">
3615
+ <label class="mc-field"><span>wisp’s port there</span><input class="input mono" name="port" type="number" min="1" max="65535" value="${m?.port || 4477}"></label>
3616
+ <label class="mc-field grow"><span>Start command</span><input class="input mono" name="command" placeholder="${esc(S.machines[0]?.defaultCommand || 'npx -y wisp-agents@<this version>')}" value="${esc(m?.command || '')}" spellcheck="false"></label>
3617
+ </div>
3618
+ <label class="check mc-check"><input type="checkbox" name="autoConnect" ${m?.autoConnect === false ? '' : 'checked'}> Connect when wisp starts</label>
3619
+ </details>
3620
+ <div class="mc-foot">
3621
+ <p class="hint">${m ? 'Changing the host, port or command reconnects.' : 'Uses your SSH keys, agent and ~/.ssh/config; wisp never asks for a password. The machine needs Node.js 20+ and the harness CLIs, signed in.'}</p>
3622
+ ${editing !== null && (m || S.machines.length) ? '<button type="button" class="ghost sm" data-a="cancel">Cancel</button>' : ''}
3623
+ <button class="primary sm" type="submit">${m ? 'Save' : `${icon('server')}Connect`}</button>
3624
+ </div>
3625
+ </form>`;
3626
+
3627
+ const paint = () => {
3628
+ if (!node.isConnected && !host) return;
3629
+ $('.mc-list', node).innerHTML = S.machines.map(card).join('');
3630
+ const add = $('.mc-add', node);
3631
+ // The form for a new machine: open when there are none yet, else behind a button.
3632
+ if (!S.machines.length || editing === '') {
3633
+ if (!$('form[data-id=""]', add)) add.innerHTML = `<h4>${S.machines.length ? 'Add another machine' : 'Add your first machine'}</h4>${form(null)}<datalist id="sshHosts">${hosts.map((x) => `<option value="${esc(x.host)}">${esc([x.user, x.hostname].filter(Boolean).join('@'))}</option>`).join('')}</datalist>`;
3634
+ } else add.innerHTML = `<button class="lp-chip add mc-new" data-a="new">${icon('plus')}Add a machine</button><datalist id="sshHosts">${hosts.map((x) => `<option value="${esc(x.host)}"></option>`).join('')}</datalist>`;
3635
+ };
3636
+
3637
+ const menu = (m, anchor) => {
3638
+ $('.menu.machine')?.close();
3639
+ const r = anchor.getBoundingClientRect();
3640
+ const box = el(`<div class="menu machine" style="top:${r.bottom + 4}px;left:${Math.max(8, r.right - 230)}px">
3641
+ <button data-m="edit">${icon('pen')}Edit…</button>
3642
+ <button data-m="projects">${icon('folder')}Projects on it…</button>
3643
+ ${m.state === 'online' ? `<button data-m="restart">${icon('rerun')}Restart wisp there</button><button data-m="stop" class="danger">${icon('stop')}Stop wisp there</button>` : ''}
3644
+ <button data-m="remove" class="danger">${icon('trash')}Remove machine</button>
3645
+ </div>`);
3646
+ const shut = () => (box.remove(), document.removeEventListener('mousedown', outside));
3647
+ const outside = (ev) => {
3648
+ if (!box.contains(ev.target)) shut();
3649
+ };
3650
+ box.close = shut;
3651
+ setTimeout(() => document.addEventListener('mousedown', outside));
3652
+ box.onclick = (ev) => {
3653
+ const a = ev.target.closest('button')?.dataset.m;
3654
+ shut();
3655
+ if (a) act(a, m);
3656
+ };
3657
+ document.body.append(box);
3658
+ };
3659
+
3660
+ const act = async (a, m) => {
3661
+ try {
3662
+ if (a === 'connect') await api(`/machines/${m.id}/connect`, { method: 'POST', body: {} });
3663
+ if (a === 'disconnect') await api(`/machines/${m.id}/disconnect`, { method: 'POST', body: {} });
3664
+ if (a === 'projects') openProjects({ machine: m.id, host: sub, focusAdd: true });
3665
+ if (a === 'edit') {
3666
+ editing = m.id;
3667
+ paint();
3668
+ $(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
3669
+ }
3670
+ if (a === 'restart') {
3671
+ if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. Restarting wisp there stops ${m.working === 1 ? 'it' : 'them'}. Restart?`)) return;
3672
+ await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
3673
+ }
3674
+ if (a === 'stop') {
3675
+ if (!confirm(`Stop wisp on ${m.name}? ${m.working ? `${m.working} working agent${m.working === 1 ? '' : 's'} stop too. ` : ''}Its sessions stay there for next time.`)) return;
3676
+ await api(`/machines/${m.id}/stop`, { method: 'POST', body: {} });
3677
+ toast(`Stopped wisp on <b>${esc(m.name)}</b>`);
3678
+ }
3679
+ if (a === 'remove') {
3680
+ if (!confirm(`Remove ${m.name} from wisp? Its projects leave the launcher. Nothing on the machine is touched, and wisp keeps running there until you stop it.`)) return;
3681
+ await api(`/machines/${m.id}`, { method: 'DELETE' });
3682
+ await loadConfig();
3683
+ await loadMachines();
3684
+ }
3685
+ } catch (err) {
3686
+ toast(esc(err.message), { tone: 'bad' });
3687
+ }
3688
+ };
3689
+
3690
+ node.addEventListener('click', async (e) => {
3691
+ const b = e.target.closest('[data-a]');
3692
+ if (!b || !body.contains(b)) return;
3693
+ const a = b.dataset.a;
3694
+ const m = machineById(b.closest('.mc')?.dataset.id);
3695
+ if (a === 'copy') {
3696
+ copy(b.dataset.cmd);
3697
+ toast('Copied. Run it in a terminal, then connect again.');
3698
+ return;
3699
+ }
3700
+ if (a === 'new') {
3701
+ editing = '';
3702
+ paint();
3703
+ $('.mc-add input[name="target"]', node)?.focus();
3704
+ return;
3705
+ }
3706
+ if (a === 'cancel') {
3707
+ editing = null;
3708
+ paint();
3709
+ return;
3710
+ }
3711
+ if (a === 'menu' && m) {
3712
+ menu(m, b);
3713
+ return;
3714
+ }
3715
+ if (m) act(a, m);
3716
+ });
3717
+ // The name follows the host until you type one.
3718
+ node.addEventListener('input', (e) => {
3719
+ if (e.target.name !== 'target') return;
3720
+ const name = $('input[name="name"]', e.target.form);
3721
+ const h = hosts.find((x) => x.host === e.target.value.trim());
3722
+ name.placeholder = (h?.host || e.target.value.trim().replace(/^.*@/, '')).slice(0, 40) || 'devbox';
3723
+ });
3724
+ node.addEventListener('submit', async (e) => {
3725
+ e.preventDefault();
3726
+ const f = e.target;
3727
+ const id = f.dataset.id;
3728
+ const v = Object.fromEntries(new FormData(f));
3729
+ const payload = { target: v.target.trim(), name: v.name.trim(), port: Number(v.port) || 4477, command: v.command.trim(), autoConnect: !!v.autoConnect };
3730
+ const btn = $('button[type="submit"]', f);
3731
+ btn.disabled = true;
3732
+ try {
3733
+ if (id) await api(`/machines/${id}`, { method: 'PATCH', body: payload });
3734
+ else await api('/machines', { method: 'POST', body: payload });
3735
+ editing = null;
3736
+ await loadMachines();
3737
+ paint();
3738
+ } catch (err) {
3739
+ toast(esc(err.message), { tone: 'bad' });
3740
+ } finally {
3741
+ btn.disabled = false;
3742
+ }
3743
+ });
3744
+ node.refresh = () => {
3745
+ // Don't clobber a half-typed form: repaint the cards only.
3746
+ const typing = node.contains(document.activeElement) && document.activeElement.matches('input, select');
3747
+ if (typing && editing !== null && editing !== '') return;
3748
+ if (typing) $('.mc-list', node).innerHTML = S.machines.map(card).join('');
3749
+ else paint();
3750
+ };
3751
+ paint();
3752
+ if (focusAdd && S.machines.length) {
3753
+ editing = '';
3754
+ paint();
3755
+ }
3756
+ if (focusAdd || !S.machines.length) setTimeout(() => $('.mc-add input[name="target"]', node)?.focus(), 60);
3757
+ }
3758
+
1817
3759
  // --- settings -----------------------------------------------------------------------------
1818
3760
 
1819
- function openSettings() {
3761
+ // A sidebar of sections and one pane at a time, like every other app's settings. Big lists
3762
+ // (memory, automations, usage history) keep their own views; a pane only summarises and links.
3763
+ const SETTINGS_PANES = [
3764
+ { id: 'general', label: 'General', icon: 'settings', lede: 'Where files open, where you take over, and how tasks start.' },
3765
+ { id: 'notifications', label: 'Notifications', icon: 'bolt', lede: 'Hear about questions and finished tasks, here or on your phone.' },
3766
+ { id: 'agents', label: 'Agents', icon: 'agent', lede: 'The harnesses wisp found on this machine. Pick one next to the model in the launcher.' },
3767
+ { id: 'machines', label: 'Machines', icon: 'server', lede: 'Other computers that run agents for you, over SSH.' },
3768
+ { id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
3769
+ { id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
3770
+ { id: 'usage', label: 'Usage', icon: 'stats', lede: 'Spend and outcomes over the last 7 days.' },
3771
+ { id: 'privacy', label: 'Privacy', icon: 'info', lede: 'What wisp sends about itself. Never prompts, paths or code.' },
3772
+ ];
3773
+ let settingsPane = 'general';
3774
+
3775
+ function openSettings(opts) {
3776
+ if (SETTINGS_PANES.some((p) => p.id === opts?.pane)) settingsPane = opts.pane;
1820
3777
  const d = S.config.defaults;
3778
+ // A row: name and a line of explanation on the left, the control on the right.
3779
+ const row = (name, desc, control, cls = '') => `<div class="sx-row ${cls}"><div class="sx-text"><b>${name}</b>${desc ? `<small>${desc}</small>` : ''}</div><div class="sx-ctl">${control}</div></div>`;
3780
+ const missing = S.agents.filter((a) => !a.available).length;
1821
3781
  const node = el(`
1822
- <div class="modal settings-modal">
1823
- <header><h2>Settings</h2><button class="icon-btn close">${icon('x')}</button></header>
1824
- <label><span>Open files in</span>
1825
- <select name="editor">${[['vscode', 'VS Code'], ['cursor', 'Cursor'], ['windsurf', 'Windsurf'], ['zed', 'Zed'], ['idea', 'JetBrains'], ['none', 'Copy path']]
1826
- .map(([v, l]) => `<option value="${v}" ${d.editor === v || (!d.editor && v === 'vscode') ? 'selected' : ''}>${l}</option>`)
1827
- .join('')}</select></label>
1828
- <label><span>Take over in</span>
1829
- <select name="terminal">${(S.config.terminals || []).map((t) => `<option value="${t.id}" ${(d.terminal || 'auto') === t.id ? 'selected' : ''}>${t.label}</option>`).join('')}</select></label>
1830
- <label class="custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><span>Terminal command</span>
1831
- <input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></label>
1832
- <div class="setting"><span>Desktop notifications</span><span class="notif-hint muted xs"></span><button class="switch" id="notif" role="switch"></button></div>
1833
- <div class="setting"><span>Anonymous usage stats</span><span class="tele-hint muted xs"></span><button class="switch" id="tele" role="switch"></button></div>
1834
- <div class="setting"><span>Memory</span><span class="learn-hint muted xs"></span><button class="ghost xs" id="openMemory">Open</button><button class="switch" id="learn" role="switch"></button></div>
1835
- <div class="agents">${S.agents
1836
- .map((a) => `<div class="agent-row"><span class="dot ${a.available ? 'ok' : 'bad'}"></span><b>${esc(a.name)}</b><span class="muted">${esc(a.available ? a.version : a.error || 'not found')}</span></div>`)
1837
- .join('')}<p class="muted xs">Pick the agent next to the model in the launcher. Others plug in via <code>server/adapters</code>.</p></div>
3782
+ <div class="modal settings-modal sx">
3783
+ <nav class="sx-nav" role="tablist" aria-orientation="vertical">
3784
+ <h2>Settings</h2>
3785
+ ${SETTINGS_PANES.map((p) => `<button role="tab" data-pane="${p.id}">${icon(p.icon)}<span>${p.label}</span>${p.id === 'agents' && missing ? `<i class="dot bad" title="${missing} not found"></i>` : ''}</button>`).join('')}
3786
+ </nav>
3787
+ <div class="sx-main">
3788
+ <header><div><h3 class="sx-title"></h3><p class="sx-lede"></p></div><button class="icon-btn close">${icon('x')}</button></header>
3789
+ <section class="sx-pane" data-pane="general">
3790
+ <div class="sx-group">
3791
+ ${row('Open files in', 'File links in cards and diffs', `<select name="editor">${[['vscode', 'VS Code'], ['cursor', 'Cursor'], ['windsurf', 'Windsurf'], ['zed', 'Zed'], ['idea', 'JetBrains'], ['none', 'Copy path']]
3792
+ .map(([v, l]) => `<option value="${v}" ${d.editor === v || (!d.editor && v === 'vscode') ? 'selected' : ''}>${l}</option>`)
3793
+ .join('')}</select>`)}
3794
+ ${row('Take over in', 'The terminal a session opens in when you take it over', `<select name="terminal">${(S.config.terminals || []).map((t) => `<option value="${t.id}" ${(d.terminal || 'auto') === t.id ? 'selected' : ''}>${t.label}</option>`).join('')}</select>`)}
3795
+ <div class="sx-row custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><div class="sx-text"><b>Terminal command</b><small><code>{cwd}</code> and <code>{cmd}</code> are filled in</small></div>
3796
+ <div class="sx-ctl"><input class="input mono" name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></div></div>
3797
+ </div>
3798
+ <div class="sx-group">
3799
+ ${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
3800
+ </div>
3801
+ </section>
3802
+ <section class="sx-pane" data-pane="notifications">
3803
+ <div class="sx-group">
3804
+ ${row('Desktop notifications', '<span class="notif-hint"></span>', '<button class="switch" id="notif" role="switch"></button>')}
3805
+ </div>
3806
+ <div class="sx-group">
3807
+ ${row('Phone', '<span class="phone-hint">ntfy or Telegram: answer questions from your phone</span>', '<button class="ghost sm" id="openPhone">Set up</button>')}
3808
+ </div>
3809
+ </section>
3810
+ <section class="sx-pane" data-pane="agents">
3811
+ <div class="sx-group">${S.agents
3812
+ .map((a) => row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, esc(a.available ? a.version : a.error || 'not found'), `<span class="sx-state ${a.available ? 'ok' : 'bad'}"><i class="dot ${a.available ? 'ok' : 'bad'}"></i>${a.available ? 'Ready' : 'Not found'}</span>`))
3813
+ .join('')}</div>
3814
+ </section>
3815
+ <section class="sx-pane" data-pane="machines">
3816
+ <div class="sx-group">
3817
+ ${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
3818
+ </div>
3819
+ <p class="hint">Also in the top bar, and in Projects: add folders on a machine, then launch there like anywhere else.</p>
3820
+ </section>
3821
+ <section class="sx-pane" data-pane="memory">
3822
+ <div class="sx-group">
3823
+ ${row('Learn from sessions', 'A short retro after each session keeps preferences and lessons', '<button class="switch" id="learn" role="switch"></button>')}
3824
+ ${row('Remembered', '<span class="learn-hint"></span>', '<button class="ghost sm" id="openMemory">Open memory</button>')}
3825
+ </div>
3826
+ </section>
3827
+ <section class="sx-pane" data-pane="automations">
3828
+ <div class="sx-group">
3829
+ ${row('Recipes', '<span class="recipe-hint"></span>', '<button class="ghost sm" data-auto="recipes">Manage</button>')}
3830
+ ${row('Triggers', '<span class="trigger-hint"></span>', '<button class="ghost sm" data-auto="triggers">Manage</button>')}
3831
+ </div>
3832
+ </section>
3833
+ <section class="sx-pane" data-pane="usage">
3834
+ <div class="us-tiles sx-usage"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
3835
+ <div class="sx-group">${row('Charts and breakdowns', 'Per day, per project and per harness · model', '<button class="ghost sm" id="openUsage">Open usage</button>')}</div>
3836
+ </section>
3837
+ <section class="sx-pane" data-pane="privacy">
3838
+ <div class="sx-group">
3839
+ ${row('Anonymous usage stats', '<span class="tele-hint"></span>', '<button class="switch" id="tele" role="switch"></button>')}
3840
+ </div>
3841
+ <p class="hint">Agent, model, status, timings and scrubbed errors only.</p>
3842
+ </section>
3843
+ </div>
1838
3844
  </div>`);
1839
3845
  const close = overlay(node);
1840
3846
  $('.close', node).onclick = close;
3847
+ const pick = (id, focus = false) => {
3848
+ settingsPane = id;
3849
+ const p = SETTINGS_PANES.find((x) => x.id === id);
3850
+ $$('.sx-nav [data-pane]', node).forEach((b) => {
3851
+ const on = b.dataset.pane === id;
3852
+ b.setAttribute('aria-selected', on);
3853
+ b.tabIndex = on ? 0 : -1;
3854
+ if (on && focus) b.focus();
3855
+ });
3856
+ $$('.sx-pane', node).forEach((s) => (s.hidden = s.dataset.pane !== id));
3857
+ $('.sx-title', node).textContent = p.label;
3858
+ $('.sx-lede', node).textContent = p.lede;
3859
+ $('.sx-main', node).scrollTop = 0;
3860
+ };
3861
+ // Memory, Automations, Usage and Phone open in the main area, next to the sidebar, with a
3862
+ // way back to the section they came from. Leaving one goes back; only the X closes Settings.
3863
+ const main = $('.sx-main', node);
3864
+ const leave = () => {
3865
+ const v = $('.sx-main > .embedded', node);
3866
+ if (!v) return;
3867
+ v.remove();
3868
+ $('.sx-main > header', node).hidden = false;
3869
+ paintHints();
3870
+ };
3871
+ const show = (id, focus) => (leave(), pick(id, focus));
3872
+ const host = (pane) => (view) => {
3873
+ show(pane);
3874
+ view.classList.add('embedded');
3875
+ const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
3876
+ back.onclick = () => show(pane);
3877
+ $('header', view).prepend(back);
3878
+ $('.close', view).onclick = close;
3879
+ $('.sx-main > header', node).hidden = true;
3880
+ $$('.sx-pane', node).forEach((s) => (s.hidden = true));
3881
+ main.append(view);
3882
+ main.scrollTop = 0;
3883
+ return close;
3884
+ };
3885
+ $('.sx-nav', node).onclick = (e) => {
3886
+ const b = e.target.closest('[data-pane]');
3887
+ if (b) show(b.dataset.pane);
3888
+ };
3889
+ $('.sx-nav', node).onkeydown = (e) => {
3890
+ const step = { ArrowDown: 1, ArrowUp: -1 }[e.key];
3891
+ if (!step) return;
3892
+ e.preventDefault();
3893
+ const i = SETTINGS_PANES.findIndex((p) => p.id === settingsPane);
3894
+ show(SETTINGS_PANES[(i + step + SETTINGS_PANES.length) % SETTINGS_PANES.length].id, true);
3895
+ };
3896
+ show(settingsPane);
1841
3897
  node.addEventListener('change', async (e) => {
1842
3898
  const { name, value } = e.target;
1843
- if (!name) return;
3899
+ if (!name || e.target.closest('.embedded')) return; // a nested view's own form
1844
3900
  if (name === 'terminal') $('.custom-term', node).hidden = value !== 'custom';
1845
3901
  await api('/config', { method: 'PATCH', body: { defaults: { [name]: value } } });
1846
3902
  await loadConfig();
@@ -1888,9 +3944,44 @@ function openSettings() {
1888
3944
  await setLearning(S.config.defaults.learn === 'off');
1889
3945
  paintLearn();
1890
3946
  };
1891
- $('#openMemory', node).onclick = () => openMemory();
1892
3947
  node.paintLearn = paintLearn;
1893
- paintLearn();
3948
+ // One line each; their lists and charts live in their own views.
3949
+ const wt = $('#wtDefault', node);
3950
+ const paintWt = () => wt.setAttribute('aria-checked', S.config.defaults.worktree !== 'off');
3951
+ wt.onclick = async () => {
3952
+ await api('/config', { method: 'PATCH', body: { defaults: { worktree: S.config.defaults.worktree === 'off' ? 'on' : 'off' } } });
3953
+ await loadConfig();
3954
+ paintWt();
3955
+ renderLauncher();
3956
+ };
3957
+ paintWt();
3958
+ // The one-line summaries; painted again on the way back from a view that may have changed them.
3959
+ const paintHints = () => {
3960
+ const r = S.config.recipes?.length || 0;
3961
+ const all = S.config.triggers || [];
3962
+ const t = all.filter((x) => x.enabled).length;
3963
+ $('.recipe-hint', node).textContent = r ? `${r} saved prompt${r === 1 ? '' : 's'}` : 'None yet · save a prompt you run often';
3964
+ $('.trigger-hint', node).textContent = all.length ? `${t} of ${all.length} on` : 'None yet · run a recipe on a schedule, CI failure or new issue';
3965
+ api('/usage?days=7')
3966
+ .then((u) => {
3967
+ const x = u.total;
3968
+ $('.sx-usage', node).innerHTML = `
3969
+ <div><span>Spend</span><b>${x.priced ? money(x.cost) : '—'}</b></div>
3970
+ <div><span>Turns</span><b>${x.turns}</b></div>
3971
+ <div><span>Worked</span><b>${pct(x.successRate)}</b></div>
3972
+ <div><span>Questions</span><b>${x.questions}</b></div>`;
3973
+ })
3974
+ .catch(() => ($('.sx-usage', node).innerHTML = ''));
3975
+ $('.machines-hint', node).textContent = machinesSummary();
3976
+ api('/remote').then((x) => ($('.phone-hint', node).textContent = phoneSummary(x) === 'Off' ? 'ntfy or Telegram: answer questions from your phone' : phoneSummary(x))).catch(() => {});
3977
+ paintLearn();
3978
+ };
3979
+ paintHints();
3980
+ $('#openMemory', node).onclick = () => openMemory({ host: host('memory') });
3981
+ $$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
3982
+ $('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
3983
+ $('#openPhone', node).onclick = () => openPhone({ host: host('notifications') });
3984
+ $('#openMachines', node).onclick = () => openMachines({ host: host('machines') });
1894
3985
  }
1895
3986
 
1896
3987
  // --- memory: what the background retros learned ---------------------------------------------
@@ -1917,15 +4008,14 @@ function memorySummary(data) {
1917
4008
  return `${n ? `${n} remembered` : 'Nothing yet'}${last ? ` · last retro ${ago(last.at)}` : ''}`;
1918
4009
  }
1919
4010
 
1920
- function openMemory({ tab = 'global' } = {}) {
4011
+ function openMemory({ tab = 'global', host } = {}) {
1921
4012
  const node = el(`<div class="modal memory-modal">
1922
4013
  <header><h2>Memory</h2><span class="mem-live"></span><button class="switch" id="memOn" role="switch" title="Learn from sessions"></button><button class="icon-btn close">${icon('x')}</button></header>
1923
4014
  <p class="mem-lede">Learned from your sessions and added to every agent's prompt. What you ask always wins.</p>
1924
4015
  <div class="mem-tabs" role="tablist"></div>
1925
4016
  <div class="mem-body"></div>
1926
4017
  </div>`);
1927
- const close = overlay(node);
1928
- $('.close', node).onclick = close;
4018
+ const close = present(node, host);
1929
4019
  let data;
1930
4020
  let editing = null; // { scope, kind, index } (index -1 = adding)
1931
4021
 
@@ -2039,7 +4129,7 @@ function telemetryNotice() {
2039
4129
  if (!S.config.telemetry?.enabled || S.config.defaults.telemetryNotice === 'seen') return;
2040
4130
  S.config.defaults.telemetryNotice = 'seen';
2041
4131
  api('/config', { method: 'PATCH', body: { defaults: { telemetryNotice: 'seen' } } }).catch(() => {});
2042
- toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: openSettings } });
4132
+ toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: () => openSettings({ pane: 'privacy' }) } });
2043
4133
  }
2044
4134
 
2045
4135
  function shortcuts() {
@@ -2056,10 +4146,12 @@ function shortcuts() {
2056
4146
  ['T', 'Take over in terminal'],
2057
4147
  ['R', 'Replay presentation'],
2058
4148
  ['⇧R', 'Rerun with another harness, model or thinking level'],
4149
+ ['S', 'Ship it: commit, push and open a PR (worktree tasks)'],
2059
4150
  ['1–9', 'Pick an answer when asked'],
2060
4151
  ['↵', 'Launch / send'],
2061
4152
  ['⇧↵', 'New line'],
2062
4153
  ['C', 'Collapse all'],
4154
+ ['U', 'Back to top'],
2063
4155
  ['Esc', 'Close / collapse'],
2064
4156
  ];
2065
4157
  const node = el(`<div class="modal keys-modal"><header><h2>Shortcuts</h2></header>${rows.map(([k, l]) => `<div class="krow"><kbd>${k}</kbd><span>${l}</span></div>`).join('')}</div>`);
@@ -2079,7 +4171,8 @@ const modKey = (e, k) =>
2079
4171
  const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
2080
4172
 
2081
4173
  function focusLauncher() {
2082
- stage.scrollTo({ top: 0, behavior: 'smooth' });
4174
+ // Docked under the top bar it opens where it is, so you keep your place in the feed.
4175
+ if (!$('#launcher').classList.contains('docked')) stage.scrollTo({ top: 0, behavior: 'smooth' });
2083
4176
  $('#prompt')?.focus({ preventScroll: true });
2084
4177
  }
2085
4178
 
@@ -2095,14 +4188,23 @@ function paletteItems() {
2095
4188
  const cur = S.focus && S.sessions.get(S.focus);
2096
4189
  if (cur) {
2097
4190
  const id = cur.id;
4191
+ const full = S.full.get(id);
4192
+ const lastTurn = full?.turns?.at(-1);
2098
4193
  const live = cur.state === 'running' || cur.state === 'waiting';
4194
+ const canRollback = cur.agent !== 'wisp' && !live && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit || cur.filesChanged);
4195
+ const rolledBack = !!lastTurn?.rolledBackAt;
2099
4196
  const g = 'This wisp';
2100
4197
  const sub = cur.title;
2101
4198
  items.push({ group: g, label: 'Follow up', sub, icon: 'send', kbd: '/', run: () => withCard(id, () => $('.reply', S.nodes.get(id)?.card)?.focus(), { open: true }) });
2102
4199
  if (!live) items.push({ group: g, label: cur.state === 'done' ? 'Move back to review' : 'Mark done', sub, icon: 'done', kbd: 'E', run: () => toggleDone(id) });
4200
+ if (canRollback) items.push({ group: g, label: rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn', sub, icon: rolledBack ? 'rerun' : 'replay', keywords: 'rollback revert undo checkpoint restore', run: () => rollbackTurn(id, lastTurn?.id, rolledBack) });
2103
4201
  if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
2104
4202
  items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
2105
4203
  items.push({ group: g, label: 'Rerun…', sub, icon: 'rerun', kbd: '⇧R', keywords: 'retry again restart model harness', run: () => rerun(id) });
4204
+ if (cur.branch && !live && cur.pr?.state !== 'MERGED') items.push({ group: g, label: cur.pr?.state === 'OPEN' ? `Push to PR #${cur.pr.number}` : 'Ship it…', sub, icon: 'send', kbd: 'S', keywords: 'commit push pull request pr github', run: () => shipDialog(id) });
4205
+ if (cur.pr?.state === 'OPEN') items.push({ group: g, label: `Merge PR #${cur.pr.number}…`, sub, icon: 'check', keywords: 'squash github', run: () => mergePr(id) });
4206
+ if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
4207
+ items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
2106
4208
  items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
2107
4209
  items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
2108
4210
  items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
@@ -2134,15 +4236,24 @@ function paletteItems() {
2134
4236
  { group: a, label: 'Add project…', icon: 'folder', keywords: 'folder repo directory', run: () => openProjects({ focusAdd: true }) },
2135
4237
  { group: a, label: 'Manage projects', icon: 'folder', keywords: 'rename color remove', run: () => openProjects() },
2136
4238
  { group: a, label: 'Memory', icon: 'spark', keywords: 'learned preferences lessons retro', run: () => openMemory() },
4239
+ { group: a, label: 'Recipes', icon: 'spark', keywords: 'automations saved prompts templates', run: () => openAutomations({ tab: 'recipes' }) },
4240
+ { group: a, label: 'Triggers', icon: 'replay', keywords: 'automations schedule cron ci issues github', run: () => openAutomations({ tab: 'triggers' }) },
4241
+ { group: a, label: 'Usage', icon: 'stats', keywords: 'spend cost tokens outcomes success', run: () => openUsage() },
4242
+ { group: a, label: 'Phone', icon: 'send', keywords: 'ntfy telegram notifications mobile remote', run: () => openPhone() },
4243
+ { group: a, label: 'Machines', icon: 'server', keywords: 'remote ssh server computer devbox cloud vm', run: () => openMachines() },
4244
+ { group: a, label: 'Add a machine…', icon: 'server', keywords: 'remote ssh server computer connect', run: () => openMachines({ focusAdd: true }) },
2137
4245
  { group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
2138
4246
  { group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
2139
4247
  );
2140
4248
 
4249
+ for (const r of S.config.recipes || []) items.push({ group: 'Recipes', label: `Run: ${r.name}`, sub: r.prompt.replace(/\s+/g, ' ').slice(0, 80), icon: 'spark', keywords: `recipe ${r.prompt}`, run: () => runRecipe({ ...r, params: recipeParams(r.prompt) }) });
4250
+
2141
4251
  const filter = (id) => () => {
2142
4252
  S.filter = id;
2143
4253
  save('wisp.filter', id);
2144
4254
  renderProjects();
2145
4255
  renderFeed();
4256
+ feedToTop();
2146
4257
  renderLaunchChips();
2147
4258
  };
2148
4259
  items.push({ group: 'Projects', label: 'All projects', sub: S.filter === 'all' ? 'Current filter' : '', dot: 'slate', keywords: 'filter clear', run: filter('all') });
@@ -2173,10 +4284,16 @@ function askNotify() {
2173
4284
  }
2174
4285
 
2175
4286
  function notifyFinished(s) {
2176
- const n = S.nodes.get(s.id);
2177
- const viewing = S.open.has(s.id) && n && isInView(n.card) && document.hasFocus();
4287
+ const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
2178
4288
  if (viewing) return;
2179
- const verb = s.state === 'waiting' ? 'has a question' : 'is ready';
4289
+ let verb = s.state === 'waiting' ? 'has a question' : 'is ready';
4290
+ // A task run in several places (or compared) says so once, when its last run finishes.
4291
+ const runs = s.groupId ? [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived) : [];
4292
+ if (runs.length > 1 && s.state !== 'waiting') {
4293
+ if (runs.some((x) => x.id !== s.id && x.state === 'running')) return;
4294
+ const places = new Set(runs.map((x) => x.machine?.id || '')).size;
4295
+ verb = places > 1 ? `is ready on ${places} machines` : `is ready (${runs.length} runs)`;
4296
+ }
2180
4297
  toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
2181
4298
  if (notifyOn() && !document.hasFocus()) {
2182
4299
  const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/favicon.svg', tag: s.id });
@@ -2190,20 +4307,24 @@ function connect() {
2190
4307
  const es = new EventSource('/api/events');
2191
4308
  es.addEventListener('summary', (e) => {
2192
4309
  const s = JSON.parse(e.data);
4310
+ if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
2193
4311
  const prev = S.sessions.get(s.id);
4312
+ const was = prev && sectionOf(prev); // before the update: a compare task's section depends on all its runs
2194
4313
  S.sessions.set(s.id, s);
2195
- // A card left open while it worked shouldn't land in "Needs you" expanded —
2196
- // fold it unless the user is actually watching it.
2197
- const n = S.nodes.get(s.id);
2198
- if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(n && isInView(n.card) && document.hasFocus())) collapse(s.id);
2199
- // A state change moves the card to another section (a follow-up sends it from
2200
- // "Needs you" to "Working"). If the user is looking at it, follow it there
2201
- // instead of leaving them on whatever slid into its place. (Marking done
2202
- // moves focus to the next item on purpose, so that case is left alone.)
2203
- const pin = prev && prev.state !== s.state && s.state !== 'done' && n?.card.isConnected && isInView(n.card) ? n.card.getBoundingClientRect().top : null;
2204
- renderFeed();
2205
- if (pin !== null && n.card.isConnected) stage.scrollTop += n.card.getBoundingClientRect().top - pin;
4314
+ // A section change moves the card (a follow-up sends it from "Needs you" to "Working").
4315
+ // If the user is watching it, the view follows it there; otherwise whatever they're
4316
+ // reading stays put. Marking done or archiving is the exception: the card leaves the
4317
+ // list and focus goes to the next one instead.
4318
+ const moved = prev && was !== sectionOf(s);
4319
+ const follow = moved && s.state !== 'done' && !s.archived && watching(s.id);
4320
+ keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id), () => {
4321
+ // A card left open while it worked shouldn't land in "Needs you" expanded —
4322
+ // fold it unless the user is actually watching it.
4323
+ if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(watching(s.id) && document.hasFocus())) collapse(s.id);
4324
+ renderFeed();
4325
+ });
2206
4326
  if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
4327
+ if (JSON.stringify(prev?.pr) !== JSON.stringify(s.pr)) renderPrStrip(s.id);
2207
4328
  });
2208
4329
  es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
2209
4330
  es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
@@ -2214,15 +4335,17 @@ function connect() {
2214
4335
  S.full.delete(id);
2215
4336
  S.open.delete(id);
2216
4337
  saveOpen();
2217
- renderFeed();
4338
+ keepInPlace(feedAnchor(id), renderFeed);
2218
4339
  });
2219
- es.addEventListener('config', () => loadConfig());
4340
+ es.addEventListener('config', () => (loadConfig(), $('.auto-modal')?.refresh()));
4341
+ es.addEventListener('remote', () => $('.phone-modal')?.refresh());
4342
+ es.addEventListener('machines', (e) => setMachines(JSON.parse(e.data)));
2220
4343
  es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
2221
4344
  es.addEventListener('preferences', () => {
2222
4345
  $('.memory-modal')?.refresh();
2223
4346
  $('.settings-modal')?.paintLearn?.();
2224
4347
  });
2225
- es.addEventListener('hello', () => (loadSessions(), loadUpdate())); // also after sleep/reconnect
4348
+ es.addEventListener('hello', () => (loadSessions(), loadUpdate(), loadMachines())); // also after sleep/reconnect
2226
4349
  }
2227
4350
 
2228
4351
  async function loadSessions() {
@@ -2230,7 +4353,7 @@ async function loadSessions() {
2230
4353
  S.sessions = new Map(list.map((s) => [s.id, s]));
2231
4354
  for (const id of S.open) if (!S.sessions.has(id)) S.open.delete(id);
2232
4355
  saveOpen();
2233
- renderFeed();
4356
+ keepInPlace(feedAnchor(), renderFeed);
2234
4357
  for (const id of S.open) scheduleRefetch(id);
2235
4358
  }
2236
4359
 
@@ -2238,6 +4361,7 @@ async function loadConfig() {
2238
4361
  S.config = await api('/config');
2239
4362
  renderProjects();
2240
4363
  renderLaunchChips();
4364
+ paintAutoBtn();
2241
4365
  }
2242
4366
 
2243
4367
  // --- updates ----------------------------------------------------------------------------
@@ -2285,6 +4409,27 @@ async function installUpdate() {
2285
4409
  // --- keyboard ---------------------------------------------------------------------------
2286
4410
 
2287
4411
  const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
4412
+ // The user takes a card out of its list (done, archive, delete). The next card — the one below,
4413
+ // or the one above at the end of the list — gets focus and lands where the old one sat, so
4414
+ // triage continues on the same spot. Call before the request, while the card is still laid
4415
+ // out; the returned function applies the change locally and places the next card.
4416
+ function handOff(id) {
4417
+ const card = S.nodes.get(id)?.card;
4418
+ if (!card?.isConnected) return (apply) => (apply(), renderFeed());
4419
+ const nextUp = (card.nextElementSibling || card.previousElementSibling)?.dataset.id || feedIds().find((x) => x !== id);
4420
+ const r = card.getBoundingClientRect();
4421
+ const inset = topInset();
4422
+ const slot = r.bottom > inset && r.top < innerHeight ? Math.max(inset, r.top) : null;
4423
+ return (apply) => {
4424
+ if (S.open.has(id)) collapse(id);
4425
+ apply(); // don't wait for the live update to drop the card
4426
+ renderFeed();
4427
+ if (!nextUp || !S.nodes.has(nextUp)) return;
4428
+ setFocus(nextUp, { scroll: slot === null });
4429
+ const next = S.nodes.get(nextUp).card;
4430
+ if (slot !== null && next.isConnected) scrollByPx(next.getBoundingClientRect().top - slot);
4431
+ };
4432
+ }
2288
4433
 
2289
4434
  document.addEventListener('keydown', (e) => {
2290
4435
  // ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
@@ -2300,7 +4445,7 @@ document.addEventListener('keydown', (e) => {
2300
4445
  }
2301
4446
  const typing = e.target.closest('input, textarea, select, [contenteditable="true"]');
2302
4447
  if (e.key === 'Escape') {
2303
- if (!$('#overlay').hidden) return closeOverlay();
4448
+ if (!$('#overlay').hidden) return $('#overlay .sx-back') ? $('#overlay .sx-back').click() : closeOverlay();
2304
4449
  if (typing) return e.target.blur();
2305
4450
  if (S.focus && S.open.has(S.focus)) {
2306
4451
  collapse(S.focus);
@@ -2329,6 +4474,10 @@ document.addEventListener('keydown', (e) => {
2329
4474
  if (s && (s.archived || (s.state !== 'running' && s.state !== 'waiting'))) setArchived(id, !s.archived);
2330
4475
  }
2331
4476
  else if (k === 'c' && S.open.size) collapseAll();
4477
+ else if (k === 'u' || k === 'home') {
4478
+ e.preventDefault();
4479
+ backToTop();
4480
+ }
2332
4481
  else if (k === 'm' && id) {
2333
4482
  e.preventDefault();
2334
4483
  rename(id);
@@ -2337,6 +4486,7 @@ document.addEventListener('keydown', (e) => {
2337
4486
  deleteSession(id);
2338
4487
  } else if (k === 't' && id) takeOver(id);
2339
4488
  else if (k === 'r' && e.shiftKey && id) rerun(id);
4489
+ else if (k === 's' && id && S.sessions.get(id)?.branch) shipDialog(id);
2340
4490
  else if (k === 'r' && id) replay(id);
2341
4491
  else if (k === '/' && id) {
2342
4492
  e.preventDefault();
@@ -2353,6 +4503,8 @@ document.addEventListener('keydown', (e) => {
2353
4503
 
2354
4504
  $('#settingsBtn').onclick = openSettings;
2355
4505
  $('#searchBtn').onclick = openPalette;
4506
+ $('#machinesBtn').onclick = () => openMachines();
4507
+ $('#autoBtn').onclick = () => openAutomations({ tab: (S.config.triggers || []).length && !(S.config.recipes || []).length ? 'triggers' : 'recipes' });
2356
4508
  $('#updateBtn').onclick = installUpdate;
2357
4509
  $('#searchBtn').title = `Search (${combo('K')})`;
2358
4510
  $('#searchBtn .mod-k').textContent = combo('K');
@@ -2380,11 +4532,23 @@ setInterval(() => {
2380
4532
 
2381
4533
  // Sticky top bar gets a hairline once the page scrolls; "back to top" shows after a screen's worth.
2382
4534
  const toTop = $('#toTop');
4535
+ // Once the launcher scrolls under the top bar it docks there, folded to one line. The slot keeps
4536
+ // its full height in the feed so docking never shifts what you're reading.
4537
+ const launchSlot = $('#launchSlot');
4538
+ const dockLauncher = () => {
4539
+ const box = $('#launcher');
4540
+ const gone = launchSlot.getBoundingClientRect().bottom < $('#topbar').offsetHeight;
4541
+ if (gone === box.classList.contains('docked')) return;
4542
+ launchSlot.style.height = gone ? `${box.offsetHeight}px` : '';
4543
+ box.classList.toggle('docked', gone);
4544
+ };
2383
4545
  stage.addEventListener('scroll', () => {
2384
4546
  $('#topbar').classList.toggle('scrolled', stage.scrollTop > 4);
4547
+ dockLauncher();
2385
4548
  toTop.hidden = stage.scrollTop < stage.clientHeight * 0.75;
2386
4549
  }, { passive: true });
2387
- toTop.onclick = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
4550
+ const backToTop = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
4551
+ toTop.onclick = backToTop;
2388
4552
 
2389
4553
  if (/Electron/.test(navigator.userAgent)) document.body.classList.add('electron', /Mac/.test(navigator.platform) ? 'mac' : 'other');
2390
4554
 
@@ -2405,7 +4569,7 @@ window.addEventListener('focus', () => {
2405
4569
  });
2406
4570
 
2407
4571
  (async () => {
2408
- [S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions()]);
4572
+ [S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions(), loadMachines()]);
2409
4573
  renderLauncher();
2410
4574
  connect();
2411
4575
  route();