wisp-agents 0.9.0 → 0.11.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
@@ -64,6 +64,28 @@ const MODES = [
64
64
  const KIND_ICON = { read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask' };
65
65
  const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
66
66
  const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'wisp' ? 'wisp' : id);
67
+ // Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
68
+ const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
69
+ // A harness's models and levels: this computer's if it's installed here, else the first machine's that has it.
70
+ function agentById(id) {
71
+ const local = S.agents.find((a) => a.id === id);
72
+ if (local?.available) return local;
73
+ return S.machines.map((m) => agentsAt(m.id)?.find((a) => a.id === id && a.available)).find(Boolean) || local;
74
+ }
75
+ // The harnesses you can pick for the places under "Run on": installed on any of them counts (a union).
76
+ // `missing` names the picked places that don't have it. Machines not online yet don't count.
77
+ function harnessesAt(places) {
78
+ const known = places.map((k) => [k, agentsAt(k)]).filter(([, l]) => l);
79
+ if (!known.length) known.push(['', S.agents]);
80
+ const ids = [...new Set([...S.agents, ...known.flatMap(([, l]) => l)].map((a) => a.id))];
81
+ return ids
82
+ .map((id) => {
83
+ const hits = known.filter(([, l]) => l.some((a) => a.id === id && a.available));
84
+ const base = hits.length ? hits[0][1].find((a) => a.id === id) : S.agents.find((a) => a.id === id) || known.flatMap(([, l]) => l).find((a) => a.id === id);
85
+ return { ...base, available: hits.length > 0, missing: hits.length ? known.filter((x) => !hits.includes(x)).map(([k]) => k) : [] };
86
+ })
87
+ .filter((a) => a.available || S.agents.some((x) => x.id === a.id));
88
+ }
67
89
 
68
90
  // Harness logos for the request bubble, vendored from @lobehub/icons into web/logos/.
69
91
  // `mono` marks are drawn in the text colour so they work in both themes.
@@ -83,14 +105,14 @@ function harnessLogo(agent) {
83
105
  // Reasoning levels the harness accepts for a model: the model's own list (opencode variants),
84
106
  // else the harness-wide one. Empty means the harness has no knob for it.
85
107
  function effortsFor(agent, model) {
86
- const a = S.agents.find((x) => x.id === agent);
108
+ const a = agentById(agent);
87
109
  return a?.models?.find((m) => m.id === model)?.efforts ?? a?.efforts ?? [];
88
110
  }
89
111
  const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
90
112
 
91
113
  function modelBadge(agent, model, effort) {
92
114
  if (agent === 'wisp') return '';
93
- const label = (model && S.agents.find((a) => a.id === agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
115
+ const label = (model && agentById(agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
94
116
  const tip = `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
95
117
  const logo = harnessLogo(agent);
96
118
  return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
@@ -294,14 +316,17 @@ function item(s) {
294
316
  n = { card, head: $('.it-head', card), body: null, turnNodes: new Map(), tray: null, showAll: false, unfolded: new Set(), latestTurn: null };
295
317
  S.nodes.set(s.id, n);
296
318
  n.head.addEventListener('click', (e) => {
297
- if (n.head.querySelector('[contenteditable="true"]')) return;
319
+ if (s.pending || n.head.querySelector('[contenteditable="true"]')) return;
298
320
  if (e.target.closest('[data-a="quick-done"]')) return toggleDone(s.id);
299
321
  setFocus(s.id);
300
322
  toggle(s.id);
301
323
  });
302
- card.addEventListener('mousedown', () => setFocus(s.id, { scroll: false }));
324
+ card.addEventListener('mousedown', () => {
325
+ if (!s.pending) setFocus(s.id, { scroll: false });
326
+ });
303
327
  n.head.addEventListener('contextmenu', (e) => {
304
328
  e.preventDefault();
329
+ if (s.pending) return;
305
330
  setFocus(s.id, { scroll: false });
306
331
  taskMenu(s.id, e);
307
332
  });
@@ -322,7 +347,7 @@ function renderHead(n, s) {
322
347
  ? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
323
348
  : '';
324
349
  if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
325
- n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
350
+ n.card.className = `item ${s.pending ? 'pending' : ''} c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
326
351
  n.head.innerHTML = `
327
352
  <span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
328
353
  <span class="it-main">
@@ -528,6 +553,17 @@ function attachTray() {
528
553
  items.length = 0;
529
554
  render();
530
555
  },
556
+ // Empty the tray for an optimistic send, and put the files back if the send fails.
557
+ take() {
558
+ const list = items.splice(0);
559
+ render();
560
+ return list;
561
+ },
562
+ restore(list) {
563
+ if (!list?.length) return;
564
+ items.unshift(...list);
565
+ render();
566
+ },
531
567
  get busy() {
532
568
  return items.some((i) => i.status === 'uploading');
533
569
  },
@@ -606,7 +642,7 @@ function syncWhere() {
606
642
  function savedModel(id) {
607
643
  const d = S.config.defaults;
608
644
  const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
609
- const models = S.agents.find((a) => a.id === id)?.models || [];
645
+ const models = agentById(id)?.models || [];
610
646
  return models.some((m) => m.id === want) ? want : '';
611
647
  }
612
648
  // Thinking level too, and only if the chosen model still accepts it.
@@ -619,21 +655,21 @@ function savedEffort(id, model) {
619
655
  function renderLauncher() {
620
656
  const box = $('#launcher');
621
657
  const mode = S.config.defaults.mode || 'edits';
622
- const ready = S.agents.filter((a) => a.available);
623
- const chosen = S.agents.find((a) => a.id === (S.config.defaults.agent || 'claude'));
624
- let agent = chosen?.available ? chosen : ready[0] || chosen || S.agents[0];
658
+ const here = harnessesAt(launchWhere);
659
+ const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'claude'));
660
+ let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
625
661
  box.innerHTML = `
626
662
  <div class="l-projects"></div>
627
663
  <textarea id="prompt" rows="2" spellcheck="false"></textarea>
628
664
  <div class="l-bar">
629
665
  <button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
666
+ <button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
630
667
  <button class="mode-btn" id="mode" aria-haspopup="menu"></button>
631
668
  <div class="runners">
632
669
  <button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
633
670
  <button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
634
671
  <button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
635
672
  </div>
636
- <button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
637
673
  <button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
638
674
  <span class="grow"></span>
639
675
  <button class="tog-btn" id="wtBtn"></button>
@@ -719,7 +755,7 @@ function renderLauncher() {
719
755
  const paintTeam = () => paintTeamBtn(teamBtn, team, teamPreset, agent?.id);
720
756
  paintTeam();
721
757
  teamBtn.onclick = () =>
722
- teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset }, (members, preset) => {
758
+ teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset, places: launchWhere }, (members, preset) => {
723
759
  if (JSON.stringify([members, preset]) === JSON.stringify([team, teamPreset])) return;
724
760
  team = members;
725
761
  teamPreset = preset;
@@ -743,19 +779,19 @@ function renderLauncher() {
743
779
  vsBtn.onclick = () => {
744
780
  if (compare) compare = null;
745
781
  else {
746
- const other = S.agents.find((a) => a.available && a.id !== agent?.id) || agent;
782
+ const other = harnessesAt(launchWhere).find((a) => a.available && a.id !== agent?.id) || agent;
747
783
  compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
748
784
  }
749
785
  paintCompare();
750
786
  };
751
787
  runner2.onclick = () =>
752
- runnerPicker(runner2, { ...compare, savedModel, savedEffort }, (id, model, effort) => {
788
+ runnerPicker(runner2, { ...compare, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
753
789
  compare = { agent: id, model, effort };
754
790
  paintCompare();
755
791
  });
756
792
  runnerBtn.onclick = () =>
757
- runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
758
- agent = S.agents.find((a) => a.id === id);
793
+ runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
794
+ agent = harnessesAt(launchWhere).find((a) => a.id === id);
759
795
  curModel = model;
760
796
  curEffort = effort;
761
797
  paintRunner();
@@ -779,21 +815,39 @@ function renderLauncher() {
779
815
  if (off) return offlineToast(off.k);
780
816
  const wanted = [agent?.id, ...(compare ? [compare.agent] : [])];
781
817
  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' });
818
+ const list = agentsAt(t.k);
819
+ const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
820
+ if (missing) return toast(`${esc(agentName(missing))} isn’t installed on <b>${esc(placeName(t.k))}</b>`, { tone: 'bad' });
784
821
  }
785
822
  const btn = $('#launch', box);
786
- btn.disabled = true;
787
- btn.classList.add('busy'); // a fetch and fresh worktrees can take a second or two
823
+ // Optimistic: wait only for uploads, then empty the box and show the task in Working at
824
+ // once. Fetching and making worktrees can take a few seconds; the real card replaces the
825
+ // placeholder when the server has it. On failure the prompt and files come back.
826
+ const attachments = await tray.ids();
827
+ const files = tray.take();
828
+ const cmp = compare;
829
+ const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
830
+ const pending = targets.map((t) => addPending(prompt, t, runner));
831
+ if (compare) (compare = null), paintCompare();
832
+ ta.value = '';
833
+ S.drafts.delete('home');
834
+ ta.dispatchEvent(new Event('input'));
835
+ if (S.filter !== 'all' && targets.some((t) => t.project.id !== S.filter)) {
836
+ S.filter = 'all';
837
+ save('wisp.filter', S.filter);
838
+ }
839
+ renderFeed();
840
+ const ghost = S.nodes.get(pending[0])?.card;
841
+ showLaunched(btn, ghost);
842
+ ta.focus();
788
843
  try {
789
- const attachments = await tray.ids();
790
844
  // Several places: one shared groupId, so the runs land as one task with a row each.
791
845
  const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
792
846
  const results = await Promise.allSettled(
793
847
  targets.map((t) =>
794
848
  api('/sessions', {
795
849
  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 },
850
+ body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp ? false : undefined, compare: cmp ? [cmp] : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
797
851
  }),
798
852
  ),
799
853
  );
@@ -801,31 +855,36 @@ function renderLauncher() {
801
855
  if (r.status === 'rejected') toast(`${esc(placeName(targets[i].k))}: ${esc(r.reason.message)}`, { tone: 'bad' });
802
856
  });
803
857
  const started = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
804
- if (!started.length) return;
858
+ for (const id of pending) S.sessions.delete(id);
805
859
  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();
808
- ta.value = '';
809
- S.drafts.delete('home');
810
- tray.clear();
811
- ta.dispatchEvent(new Event('input'));
812
- if (S.filter !== 'all' && (started.length > 1 || S.filter !== s.projectId)) {
813
- S.filter = 'all';
814
- save('wisp.filter', S.filter);
860
+ keepInPlace(feedAnchor(), renderFeed);
861
+ if (!started.length) {
862
+ if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
863
+ tray.restore(files);
864
+ if (cmp && !compare) (compare = cmp), paintCompare();
865
+ return;
815
866
  }
816
- renderFeed();
817
- const card = S.nodes.get(s.id)?.card;
818
- showLaunched(btn, card?.closest('.task-item') || card);
819
867
  askNotify();
820
868
  } catch (err) {
869
+ for (const id of pending) S.sessions.delete(id);
870
+ renderFeed();
821
871
  toast(esc(err.message), { tone: 'bad' });
822
- } finally {
823
- btn.disabled = false;
824
- btn.classList.remove('busy');
825
- ta.focus();
826
872
  }
827
873
  };
828
874
  $('#launch', box).onclick = launch;
875
+ // Picking other places (or a machine coming online) changes which harnesses exist: keep the
876
+ // pick if one of them still has it, else move to one that's installed there.
877
+ box.placesChanged = () => {
878
+ const here = harnessesAt(launchWhere);
879
+ if (!agent || here.find((a) => a.id === agent.id)?.available) return paintRunner();
880
+ const next = here.find((a) => a.id === S.config.defaults.agent && a.available) || here.find((a) => a.available);
881
+ if (!next) return paintRunner();
882
+ agent = next;
883
+ curModel = savedModel(agent.id);
884
+ curEffort = savedEffort(agent.id, curModel);
885
+ paintRunner();
886
+ paintTeam();
887
+ };
829
888
  ta.addEventListener('keydown', (e) => {
830
889
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
831
890
  e.preventDefault();
@@ -840,7 +899,7 @@ function renderLauncher() {
840
899
 
841
900
  // The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
842
901
  function paintRunnerBtn(btn, agentId, model, effort) {
843
- const a = S.agents.find((x) => x.id === agentId);
902
+ const a = agentById(agentId);
844
903
  const m = (a?.models || []).find((x) => x.id === model);
845
904
  btn.innerHTML = agentId
846
905
  ? `<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')}`
@@ -856,9 +915,9 @@ const MAX_COPIES = 5; // parallel copies of one row
856
915
  const MAX_INSTANCES = 12; // copies across the team
857
916
  const copiesOf = (m) => Math.max(1, m.count || 1);
858
917
  const teamSize = (team) => team.reduce((n, m) => n + copiesOf(m), 0);
859
- const canLead = (id) => S.agents.find((a) => a.id === id)?.delegates !== false;
918
+ const canLead = (id) => agentById(id)?.delegates !== false;
860
919
  const memberLabel = (m) => {
861
- const a = S.agents.find((x) => x.id === m.agent);
920
+ const a = agentById(m.agent);
862
921
  const model = (m.model && a?.models?.find((x) => x.id === m.model)?.label) || m.model || 'Default model';
863
922
  return `${a?.name || agentName(m.agent)} · ${model}${m.effort ? ` · ${effortLabel(m.effort)}` : ''}`;
864
923
  };
@@ -905,7 +964,7 @@ function paintTeamBtn(btn, team, presetId, lead) {
905
964
  : `${agentName(lead)} can't delegate (wisp can't give it tools), so this launch runs without subagents`;
906
965
  }
907
966
 
908
- function teamEditor(anchor, { lead, team, presetId }, onChange) {
967
+ function teamEditor(anchor, { lead, team, presetId, places }, onChange) {
909
968
  const open = $('.team-pop');
910
969
  if (open) return open.close();
911
970
  let members = team.map((m) => ({ ...m }));
@@ -969,7 +1028,8 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
969
1028
  const add = () => {
970
1029
  if (members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES) return;
971
1030
  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);
1031
+ const pool = places ? harnessesAt(places) : S.agents;
1032
+ const a = pool.find((x) => x.available && !used.has(x.id)) || pool.find((x) => x.available && x.id !== lead) || pool.find((x) => x.available);
973
1033
  if (!a) return toast('No harness is installed.', { tone: 'bad' });
974
1034
  const model = savedModel(a.id);
975
1035
  members.push({ name: '', agent: a.id, model, effort: savedEffort(a.id, model), use: '', count: 1 });
@@ -1072,7 +1132,7 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
1072
1132
  const rb = e.target.closest('.tp-runner');
1073
1133
  if (rb) {
1074
1134
  const m = members[i];
1075
- runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort }, (agent, model, effort) => {
1135
+ runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort, places }, (agent, model, effort) => {
1076
1136
  Object.assign(m, { agent, model, effort });
1077
1137
  paintList();
1078
1138
  commit();
@@ -1349,6 +1409,7 @@ function whereMenu(anchor) {
1349
1409
  // A machine picked while disconnected starts connecting; you asked to run there.
1350
1410
  for (const k of launchWhere) if (k && machineById(k)?.state === 'off') connectMachine(k);
1351
1411
  renderLaunchChips();
1412
+ $('#launcher')?.placesChanged?.();
1352
1413
  paint();
1353
1414
  saveWhere();
1354
1415
  };
@@ -1467,6 +1528,65 @@ function launchedFx(from, to) {
1467
1528
  ).onfinish = () => w.remove();
1468
1529
  }
1469
1530
 
1531
+ // A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
1532
+ // "Starting up" card. It can't be opened; the real card takes its place.
1533
+ let pendingSeq = 0;
1534
+ function addPending(prompt, { project: p, m }, { agent, model, effort }) {
1535
+ const id = `pending-${++pendingSeq}`;
1536
+ const line = prompt.split('\n')[0].replace(/\s+/g, ' ');
1537
+ const now = Date.now();
1538
+ S.sessions.set(id, {
1539
+ id,
1540
+ pending: true,
1541
+ title: line.length > 64 ? `${line.slice(0, 63)}…` : line,
1542
+ agent,
1543
+ model,
1544
+ effort,
1545
+ cwd: p.path,
1546
+ project: p.name,
1547
+ projectId: p.id,
1548
+ color: p.color || 'slate',
1549
+ state: 'running',
1550
+ live: 'Starting up',
1551
+ createdAt: now,
1552
+ updatedAt: now,
1553
+ machine: m ? { id: m.id, name: m.name, target: m.target } : null,
1554
+ });
1555
+ return id;
1556
+ }
1557
+
1558
+ // A launch from a dialog (rerun, recipes): the dialog closes and a placeholder shows in Working
1559
+ // at once; the real session replaces it, or it disappears and the error is thrown on.
1560
+ async function launchBehind({ prompt, cwd, runner }, request) {
1561
+ const sum = S.config.projects.find((p) => p.path === cwd);
1562
+ const project = sum || { path: cwd, name: cwd.split('/').filter(Boolean).pop() || cwd, id: null, color: 'slate' };
1563
+ const ghost = addPending(prompt, { project, m: project.machine ? machineById(project.machine) : null }, runner);
1564
+ if (S.filter !== 'all' && S.filter !== project.id) {
1565
+ S.filter = 'all';
1566
+ save('wisp.filter', S.filter);
1567
+ }
1568
+ renderFeed();
1569
+ const card = S.nodes.get(ghost)?.card;
1570
+ if (card) (card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), card.classList.add('arrived'), setTimeout(() => card.classList.remove('arrived'), 1600));
1571
+ try {
1572
+ const s = await request();
1573
+ S.sessions.delete(ghost);
1574
+ for (const x of s.group || [s]) S.sessions.set(x.id, x);
1575
+ keepInPlace(feedAnchor(), renderFeed);
1576
+ return s;
1577
+ } catch (err) {
1578
+ S.sessions.delete(ghost);
1579
+ renderFeed();
1580
+ throw err;
1581
+ }
1582
+ }
1583
+
1584
+ // The real session showed up (live events can beat the launch response): drop its placeholder.
1585
+ function dropPending(s) {
1586
+ if (s.pending || s.parentId) return;
1587
+ for (const x of S.sessions.values()) if (x.pending && x.projectId === s.projectId) return S.sessions.delete(x.id);
1588
+ }
1589
+
1470
1590
  // A new card lands in Working, below everything in "Needs you", so it's often off-screen. Scroll
1471
1591
  // it into view (just enough) and flash it; the flying wisp only plays when the card is already
1472
1592
  // on screen, since the launcher it starts from may scroll away or fold into the dock.
@@ -1633,7 +1753,6 @@ function renderBody(id, { fresh }) {
1633
1753
  ${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
1754
  const live = s.state === 'running' || s.state === 'waiting';
1635
1755
  // 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
1756
  renderPrStrip(id);
1638
1757
  $$('[data-a="done"]', body).forEach((b) => {
1639
1758
  b.classList.toggle('on', s.state === 'done');
@@ -1808,8 +1927,18 @@ function renderWorking(s, t) {
1808
1927
  .filter((a) => a.kind !== 'text')
1809
1928
  .slice(-5)
1810
1929
  .forEach((a) => ticker.prepend(actItem(a)));
1811
- $('.stop', node).onclick = async () => {
1812
- await api(`/sessions/${s.id}/stop`, { method: 'POST', body: {} }).catch((e) => toast(esc(e.message), { tone: 'bad' }));
1930
+ $('.stop', node).onclick = async (e) => {
1931
+ // Shows at once; the run winds down and the live update replaces this panel.
1932
+ const btn = e.currentTarget;
1933
+ btn.disabled = true;
1934
+ btn.lastChild.textContent = 'Stopping…';
1935
+ $('.w-status b', node).textContent = `Stopping ${agentName(s.agent)}…`;
1936
+ await api(`/sessions/${s.id}/stop`, { method: 'POST', body: {} }).catch((err) => {
1937
+ btn.disabled = false;
1938
+ btn.lastChild.textContent = 'Stop';
1939
+ $('.w-status b', node).textContent = `${agentName(s.agent)} is working`;
1940
+ toast(esc(err.message), { tone: 'bad' });
1941
+ });
1813
1942
  };
1814
1943
  $('.peek', node).onclick = (e) => {
1815
1944
  const log = $('.log', node);
@@ -1837,7 +1966,15 @@ function renderAskCard(s) {
1837
1966
  const answer = async (text) => {
1838
1967
  if (!text.trim()) return;
1839
1968
  node.classList.add('sent');
1840
- await api(`/sessions/${s.id}/answer`, { method: 'POST', body: { text } }).catch((e) => toast(esc(e.message), { tone: 'bad' }));
1969
+ const undo = startOptimistic(s.id, text, null, { answering: true });
1970
+ try {
1971
+ await api(`/sessions/${s.id}/answer`, { method: 'POST', body: { text } });
1972
+ if (undo) scheduleRefetch(s.id);
1973
+ } catch (e) {
1974
+ node.classList.remove('sent');
1975
+ undo?.();
1976
+ toast(esc(e.message), { tone: 'bad' });
1977
+ }
1841
1978
  };
1842
1979
  node.addEventListener('click', (e) => {
1843
1980
  const b = e.target.closest('.opt');
@@ -2064,21 +2201,58 @@ async function sendReply(id, text) {
2064
2201
  if (!s || !text) return;
2065
2202
  if (n?.tray?.busy) toast('Waiting for uploads…');
2066
2203
  const attachments = n?.tray ? await n.tray.ids() : [];
2204
+ const answering = !!s.ask && !attachments.length;
2205
+ // Optimistic: the composer empties and the card goes to Working before the server answers
2206
+ // (it may first have to bring back an archived worktree, or reach another machine).
2207
+ const ta = n && $('.reply', n.card);
2208
+ if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
2209
+ S.drafts.delete(id);
2210
+ const files = n?.tray?.take();
2211
+ const undo = startOptimistic(id, text, files, { answering });
2067
2212
  try {
2068
- const r = s.ask && !attachments.length
2213
+ const r = answering
2069
2214
  ? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
2070
2215
  : await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing() } });
2071
- const ta = n && $('.reply', n.card);
2072
- if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
2073
- S.drafts.delete(id);
2074
- n?.tray?.clear();
2216
+ if (undo) scheduleRefetch(id); // the summary that follows says "running" too, so it won't refetch by itself
2075
2217
  if (r.queued)
2076
2218
  toast(r.ahead ? `Queued behind ${r.ahead} earlier follow-up${r.ahead > 1 ? 's' : ''} — they go out first, in order.` : 'Queued — it goes out as soon as the agent finishes.');
2077
2219
  } catch (err) {
2220
+ undo?.();
2221
+ // Give the message back, unless the user has already started typing another one.
2222
+ if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
2223
+ n?.tray?.restore(files);
2078
2224
  toast(esc(err.message), { tone: 'bad' });
2079
2225
  }
2080
2226
  }
2081
2227
 
2228
+ // Show a follow-up as started right away: the card moves to Working with the prompt as a live
2229
+ // turn. Only when it will start now, not when it queues or interrupts a run already working.
2230
+ // Returns an undo for a failed send, or null when nothing changed.
2231
+ function startOptimistic(id, text, files, { answering }) {
2232
+ const full = S.full.get(id);
2233
+ const sum = S.sessions.get(id);
2234
+ if (!full || !sum) return null;
2235
+ const live = full.state === 'running' || full.state === 'waiting';
2236
+ if (live && !answering) return null;
2237
+ const now = Date.now();
2238
+ const turn = { id: `pending-${now}`, prompt: text, status: 'running', startedAt: now, activity: [], attachments: (files || []).filter((f) => f.id).map((f) => ({ id: f.id, name: f.name, mime: f.mime })) };
2239
+ const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn] };
2240
+ const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : 'Starting up', updatedAt: now };
2241
+ const apply = (f, sm) => {
2242
+ S.full.set(id, f);
2243
+ S.sessions.set(id, sm);
2244
+ const card = S.nodes.get(id)?.card;
2245
+ keepInPlace(watching(id) ? card : feedAnchor(id), renderFeed);
2246
+ renderBody(id, { fresh: false });
2247
+ };
2248
+ apply(nextFull, nextSum);
2249
+ return () => {
2250
+ // A live update may already have replaced the guess; only roll back what is still ours.
2251
+ if (S.full.get(id) !== nextFull || S.sessions.get(id) !== nextSum) return scheduleRefetch(id);
2252
+ apply(full, sum);
2253
+ };
2254
+ }
2255
+
2082
2256
  // --- item actions ------------------------------------------------------------------
2083
2257
 
2084
2258
  async function toggleDone(id) {
@@ -2086,23 +2260,48 @@ async function toggleDone(id) {
2086
2260
  if (!s) return;
2087
2261
  if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
2088
2262
  const next = s.state === 'done' ? 'review' : 'done';
2089
- const leave = next === 'done' && handOff(id); // measured before the live update moves the card
2090
- await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
2091
- if (leave) {
2092
- leave(() => S.sessions.set(id, { ...S.sessions.get(id), state: 'done' }));
2093
- toast(`Marked done`, { action: { label: 'Undo', run: () => api(`/sessions/${id}`, { method: 'PATCH', body: { state: 'review' } }) } });
2263
+ // Optimistic: the card moves now; a failed save puts it back.
2264
+ move(id, { ...s, state: next }, next === 'done');
2265
+ if (next === 'done') toast(`Marked done`, { action: { label: 'Undo', run: () => toggleDone(id) } });
2266
+ try {
2267
+ await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
2268
+ } catch (err) {
2269
+ revertSummary(id, s);
2270
+ toast(esc(err.message), { tone: 'bad' });
2094
2271
  }
2095
2272
  }
2096
2273
 
2274
+ // Apply a summary change locally before the server confirms it. Leaving the list (done, archived)
2275
+ // hands focus to the next card; coming back just re-renders around the reader.
2276
+ function move(id, next, leaving) {
2277
+ if (leaving) return handOff(id)(() => S.sessions.set(id, next));
2278
+ S.sessions.set(id, next);
2279
+ keepInPlace(feedAnchor(id), renderFeed);
2280
+ }
2281
+
2282
+ // Put back a summary changed optimistically, unless a live update has replaced it since.
2283
+ function revertSummary(id, prev) {
2284
+ const cur = S.sessions.get(id);
2285
+ if (cur && cur.updatedAt > prev.updatedAt) return;
2286
+ S.sessions.set(id, prev);
2287
+ keepInPlace(feedAnchor(id), renderFeed);
2288
+ }
2289
+
2097
2290
  // Every finished run of a compare task at once, with one toast to undo them all.
2098
2291
  async function taskDone(ids) {
2099
2292
  if (!ids.length) return;
2100
2293
  const card = S.nodes.get(ids[0])?.card.closest('.task-item');
2101
2294
  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();
2295
+ const set = (state) => {
2296
+ const prev = ids.map((id) => S.sessions.get(id));
2297
+ for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state });
2298
+ renderFeed();
2299
+ Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } }))).catch((err) => {
2300
+ prev.forEach((p, i) => p && revertSummary(ids[i], p));
2301
+ toast(esc(err.message), { tone: 'bad' });
2302
+ });
2303
+ };
2304
+ set('done');
2106
2305
  if (next && S.nodes.has(next)) setFocus(next);
2107
2306
  toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
2108
2307
  }
@@ -2183,10 +2382,17 @@ function rename(id) {
2183
2382
  // One popover for all three choices: harnesses on the left, the focused harness's models on the
2184
2383
  // right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
2185
2384
  // models; picking a model commits harness + model, picking a thinking level commits it in place.
2186
- function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort }, pick) {
2385
+ // places: the "Run on" picks, so the list is what's installed there rather than only on this computer.
2386
+ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, places }, pick) {
2187
2387
  const open = $('.picker');
2188
2388
  if (open) return open.close();
2189
- let focusAgent = S.agents.find((a) => a.id === agent && a.available) ? agent : S.agents.find((a) => a.available)?.id;
2389
+ const agents = places ? harnessesAt(places) : S.agents;
2390
+ const sub = (a) => {
2391
+ if (!a.available) return places?.length > 1 || places?.[0] ? 'Not installed there' : 'Not installed';
2392
+ const models = (a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model';
2393
+ return a.missing?.length ? `Not on ${a.missing.map(placeName).join(', ')}` : models;
2394
+ };
2395
+ let focusAgent = agents.find((a) => a.id === agent && a.available) ? agent : agents.find((a) => a.available)?.id;
2190
2396
 
2191
2397
  const r = anchor.getBoundingClientRect();
2192
2398
  const width = Math.min(580, innerWidth - 24);
@@ -2199,11 +2405,11 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
2199
2405
  <div class="picker" role="dialog" aria-label="Harness, model and thinking level" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
2200
2406
  <div class="pk-side" role="listbox" aria-label="Harness">
2201
2407
  <div class="pk-head">Harness</div>
2202
- ${S.agents
2408
+ ${agents
2203
2409
  .map(
2204
2410
  (a) => `<button class="pk-h${a.available ? '' : ' off'}" role="option" data-id="${esc(a.id)}" ${a.available ? '' : 'disabled'}>
2205
2411
  <span class="pk-logo">${harnessLogo(a.id)}</span>
2206
- <span class="pk-text"><b>${esc(a.name)}</b><small>${esc(a.available ? `${(a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model'}` : 'Not installed')}</small></span>
2412
+ <span class="pk-text"><b>${esc(a.name)}</b><small${a.missing?.length ? ' class="warn"' : ''}>${esc(sub(a))}</small></span>
2207
2413
  ${a.id === agent ? '<span class="pk-dot" title="In use"></span>' : ''}
2208
2414
  </button>`,
2209
2415
  )
@@ -2247,7 +2453,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
2247
2453
  };
2248
2454
 
2249
2455
  const paintModels = () => {
2250
- const a = S.agents.find((x) => x.id === focusAgent);
2456
+ const a = agents.find((x) => x.id === focusAgent);
2251
2457
  sideRows.forEach((b) => b.classList.toggle('on', b.dataset.id === focusAgent));
2252
2458
  const models = a?.models || [{ id: '', label: 'Default' }];
2253
2459
  const current = focusAgent === agent ? model : savedModel(focusAgent);
@@ -2438,7 +2644,7 @@ function taskMenu(id, e) {
2438
2644
  ${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
2439
2645
  <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
2440
2646
  <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>` : ''}
2647
+ ${shippable(s) ? `<button data-a="ship">${icon('send')}${shipLabel(s)}${keyHint('S')}</button>` : ''}
2442
2648
  ${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
2443
2649
  ${s.groupId ? `<button data-a="compare"><b class="vs-mark">vs</b>Compare runs</button>` : ''}
2444
2650
  <button data-a="recipe">${icon('spark')}Save as recipe…</button>
@@ -2477,9 +2683,12 @@ const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
2477
2683
 
2478
2684
  async function deleteSession(id) {
2479
2685
  if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
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));
2686
+ const prev = S.sessions.get(id);
2687
+ handOff(id)(() => S.sessions.delete(id));
2688
+ await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => {
2689
+ if (prev && !S.sessions.has(id)) (S.sessions.set(id, prev), keepInPlace(feedAnchor(id), renderFeed));
2690
+ toast(esc(err.message), { tone: 'bad' });
2691
+ });
2483
2692
  }
2484
2693
 
2485
2694
  // Launch the task again as a new session in the same folder: same prompt and attachments,
@@ -2538,22 +2747,13 @@ async function rerun(id) {
2538
2747
  const go = async () => {
2539
2748
  const prompt = ta.value.trim();
2540
2749
  if (!prompt) return ta.focus();
2541
- const btn = $('[data-a="go"]', node);
2542
- btn.disabled = true;
2750
+ close();
2543
2751
  try {
2544
- const next = await api('/sessions', { method: 'POST', body: { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) } });
2545
- S.sessions.set(next.id, next);
2546
- close();
2547
- if (S.filter !== 'all' && S.filter !== next.projectId) {
2548
- S.filter = 'all';
2549
- save('wisp.filter', S.filter);
2550
- }
2551
- renderFeed();
2752
+ const body = { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) };
2753
+ const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
2552
2754
  setFocus(next.id);
2553
- toast(`Rerunning on ${esc(agentName(agent))}`);
2554
2755
  } catch (err) {
2555
- btn.disabled = false;
2556
- toast(esc(err.message), { tone: 'bad' });
2756
+ toast(`Rerun failed: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Edit again', run: () => rerun(id) } });
2557
2757
  }
2558
2758
  };
2559
2759
  $('[data-a="go"]', node).onclick = go;
@@ -2569,15 +2769,16 @@ async function rerun(id) {
2569
2769
  }
2570
2770
 
2571
2771
  async function setArchived(id, archived) {
2572
- const leave = archived && handOff(id);
2772
+ const prev = S.sessions.get(id);
2773
+ if (!prev) return;
2774
+ // Optimistic: the card leaves (or comes back) now; a failed save puts it back.
2775
+ move(id, { ...prev, archived }, archived);
2776
+ if (archived) toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
2573
2777
  try {
2574
2778
  await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
2575
2779
  } catch (err) {
2576
- return toast(esc(err.message), { tone: 'bad' });
2577
- }
2578
- if (leave) {
2579
- leave(() => S.sessions.set(id, { ...S.sessions.get(id), archived: true }));
2580
- toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
2780
+ revertSummary(id, prev);
2781
+ toast(esc(err.message), { tone: 'bad' });
2581
2782
  }
2582
2783
  }
2583
2784
 
@@ -2837,6 +3038,15 @@ function prLine(pr) {
2837
3038
  const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
2838
3039
  return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
2839
3040
  }
3041
+ // Ship it only makes sense on a finished worktree task with something to ship: uncommitted
3042
+ // changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
3043
+ function shippable(s) {
3044
+ if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED') return false;
3045
+ const t = s.toShip;
3046
+ return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
3047
+ }
3048
+ const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
3049
+
2840
3050
  const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
2841
3051
 
2842
3052
  // The PR row under a card's bar; reads the live summary, so polled check results show up.
@@ -2846,7 +3056,10 @@ function renderPrStrip(id) {
2846
3056
  const s = S.sessions.get(id);
2847
3057
  if (!node || !s) return;
2848
3058
  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'));
3059
+ $$('[data-a="ship"]', n.body).forEach((b) => {
3060
+ b.hidden = !shippable(s);
3061
+ $('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
3062
+ });
2850
3063
  node.hidden = !pr;
2851
3064
  if (!pr) return;
2852
3065
  const tone = prTone(pr);
@@ -2997,16 +3210,21 @@ async function compareView(id) {
2997
3210
  if (a === 'open') return close(), reveal(keep);
2998
3211
  const others = group.filter((g) => g.id !== keep);
2999
3212
  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;
3213
+ // Optimistic: the others leave the feed now; stopping and cleaning them up goes on behind.
3214
+ for (const o of others) S.sessions.set(o.id, { ...S.sessions.get(o.id), archived: true });
3215
+ close();
3216
+ renderFeed();
3217
+ reveal(keep);
3218
+ toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
3219
+ let failed = false;
3000
3220
  for (const o of others) {
3001
3221
  if (o.state === 'running' || o.state === 'waiting') {
3002
3222
  await api(`/sessions/${o.id}/stop`, { method: 'POST', body: {} }).catch(() => {});
3003
3223
  await until(() => S.sessions.get(o.id)?.state !== 'running', 5000);
3004
3224
  }
3005
- await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
3225
+ await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => ((failed = true), toast(esc(err.message), { tone: 'bad' })));
3006
3226
  }
3007
- close();
3008
- reveal(keep);
3009
- toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
3227
+ if (failed) loadSessions(); // show what really got archived
3010
3228
  };
3011
3229
  }
3012
3230
 
@@ -3271,18 +3489,17 @@ function runRecipe(r) {
3271
3489
  const params = Object.fromEntries($$('input[name^="p:"]', node).map((i) => [i.name.slice(2), i.value]));
3272
3490
  const d = S.config.defaults;
3273
3491
  const agent = r.agent || d.agent;
3492
+ const cwd = needsProject ? $('[name="cwd"]', node).value : r.cwd;
3493
+ const model = r.agent ? r.model : savedModel(agent);
3494
+ const effort = r.agent ? r.effort : savedEffort(agent, model);
3495
+ const body = { params, cwd: needsProject ? cwd : undefined, agent, model: r.agent ? undefined : model, effort: r.agent ? undefined : effort, mode: r.mode || d.mode };
3496
+ close();
3274
3497
  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();
3498
+ const prompt = r.prompt.replace(/\{([a-zA-Z][\w-]{0,30})\}/g, (_, k) => params[k] || ''); // as the server fills it
3499
+ const s = await launchBehind({ prompt, cwd, runner: { agent, model, effort } }, () => api(`/recipes/${r.id}/run`, { method: 'POST', body }));
3282
3500
  setFocus(s.id);
3283
- toast(`Running “${esc(r.name)}”`);
3284
3501
  } catch (err) {
3285
- toast(esc(err.message), { tone: 'bad' });
3502
+ toast(`“${esc(r.name)}” didn’t start: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Try again', run: () => runRecipe(r) } });
3286
3503
  }
3287
3504
  };
3288
3505
  // Always wait for Run, even with nothing to fill in, so a click on a recipe never launches it outright.
@@ -3489,6 +3706,7 @@ function setMachines(list) {
3489
3706
  S.machines = list;
3490
3707
  paintMachinesBtn();
3491
3708
  renderLaunchChips();
3709
+ $('#launcher')?.placesChanged?.();
3492
3710
  renderProjects();
3493
3711
  $('.machines-modal')?.refresh();
3494
3712
  $('.projects-modal')?.refresh?.();
@@ -3539,14 +3757,31 @@ function openMachines({ host, focusAdd = false } = {}) {
3539
3757
  const close = present(node, host);
3540
3758
  const body = $('.mc-body', node);
3541
3759
  let editing = null; // machine id whose form is open, '' = a new one, null = closed
3542
- let hosts = [];
3543
- api('/machines/ssh-hosts')
3760
+ let hosts = []; // found on this computer: ~/.ssh/config, Tailscale peers, ~/.ssh/known_hosts
3761
+ api('/machines/discover')
3544
3762
  .then((h) => {
3545
3763
  hosts = h;
3764
+ const slot = $('.mc-found-slot', node);
3765
+ if (slot) slot.innerHTML = found();
3546
3766
  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('');
3767
+ if (dl) dl.innerHTML = datalist();
3548
3768
  })
3549
3769
  .catch(() => {});
3770
+ const datalist = () => hosts.map((x) => `<option value="${esc(x.target)}">${esc(x.detail || '')}</option>`).join('');
3771
+ const SOURCE = { 'ssh-config': '~/.ssh/config', tailscale: 'Tailscale', 'known-hosts': 'You’ve SSHed here before' };
3772
+ // Hosts not added yet, one click from a filled-in form.
3773
+ const found = () => {
3774
+ const added = new Set(S.machines.map((m) => m.target));
3775
+ const list = hosts.filter((x) => !added.has(x.target));
3776
+ if (!list.length) return '';
3777
+ return `<div class="mc-found"><h4>Found on this computer</h4><div class="mc-found-list">${list
3778
+ .slice(0, 12)
3779
+ .map(
3780
+ (x) =>
3781
+ `<button type="button" class="lp-chip${x.online === false ? ' off' : ''}" data-a="pick" data-target="${esc(x.target)}" data-name="${esc(x.name)}" title="${esc([SOURCE[x.source], x.target, x.detail].filter(Boolean).join(' · '))}">${icon('server')}${esc(x.name)}${x.online !== undefined ? `<i class="mc-fdot"></i>` : ''}</button>`,
3782
+ )
3783
+ .join('')}</div></div>`;
3784
+ };
3550
3785
 
3551
3786
  // Projects on a machine open here, with a way back.
3552
3787
  const sub = (view) => {
@@ -3596,7 +3831,7 @@ function openMachines({ host, focusAdd = false } = {}) {
3596
3831
  </div>
3597
3832
  ${
3598
3833
  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>`
3834
+ ? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${m.outdated ? `<button class="ghost xs mc-update" data-a="update" title="Restart wisp on ${esc(m.name)} as ${esc(m.outdated)}">${icon('rerun')}Update to ${esc(m.outdated)}</button>` : ''}${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
3835
  : ''
3601
3836
  }
3602
3837
  ${busy || st === 'error' ? steps(m) : ''}
@@ -3630,8 +3865,10 @@ function openMachines({ host, focusAdd = false } = {}) {
3630
3865
  const add = $('.mc-add', node);
3631
3866
  // The form for a new machine: open when there are none yet, else behind a button.
3632
3867
  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>`;
3868
+ // Keep a half-filled form; only the suggestions above it change.
3869
+ if ($('form[data-id=""]', add)) $('.mc-found-slot', add).innerHTML = found();
3870
+ else add.innerHTML = `<div class="mc-found-slot">${found()}</div><h4>${S.machines.length ? 'Add another machine' : 'Add your first machine'}</h4>${form(null)}<datalist id="sshHosts">${datalist()}</datalist>`;
3871
+ } else add.innerHTML = `<div class="mc-found-slot">${found()}</div><button class="lp-chip add mc-new" data-a="new">${icon('plus')}Add a machine</button><datalist id="sshHosts">${datalist()}</datalist>`;
3635
3872
  };
3636
3873
 
3637
3874
  const menu = (m, anchor) => {
@@ -3667,8 +3904,9 @@ function openMachines({ host, focusAdd = false } = {}) {
3667
3904
  paint();
3668
3905
  $(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
3669
3906
  }
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;
3907
+ if (a === 'restart' || a === 'update') {
3908
+ const verb = a === 'update' ? `Updating wisp there to ${m.outdated}` : 'Restarting wisp there';
3909
+ if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. ${verb} stops ${m.working === 1 ? 'it' : 'them'}. ${a === 'update' ? 'Update' : 'Restart'}?`)) return;
3672
3910
  await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
3673
3911
  }
3674
3912
  if (a === 'stop') {
@@ -3703,6 +3941,16 @@ function openMachines({ host, focusAdd = false } = {}) {
3703
3941
  $('.mc-add input[name="target"]', node)?.focus();
3704
3942
  return;
3705
3943
  }
3944
+ if (a === 'pick') {
3945
+ // Fill in the form; connecting still waits for the button.
3946
+ editing = '';
3947
+ paint();
3948
+ const f = $('.mc-add form[data-id=""]', node);
3949
+ f.target.value = b.dataset.target;
3950
+ f.name.value = b.dataset.name;
3951
+ $('button[type="submit"]', f).focus();
3952
+ return;
3953
+ }
3706
3954
  if (a === 'cancel') {
3707
3955
  editing = null;
3708
3956
  paint();
@@ -3718,8 +3966,8 @@ function openMachines({ host, focusAdd = false } = {}) {
3718
3966
  node.addEventListener('input', (e) => {
3719
3967
  if (e.target.name !== 'target') return;
3720
3968
  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';
3969
+ const h = hosts.find((x) => x.target === e.target.value.trim());
3970
+ name.placeholder = (h?.name || e.target.value.trim().replace(/^.*@/, '')).slice(0, 40) || 'devbox';
3723
3971
  });
3724
3972
  node.addEventListener('submit', async (e) => {
3725
3973
  e.preventDefault();
@@ -4201,7 +4449,7 @@ function paletteItems() {
4201
4449
  if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
4202
4450
  items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
4203
4451
  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) });
4452
+ if (shippable(cur)) items.push({ group: g, label: shipLabel(cur), sub, icon: 'send', kbd: 'S', keywords: 'commit push pull request pr github', run: () => shipDialog(id) });
4205
4453
  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
4454
  if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
4207
4455
  items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
@@ -4210,7 +4458,7 @@ function paletteItems() {
4210
4458
  items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
4211
4459
  }
4212
4460
 
4213
- const sessions = [...S.sessions.values()].sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
4461
+ const sessions = [...S.sessions.values()].filter((s) => !s.pending).sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
4214
4462
  for (const s of sessions) {
4215
4463
  const state = s.archived ? 'Archived' : s.state === 'review' ? STATUS_LABEL[s.status] || 'Review' : STATE_LABEL[s.state] || s.state;
4216
4464
  items.push({
@@ -4296,7 +4544,7 @@ function notifyFinished(s) {
4296
4544
  }
4297
4545
  toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
4298
4546
  if (notifyOn() && !document.hasFocus()) {
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 });
4547
+ const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/apple-touch-icon.png', tag: s.id });
4300
4548
  note.onclick = () => (window.focus(), reveal(s.id));
4301
4549
  }
4302
4550
  }
@@ -4309,6 +4557,7 @@ function connect() {
4309
4557
  const s = JSON.parse(e.data);
4310
4558
  if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
4311
4559
  const prev = S.sessions.get(s.id);
4560
+ if (!prev) dropPending(s);
4312
4561
  const was = prev && sectionOf(prev); // before the update: a compare task's section depends on all its runs
4313
4562
  S.sessions.set(s.id, s);
4314
4563
  // A section change moves the card (a follow-up sends it from "Needs you" to "Working").
@@ -4324,7 +4573,7 @@ function connect() {
4324
4573
  renderFeed();
4325
4574
  });
4326
4575
  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);
4576
+ if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state]) !== JSON.stringify([s.pr, s.toShip, s.state])) renderPrStrip(s.id);
4328
4577
  });
4329
4578
  es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
4330
4579
  es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
@@ -4408,7 +4657,7 @@ async function installUpdate() {
4408
4657
 
4409
4658
  // --- keyboard ---------------------------------------------------------------------------
4410
4659
 
4411
- const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
4660
+ const feedIds = () => $$('#feed .item:not(.pending)').map((x) => x.dataset.id);
4412
4661
  // The user takes a card out of its list (done, archive, delete). The next card — the one below,
4413
4662
  // or the one above at the end of the list — gets focus and lands where the old one sat, so
4414
4663
  // triage continues on the same spot. Call before the request, while the card is still laid
@@ -4486,7 +4735,7 @@ document.addEventListener('keydown', (e) => {
4486
4735
  deleteSession(id);
4487
4736
  } else if (k === 't' && id) takeOver(id);
4488
4737
  else if (k === 'r' && e.shiftKey && id) rerun(id);
4489
- else if (k === 's' && id && S.sessions.get(id)?.branch) shipDialog(id);
4738
+ else if (k === 's' && id && shippable(S.sessions.get(id))) shipDialog(id);
4490
4739
  else if (k === 'r' && id) replay(id);
4491
4740
  else if (k === '/' && id) {
4492
4741
  e.preventDefault();