wisp-agents 0.12.0 → 0.14.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
@@ -21,6 +21,7 @@ const S = {
21
21
  showDone: load('wisp.showDone', false),
22
22
  showArchived: false,
23
23
  drafts: new Map(),
24
+ replyModes: new Map(), // session id → the mode its next follow-up switches to
24
25
  };
25
26
 
26
27
  const $ = (s, root = document) => root.querySelector(s);
@@ -61,20 +62,30 @@ const MODES = [
61
62
  { id: 'edits', label: 'Edits', icon: 'pen', hint: 'May edit files. Shell commands that need approval are denied.' },
62
63
  { id: 'auto', label: 'Full auto', icon: 'spark', hint: 'No permission prompts at all. Trusted repos only.' },
63
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
+ 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', background: 'clock' };
65
66
  const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
66
- const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'wisp' ? 'wisp' : id);
67
+ const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'auto' ? 'Auto' : id === 'wisp' ? 'wisp' : id);
68
+ // Auto: not a CLI but wisp's router (server/router.js), which picks harness and model per task.
69
+ // Its "models" are the goal it optimises for; there are no thinking levels to pick.
70
+ const AUTO_GOALS = [
71
+ { id: 'balanced', label: 'Balanced', hint: 'Best result per dollar' },
72
+ { id: 'cheap', label: 'Cheap', hint: 'Lowest cost that still gets it done' },
73
+ { id: 'best', label: 'Best', hint: 'Best result; cost only breaks ties' },
74
+ ];
75
+ const AUTO = { id: 'auto', name: 'Auto', available: true, delegates: true, models: AUTO_GOALS, efforts: [], hint: 'wisp picks the harness and model from what worked before' };
67
76
  // Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
68
77
  const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
69
78
  // A harness's models and levels: this computer's if it's installed here, else the first machine's that has it.
70
79
  function agentById(id) {
80
+ if (id === 'auto') return AUTO;
71
81
  const local = S.agents.find((a) => a.id === id);
72
82
  if (local?.available) return local;
73
83
  return S.machines.map((m) => agentsAt(m.id)?.find((a) => a.id === id && a.available)).find(Boolean) || local;
74
84
  }
75
85
  // The harnesses you can pick for the places under "Run on": installed on any of them counts (a union).
76
86
  // `missing` names the picked places that don't have it. Machines not online yet don't count.
