wisp-agents 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +29 -13
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +2 -2
  6. package/server/adapters/claude.js +31 -50
  7. package/server/adapters/codex.js +2 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +2 -2
  10. package/server/adapters/grok.js +2 -2
  11. package/server/adapters/index.js +16 -1
  12. package/server/adapters/opencode.js +47 -6
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +5 -3
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +70 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-wisp.js +17 -5
  25. package/server/openapi.js +33 -2
  26. package/server/paths.js +32 -0
  27. package/server/preferences.js +2 -6
  28. package/server/proc.js +54 -0
  29. package/server/recipes.js +2 -4
  30. package/server/remote.js +14 -14
  31. package/server/router.js +7 -3
  32. package/server/routes/auto.js +29 -0
  33. package/server/routes/automations.js +92 -0
  34. package/server/routes/internal.js +56 -0
  35. package/server/routes/machines.js +43 -0
  36. package/server/routes/meta.js +76 -0
  37. package/server/routes/proxy.js +47 -0
  38. package/server/routes/sessions.js +339 -0
  39. package/server/routes/settings.js +144 -0
  40. package/server/routes/ship.js +61 -0
  41. package/server/routes/uploads.js +19 -0
  42. package/server/runner.js +87 -36
  43. package/server/services/auto.js +67 -0
  44. package/server/services/feedback.js +53 -0
  45. package/server/services/install.js +69 -0
  46. package/server/services/launch.js +127 -0
  47. package/server/services/phone.js +65 -0
  48. package/server/services/ship.js +82 -0
  49. package/server/services/telemetry.js +34 -0
  50. package/server/services/uploads.js +59 -0
  51. package/server/services/webhooks.js +28 -0
  52. package/server/store.js +5 -3
  53. package/server/subagents.js +5 -4
  54. package/server/telemetry.js +1 -1
  55. package/server/terminal.js +2 -1
  56. package/server/triggers.js +4 -6
  57. package/server/update.js +4 -3
  58. package/server/util.js +42 -0
  59. package/server/welcome.js +1 -1
  60. package/web/css/auto.css +29 -0
  61. package/web/css/automations.css +71 -0
  62. package/web/css/base.css +83 -0
  63. package/web/css/blocks.css +461 -0
  64. package/web/css/composer.css +62 -0
  65. package/web/css/feed.css +208 -0
  66. package/web/css/launcher.css +204 -0
  67. package/web/css/layout.css +61 -0
  68. package/web/css/machines.css +125 -0
  69. package/web/css/modals.css +176 -0
  70. package/web/css/palette.css +79 -0
  71. package/web/css/responsive.css +35 -0
  72. package/web/css/ship.css +95 -0
  73. package/web/css/subagents.css +130 -0
  74. package/web/css/tokens.css +115 -0
  75. package/web/index.html +16 -1
  76. package/web/js/app.js +28 -5232
  77. package/web/js/core/api.js +25 -0
  78. package/web/js/core/bus.js +28 -0
  79. package/web/js/core/dom.js +7 -0
  80. package/web/js/core/format.js +27 -0
  81. package/web/js/core/state.js +43 -0
  82. package/web/js/features/actions.js +339 -0
  83. package/web/js/features/attachments.js +115 -0
  84. package/web/js/features/auto.js +54 -0
  85. package/web/js/features/automations.js +305 -0
  86. package/web/js/features/commands.js +123 -0
  87. package/web/js/features/compare.js +104 -0
  88. package/web/js/features/composer.js +311 -0
  89. package/web/js/features/feed.js +400 -0
  90. package/web/js/features/feedback.js +127 -0
  91. package/web/js/features/keys.js +83 -0
  92. package/web/js/features/launcher.js +559 -0
  93. package/web/js/features/live.js +91 -0
  94. package/web/js/features/machines.js +313 -0
  95. package/web/js/features/memory.js +154 -0
  96. package/web/js/features/notify.js +36 -0
  97. package/web/js/features/phone.js +124 -0
  98. package/web/js/features/places.js +38 -0
  99. package/web/js/features/projects.js +284 -0
  100. package/web/js/features/runner-picker.js +233 -0
  101. package/web/js/features/runners.js +161 -0
  102. package/web/js/features/scroll.js +75 -0
  103. package/web/js/features/settings.js +390 -0
  104. package/web/js/features/setup.js +94 -0
  105. package/web/js/features/ship.js +122 -0
  106. package/web/js/features/team.js +386 -0
  107. package/web/js/features/turns.js +401 -0
  108. package/web/js/features/update.js +48 -0
  109. package/web/js/features/usage.js +75 -0
  110. package/web/js/ui/kbd.js +46 -0
  111. package/web/js/ui/overlay.js +38 -0
  112. package/web/js/ui/popover.js +52 -0
  113. package/web/js/ui/toast.js +23 -0
  114. package/web/css/app.css +0 -1901