77
- function harnessesAt(places) {
87
+ function harnessesAt(places, { auto = false } = {}) {
88
+ if (auto) return [AUTO, ...harnessesAt(places)];
78
89
  const known = places.map((k) => [k, agentsAt(k)]).filter(([, l]) => l);
79
90
  if (!known.length) known.push(['', S.agents]);
80
91
  const ids = [...new Set([...S.agents, ...known.flatMap(([, l]) => l)].map((a) => a.id))];
@@ -98,6 +109,7 @@ const HARNESS_LOGO = {
98
109
  grok: { file: 'grok', mono: true },
99
110
  };
100
111
  function harnessLogo(agent) {
112
+ if (agent === 'auto') return `<i class="auto-mark">${icon('spark')}</i>`;
101
113
  const l = HARNESS_LOGO[agent];
102
114
  const src = l && `/logos/${l.file}.svg`;
103
115
  return !l ? icon('spark') : l.mono ? `<i class="mono" style="--logo:url(${src})"></i>` : `<img src="${src}" alt="">`;
@@ -110,12 +122,19 @@ function effortsFor(agent, model) {
110
122
  }
111
123
  const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
112
124
 
113
- function modelBadge(agent, model, effort) {
125
+ function modelBadge(agent, model, effort, route) {
114
126
  if (agent === 'wisp') return '';
115
127
  const label = (model && agentById(agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
116
- const tip = `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
128
+ const tip = route ? routeTip(route) : `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
117
129
  const logo = harnessLogo(agent);
118
- return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
130
+ return `<span class="model-logo${route ? ' routed' : ''}" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
131
+ }
132
+ // Why Auto picked what it picked, in one line.
133
+ function routeTip(r) {
134
+ const goal = AUTO_GOALS.find((g) => g.id === r.goal)?.label || 'Balanced';
135
+ const record = r.n ? `${Math.round(r.chance * 100)}% on your ${r.type} tasks (${r.n})` : `${Math.round(r.chance * 100)}% expected on ${r.type} tasks`;
136
+ const why = r.trying ? 'new model, trying it out' : r.explored ? 'trying an alternative to learn' : record;
137
+ return `Auto · ${goal} → ${r.label} · ${why}${r.usd ? ` · ~$${r.usd.toFixed(2)}` : ''}`;
119
138
  }
120
139
  const projectById = (id) => S.config.projects.find((p) => p.id === id);
121
140
 
@@ -358,7 +377,7 @@ function renderHead(n, s) {
358
377
  ${pill}
359
378
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
360
379
  ${s.pr ? prPill(s.pr) : ''}
361
- ${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort)}
380
+ ${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort, s.route)}
362
381
  ${s.machine ? machinePill(s.machine) : ''}
363
382
  <span class="q-proj">${esc(s.project)}</span>
364
383
  <span class="q-time">${ago(s.updatedAt)}</span>
@@ -641,6 +660,7 @@ function syncWhere() {
641
660
  // The model is remembered per harness (ids aren't portable between them).
642
661
  function savedModel(id) {
643
662
  const d = S.config.defaults;
663
+ if (id === 'auto') return AUTO_GOALS.some((g) => g.id === d.models?.auto) ? d.models.auto : 'balanced';
644
664
  const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
645
665
  const models = agentById(id)?.models || [];
646
666
  return models.some((m) => m.id === want) ? want : '';
@@ -655,8 +675,8 @@ function savedEffort(id, model) {
655
675
  function renderLauncher() {
656
676
  const box = $('#launcher');
657
677
  const mode = S.config.defaults.mode || 'edits';
658
- const here = harnessesAt(launchWhere);
659
- const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'claude'));
678
+ const here = harnessesAt(launchWhere, { auto: true });
679
+ const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'auto'));
660
680
  let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
661
681
  box.innerHTML = `
662
682
  <div class="l-projects"></div>
@@ -667,8 +687,7 @@ function renderLauncher() {
667
687
  <button class="mode-btn" id="mode" aria-haspopup="menu"></button>
668
688
  <div class="runners">
669
689
  <button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
670
- <button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
671
- <button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
690
+ <button class="vs-btn vs-add" id="compareBtn"><span>vs</span>${icon('plus')}</button>
672
691
  </div>
673
692
  <button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
674
693
  <span class="grow"></span>
@@ -726,9 +745,8 @@ function renderLauncher() {
726
745
  const runnerBtn = $('#runner', box);
727
746
  const paintRunner = () => {
728
747
  paintRunnerBtn(runnerBtn, agent?.id, curModel, curEffort);
729
- runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
748
+ runnerBtn.title = compare.length ? runnerTip(agent?.id, curModel, curEffort) : `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
730
749
  };
731
- paintRunner();
732
750
 
733
751
  // Worktree: each launch gets a fresh branch from the remote's default branch (git repos only).
734
752
  const wtBtn = $('#wtBtn', box);
@@ -764,34 +782,64 @@ function renderLauncher() {
764
782
  api('/config', { method: 'PATCH', body: { defaults: { subagents: team, teamPreset } } }).catch(() => {});
765
783
  });
766
784
 
767
- // Compare: a second harness gets the same task; picks here don't touch the launcher defaults.
768
- let compare = null;
785
+ // Compare: more harnesses get the same task (up to MAX_RUNNERS in all); picks here don't touch
786
+ // the launcher defaults. Each extra sits behind a "vs" that removes it; the button at the end
787
+ // adds the next one. Past two, the pill folds the names away and keeps the logos.
788
+ let compare = [];
769
789
  const vsBtn = $('#compareBtn', box);
770
- const runner2 = $('#runner2', box);
790
+ const runners = vsBtn.parentElement;
771
791
  const paintCompare = () => {
772
- vsBtn.parentElement.classList.toggle('cmp', !!compare);
773
- vsBtn.setAttribute('aria-pressed', String(!!compare));
774
- vsBtn.title = compare ? 'Stop comparing' : 'Compare: run the same task on a second harness, side by side';
775
- runner2.hidden = !compare;
776
- if (compare) paintRunnerBtn(runner2, compare.agent, compare.model, compare.effort);
792
+ const n = compare.length;
793
+ runners.classList.toggle('cmp', n > 0);
794
+ runners.classList.toggle('many', n > 1);
795
+ for (const x of $$('.cmp-x', runners)) x.remove();
796
+ compare.forEach((c, i) => {
797
+ const name = agentName(c.agent);
798
+ const del = el(`<button class="vs-btn vs-del cmp-x" data-i="${i}" title="Remove ${esc(name)} from the comparison" aria-label="Remove ${esc(name)}"><span>vs</span>${icon('x')}</button>`);
799
+ const btn = el(`<button class="runner-btn cmp-x" data-i="${i}" aria-haspopup="dialog"></button>`);
800
+ paintRunnerBtn(btn, c.agent, c.model, c.effort);
801
+ btn.title = runnerTip(c.agent, c.model, c.effort);
802
+ vsBtn.before(del, btn);
803
+ });
804
+ vsBtn.hidden = n >= MAX_RUNNERS - 1;
805
+ vsBtn.title = n ? `Add another harness (up to ${MAX_RUNNERS} side by side)` : 'Compare: run the same task on another harness, side by side';
806
+ paintRunner(); // its tooltip spells everything out once the names fold away
777
807
  };
778
- paintCompare();
779
808
  vsBtn.onclick = () => {
780
- if (compare) compare = null;
781
- else {
782
- const other = harnessesAt(launchWhere).find((a) => a.available && a.id !== agent?.id) || agent;
783
- compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
784
- }
809
+ // The next harness nobody in the pill uses yet; once they're all in, another copy of the lead.
810
+ const used = new Set([agent?.id, ...compare.map((c) => c.agent)]);
811
+ const here = harnessesAt(launchWhere).filter((a) => a.available);
812
+ const next = here.find((a) => !used.has(a.id)) || agent;
813
+ const model = savedModel(next?.id);
814
+ compare = [...compare, { agent: next?.id, model, effort: savedEffort(next?.id, model) }];
785
815
  paintCompare();
816
+ // Its "vs" lands right under the pointer: it can't remove anything until the pointer has left.
817
+ const fresh = $$('.vs-del', runners).at(-1);
818
+ fresh?.classList.add('fresh');
819
+ fresh?.addEventListener('pointerleave', () => fresh.classList.remove('fresh'), { once: true });
820
+ // Straight into picking it when it's a repeat, since that's rarely the one you want.
821
+ if (used.has(next?.id)) $$('.runner-btn.cmp-x', runners).at(-1)?.click();
786
822
  };
787
- runner2.onclick = () =>
788
- runnerPicker(runner2, { ...compare, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
789
- compare = { agent: id, model, effort };
823
+ runners.addEventListener('click', (e) => {
824
+ const x = e.target.closest('.cmp-x');
825
+ if (!x) return;
826
+ const i = Number(x.dataset.i);
827
+ if (x.classList.contains('vs-del')) {
828
+ if (x.classList.contains('fresh')) return;
829
+ compare = compare.filter((_, k) => k !== i);
830
+ paintCompare();
831
+ (compare.length ? $$('.vs-del', runners)[Math.min(i, compare.length - 1)] : vsBtn).focus();
832
+ return;
833
+ }
834
+ runnerPicker(x, { ...compare[i], savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
835
+ compare = compare.map((c, k) => (k === i ? { agent: id, model, effort } : c));
790
836
  paintCompare();
791
837
  });
838
+ });
839
+ paintCompare();
792
840
  runnerBtn.onclick = () =>
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);
841
+ runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launchWhere, auto: true }, (id, model, effort) => {
842
+ agent = harnessesAt(launchWhere, { auto: true }).find((a) => a.id === id);
795
843
  curModel = model;
796
844
  curEffort = effort;
797
845
  paintRunner();
@@ -807,13 +855,18 @@ function renderLauncher() {
807
855
  const launch = async () => {
808
856
  const prompt = ta.value.trim();
809
857
  if (!prompt) return ta.focus();
858
+ if (isFeedback(prompt)) {
859
+ ta.value = '';
860
+ ta.dispatchEvent(new Event('input'));
861
+ return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
862
+ }
810
863
  // One run per place picked under "Run on", each in the project picked for it.
811
864
  const targets = launchWhere.map((k) => ({ k, m: k ? machineById(k) : null, project: projectById(launchPicks[k]) }));
812
865
  const unset = targets.find((t) => !t.project);
813
866
  if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
814
867
  const off = targets.find((t) => t.m && t.m.state !== 'online');
815
868
  if (off) return offlineToast(off.k);
816
- const wanted = [agent?.id, ...(compare ? [compare.agent] : [])];
869
+ const wanted = [agent?.id, ...compare.map((c) => c.agent)].filter((id) => id !== 'auto'); // Auto picks from what's there
817
870
  for (const t of targets) {
818
871
  const list = agentsAt(t.k);
819
872
  const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
@@ -828,7 +881,7 @@ function renderLauncher() {
828
881
  const cmp = compare;
829
882
  const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
830
883
  const pending = targets.map((t) => addPending(prompt, t, runner));
831
- if (compare) (compare = null), paintCompare();
884
+ if (cmp.length) (compare = []), paintCompare();
832
885
  ta.value = '';
833
886
  S.drafts.delete('home');
834
887
  ta.dispatchEvent(new Event('input'));
@@ -847,7 +900,7 @@ function renderLauncher() {
847
900
  targets.map((t) =>
848
901
  api('/sessions', {
849
902
  method: 'POST',
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 },
903
+ 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.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
851
904
  }),
852
905
  ),
853
906
  );
@@ -861,7 +914,7 @@ function renderLauncher() {
861
914
  if (!started.length) {
862
915
  if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
863
916
  tray.restore(files);
864
- if (cmp && !compare) (compare = cmp), paintCompare();
917
+ if (cmp.length && !compare.length) (compare = cmp), paintCompare();
865
918
  return;
866
919
  }
867
920
  askNotify();
@@ -875,7 +928,7 @@ function renderLauncher() {
875
928
  // Picking other places (or a machine coming online) changes which harnesses exist: keep the
876
929
  // pick if one of them still has it, else move to one that's installed there.
877
930
  box.placesChanged = () => {
878
- const here = harnessesAt(launchWhere);
931
+ const here = harnessesAt(launchWhere, { auto: true }); // Auto works anywhere: it picks from what's there
879
932
  if (!agent || here.find((a) => a.id === agent.id)?.available) return paintRunner();
880
933
  const next = here.find((a) => a.id === S.config.defaults.agent && a.available) || here.find((a) => a.available);
881
934
  if (!next) return paintRunner();
@@ -897,6 +950,15 @@ function renderLauncher() {
897
950
  });
898
951
  }
899
952
 
953
+ // One launch runs on at most this many harnesses side by side (the server's MAX_COMPARE).
954
+ const MAX_RUNNERS = 4;
955
+ // A runner button's tooltip, for when the pill is too full to show names.
956
+ function runnerTip(agentId, model, effort) {
957
+ const a = agentById(agentId);
958
+ const m = (a?.models || []).find((x) => x.id === model);
959
+ return [a?.name || agentName(agentId), m?.label || model || 'Default model', effort && effortLabel(effort)].filter(Boolean).join(' · ');
960
+ }
961
+
900
962
  // The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
901
963
  function paintRunnerBtn(btn, agentId, model, effort) {
902
964
  const a = agentById(agentId);
@@ -1132,7 +1194,7 @@ function teamEditor(anchor, { lead, team, presetId, places }, onChange) {
1132
1194
  const rb = e.target.closest('.tp-runner');
1133
1195
  if (rb) {
1134
1196
  const m = members[i];
1135
- runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort, places }, (agent, model, effort) => {
1197
+ runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort, places, auto: true }, (agent, model, effort) => {
1136
1198
  Object.assign(m, { agent, model, effort });
1137
1199
  paintList();
1138
1200
  commit();
@@ -1313,7 +1375,7 @@ function modeMenu(anchor, current, pick) {
1313
1375
  if (!b) return;
1314
1376
  close();
1315
1377
  pick(b.dataset.mode);
1316
- $('#prompt')?.focus();
1378
+ if (anchor.id === 'mode') $('#prompt')?.focus();
1317
1379
  };
1318
1380
  menu.addEventListener('keydown', (ev) => {
1319
1381
  const items = $$('button', menu);
@@ -1750,7 +1812,7 @@ function renderBody(id, { fresh }) {
1750
1812
  ${mode && mode.id !== 'edits' ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
1751
1813
  ${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
1752
1814
  ${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>` : ''}
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>` : ''}`;
1815
+ ${s.origin ? `<span class="m" title="${s.origin.kind === 'telegram' ? 'Started from Telegram' : `Launched by a ${esc(s.origin.kind)}`}">${icon({ trigger: 'replay', telegram: 'send' }[s.origin.kind] || 'spark')}${esc(s.origin.name)}</span>` : ''}`;
1754
1816
  const live = s.state === 'running' || s.state === 'waiting';
1755
1817
  // Ship sits in the top bar and again next to the composer, so it's there after reading the result.
1756
1818
  renderPrStrip(id);
@@ -1770,7 +1832,14 @@ function renderBody(id, { fresh }) {
1770
1832
  earlier.textContent = `Show ${hiddenCount} earlier turn${hiddenCount === 1 ? '' : 's'}`;
1771
1833
  // A new turn re-folds everything older, so the latest exchange is what's open by default.
1772
1834
  const latestId = s.turns.at(-1)?.id;
1773
- if (n.latestTurn !== latestId) (n.unfolded.clear(), (n.latestTurn = latestId));
1835
+ const newTurn = n.latestTurn !== latestId;
1836
+ if (newTurn) (n.unfolded.clear(), (n.latestTurn = latestId));
1837
+ // Folding the last answer when a follow-up starts can take a screen or more out of the card
1838
+ // above the composer. Hold the composer still, so the new exchange lands where the user was
1839
+ // typing instead of the whole card sliding off the top of the screen.
1840
+ const watched = watching(id);
1841
+ const dock = watched && newTurn && !fresh ? $('.it-dock', body) : null;
1842
+ const dockTop = dock?.getBoundingClientRect().top;
1774
1843
  const keep = new Set();
1775
1844
  let firstNew = null;
1776
1845
  turns.forEach((t) => {
@@ -1794,7 +1863,8 @@ function renderBody(id, { fresh }) {
1794
1863
  for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
1795
1864
 
1796
1865
  updateDock(id);
1797
- if (firstNew && watching(id)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
1866
+ if (dock?.isConnected) scrollByPx(dock.getBoundingClientRect().top - dockTop);
1867
+ if (firstNew && watched) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
1798
1868
  }
1799
1869
 
1800
1870
  // Whether the user is reading a card, not just catching its edge at the top or bottom of the
@@ -1860,6 +1930,14 @@ function renderFoldedTurn(t, unfold) {
1860
1930
  return node;
1861
1931
  }
1862
1932
 
1933
+ // A follow-up that switched the mode says so, next to the model it ran on.
1934
+ function modeSwitch(s, t) {
1935
+ const i = s.turns.indexOf(t);
1936
+ const prev = s.turns.slice(0, i).findLast((x) => x.mode)?.mode;
1937
+ const m = t.mode && prev && t.mode !== prev && MODES.find((x) => x.id === t.mode);
1938
+ return m ? `<span class="t-mode m-${m.id}" title="Switched from ${esc(MODES.find((x) => x.id === prev)?.label || prev)} to ${m.label} for this follow-up">${icon(m.icon)}${m.label}</span>` : '';
1939
+ }
1940
+
1863
1941
  // `fold` is set for older turns the user unfolded, so they can tuck them away again.
1864
1942
  function renderTurn(s, t, isLatest, fold) {
1865
1943
  const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
@@ -1873,6 +1951,7 @@ function renderTurn(s, t, isLatest, fold) {
1873
1951
  ${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>` : ''}
1874
1952
  <div class="ask-foot">
1875
1953
  ${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}
1954
+ ${modeSwitch(s, t)}
1876
1955
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
1877
1956
  ${cpLabel ? `<span class="cp-sha" title="Git checkpoint before this turn (${esc(cp.head)}${cp.dirty ? ' + uncommitted changes' : ''})">${icon('branch')}${esc(cpLabel)}</span>` : ''}
1878
1957
  ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
@@ -2001,6 +2080,7 @@ function renderTrail(s, t) {
2001
2080
  const st = t.stats || {};
2002
2081
  const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (t.changes?.length || t.checkpoint?.commit);
2003
2082
  const rolledBack = !!t.rolledBackAt;
2083
+ const rated = t.feedback?.rating;
2004
2084
  const node = el(`
2005
2085
  <div class="trail">
2006
2086
  <div class="trail-bar">
@@ -2012,6 +2092,7 @@ function renderTrail(s, t) {
2012
2092
  ${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>` : ''}
2013
2093
  ${acts.length ? `<button class="link xs" data-tab="activity">Activity</button>` : ''}
2014
2094
  ${t.presentation?.notes ? `<button class="link xs" data-tab="notes">Agent notes</button>` : ''}
2095
+ ${feedbackOn() ? `<span class="rate ${rated ? 'rated' : ''}">${['up', 'down'].map((r) => `<button class="rate-btn ${rated === r ? 'on' : ''}" data-rate="${r}" title="${r === 'up' ? 'Good result' : 'Bad result'}: tell the wisp team" aria-pressed="${rated === r}">${icon(`thumb-${r}`)}</button>`).join('')}</span>` : ''}
2015
2096
  </div>
2016
2097
  <div class="trail-body" hidden></div>
2017
2098
  </div>`);
@@ -2021,6 +2102,20 @@ function renderTrail(s, t) {
2021
2102
  if (e.target.closest('[data-a="rollback"]')) {
2022
2103
  return rollbackTurn(s.id, t.id, rolledBack);
2023
2104
  }
2105
+ const rb = e.target.closest('[data-rate]');
2106
+ if (rb) {
2107
+ const next = t.feedback?.rating === rb.dataset.rate ? null : rb.dataset.rate;
2108
+ rateTurn(s, t, node, next);
2109
+ if (next !== 'down' && open === 'rate') ((open = null), (body.hidden = true));
2110
+ if (next === 'down') {
2111
+ open = 'rate';
2112
+ $$('[data-tab]', node).forEach((x) => x.classList.remove('on'));
2113
+ body.hidden = false;
2114
+ body.innerHTML = '';
2115
+ body.append(whyForm(s, t, () => ((open = null), (body.hidden = true))));
2116
+ }
2117
+ return;
2118
+ }
2024
2119
  const b = e.target.closest('[data-tab]');
2025
2120
  if (!b) return;
2026
2121
  const tab = b.dataset.tab;
@@ -2074,6 +2169,7 @@ function renderDock(id) {
2074
2169
  <textarea class="reply" rows="1" spellcheck="false"></textarea>
2075
2170
  <div class="c-bar">
2076
2171
  <button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
2172
+ <button class="mode-btn c-mode" aria-haspopup="menu"></button>
2077
2173
  <span class="c-hint"></span>
2078
2174
  <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>
2079
2175
  <button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
@@ -2091,6 +2187,8 @@ function renderDock(id) {
2091
2187
  $('.c-done', comp).onclick = () => toggleDone(id);
2092
2188
  $('.c-ship', comp).onclick = () => shipDialog(id);
2093
2189
  $('.c-queue', comp).onclick = toggleQueue;
2190
+ const modeBtn = $('.c-mode', comp);
2191
+ modeBtn.onclick = () => modeMenu(modeBtn, replyMode(id), (m) => (setReplyMode(id, m), ta.focus()));
2094
2192
  ta.value = S.drafts.get(id) || '';
2095
2193
  ta.addEventListener('input', () => S.drafts.set(id, ta.value));
2096
2194
  ta.addEventListener('focus', () => setFocus(id, { scroll: false }));
@@ -2099,10 +2197,37 @@ function renderDock(id) {
2099
2197
  e.preventDefault();
2100
2198
  sendReply(id, ta.value);
2101
2199
  }
2200
+ if (e.key === 'Tab' && e.shiftKey) {
2201
+ e.preventDefault();
2202
+ setReplyMode(id, MODES[(MODES.findIndex((m) => m.id === replyMode(id)) + 1) % MODES.length].id);
2203
+ }
2102
2204
  });
2103
2205
  $('.send', comp).onclick = () => sendReply(id, ta.value);
2104
2206
  }
2105
2207
 
2208
+ // The mode the next follow-up runs in: the session's own until the user picks another here.
2209
+ // The pick goes out with that follow-up and stays from then on.
2210
+ const replyMode = (id) => S.replyModes.get(id) || S.full.get(id)?.mode || 'edits';
2211
+
2212
+ function setReplyMode(id, mode) {
2213
+ if (mode === S.full.get(id)?.mode) S.replyModes.delete(id);
2214
+ else S.replyModes.set(id, mode);
2215
+ updateDock(id);
2216
+ }
2217
+
2218
+ function paintReplyMode(id, btn) {
2219
+ const s = S.full.get(id);
2220
+ const m = MODES.find((x) => x.id === replyMode(id)) || MODES[1];
2221
+ const from = s && S.replyModes.has(id) && MODES.find((x) => x.id === s.mode);
2222
+ btn.className = `mode-btn c-mode m-${m.id}${from ? ' changed' : ''}`;
2223
+ btn.innerHTML = `${icon(m.icon)}${m.id === 'edits' && !from ? '' : `<span>${m.label}</span>`}`;
2224
+ btn.title = from
2225
+ ? `${m.label}: ${m.hint}\nYour next follow-up switches this task from ${from.label} to ${m.label}. Shift+Tab to switch`
2226
+ : `${m.label}: ${m.hint}\nPick another mode for your next follow-up. Shift+Tab to switch`;
2227
+ // Subagents run in their lead's mode; the tour has nothing to run.
2228
+ btn.hidden = !s || s.agent === 'wisp' || !!s.parentId;
2229
+ }
2230
+
2106
2231
  function updateDock(id) {
2107
2232
  const s = S.full.get(id);
2108
2233
  const n = S.nodes.get(id);
@@ -2123,6 +2248,7 @@ function updateDock(id) {
2123
2248
  ? 'Follow up to reopen…'
2124
2249
  : `Follow up with ${name}…`;
2125
2250
  $('.c-hint', n.card).innerHTML = s.queue?.length ? `${s.queue.length} queued` : '';
2251
+ paintReplyMode(id, $('.c-mode', n.card));
2126
2252
  renderQueued(s, $('.c-queued', n.card), name);
2127
2253
  const q = $('.c-queue', n.card);
2128
2254
  q.hidden = tour || s.state !== 'running';
@@ -2199,20 +2325,28 @@ async function sendReply(id, text) {
2199
2325
  const n = S.nodes.get(id);
2200
2326
  text = (text || '').trim();
2201
2327
  if (!s || !text) return;
2328
+ const ta = n && $('.reply', n.card);
2329
+ if (isFeedback(text)) {
2330
+ if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
2331
+ S.drafts.delete(id);
2332
+ return feedbackCommand(text, { sessionId: id, restore: () => ta?.isConnected && !ta.value && ((ta.value = text), ta.dispatchEvent(new Event('input'))) });
2333
+ }
2202
2334
  if (n?.tray?.busy) toast('Waiting for uploads…');
2203
2335
  const attachments = n?.tray ? await n.tray.ids() : [];
2204
- const answering = !!s.ask && !attachments.length;
2336
+ const mode = S.replyModes.get(id);
2337
+ // An answer goes to the run that asked; a new mode needs a new run, so that's a follow-up.
2338
+ const answering = !!s.ask && !attachments.length && !mode;
2205
2339
  // Optimistic: the composer empties and the card goes to Working before the server answers
2206
2340
  // (it may first have to bring back an archived worktree, or reach another machine).
2207
- const ta = n && $('.reply', n.card);
2208
2341
  if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
2209
2342
  S.drafts.delete(id);
2343
+ S.replyModes.delete(id);
2210
2344
  const files = n?.tray?.take();
2211
- const undo = startOptimistic(id, text, files, { answering });
2345
+ const undo = startOptimistic(id, text, files, { answering, mode });
2212
2346
  try {
2213
2347
  const r = answering
2214
2348
  ? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
2215
- : await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing() } });
2349
+ : await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing(), mode } });
2216
2350
  if (undo) scheduleRefetch(id); // the summary that follows says "running" too, so it won't refetch by itself
2217
2351
  if (r.queued)
2218
2352
  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.');
@@ -2220,6 +2354,7 @@ async function sendReply(id, text) {
2220
2354
  undo?.();
2221
2355
  // Give the message back, unless the user has already started typing another one.
2222
2356
  if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
2357
+ if (mode && !S.replyModes.has(id)) setReplyMode(id, mode);
2223
2358
  n?.tray?.restore(files);
2224
2359
  toast(esc(err.message), { tone: 'bad' });
2225
2360
  }
@@ -2228,7 +2363,7 @@ async function sendReply(id, text) {
2228
2363
  // Show a follow-up as started right away: the card moves to Working with the prompt as a live
2229
2364
  // turn. Only when it will start now, not when it queues or interrupts a run already working.
2230
2365
  // Returns an undo for a failed send, or null when nothing changed.
2231
- function startOptimistic(id, text, files, { answering }) {
2366
+ function startOptimistic(id, text, files, { answering, mode }) {
2232
2367
  const full = S.full.get(id);
2233
2368
  const sum = S.sessions.get(id);
2234
2369
  if (!full || !sum) return null;
@@ -2236,8 +2371,9 @@ function startOptimistic(id, text, files, { answering }) {
2236
2371
  if (live && !answering) return null;
2237
2372
  const now = Date.now();
2238
2373
  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 };
2374
+ if (mode) turn.mode = mode;
2375
+ const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn], ...(mode && { mode }) };
2376
+ const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : 'Starting up', updatedAt: now, ...(mode && { mode }) };
2241
2377
  const apply = (f, sm) => {
2242
2378
  S.full.set(id, f);
2243
2379
  S.sessions.set(id, sm);
@@ -2383,11 +2519,12 @@ function rename(id) {
2383
2519
  // right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
2384
2520
  // models; picking a model commits harness + model, picking a thinking level commits it in place.
2385
2521
  // 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) {
2522
+ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, places, auto = false }, pick) {
2387
2523
  const open = $('.picker');
2388
2524
  if (open) return open.close();
2389
- const agents = places ? harnessesAt(places) : S.agents;
2525
+ const agents = places ? harnessesAt(places, { auto }) : auto ? [AUTO, ...S.agents] : S.agents;
2390
2526
  const sub = (a) => {
2527
+ if (a.id === 'auto') return 'Picks harness and model for you';
2391
2528
  if (!a.available) return places?.length > 1 || places?.[0] ? 'Not installed there' : 'Not installed';
2392
2529
  const models = (a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model';
2393
2530
  return a.missing?.length ? `Not on ${a.missing.map(placeName).join(', ')}` : models;
@@ -2458,6 +2595,17 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, p
2458
2595
  const models = a?.models || [{ id: '', label: 'Default' }];
2459
2596
  const current = focusAgent === agent ? model : savedModel(focusAgent);
2460
2597
  input.placeholder = `Search ${a?.name || ''} models`;
2598
+ if (a?.id === 'auto') {
2599
+ list.innerHTML = `<div class="pk-group"><div class="pk-ghead">Goal</div>${AUTO_GOALS.map((g) => `<button class="pk-m${g.id === current ? ' on' : ''}" role="option" aria-selected="${g.id === current}" data-id="${g.id}"><span class="pk-text"><b>${g.label}</b><small>${g.hint}</small></span>${icon('check', 'pk-check')}</button>`).join('')}</div><p class="pk-note">wisp picks the harness and model for each task from what worked before, and learns from how it went. ${'<a href="#" class="pk-auto-more">How Auto picks</a>'}</p><div class="pk-empty" hidden>No models match</div>`;
2600
+ $('.pk-search', node).hidden = true;
2601
+ $('.pk-auto-more', list).onclick = (e) => {
2602
+ e.preventDefault();
2603
+ close();
2604
+ openSettings({ pane: 'auto' });
2605
+ };
2606
+ paintEffort();
2607
+ return;
2608
+ }
2461
2609
  $('.pk-search', node).hidden = models.length <= 6;
2462
2610
  // opencode-style ids (provider/model) group by provider.
2463
2611
  const groups = new Map();
@@ -2739,7 +2887,7 @@ async function rerun(id) {
2739
2887
  modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
2740
2888
  // Picks here only apply to this rerun; the launcher's remembered defaults are left alone.
2741
2889
  runnerBtn.onclick = () =>
2742
- runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort }, (a, m, e) => {
2890
+ runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort, auto: true }, (a, m, e) => {
2743
2891
  [agent, model, effort] = [a, m, e];
2744
2892
  paintRunner();
2745
2893
  });
@@ -3330,7 +3478,7 @@ function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
3330
3478
  const [btn, reset] = wrap.children;
3331
3479
  const paint = () => (paintRunnerBtn(btn, draft.agent, draft.model, draft.effort), (reset.hidden = !draft.agent));
3332
3480
  btn.onclick = () =>
3333
- runnerPicker(btn, { agent: draft.agent || S.config.defaults.agent, model: draft.model, effort: draft.effort, savedModel, savedEffort }, (a, m, e) => {
3481
+ runnerPicker(btn, { agent: draft.agent || S.config.defaults.agent, model: draft.model, effort: draft.effort, savedModel, savedEffort, auto: true }, (a, m, e) => {
3334
3482
  Object.assign(draft, { agent: a, model: m, effort: e });
3335
3483
  paint();
3336
3484
  });
@@ -3610,6 +3758,8 @@ async function openPhone({ host } = {}) {
3610
3758
  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>`);
3611
3759
  const paint = () => {
3612
3760
  const tg = r.telegram;
3761
+ const projects = S.config.projects || [];
3762
+ const lastUsed = [...projects].sort(recentFirst)[0];
3613
3763
  $('.ph-body', node).innerHTML = `
3614
3764
  <div class="settings-modal ph-sec">
3615
3765
  <div class="setting"><span>Send me</span>
@@ -3637,6 +3787,22 @@ async function openPhone({ host } = {}) {
3637
3787
  ? `Now send <code>/link ${esc(tg.linkCode)}</code> to your bot. Only the chat that sends this code gets linked.`
3638
3788
  : 'Create a bot with @BotFather, paste its token here and switch Telegram on.'
3639
3789
  }</p>
3790
+ ${
3791
+ tg.linked
3792
+ ? `<div class="ph-start">
3793
+ <div class="ph-row"><span class="grow">Start tasks from Telegram</span><button class="switch" data-sw="tgStart" role="switch" aria-checked="${tg.allowStart}"></button></div>
3794
+ ${
3795
+ tg.allowStart
3796
+ ? `<div class="settings-modal"><label><span>Default project</span><select class="input" data-tg="project">
3797
+ <option value="">Last used${lastUsed ? ` (${esc(lastUsed.name)})` : ''}</option>
3798
+ ${projects.map((p) => `<option value="${esc(p.id)}" ${p.id === tg.project ? 'selected' : ''}>${esc(p.name)}${p.machine ? ` · ${esc(placeName(p.machine))}` : ''}</option>`).join('')}
3799
+ </select></label></div>
3800
+ <p class="hint">Send <code>/new fix the flaky login test</code> to start a task in the default project, or <code>/new @${esc(projects[0]?.name || 'project')} …</code> to pick one; <code>/projects</code> lists them. Tasks use the launcher's harness, model and mode. Only your linked chat can start them.</p>`
3801
+ : '<p class="hint">Off: the bot only answers and follows up on tasks you started here.</p>'
3802
+ }
3803
+ </div>`
3804
+ : ''
3805
+ }
3640
3806
  </section>
3641
3807
  <div class="ship-foot"><span class="grow"></span><button class="ghost sm" data-act="test">${icon('send')}Send a test</button></div>`;
3642
3808
  };
@@ -3644,7 +3810,8 @@ async function openPhone({ host } = {}) {
3644
3810
  const t = e.target;
3645
3811
  if (t.dataset.ev) put({ events: $$('[data-ev]', node).filter((x) => x.checked).map((x) => x.dataset.ev) });
3646
3812
  if (t.dataset.ntfy) put({ ntfy: { ...r.ntfy, [t.dataset.ntfy]: t.value.trim() } });
3647
- if (t.dataset.tg) put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
3813
+ if (t.dataset.tg === 'token') put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
3814
+ if (t.dataset.tg === 'project') put({ telegram: { project: t.value } });
3648
3815
  });
3649
3816
  node.addEventListener('click', async (e) => {
3650
3817
  const sw = e.target.closest('[data-sw]')?.dataset.sw;
@@ -3654,6 +3821,7 @@ async function openPhone({ host } = {}) {
3654
3821
  return put({ ntfy: { ...r.ntfy, enabled: !r.ntfy.enabled, topic } });
3655
3822
  }
3656
3823
  if (sw === 'telegram') return put({ telegram: { enabled: !r.telegram.enabled, token: r.telegram.token } });
3824
+ if (sw === 'tgStart') return put({ telegram: { allowStart: !r.telegram.allowStart } });
3657
3825
  const act = e.target.closest('[data-act]')?.dataset.act;
3658
3826
  if (act === 'unlink') return put({ telegram: { enabled: r.telegram.enabled, token: r.telegram.token, unlink: true } });
3659
3827
  if (act === 'test') {
@@ -4012,6 +4180,7 @@ const SETTINGS_PANES = [
4012
4180
  { id: 'general', label: 'General', icon: 'settings', lede: 'Where files open, where you take over, and how tasks start.' },
4013
4181
  { id: 'notifications', label: 'Notifications', icon: 'bolt', lede: 'Hear about questions and finished tasks, here or on your phone.' },
4014
4182
  { id: 'agents', label: 'Agents', icon: 'agent', lede: 'The harnesses wisp found on this machine. Pick one next to the model in the launcher.' },
4183
+ { id: 'auto', label: 'Auto', icon: 'spark', lede: 'wisp picks the harness and model for each task, and learns from how it went.' },
4015
4184
  { id: 'machines', label: 'Machines', icon: 'server', lede: 'Other computers that run agents for you, over SSH.' },
4016
4185
  { id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
4017
4186
  { id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
@@ -4077,6 +4246,16 @@ function openSettings(opts) {
4077
4246
  ${row('Installed something new?', '<span class="recheck-hint"></span>', `<button class="ghost sm" id="recheck">${icon('replay')}Re-check</button>`)}
4078
4247
  </div>
4079
4248
  </section>
4249
+ <section class="sx-pane" data-pane="auto">
4250
+ <div class="sx-group">
4251
+ ${row('Goal', 'What Auto optimises for. Also in the launcher, next to Auto', `<span class="pk-seg ro-goal">${AUTO_GOALS.map((g) => `<button data-goal="${g.id}" title="${esc(g.hint)}">${g.label}</button>`).join('')}</span>`)}
4252
+ ${row('Try new models', 'A new model or harness gets a few tasks that aren’t marked important before Auto relies on it', '<button class="switch" id="roExplore" role="switch"></button>')}
4253
+ ${row('Harnesses it may use', 'Leave all off to let Auto use every one you have', '<span class="ro-allowed"></span>')}
4254
+ </div>
4255
+ <div class="sx-group">
4256
+ ${row('What it learned', '<span class="ro-hint"></span>', '<button class="ghost sm" id="openRouter">Open</button>')}
4257
+ </div>
4258
+ </section>
4080
4259
  <section class="sx-pane" data-pane="machines">
4081
4260
  <div class="sx-group">
4082
4261
  ${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
@@ -4255,6 +4434,52 @@ function openSettings(opts) {
4255
4434
  renderLauncher();
4256
4435
  };
4257
4436
  paintWt();
4437
+ // Auto: goal, exploring and allowed harnesses here; the full record in its own view.
4438
+ const paintAuto = async () => {
4439
+ const r = await api('/router').catch(() => null);
4440
+ if (!r || !node.isConnected) return;
4441
+ $$('.ro-goal button', node).forEach((b) => b.classList.toggle('on', b.dataset.goal === r.goal));
4442
+ $('#roExplore', node).setAttribute('aria-checked', r.settings.explore);
4443
+ const allowed = r.settings.allowed || [];
4444
+ $('.ro-allowed', node).innerHTML = S.agents
4445
+ .filter((a) => a.available)
4446
+ .map((a) => `<button class="chip-tog${allowed.includes(a.id) ? ' on' : ''}" data-id="${esc(a.id)}" aria-pressed="${allowed.includes(a.id)}" title="${esc(a.name)}"><span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}</button>`)
4447
+ .join('');
4448
+ $('.ro-hint', node).textContent = r.learned
4449
+ ? `${r.learned} task${r.learned === 1 ? '' : 's'} learned from · ${r.arms.filter((a) => a.state !== 'gone').length} harness · model pairs`
4450
+ : `Nothing of yours yet · starting from wisp-bench (${r.priors.version})`;
4451
+ };
4452
+ const patchAuto = async (body) => {
4453
+ try {
4454
+ await api('/router', { method: 'PATCH', body });
4455
+ await loadConfig();
4456
+ renderLauncher();
4457
+ } catch (err) {
4458
+ toast(esc(err.message), { tone: 'bad' });
4459
+ }
4460
+ paintAuto();
4461
+ };
4462
+ $('.ro-goal', node).onclick = (e) => {
4463
+ const b = e.target.closest('[data-goal]');
4464
+ if (b) {
4465
+ $$('.ro-goal button', node).forEach((x) => x.classList.toggle('on', x === b));
4466
+ patchAuto({ goal: b.dataset.goal });
4467
+ }
4468
+ };
4469
+ $('#roExplore', node).onclick = (e) => {
4470
+ const on = e.currentTarget.getAttribute('aria-checked') !== 'true';
4471
+ e.currentTarget.setAttribute('aria-checked', on);
4472
+ patchAuto({ explore: on });
4473
+ };
4474
+ $('.ro-allowed', node).onclick = (e) => {
4475
+ const b = e.target.closest('[data-id]');
4476
+ if (!b) return;
4477
+ b.classList.toggle('on');
4478
+ b.setAttribute('aria-pressed', b.classList.contains('on'));
4479
+ patchAuto({ allowed: $$('.ro-allowed .on', node).map((x) => x.dataset.id) });
4480
+ };
4481
+ node.paintAuto = paintAuto;
4482
+
4258
4483
  // The one-line summaries; painted again on the way back from a view that may have changed them.
4259
4484
  const paintHints = () => {
4260
4485
  const r = S.config.recipes?.length || 0;
@@ -4275,8 +4500,10 @@ function openSettings(opts) {
4275
4500
  $('.machines-hint', node).textContent = machinesSummary();
4276
4501
  api('/remote').then((x) => ($('.phone-hint', node).textContent = phoneSummary(x) === 'Off' ? 'ntfy or Telegram: answer questions from your phone' : phoneSummary(x))).catch(() => {});
4277
4502
  paintLearn();
4503
+ paintAuto();
4278
4504
  };
4279
4505
  paintHints();
4506
+ $('#openRouter', node).onclick = () => openRouter({ host: host('auto') });
4280
4507
  $('#openMemory', node).onclick = () => openMemory({ host: host('memory') });
4281
4508
  $$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
4282
4509
  $('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
@@ -4284,6 +4511,52 @@ function openSettings(opts) {
4284
4511
  $('#openMachines', node).onclick = () => openMachines({ host: host('machines') });
4285
4512
  }
4286
4513
 
4514
+ // --- Auto: what the router learned ---------------------------------------------------------
4515
+
4516
+ const ARM_STATE = { ready: ['Ready', 'ok'], new: ['Trying out', 'warn'], untried: ['No data yet', ''], gone: ['Not installed', 'bad'] };
4517
+ async function openRouter({ host } = {}) {
4518
+ const node = el(`<div class="modal router-modal">
4519
+ <header><h2>Auto</h2><button class="icon-btn close">${icon('x')}</button></header>
4520
+ <div class="ro-body"><div class="it-loading"><span class="orb tiny"></span>Loading…</div></div>
4521
+ </div>`);
4522
+ const close = present(node, host);
4523
+ const pctOf = (x) => `${Math.round(x * 100)}%`;
4524
+ const outcome = (r) => (r === null ? '<span class="dim">waiting</span>' : r >= 0.9 ? '<span class="ok">worked</span>' : r >= 0.5 ? '<span>finished</span>' : r >= 0.25 ? '<span class="warn">partly</span>' : '<span class="bad">didn’t work</span>');
4525
+ const render = async () => {
4526
+ const r = await api('/router').catch((err) => (toast(esc(err.message), { tone: 'bad' }), null));
4527
+ if (!r || !node.isConnected) return;
4528
+ const arms = r.arms.filter((a) => a.state !== 'gone' || a.n);
4529
+ $('.ro-body', node).innerHTML = `
4530
+ <p class="hint">For each task Auto estimates every harness · model’s chance of doing it well, from wisp-bench (${esc(r.priors.version)}) and then more and more from your own tasks: merged PRs, 👍 / 👎, rollbacks, follow-ups and how the run ended. It picks the best value for your goal (<b>${esc(AUTO_GOALS.find((g) => g.id === r.goal)?.label || '')}</b>), now and then trying an alternative so the estimates stay honest.</p>
4531
+ <h4>Harness · model</h4>
4532
+ <table class="us-table ro-table"><thead><tr><th></th><th>Works</th><th>Your tasks</th><th>~Cost</th><th>~Time</th><th></th></tr></thead><tbody>${arms
4533
+ .map((a) => {
4534
+ const [state, tone] = ARM_STATE[a.state] || ARM_STATE.ready;
4535
+ const types = Object.entries(a.byType).map(([t, x]) => `${t} ${pctOf(x.chance)} (${x.n})`).join(' · ');
4536
+ return `<tr><td><span class="us-runner"><span class="model-logo">${harnessLogo(a.agent)}</span>${esc(a.label)}</span>${types ? `<small class="ro-types">${esc(types)}</small>` : ''}</td>${a.state === 'untried' ? '<td>—</td><td>—</td><td>—</td><td>—</td>' : `<td><b>${pctOf(a.chance)}</b></td><td>${a.n || '—'}</td><td>${a.usd ? `$${a.usd.toFixed(2)}` : '$0'}</td><td>${a.minutes}m</td>`}<td><span class="sx-state ${tone}"><i class="dot ${tone}"></i>${state}</span></td></tr>`;
4537
+ })
4538
+ .join('')}</tbody></table>
4539
+ <h4>Recent picks</h4>
4540
+ ${r.recent.length
4541
+ ? `<ul class="ro-recent">${r.recent
4542
+ .map((x) => {
4543
+ const s = S.sessions.get(x.id);
4544
+ return `<li><span class="model-logo">${harnessLogo(x.key.split(':')[0])}</span><span class="ro-title">${esc(s?.title || 'Task')}</span><small>${esc(x.label)} · ${esc(x.type)}${x.trying ? ' · trying' : x.explored ? ' · exploring' : ''}</small>${outcome(x.r)}</li>`;
4545
+ })
4546
+ .join('')}</ul>`
4547
+ : '<p class="mem-empty">Nothing yet. Launch with Auto and its picks show up here.</p>'}
4548
+ <div class="ro-foot"><button class="ghost sm danger" id="roReset">Forget what Auto learned</button><span class="hint">The wisp-bench starting point stays.</span></div>`;
4549
+ $('#roReset', node).onclick = async () => {
4550
+ if (!confirm('Forget what Auto learned from your tasks?')) return;
4551
+ await api('/router/reset', { method: 'POST', body: {} }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
4552
+ render();
4553
+ };
4554
+ };
4555
+ node.refresh = render;
4556
+ render();
4557
+ return close;
4558
+ }
4559
+
4287
4560
  // --- memory: what the background retros learned ---------------------------------------------
4288
4561
 
4289
4562
  const MEM_KINDS = [
@@ -4450,6 +4723,7 @@ function shortcuts() {
4450
4723
  ['1–9', 'Pick an answer when asked'],
4451
4724
  ['↵', 'Launch / send'],
4452
4725
  ['⇧↵', 'New line'],
4726
+ ['/feedback', 'Send feedback to the wisp team (in any prompt box)'],
4453
4727
  ['C', 'Collapse all'],
4454
4728
  ['U', 'Back to top'],
4455
4729
  ['Esc', 'Close / collapse'],
@@ -4458,6 +4732,129 @@ function shortcuts() {
4458
4732
  overlay(node);
4459
4733
  }
4460
4734
 
4735
+ // --- feedback ---------------------------------------------------------------------------
4736
+ // Three ways in, none of them taking room: thumbs at the end of a turn's trail, "/feedback …" in
4737
+ // any prompt box, and "Send feedback" in ⌘K. It all goes to the wisp team (PostHog), with the
4738
+ // run's harness, model and status but never the prompt or the result.
4739
+
4740
+ const feedbackOn = () => S.config?.feedback !== false;
4741
+ const FEEDBACK_CMD = /^\/feedback(?:\s|$)/i;
4742
+ const isFeedback = (text) => FEEDBACK_CMD.test(text);
4743
+ const THANKS = 'Thanks! Sent to the wisp team.';
4744
+
4745
+ // The box is already empty; `restore` puts the text back if the server refuses it.
4746
+ function feedbackCommand(text, { sessionId, restore } = {}) {
4747
+ if (!feedbackOn()) return (restore?.(), toast('Feedback is turned off on this install.', { tone: 'bad' }));
4748
+ const note = text.replace(FEEDBACK_CMD, '').trim();
4749
+ if (!note) return openFeedback({ sessionId });
4750
+ postFeedback({ text: note, sessionId, source: 'command' }, { onFail: restore });
4751
+ }
4752
+
4753
+ // Optimistic: thanks at once. Offline, the server keeps it and sends it later.
4754
+ function postFeedback(body, { onFail, quiet = false } = {}) {
4755
+ if (!quiet) toast(THANKS);
4756
+ return api('/feedback', { method: 'POST', body })
4757
+ .then((r) => {
4758
+ if (!r.sent && r.available !== false) toast('Saved: it goes out once wisp is back online.');
4759
+ })
4760
+ .catch((err) => {
4761
+ onFail?.();
4762
+ toast(esc(err.message), { tone: 'bad' });
4763
+ });
4764
+ }
4765
+
4766
+ function paintRate(node, rating) {
4767
+ $('.rate', node)?.classList.toggle('rated', !!rating);
4768
+ $$('[data-rate]', node).forEach((b) => {
4769
+ b.classList.toggle('on', b.dataset.rate === rating);
4770
+ b.setAttribute('aria-pressed', b.dataset.rate === rating);
4771
+ });
4772
+ }
4773
+
4774
+ function rateTurn(s, t, node, rating) {
4775
+ const prev = t.feedback;
4776
+ t.feedback = rating ? { rating, at: Date.now() } : undefined;
4777
+ paintRate(node, rating);
4778
+ if (rating === 'up') {
4779
+ const tip = $('.rate', node);
4780
+ tip.classList.remove('thanked');
4781
+ void tip.offsetWidth; // restart the animation
4782
+ tip.classList.add('thanked');
4783
+ }
4784
+ postFeedback({ rating, sessionId: s.id, turnId: t.id, source: 'turn' }, {
4785
+ quiet: true,
4786
+ onFail: () => ((t.feedback = prev), paintRate(node, prev?.rating)),
4787
+ });
4788
+ }
4789
+
4790
+ // After 👎: one optional line on what went wrong. The rating is already sent.
4791
+ function whyForm(s, t, close) {
4792
+ const f = el(`
4793
+ <form class="why">
4794
+ <textarea class="textarea" rows="2" placeholder="What went wrong? Optional, goes to the wisp team" maxlength="4000"></textarea>
4795
+ <div class="why-foot"><span class="muted xs">Sent with the harness and model, not your prompt or code.</span><button type="button" class="link xs" data-a="skip">Skip</button><button class="primary xs">Send</button></div>
4796
+ </form>`);
4797
+ const ta = $('textarea', f);
4798
+ f.onsubmit = (e) => {
4799
+ e.preventDefault();
4800
+ const text = ta.value.trim();
4801
+ if (!text) return close();
4802
+ close();
4803
+ postFeedback({ rating: 'down', text, sessionId: s.id, turnId: t.id, source: 'turn' }, { onFail: () => openFeedback({ sessionId: s.id, text, rating: 'down' }) });
4804
+ };
4805
+ ta.onkeydown = (e) => {
4806
+ if (e.key === 'Enter' && !e.shiftKey) (e.preventDefault(), f.requestSubmit());
4807
+ if (e.key === 'Escape') (e.stopPropagation(), close());
4808
+ };
4809
+ $('[data-a="skip"]', f).onclick = () => {
4810
+ close();
4811
+ toast(THANKS);
4812
+ };
4813
+ requestAnimationFrame(() => ta.focus());
4814
+ return f;
4815
+ }
4816
+
4817
+ function openFeedback({ sessionId, text = '', rating = null } = {}) {
4818
+ if (!feedbackOn()) return toast('Feedback is turned off on this install.', { tone: 'bad' });
4819
+ const node = el(`
4820
+ <form class="modal fb-modal">
4821
+ <header><h2>Send feedback</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
4822
+ <div class="fb-rate"><span>How is wisp working for you?</span>
4823
+ ${['up', 'down'].map((r) => `<button type="button" class="rate-btn" data-rate="${r}" aria-pressed="false" title="${r === 'up' ? 'Good' : 'Not good'}">${icon(`thumb-${r}`)}</button>`).join('')}
4824
+ </div>
4825
+ <textarea class="textarea" name="text" rows="5" maxlength="4000" placeholder="What works, what breaks, what you wish it did…"></textarea>
4826
+ <div class="ship-foot"><span class="muted xs">Goes to the wisp team with your wisp version and OS. Not your code or prompts. Tip: type <code>/feedback …</code> in any prompt box.</span><button class="primary sm" data-a="send">${icon('send')}Send</button></div>
4827
+ </form>`);
4828
+ const close = overlay(node);
4829
+ $('.close', node).onclick = close;
4830
+ const ta = $('textarea', node);
4831
+ const send = $('[data-a="send"]', node);
4832
+ ta.value = text;
4833
+ const paint = () => {
4834
+ paintRate(node, rating);
4835
+ send.disabled = !rating && !ta.value.trim();
4836
+ };
4837
+ $$('[data-rate]', node).forEach((b) => {
4838
+ b.onclick = () => {
4839
+ rating = rating === b.dataset.rate ? null : b.dataset.rate;
4840
+ paint();
4841
+ };
4842
+ });
4843
+ ta.oninput = paint;
4844
+ ta.onkeydown = (e) => {
4845
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) (e.preventDefault(), node.requestSubmit());
4846
+ };
4847
+ node.onsubmit = (e) => {
4848
+ e.preventDefault();
4849
+ const note = ta.value.trim();
4850
+ if (!rating && !note) return;
4851
+ close();
4852
+ postFeedback({ rating: rating || undefined, text: note, sessionId, source: 'dialog' }, { onFail: () => openFeedback({ sessionId, text: note, rating }) });
4853
+ };
4854
+ paint();
4855
+ requestAnimationFrame(() => ta.focus());
4856
+ }
4857
+
4461
4858
  // --- command palette ------------------------------------------------------------------
4462
4859
 
4463
4860
  const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
@@ -4545,6 +4942,7 @@ function paletteItems() {
4545
4942
  { group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
4546
4943
  { group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
4547
4944
  );
4945
+ if (feedbackOn()) items.push({ group: a, label: 'Send feedback…', icon: 'thumb-up', keywords: 'feedback bug report idea suggestion thumbs contact team', run: () => openFeedback() });
4548
4946
 
4549
4947
  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) }) });
4550
4948
 
@@ -4642,6 +5040,13 @@ function connect() {
4642
5040
  es.addEventListener('remote', () => $('.phone-modal')?.refresh());
4643
5041
  es.addEventListener('machines', (e) => setMachines(JSON.parse(e.data)));
4644
5042
  es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
5043
+ // A new harness or model Auto can use: it gets a few tasks before Auto relies on it.
5044
+ es.addEventListener('router', (e) => {
5045
+ const { added = [] } = JSON.parse(e.data || '{}');
5046
+ $('.router-modal')?.refresh();
5047
+ $('.settings-modal')?.paintAuto?.();
5048
+ if (added.length) toast(`New for Auto: <b>${added.map((a) => esc(a.label)).join('</b>, <b>')}</b>${added.some((a) => a.state === 'new') ? ' · it will try it on a few tasks' : ''}`, { action: { label: 'Auto settings', run: () => openSettings({ pane: 'auto' }) }, timeout: 8000 });
5049
+ });
4645
5050
  es.addEventListener('preferences', () => {
4646
5051
  $('.memory-modal')?.refresh();
4647
5052
  $('.settings-modal')?.paintLearn?.();