@@ -0,0 +1,161 @@
1
+ // How a task runs: the harness (a coding-agent CLI, or Auto), its model and thinking level, and
2
+ // the permission mode. Shared by the launcher, follow-ups, reruns, recipes and subagents.
3
+
4
+ import { esc } from '../md.js';
5
+ import { $, $$, el, icon } from '../core/dom.js';
6
+ import { S } from '../core/state.js';
7
+ import { popover } from '../ui/popover.js';
8
+ import { machineById } from './places.js';
9
+
10
+ export const MODES = [
11
+ { id: 'plan', label: 'Plan', icon: 'plan', hint: 'Read-only. The agent investigates and proposes.' },
12
+ { id: 'edits', label: 'Edits', icon: 'pen', hint: 'May edit files. Shell commands that need approval are denied.' },
13
+ { id: 'auto', label: 'Full auto', icon: 'spark', hint: 'No permission prompts at all. Trusted repos only.' },
14
+ ];
15
+
16
+ export const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'auto' ? 'Auto' : id === 'wisp' ? 'wisp' : id);
17
+
18
+ // Auto: not a CLI but wisp's router (server/router.js), which picks harness and model per task.
19
+ // Its "models" are the goal it optimises for; there are no thinking levels to pick.
20
+ export const AUTO_GOALS = [
21
+ { id: 'balanced', label: 'Balanced', hint: 'Best result per dollar' },
22
+ { id: 'cheap', label: 'Cheap', hint: 'Lowest cost that still gets it done' },
23
+ { id: 'best', label: 'Best', hint: 'Best result; cost only breaks ties' },
24
+ ];
25
+ export 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' };
26
+
27
+ // Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
28
+ export const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
29
+
30
+ // A harness's models and levels: this computer's if it's installed here, else the first machine's that has it.
31
+ export function agentById(id) {
32
+ if (id === 'auto') return AUTO;
33
+ const local = S.agents.find((a) => a.id === id);
34
+ if (local?.available) return local;
35
+ return S.machines.map((m) => agentsAt(m.id)?.find((a) => a.id === id && a.available)).find(Boolean) || local;
36
+ }
37
+
38
+ // The harnesses you can pick for the places under "Run on": installed on any of them counts (a union).
39
+ // `missing` names the picked places that don't have it. Machines not online yet don't count.
40
+ export function harnessesAt(places, { auto = false } = {}) {
41
+ if (auto) return [AUTO, ...harnessesAt(places)];
42
+ const known = places.map((k) => [k, agentsAt(k)]).filter(([, l]) => l);
43
+ if (!known.length) known.push(['', S.agents]);
44
+ const ids = [...new Set([...S.agents, ...known.flatMap(([, l]) => l)].map((a) => a.id))];
45
+ return ids
46
+ .map((id) => {
47
+ const hits = known.filter(([, l]) => l.some((a) => a.id === id && a.available));
48
+ 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);
49
+ return { ...base, available: hits.length > 0, missing: hits.length ? known.filter((x) => !hits.includes(x)).map(([k]) => k) : [] };
50
+ })
51
+ .filter((a) => a.available || S.agents.some((x) => x.id === a.id));
52
+ }
53
+
54
+ // Harness logos for the request bubble, vendored from @lobehub/icons into web/logos/.
55
+ // `mono` marks are drawn in the text colour so they work in both themes.
56
+ const HARNESS_LOGO = {
57
+ claude: { file: 'claudecode-color' },
58
+ codex: { file: 'codex-color' },
59
+ opencode: { file: 'opencode', mono: true },
60
+ cursor: { file: 'cursor', mono: true },
61
+ antigravity: { file: 'antigravity-color' },
62
+ grok: { file: 'grok', mono: true },
63
+ };
64
+ export function harnessLogo(agent) {
65
+ if (agent === 'auto') return `<i class="auto-mark">${icon('spark')}</i>`;
66
+ const l = HARNESS_LOGO[agent];
67
+ const src = l && `/logos/${l.file}.svg`;
68
+ return !l ? icon('spark') : l.mono ? `<i class="mono" style="--logo:url(${src})"></i>` : `<img src="${src}" alt="">`;
69
+ }
70
+
71
+ // Reasoning levels the harness accepts for a model: the model's own list (opencode variants),
72
+ // else the harness-wide one. Empty means the harness has no knob for it.
73
+ export function effortsFor(agent, model) {
74
+ const a = agentById(agent);
75
+ return a?.models?.find((m) => m.id === model)?.efforts ?? a?.efforts ?? [];
76
+ }
77
+ export const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
78
+
79
+ export function modelBadge(agent, model, effort, route) {
80
+ if (agent === 'wisp') return '';
81
+ const label = (model && agentById(agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
82
+ const tip = route ? routeTip(route) : `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
83
+ const logo = harnessLogo(agent);
84
+ return `<span class="model-logo${route ? ' routed' : ''}" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
85
+ }
86
+
87
+ // Why Auto picked what it picked, in one line.
88
+ function routeTip(r) {
89
+ const goal = AUTO_GOALS.find((g) => g.id === r.goal)?.label || 'Balanced';
90
+ 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`;
91
+ const why = r.trying ? 'new model, trying it out' : r.explored ? 'trying an alternative to learn' : record;
92
+ return `Auto · ${goal} → ${r.label} · ${why}${r.usd ? ` · ~$${r.usd.toFixed(2)}` : ''}`;
93
+ }
94
+
95
+ // The model is remembered per harness (ids aren't portable between them).
96
+ export function savedModel(id) {
97
+ const d = S.config.defaults;
98
+ if (id === 'auto') return AUTO_GOALS.some((g) => g.id === d.models?.auto) ? d.models.auto : 'balanced';
99
+ const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
100
+ const models = agentById(id)?.models || [];
101
+ return models.some((m) => m.id === want) ? want : '';
102
+ }
103
+
104
+ // Thinking level too, and only if the chosen model still accepts it.
105
+ export function savedEffort(id, model) {
106
+ const d = S.config.defaults;
107
+ const want = d.efforts?.[id] ?? (id === d.agent ? d.effort : '');
108
+ return effortsFor(id, model).includes(want) ? want : '';
109
+ }
110
+
111
+ // One launch runs on at most this many harnesses side by side (the server's MAX_COMPARE).
112
+ export const MAX_RUNNERS = 4;
113
+
114
+ // A runner button's tooltip, for when the pill is too full to show names.
115
+ export function runnerTip(agentId, model, effort) {
116
+ const a = agentById(agentId);
117
+ const m = (a?.models || []).find((x) => x.id === model);
118
+ return [a?.name || agentName(agentId), m?.label || model || 'Default model', effort && effortLabel(effort)].filter(Boolean).join(' · ');
119
+ }
120
+
121
+ // The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
122
+ export function paintRunnerBtn(btn, agentId, model, effort) {
123
+ const a = agentById(agentId);
124
+ const m = (a?.models || []).find((x) => x.id === model);
125
+ btn.innerHTML = agentId
126
+ ? `<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')}`
127
+ : `<span class="rb-logo">${icon('spark')}</span><span class="rb-h">Launcher default</span>${icon('chevron', 'chev')}`;
128
+ }
129
+
130
+ export function modeMenu(anchor, current, pick) {
131
+ const open = $('.menu.mode');
132
+ if (open) return open.close();
133
+ const menu = el(`
134
+ <div class="menu mode" role="menu">
135
+ ${MODES.map((m) => `<button role="menuitemradio" aria-checked="${m.id === current}" data-mode="${m.id}" class="m-${m.id}${m.id === current ? ' on' : ''}">
136
+ ${icon(m.icon)}<span class="mm-text"><b>${m.label}</b><small>${esc(m.hint)}</small></span>${icon('check', 'mm-check')}
137
+ </button>`).join('')}
138
+ </div>`);
139
+ menu.onclick = (ev) => {
140
+ const b = ev.target.closest('button');
141
+ if (!b) return;
142
+ close();
143
+ pick(b.dataset.mode);
144
+ if (anchor.id === 'mode') $('#prompt')?.focus();
145
+ };
146
+ menu.addEventListener('keydown', (ev) => {
147
+ const items = $$('button', menu);
148
+ const i = items.indexOf(document.activeElement);
149
+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
150
+ ev.preventDefault();
151
+ items[(i + (ev.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length].focus();
152
+ }
153
+ });
154
+ const close = popover(menu, { anchor, onClose: () => anchor.classList.remove('open') });
155
+ anchor.classList.add('open');
156
+ const r = anchor.getBoundingClientRect();
157
+ const h = menu.offsetHeight;
158
+ menu.style.left = `${Math.max(8, Math.min(r.left, innerWidth - menu.offsetWidth - 8))}px`;
159
+ menu.style.top = `${r.bottom + 6 + h < innerHeight - 8 ? r.bottom + 6 : r.top - 6 - h}px`;
160
+ $('button.on', menu)?.focus();
161
+ }
@@ -0,0 +1,75 @@
1
+ // The page scrolls inside #stage, under a sticky top bar and the launcher, which docks there once
2
+ // it scrolls away. Everything that moves the page on a card's behalf goes through here.
3
+
4
+ import { $, $$ } from '../core/dom.js';
5
+ import { S } from '../core/state.js';
6
+
7
+ export const stage = $('#stage');
8
+
9
+ // How much of the feed the sticky top bar and docked launcher cover: the stage's scroll-padding-top,
10
+ // which scrollIntoView already honours, so hand-placed cards line up with scrolled-to ones.
11
+ export const topInset = () => parseFloat(getComputedStyle(stage).scrollPaddingTop) || 0;
12
+
13
+ // Whether the user is reading a card, not just catching its edge at the top or bottom of the
14
+ // screen: it crosses the middle of the viewport, or it's focused and a fair share of it shows.
15
+ // Anything that moves the page on a card's behalf asks this first.
16
+ export function watching(id) {
17
+ const card = S.nodes.get(id)?.card;
18
+ if (!card?.isConnected) return false;
19
+ const r = card.getBoundingClientRect();
20
+ const mid = innerHeight / 2;
21
+ if (r.top <= mid && r.bottom >= mid) return true;
22
+ const shown = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0);
23
+ return S.focus === id && shown >= Math.min(r.height, innerHeight) / 3;
24
+ }
25
+
26
+ // Run a DOM change without moving `card` on screen. The browser's own scroll anchoring misses
27
+ // cards that are moved between lists, so feed re-renders do this explicitly.
28
+ export function keepInPlace(card, change) {
29
+ const top = card?.isConnected ? card.getBoundingClientRect().top : null;
30
+ change();
31
+ if (top !== null && card.isConnected) scrollByPx(card.getBoundingClientRect().top - top);
32
+ }
33
+
34
+ // Any write to scrollTop cancels a smooth scroll in flight (j/k, Open, a launch), so only
35
+ // write when there's an actual shift to undo.
36
+ export function scrollByPx(dy) {
37
+ if (Math.abs(dy) >= 1) stage.scrollTop += dy;
38
+ }
39
+
40
+ // The card to hold still while the feed changes around it: the first one showing under the top
41
+ // bar and docked launcher, skipping `id` (the card that's changing). None at the very top of the page, where new
42
+ // cards should push the list down, or when `id` itself is the top card on screen: then the next
43
+ // one should slide up into its place.
44
+ export function feedAnchor(id) {
45
+ if (stage.scrollTop < 1) return null;
46
+ const moving = id && S.nodes.get(id)?.card;
47
+ const inset = topInset();
48
+ for (const card of $$('#feed .item')) {
49
+ const r = card.getBoundingClientRect();
50
+ if (r.bottom > inset) return card === moving ? null : card;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ const toTop = $('#toTop');
56
+
57
+ // Once the launcher scrolls under the top bar it docks there, folded to one line. The slot keeps
58
+ // its full height in the feed so docking never shifts what you're reading.
59
+ const launchSlot = $('#launchSlot');
60
+ const dockLauncher = () => {
61
+ const box = $('#launcher');
62
+ const gone = launchSlot.getBoundingClientRect().bottom < $('#topbar').offsetHeight;
63
+ if (gone === box.classList.contains('docked')) return;
64
+ launchSlot.style.height = gone ? `${box.offsetHeight}px` : '';
65
+ box.classList.toggle('docked', gone);
66
+ };
67
+
68
+ export const backToTop = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
69
+
70
+ // Sticky top bar gets a hairline once the page scrolls; "back to top" shows after a screen's worth.
71
+ export function onScroll() {
72
+ $('#topbar').classList.toggle('scrolled', stage.scrollTop > 4);
73
+ dockLauncher();
74
+ toTop.hidden = stage.scrollTop < stage.clientHeight * 0.75;
75
+ }
@@ -0,0 +1,390 @@
1
+ // Settings: a sidebar of sections and one pane at a time, like every other app's settings. Big lists
2
+ // (memory, automations, usage history) keep their own views; a pane only summarises and links.
3
+
4
+ import { esc } from '../md.js';
5
+ import { api } from '../core/api.js';
6
+ import * as bus from '../core/bus.js';
7
+ import { $, $$, el, icon } from '../core/dom.js';
8
+ import { money, pct } from '../core/format.js';
9
+ import { S } from '../core/state.js';
10
+ import { overlay } from '../ui/overlay.js';
11
+ import { toast, toastError, tryApi } from '../ui/toast.js';
12
+ import { openRouter } from './auto.js';
13
+ import { openAutomations } from './automations.js';
14
+ import { renderLauncher } from './launcher.js';
15
+ import { loadConfig } from './live.js';
16
+ import { openMachines } from './machines.js';
17
+ import { memorySummary, openMemory, setLearning } from './memory.js';
18
+ import { notifyOn } from './notify.js';
19
+ import { openPhone, phoneSummary } from './phone.js';
20
+ import { machinesSummary } from './places.js';
21
+ import { AUTO_GOALS, harnessLogo } from './runners.js';
22
+ import { INSTALLABLE, installHarness, isInstalling } from './setup.js';
23
+ import { openUsage } from './usage.js';
24
+
25
+ // A sidebar of sections and one pane at a time, like every other app's settings. Big lists
26
+ // (memory, automations, usage history) keep their own views; a pane only summarises and links.
27
+ const SETTINGS_PANES = [
28
+ { id: 'general', label: 'General', icon: 'settings', lede: 'Where files open, where you take over, and how tasks start.' },
29
+ { id: 'notifications', label: 'Notifications', icon: 'bolt', lede: 'Hear about questions and finished tasks, here or on your phone.' },
30
+ { id: 'agents', label: 'Agents', icon: 'agent', lede: 'The harnesses wisp found on this machine. Pick one next to the model in the launcher.' },
31
+ { id: 'auto', label: 'Auto', icon: 'spark', lede: 'wisp picks the harness and model for each task, and learns from how it went.' },
32
+ { id: 'machines', label: 'Machines', icon: 'server', lede: 'Other computers that run agents for you, over SSH.' },
33
+ { id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
34
+ { id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
35
+ { id: 'usage', label: 'Usage', icon: 'stats', lede: 'Spend and outcomes over the last 7 days.' },
36
+ { id: 'privacy', label: 'Privacy', icon: 'info', lede: 'What wisp sends about itself. Never prompts, paths or code.' },
37
+ ];
38
+ let settingsPane = 'general';
39
+
40
+ // What a re-check turned up: harnesses found or gone, and model lists that grew or shrank.
41
+ function recheckSummary(before, after) {
42
+ const count = (a) => (a?.models || []).filter((m) => m.id).length;
43
+ const notes = [];
44
+ for (const a of after) {
45
+ const was = before.get(a.id);
46
+ if (a.available && !was?.available) notes.push(`<b>${esc(a.name)}</b> found`);
47
+ else if (!a.available && was?.available) notes.push(`<b>${esc(a.name)}</b> not found`);
48
+ else if (a.available && count(a) !== count(was)) {
49
+ const d = count(a) - count(was);
50
+ notes.push(`<b>${esc(a.name)}</b> ${d > 0 ? '+' : '−'}${Math.abs(d)} model${Math.abs(d) === 1 ? '' : 's'}`);
51
+ } else if (a.available && a.version !== was?.version) notes.push(`<b>${esc(a.name)}</b> updated`);
52
+ }
53
+ return notes.length ? notes.join(' · ') : 'Up to date: nothing new';
54
+ }
55
+
56
+ export function openSettings(opts) {
57
+ if (SETTINGS_PANES.some((p) => p.id === opts?.pane)) settingsPane = opts.pane;
58
+ const d = S.config.defaults;
59
+ // A row: name and a line of explanation on the left, the control on the right.
60
+ const row = (name, desc, control, cls = '') => `<div class="sx-row ${cls}"><div class="sx-text"><b>${name}</b>${desc ? `<small>${desc}</small>` : ''}</div><div class="sx-ctl">${control}</div></div>`;
61
+ const missing = S.agents.filter((a) => !a.available).length;
62
+ const node = el(`
63
+ <div class="modal settings-modal sx">
64
+ <nav class="sx-nav" role="tablist" aria-orientation="vertical">
65
+ <h2>Settings</h2>
66
+ ${SETTINGS_PANES.map((p) => `<button role="tab" data-pane="${p.id}">${icon(p.icon)}<span>${p.label}</span>${p.id === 'agents' && missing ? `<i class="dot bad" title="${missing} not found"></i>` : ''}</button>`).join('')}
67
+ </nav>
68
+ <div class="sx-main">
69
+ <header><div><h3 class="sx-title"></h3><p class="sx-lede"></p></div><button class="icon-btn close">${icon('x')}</button></header>
70
+ <section class="sx-pane" data-pane="general">
71
+ <div class="sx-group">
72
+ ${row('Open files in', 'File links in cards and diffs', `<select name="editor">${[['vscode', 'VS Code'], ['cursor', 'Cursor'], ['windsurf', 'Windsurf'], ['zed', 'Zed'], ['idea', 'JetBrains'], ['none', 'Copy path']]
73
+ .map(([v, l]) => `<option value="${v}" ${d.editor === v || (!d.editor && v === 'vscode') ? 'selected' : ''}>${l}</option>`)
74
+ .join('')}</select>`)}
75
+ ${row('Take over in', 'The terminal a session opens in when you take it over', `<select name="terminal">${(S.config.terminals || []).map((t) => `<option value="${t.id}" ${(d.terminal || 'auto') === t.id ? 'selected' : ''}>${t.label}</option>`).join('')}</select>`)}
76
+ <div class="sx-row custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><div class="sx-text"><b>Terminal command</b><small><code>{cwd}</code> and <code>{cmd}</code> are filled in</small></div>
77
+ <div class="sx-ctl"><input class="input mono" name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></div></div>
78
+ </div>
79
+ <div class="sx-group">
80
+ ${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
81
+ </div>
82
+ </section>
83
+ <section class="sx-pane" data-pane="notifications">
84
+ <div class="sx-group">
85
+ ${row('Desktop notifications', '<span class="notif-hint"></span>', '<button class="switch" id="notif" role="switch"></button>')}
86
+ </div>
87
+ <div class="sx-group">
88
+ ${row('Phone', '<span class="phone-hint">ntfy or Telegram: answer questions from your phone</span>', '<button class="ghost sm" id="openPhone">Set up</button>')}
89
+ </div>
90
+ </section>
91
+ <section class="sx-pane" data-pane="agents">
92
+ <div class="sx-group sx-agents"></div>
93
+ <div class="sx-group">
94
+ ${row('Installed something new?', '<span class="recheck-hint"></span>', `<button class="ghost sm" id="recheck">${icon('replay')}Re-check</button>`)}
95
+ </div>
96
+ </section>
97
+ <section class="sx-pane" data-pane="auto">
98
+ <div class="sx-group">
99
+ ${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>`)}
100
+ ${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>')}
101
+ ${row('Harnesses it may use', 'Leave all off to let Auto use every one you have', '<span class="ro-allowed"></span>')}
102
+ </div>
103
+ <div class="sx-group">
104
+ ${row('What it learned', '<span class="ro-hint"></span>', '<button class="ghost sm" id="openRouter">Open</button>')}
105
+ </div>
106
+ </section>
107
+ <section class="sx-pane" data-pane="machines">
108
+ <div class="sx-group">
109
+ ${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
110
+ </div>
111
+ <p class="hint">Also in the top bar, and in Projects: add folders on a machine, then launch there like anywhere else.</p>
112
+ </section>
113
+ <section class="sx-pane" data-pane="memory">
114
+ <div class="sx-group">
115
+ ${row('Learn from sessions', 'A short retro after each session keeps preferences and lessons', '<button class="switch" id="learn" role="switch"></button>')}
116
+ ${row('Remembered', '<span class="learn-hint"></span>', '<button class="ghost sm" id="openMemory">Open memory</button>')}
117
+ </div>
118
+ </section>
119
+ <section class="sx-pane" data-pane="automations">
120
+ <div class="sx-group">
121
+ ${row('Recipes', '<span class="recipe-hint"></span>', '<button class="ghost sm" data-auto="recipes">Manage</button>')}
122
+ ${row('Triggers', '<span class="trigger-hint"></span>', '<button class="ghost sm" data-auto="triggers">Manage</button>')}
123
+ </div>
124
+ </section>
125
+ <section class="sx-pane" data-pane="usage">
126
+ <div class="us-tiles sx-usage"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
127
+ <div class="sx-group">${row('Charts and breakdowns', 'Per day, per project and per harness · model', '<button class="ghost sm" id="openUsage">Open usage</button>')}</div>
128
+ </section>
129
+ <section class="sx-pane" data-pane="privacy">
130
+ <div class="sx-group">
131
+ ${row('Anonymous usage stats', '<span class="tele-hint"></span>', '<button class="switch" id="tele" role="switch"></button>')}
132
+ </div>
133
+ <p class="hint">Agent, model, status, timings and scrubbed errors only.</p>
134
+ </section>
135
+ </div>
136
+ </div>`);
137
+ const close = overlay(node);
138
+ $('.close', node).onclick = close;
139
+ const paintAgents = () => {
140
+ $('.sx-agents', node).innerHTML = S.agents.length
141
+ ? S.agents.map((a) => {
142
+ const n = (a.models || []).filter((m) => m.id).length;
143
+ // opencode: wisp can install it (free models, no account), so its row offers that.
144
+ const canInstall = !a.available && INSTALLABLE.has(a.id);
145
+ const busy = isInstalling(a.id);
146
+ const desc = a.available
147
+ ? [a.version, n && `${n} model${n === 1 ? '' : 's'}`].filter(Boolean).join(' · ')
148
+ : busy ? 'Installing into ~/.wisp/tools…' : canInstall ? 'Install it here: free models, no account needed' : `Install the ${a.name} CLI and sign in, then re-check`;
149
+ const install = canInstall ? `<button class="ghost sm" data-install="${esc(a.id)}" ${busy ? 'disabled' : ''}>${busy ? 'Installing…' : 'Install'}</button>` : '';
150
+ return row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, esc(desc), `${install}<span class="sx-state ${a.available ? 'ok' : 'bad'}" ${a.error ? `title="${esc(a.error)}"` : ''}><i class="dot ${a.available ? 'ok' : 'bad'}"></i>${a.available ? 'Ready' : 'Not found'}</span>`);
151
+ }).join('')
152
+ : '<div class="sx-row"><div class="it-loading"><span class="orb tiny"></span>Looking for harnesses…</div></div>';
153
+ const missing = S.agents.filter((a) => !a.available).length;
154
+ const dot = $('.sx-nav [data-pane="agents"] .dot', node);
155
+ if (dot && !missing) dot.remove();
156
+ if (!dot && missing) $('.sx-nav [data-pane="agents"]', node).insertAdjacentHTML('beforeend', `<i class="dot bad" title="${missing} not found"></i>`);
157
+ const online = S.machines.filter((m) => m.state === 'online').length;
158
+ $('.recheck-hint', node).textContent = `After installing a CLI, signing in or adding a provider, look for harnesses and models again${online ? `, here and on ${online} machine${online === 1 ? '' : 's'}` : ''}`;
159
+ };
160
+ paintAgents();
161
+ bus.on('agents', paintAgents, { node });
162
+ bus.on('install', paintAgents, { node });
163
+ $('.sx-agents', node).onclick = (e) => {
164
+ const b = e.target.closest('[data-install]');
165
+ if (b) installHarness(b.dataset.install);
166
+ };
167
+ $('#recheck', node).onclick = async (e) => {
168
+ const btn = e.currentTarget;
169
+ btn.disabled = true;
170
+ btn.classList.add('busy');
171
+ const before = new Map(S.agents.map((a) => [a.id, a]));
172
+ try {
173
+ S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
174
+ if (!node.isConnected) return;
175
+ paintAgents();
176
+ $('#launcher')?.placesChanged?.();
177
+ toast(recheckSummary(before, S.agents));
178
+ } catch (err) {
179
+ toastError(err);
180
+ } finally {
181
+ btn.disabled = false;
182
+ btn.classList.remove('busy');
183
+ }
184
+ };
185
+ const pick = (id, focus = false) => {
186
+ settingsPane = id;
187
+ const p = SETTINGS_PANES.find((x) => x.id === id);
188
+ $$('.sx-nav [data-pane]', node).forEach((b) => {
189
+ const on = b.dataset.pane === id;
190
+ b.setAttribute('aria-selected', on);
191
+ b.tabIndex = on ? 0 : -1;
192
+ if (on && focus) b.focus();
193
+ });
194
+ $$('.sx-pane', node).forEach((s) => (s.hidden = s.dataset.pane !== id));
195
+ $('.sx-title', node).textContent = p.label;
196
+ $('.sx-lede', node).textContent = p.lede;
197
+ $('.sx-main', node).scrollTop = 0;
198
+ };
199
+ // Memory, Automations, Usage and Phone open in the main area, next to the sidebar, with a
200
+ // way back to the section they came from. Leaving one goes back; only the X closes Settings.
201
+ const main = $('.sx-main', node);
202
+ const leave = () => {
203
+ const v = $('.sx-main > .embedded', node);
204
+ if (!v) return;
205
+ v.remove();
206
+ $('.sx-main > header', node).hidden = false;
207
+ paintHints();
208
+ };
209
+ const show = (id, focus) => (leave(), pick(id, focus));
210
+ const host = (pane) => (view) => {
211
+ show(pane);
212
+ view.classList.add('embedded');
213
+ const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
214
+ back.onclick = () => show(pane);
215
+ $('header', view).prepend(back);
216
+ $('.close', view).onclick = close;
217
+ $('.sx-main > header', node).hidden = true;
218
+ $$('.sx-pane', node).forEach((s) => (s.hidden = true));
219
+ main.append(view);
220
+ main.scrollTop = 0;
221
+ return close;
222
+ };
223
+ $('.sx-nav', node).onclick = (e) => {
224
+ const b = e.target.closest('[data-pane]');
225
+ if (b) show(b.dataset.pane);
226
+ };
227
+ $('.sx-nav', node).onkeydown = (e) => {
228
+ const step = { ArrowDown: 1, ArrowUp: -1 }[e.key];
229
+ if (!step) return;
230
+ e.preventDefault();
231
+ const i = SETTINGS_PANES.findIndex((p) => p.id === settingsPane);
232
+ show(SETTINGS_PANES[(i + step + SETTINGS_PANES.length) % SETTINGS_PANES.length].id, true);
233
+ };
234
+ show(settingsPane);
235
+ node.addEventListener('change', async (e) => {
236
+ const { name, value } = e.target;
237
+ if (!name || e.target.closest('.embedded')) return; // a nested view's own form
238
+ if (name === 'terminal') $('.custom-term', node).hidden = value !== 'custom';
239
+ await tryApi(async () => {
240
+ await api('/config', { method: 'PATCH', body: { defaults: { [name]: value } } });
241
+ await loadConfig();
242
+ });
243
+ });
244
+ // The switch shows wisp's own on/off preference; the browser permission can only be
245
+ // granted from here, never revoked, so it surfaces as a hint when it blocks the switch.
246
+ const sw = $('#notif', node);
247
+ const paint = () => {
248
+ const perm = 'Notification' in window ? Notification.permission : 'unsupported';
249
+ const on = notifyOn();
250
+ sw.setAttribute('aria-checked', on);
251
+ sw.disabled = perm === 'denied' || perm === 'unsupported';
252
+ $('.notif-hint', node).textContent =
253
+ perm === 'denied' ? 'Blocked — allow notifications for this site in your browser' : perm === 'unsupported' ? 'Not supported here' : on ? 'On' : 'Off';
254
+ };
255
+ sw.onclick = async () => {
256
+ let want = !notifyOn();
257
+ if (want && Notification.permission !== 'granted') want = (await Notification.requestPermission()) === 'granted';
258
+ await tryApi(async () => {
259
+ await api('/config', { method: 'PATCH', body: { defaults: { notify: want ? 'on' : 'off' } } });
260
+ await loadConfig();
261
+ });
262
+ paint();
263
+ };
264
+ paint();
265
+ // Agent, model, status, timings and scrubbed errors only; never prompts, paths or code.
266
+ const tele = $('#tele', node);
267
+ const paintTele = () => {
268
+ const t = S.config.telemetry || {};
269
+ tele.setAttribute('aria-checked', !!t.enabled);
270
+ tele.disabled = !t.available;
271
+ $('.tele-hint', node).textContent = !t.available ? 'Off (DO_NOT_TRACK, WISP_TELEMETRY=0 or CI)' : 'No prompts, paths or code';
272
+ };
273
+ tele.onclick = async () => {
274
+ await tryApi(async () => {
275
+ await api('/config', { method: 'PATCH', body: { defaults: { telemetry: S.config.telemetry?.enabled ? 'off' : 'on' } } });
276
+ await loadConfig();
277
+ });
278
+ paintTele();
279
+ };
280
+ paintTele();
281
+ // Memory: one line here (on/off and a summary); the lists and retros live in their own view.
282
+ const learn = $('#learn', node);
283
+ const paintLearn = async () => {
284
+ learn.setAttribute('aria-checked', S.config.defaults.learn !== 'off');
285
+ $('.learn-hint', node).textContent = memorySummary(await api('/preferences').catch(() => null));
286
+ };
287
+ learn.onclick = async () => {
288
+ await tryApi(() => setLearning(S.config.defaults.learn === 'off'));
289
+ paintLearn();
290
+ };
291
+ bus.on('preferences', paintLearn, { node });
292
+ // One line each; their lists and charts live in their own views.
293
+ const wt = $('#wtDefault', node);
294
+ const paintWt = () => wt.setAttribute('aria-checked', S.config.defaults.worktree !== 'off');
295
+ wt.onclick = async () => {
296
+ const saved = await tryApi(async () => {
297
+ await api('/config', { method: 'PATCH', body: { defaults: { worktree: S.config.defaults.worktree === 'off' ? 'on' : 'off' } } });
298
+ await loadConfig();
299
+ return true;
300
+ });
301
+ if (!saved) return;
302
+ paintWt();
303
+ renderLauncher();
304
+ };
305
+ paintWt();
306
+ // Auto: goal, exploring and allowed harnesses here; the full record in its own view.
307
+ const paintAuto = async () => {
308
+ const r = await api('/router').catch(() => null);
309
+ if (!r || !node.isConnected) return;
310
+ $$('.ro-goal button', node).forEach((b) => b.classList.toggle('on', b.dataset.goal === r.goal));
311
+ $('#roExplore', node).setAttribute('aria-checked', r.settings.explore);
312
+ const allowed = r.settings.allowed || [];
313
+ $('.ro-allowed', node).innerHTML = S.agents
314
+ .filter((a) => a.available)
315
+ .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>`)
316
+ .join('');
317
+ $('.ro-hint', node).textContent = r.learned
318
+ ? `${r.learned} task${r.learned === 1 ? '' : 's'} learned from · ${r.arms.filter((a) => a.state !== 'gone').length} harness · model pairs`
319
+ : `Nothing of yours yet · starting from wisp-bench (${r.priors.version})`;
320
+ };
321
+ const patchAuto = async (body) => {
322
+ try {
323
+ await api('/router', { method: 'PATCH', body });
324
+ await loadConfig();
325
+ renderLauncher();
326
+ } catch (err) {
327
+ toastError(err);
328
+ }
329
+ paintAuto();
330
+ };
331
+ $('.ro-goal', node).onclick = (e) => {
332
+ const b = e.target.closest('[data-goal]');
333
+ if (b) {
334
+ $$('.ro-goal button', node).forEach((x) => x.classList.toggle('on', x === b));
335
+ patchAuto({ goal: b.dataset.goal });
336
+ }
337
+ };
338
+ $('#roExplore', node).onclick = (e) => {
339
+ const on = e.currentTarget.getAttribute('aria-checked') !== 'true';
340
+ e.currentTarget.setAttribute('aria-checked', on);
341
+ patchAuto({ explore: on });
342
+ };
343
+ $('.ro-allowed', node).onclick = (e) => {
344
+ const b = e.target.closest('[data-id]');
345
+ if (!b) return;
346
+ b.classList.toggle('on');
347
+ b.setAttribute('aria-pressed', b.classList.contains('on'));
348
+ patchAuto({ allowed: $$('.ro-allowed .on', node).map((x) => x.dataset.id) });
349
+ };
350
+ bus.on('router', paintAuto, { node });
351
+
352
+ // The one-line summaries; painted again on the way back from a view that may have changed them.
353
+ const paintHints = () => {
354
+ const r = S.config.recipes?.length || 0;
355
+ const all = S.config.triggers || [];
356
+ const t = all.filter((x) => x.enabled).length;
357
+ $('.recipe-hint', node).textContent = r ? `${r} saved prompt${r === 1 ? '' : 's'}` : 'None yet · save a prompt you run often';
358
+ $('.trigger-hint', node).textContent = all.length ? `${t} of ${all.length} on` : 'None yet · run a recipe on a schedule, CI failure or new issue';
359
+ api('/usage?days=7')
360
+ .then((u) => {
361
+ const x = u.total;
362
+ $('.sx-usage', node).innerHTML = `
363
+ <div><span>Spend</span><b>${x.priced ? money(x.cost) : '—'}</b></div>
364
+ <div><span>Turns</span><b>${x.turns}</b></div>
365
+ <div><span>Worked</span><b>${pct(x.successRate)}</b></div>
366
+ <div><span>Questions</span><b>${x.questions}</b></div>`;
367
+ })
368
+ .catch(() => ($('.sx-usage', node).innerHTML = ''));
369
+ $('.machines-hint', node).textContent = machinesSummary();
370
+ api('/remote').then((x) => ($('.phone-hint', node).textContent = phoneSummary(x) === 'Off' ? 'ntfy or Telegram: answer questions from your phone' : phoneSummary(x))).catch(() => {});
371
+ paintLearn();
372
+ paintAuto();
373
+ };
374
+ paintHints();
375
+ bus.on('machines', () => ($('.machines-hint', node).textContent = machinesSummary()), { node });
376
+ $('#openRouter', node).onclick = () => openRouter({ host: host('auto') });
377
+ $('#openMemory', node).onclick = () => openMemory({ host: host('memory') });
378
+ $$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
379
+ $('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
380
+ $('#openPhone', node).onclick = () => openPhone({ host: host('notifications') });
381
+ $('#openMachines', node).onclick = () => openMachines({ host: host('machines') });
382
+ }
383
+
384
+ // Say once that anonymous stats are on, with a way out.
385
+ export function telemetryNotice() {
386
+ if (!S.config.telemetry?.enabled || S.config.defaults.telemetryNotice === 'seen') return;
387
+ S.config.defaults.telemetryNotice = 'seen';
388
+ api('/config', { method: 'PATCH', body: { defaults: { telemetryNotice: 'seen' } } }).catch(() => {});
389
+ toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: () => openSettings({ pane: 'privacy' }) } });
390
+ }