wisp-agents 0.15.0 → 0.17.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 (122) hide show
  1. package/CHANGELOG.md +55 -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 +7 -3
  6. package/server/adapters/claude.js +79 -55
  7. package/server/adapters/codex.js +5 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +4 -2
  10. package/server/adapters/grok.js +17 -2
  11. package/server/adapters/index.js +25 -2
  12. package/server/adapters/opencode.js +78 -12
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +6 -4
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +71 -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-ask.js +113 -8
  25. package/server/mcp-wisp.js +17 -5
  26. package/server/mentions.js +39 -0
  27. package/server/openapi.js +36 -4
  28. package/server/paths.js +32 -0
  29. package/server/preferences.js +2 -6
  30. package/server/proc.js +54 -0
  31. package/server/protocol.js +8 -1
  32. package/server/recipes.js +2 -4
  33. package/server/remote.js +14 -14
  34. package/server/render.js +3 -1
  35. package/server/router.js +7 -3
  36. package/server/routes/auto.js +29 -0
  37. package/server/routes/automations.js +92 -0
  38. package/server/routes/internal.js +176 -0
  39. package/server/routes/machines.js +43 -0
  40. package/server/routes/meta.js +76 -0
  41. package/server/routes/proxy.js +47 -0
  42. package/server/routes/sessions.js +351 -0
  43. package/server/routes/settings.js +144 -0
  44. package/server/routes/ship.js +61 -0
  45. package/server/routes/uploads.js +19 -0
  46. package/server/runner.js +216 -49
  47. package/server/services/auto.js +67 -0
  48. package/server/services/feedback.js +53 -0
  49. package/server/services/install.js +69 -0
  50. package/server/services/launch.js +127 -0
  51. package/server/services/phone.js +65 -0
  52. package/server/services/ship.js +82 -0
  53. package/server/services/telemetry.js +34 -0
  54. package/server/services/uploads.js +59 -0
  55. package/server/services/webhooks.js +28 -0
  56. package/server/store.js +13 -5
  57. package/server/subagents.js +5 -4
  58. package/server/telemetry.js +1 -1
  59. package/server/terminal.js +2 -1
  60. package/server/triggers.js +5 -7
  61. package/server/update.js +4 -3
  62. package/server/usage.js +94 -0
  63. package/server/util.js +42 -0
  64. package/server/welcome.js +3 -3
  65. package/web/css/auto.css +29 -0
  66. package/web/css/automations.css +71 -0
  67. package/web/css/base.css +83 -0
  68. package/web/css/blocks.css +461 -0
  69. package/web/css/composer.css +84 -0
  70. package/web/css/feed.css +235 -0
  71. package/web/css/launcher.css +204 -0
  72. package/web/css/layout.css +61 -0
  73. package/web/css/machines.css +125 -0
  74. package/web/css/modals.css +176 -0
  75. package/web/css/palette.css +79 -0
  76. package/web/css/responsive.css +35 -0
  77. package/web/css/ship.css +95 -0
  78. package/web/css/subagents.css +130 -0
  79. package/web/css/tokens.css +115 -0
  80. package/web/index.html +17 -1
  81. package/web/js/app.js +28 -5237
  82. package/web/js/blocks.js +46 -0
  83. package/web/js/core/api.js +25 -0
  84. package/web/js/core/bus.js +28 -0
  85. package/web/js/core/dom.js +7 -0
  86. package/web/js/core/format.js +27 -0
  87. package/web/js/core/state.js +43 -0
  88. package/web/js/features/actions.js +344 -0
  89. package/web/js/features/attachments.js +115 -0
  90. package/web/js/features/auto.js +54 -0
  91. package/web/js/features/automations.js +305 -0
  92. package/web/js/features/commands.js +123 -0
  93. package/web/js/features/compare.js +105 -0
  94. package/web/js/features/composer.js +315 -0
  95. package/web/js/features/feed.js +400 -0
  96. package/web/js/features/feedback.js +127 -0
  97. package/web/js/features/keys.js +83 -0
  98. package/web/js/features/launcher.js +561 -0
  99. package/web/js/features/live.js +91 -0
  100. package/web/js/features/machines.js +313 -0
  101. package/web/js/features/memory.js +154 -0
  102. package/web/js/features/mentions.js +125 -0
  103. package/web/js/features/notify.js +36 -0
  104. package/web/js/features/phone.js +124 -0
  105. package/web/js/features/places.js +38 -0
  106. package/web/js/features/projects.js +284 -0
  107. package/web/js/features/runner-picker.js +233 -0
  108. package/web/js/features/runners.js +163 -0
  109. package/web/js/features/scroll.js +75 -0
  110. package/web/js/features/settings.js +390 -0
  111. package/web/js/features/setup.js +94 -0
  112. package/web/js/features/ship.js +122 -0
  113. package/web/js/features/team.js +386 -0
  114. package/web/js/features/turns.js +432 -0
  115. package/web/js/features/update.js +48 -0
  116. package/web/js/features/usage.js +75 -0
  117. package/web/js/mention-text.js +59 -0
  118. package/web/js/ui/kbd.js +46 -0
  119. package/web/js/ui/overlay.js +38 -0
  120. package/web/js/ui/popover.js +52 -0
  121. package/web/js/ui/toast.js +23 -0
  122. package/web/css/app.css +0 -1910
@@ -0,0 +1,432 @@
1
+ // An expanded card's body: its turns (prompt, live progress or presentation, trail), newest open
2
+ // and older ones folded.
3
+
4
+ import { countChanges, fmtDuration, renderPresentation } from '../blocks.js';
5
+ import { esc, inlineMd, md } from '../md.js';
6
+ import { mentionHtml } from '../mention-text.js';
7
+ import { api } from '../core/api.js';
8
+ import { $, $$, el, icon } from '../core/dom.js';
9
+ import { S } from '../core/state.js';
10
+ import { overlay } from '../ui/overlay.js';
11
+ import { toastError } from '../ui/toast.js';
12
+ import { execAction, moreMenu, openInEditor, revertFile, rollbackTurn, takeOver, toggleDone } from './actions.js';
13
+ import { draftInto, renderDock, sendReply, startOptimistic, updateDock } from './composer.js';
14
+ import { scheduleRefetch } from './feed.js';
15
+ import { feedbackOn, rateTurn, whyForm } from './feedback.js';
16
+ import { projectById } from './places.js';
17
+ import { agentName, DEFAULT_MODE, effortLabel, modelBadge, MODES } from './runners.js';
18
+ import { scrollByPx, watching } from './scroll.js';
19
+ import { renderPrStrip, shipDialog } from './ship.js';
20
+ import { copiesOf, memberLabel, renderSubs, subKey, teamLiveHtml, teamSize } from './team.js';
21
+
22
+ 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' };
23
+
24
+ export function ctxFor(s, turn, isLatest) {
25
+ return {
26
+ session: s,
27
+ turn,
28
+ isLatest,
29
+ reply: (text) => sendReply(s.id, text),
30
+ draft: (text) => draftInto(s.id, text),
31
+ openFile: (p, line) => openInEditor(s, p, line),
32
+ fileUrl: (p) => `/api/sessions/${s.id}/file?path=${encodeURIComponent(p)}`,
33
+ zoom: (src) => overlay(el(`<img class="lightbox" src="${esc(src)}">`)),
34
+ revertFile: (file, restore) => revertFile(s.id, turn.id, file, restore),
35
+ exec: (command, key) => execAction(s.id, turn.id, command, key),
36
+ approve: (allow) => approveBlocked(s, turn, allow),
37
+ };
38
+ }
39
+
40
+ // The "Blocked by permissions" card's buttons. Allow starts the retry, so the card goes to
41
+ // Working at once; a refusal rolls that back and shows why.
42
+ async function approveBlocked(s, turn, allow) {
43
+ const undo = allow ? startOptimistic(s.id, 'Allow & retry what was blocked', null, {}) : null;
44
+ try {
45
+ await api(`/sessions/${s.id}/approve`, { method: 'POST', body: { turnId: turn.id, allow } });
46
+ if (undo) scheduleRefetch(s.id);
47
+ } catch (err) {
48
+ undo?.();
49
+ toastError(err);
50
+ throw err;
51
+ }
52
+ }
53
+
54
+ // Older turns render as one-line folded rows; past this many they hide behind "Show earlier".
55
+ const VISIBLE_TURNS = 8;
56
+
57
+ export function renderBody(id, { fresh }) {
58
+ const s = S.full.get(id);
59
+ const n = S.nodes.get(id);
60
+ if (!s || !n) return;
61
+ const summary = S.sessions.get(id) || {};
62
+
63
+ if (fresh || !n.body?.querySelector('.turns')) {
64
+ n.turnNodes.clear();
65
+ const body = el(`
66
+ <div class="it-body">
67
+ <div class="it-bar">
68
+ <div class="it-meta"></div>
69
+ <div class="it-actions">
70
+ <button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
71
+ <button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}</button>
72
+ <button class="ghost xs" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
73
+ <button class="ghost xs it-done" data-a="done" title="Mark done (E)" hidden>${icon('done')}<span>Mark done</span></button>
74
+ <button class="ghost xs" data-a="more" title="More">${icon('dots')}</button>
75
+ </div>
76
+ </div>
77
+ <button class="link xs earlier" hidden></button>
78
+ <div class="turns"></div>
79
+ <div class="pr-strip" hidden></div>
80
+ <div class="it-dock"></div>
81
+ </div>`);
82
+ n.body ? n.body.replaceWith(body) : n.card.append(body);
83
+ n.body = body;
84
+ $('.it-bar', body).addEventListener('click', (e) => {
85
+ const a = e.target.closest('[data-a]')?.dataset.a;
86
+ if (a === 'replay') replay(id);
87
+ if (a === 'takeover') takeOver(id);
88
+ if (a === 'ship') shipDialog(id);
89
+ if (a === 'done') toggleDone(id);
90
+ if (a === 'more') moreMenu(id, e.target.closest('button'));
91
+ });
92
+ $('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
93
+ renderDock(id);
94
+ }
95
+
96
+ const body = n.body;
97
+ const proj = projectById(summary.projectId);
98
+ // The head already shows the harness, model and project, and the task head has Compare. This bar
99
+ // says how the run was set up: a mode worth noticing (Plan or Edits, as on the launcher), the
100
+ // branch, and what launched it.
101
+ const mode = MODES.find((m) => m.id === s.mode);
102
+ const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
103
+ $('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
104
+ $('.it-meta', body).innerHTML = `
105
+ ${mode && mode !== DEFAULT_MODE ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
106
+ ${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
107
+ ${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>` : ''}
108
+ ${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>` : ''}`;
109
+ const live = s.state === 'running' || s.state === 'waiting';
110
+ // Ship and Done sit in the top bar and again next to the composer, so they're there on opening the
111
+ // card and after reading the result. The PR row sits right above the composer for the same reason.
112
+ renderPrStrip(id);
113
+ $$('[data-a="done"]', body).forEach((b) => {
114
+ b.classList.toggle('on', s.state === 'done');
115
+ b.hidden = s.state === 'running' || s.state === 'waiting';
116
+ $('span', b).textContent = s.state === 'done' ? 'Reopen' : 'Mark done';
117
+ });
118
+ $('[data-a="takeover"]', body).hidden = !s.agentSessionId;
119
+
120
+ // Turns: keep nodes whose content hasn't changed so animations don't replay.
121
+ const turnsEl = $('.turns', body);
122
+ const turns = n.showAll ? s.turns : s.turns.slice(-VISIBLE_TURNS);
123
+ const hiddenCount = s.turns.length - turns.length;
124
+ const earlier = $('.earlier', body);
125
+ earlier.hidden = !hiddenCount;
126
+ earlier.textContent = `Show ${hiddenCount} earlier turn${hiddenCount === 1 ? '' : 's'}`;
127
+ // A new turn re-folds everything older, so the latest exchange is what's open by default.
128
+ const latestId = s.turns.at(-1)?.id;
129
+ const newTurn = n.latestTurn !== latestId;
130
+ if (newTurn) (n.unfolded.clear(), (n.latestTurn = latestId));
131
+ // Folding the last answer when a follow-up starts can take a screen or more out of the card
132
+ // above the composer. Hold the composer still, so the new exchange lands where the user was
133
+ // typing instead of the whole card sliding off the top of the screen.
134
+ const watched = watching(id);
135
+ const dock = watched && newTurn && !fresh ? $('.it-dock', body) : null;
136
+ const dockTop = dock?.getBoundingClientRect().top;
137
+ const keep = new Set();
138
+ let firstNew = null;
139
+ turns.forEach((t, i) => {
140
+ const isLatest = t.id === latestId;
141
+ const folded = !isLatest && !n.unfolded.has(t.id);
142
+ const revKey = `${t.rolledBackAt || 0}:${(t.changes || []).map((c) => (c.reverted ? 1 : 0)).join('')}`;
143
+ const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.approval || ''}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
144
+ const prev = n.turnNodes.get(t.id);
145
+ let node = prev?.node;
146
+ if (!prev || prev.key !== key) {
147
+ const toggle = () => (n.unfolded.has(t.id) ? n.unfolded.delete(t.id) : n.unfolded.add(t.id), renderBody(id, { fresh: false }));
148
+ node = folded ? renderFoldedTurn(t, toggle) : renderTurn(s, t, isLatest, isLatest ? null : toggle);
149
+ if (prev) prev.node.replaceWith(node);
150
+ if (!prev && !fresh && !folded) firstNew ??= node;
151
+ if (prev && prev.status === 'running' && t.status !== 'running') firstNew ??= node;
152
+ n.turnNodes.set(t.id, { key, node, status: t.status });
153
+ }
154
+ keep.add(t.id);
155
+ // Place the turn without re-inserting a node that's already here: re-parenting restarts its
156
+ // CSS animations, so a refetch would replay the whole card's entrance.
157
+ if (turnsEl.children[i] !== node) turnsEl.insertBefore(node, turnsEl.children[i] || null);
158
+ });
159
+ for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
160
+
161
+ updateDock(id);
162
+ if (dock?.isConnected) scrollByPx(dock.getBoundingClientRect().top - dockTop);
163
+ if (firstNew && watched) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
164
+ }
165
+
166
+ function renderFoldedTurn(t, unfold) {
167
+ const hero = t.presentation?.blocks?.find((b) => b.type === 'hero');
168
+ const node = el(`
169
+ <article class="turn folded" data-turn="${t.id}">
170
+ <button class="t-fold" title="Show this exchange">
171
+ ${icon('chevron', 'chev')}
172
+ <span class="t-prompt">${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
173
+ ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
174
+ ${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
175
+ <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
176
+ </button>
177
+ </article>`);
178
+ $('.t-fold', node).onclick = unfold;
179
+ return node;
180
+ }
181
+
182
+ // A follow-up that switched the mode says so, next to the model it ran on.
183
+ function modeSwitch(s, t) {
184
+ const i = s.turns.indexOf(t);
185
+ const prev = s.turns.slice(0, i).findLast((x) => x.mode)?.mode;
186
+ const m = t.mode && prev && t.mode !== prev && MODES.find((x) => x.id === t.mode);
187
+ 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>` : '';
188
+ }
189
+
190
+ // `fold` is set for older turns the user unfolded, so they can tuck them away again.
191
+ function renderTurn(s, t, isLatest, fold) {
192
+ const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
193
+ const long = t.prompt.length > 360 || t.prompt.split('\n').length > 5;
194
+ const cp = t.checkpoint;
195
+ const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
196
+ const ask = el(`
197
+ <div class="ask">
198
+ <div class="ask-text ${long ? 'clamped' : ''}">${mentionHtml(esc(t.prompt))}</div>
199
+ ${long ? '<button class="link xs more-btn">Show all</button>' : ''}
200
+ ${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>` : ''}
201
+ <div class="ask-foot">
202
+ ${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}
203
+ ${modeSwitch(s, t)}
204
+ <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
205
+ ${cpLabel ? `<span class="cp-sha" title="Git checkpoint before this turn (${esc(cp.head)}${cp.dirty ? ' + uncommitted changes' : ''})">${icon('branch')}${esc(cpLabel)}</span>` : ''}
206
+ ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
207
+ </div>
208
+ ${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
209
+ </div>`);
210
+ if (fold) ask.querySelector('.t-unfold').onclick = fold;
211
+ ask.querySelector('.more-btn')?.addEventListener('click', (e) => {
212
+ ask.querySelector('.ask-text').classList.toggle('clamped');
213
+ e.target.textContent = ask.querySelector('.ask-text').classList.contains('clamped') ? 'Show all' : 'Show less';
214
+ });
215
+ ask.querySelectorAll('.ask-atts img').forEach((img) => img.addEventListener('click', () => overlay(el(`<img class="lightbox" src="${img.src}">`))));
216
+ node.append(ask);
217
+
218
+ if (t.status === 'running') {
219
+ node.append(renderWorking(s, t));
220
+ } else {
221
+ node.append(renderPresentation(t.presentation, ctxFor(s, t, isLatest)));
222
+ const subs = renderSubs(s, t);
223
+ if (subs) node.append(subs);
224
+ node.append(renderTrail(s, t));
225
+ }
226
+ return node;
227
+ }
228
+
229
+ function renderWorking(s, t) {
230
+ const waiting = !!s.ask;
231
+ const node = el(`
232
+ <div class="w-panel ${waiting ? 'waiting' : ''}">
233
+ <div class="w-orb"><div class="orb live"><i></i><i></i><i></i></div></div>
234
+ <div class="w-body">
235
+ <div class="w-status">
236
+ <b>${waiting ? `${esc(agentName(s.agent))} is waiting for you` : `${esc(agentName(s.agent))} is working`}</b>
237
+ <span class="elapsed" data-start="${t.startedAt}">${fmtDuration(Date.now() - t.startedAt)}</span>
238
+ <span class="w-count">${t.activity.filter((a) => a.kind !== 'text').length} actions</span>
239
+ </div>
240
+ <div class="ask-slot"></div>
241
+ ${teamLiveHtml(s, t)}
242
+ <ul class="ticker"></ul>
243
+ <div class="w-actions">
244
+ <button class="link xs peek">Show all activity</button>
245
+ ${s.queue?.length ? `<span class="w-queued">${s.queue.length} follow-up${s.queue.length > 1 ? 's' : ''} queued</span>` : ''}
246
+ <span class="grow"></span>
247
+ <button class="ghost sm danger stop">${icon('stop')}Stop</button>
248
+ </div>
249
+ <ul class="log" hidden></ul>
250
+ </div>
251
+ </div>`);
252
+ if (waiting) $('.ask-slot', node).append(renderAskCard(s));
253
+ const ticker = $('.ticker', node);
254
+ t.activity
255
+ .filter((a) => a.kind !== 'text')
256
+ .slice(-5)
257
+ .forEach((a) => ticker.prepend(actItem(a)));
258
+ $('.stop', node).onclick = async (e) => {
259
+ // Shows at once; the run winds down and the live update replaces this panel.
260
+ const btn = e.currentTarget;
261
+ btn.disabled = true;
262
+ btn.lastChild.textContent = 'Stopping…';
263
+ $('.w-status b', node).textContent = `Stopping ${agentName(s.agent)}…`;
264
+ await api(`/sessions/${s.id}/stop`, { method: 'POST', body: {} }).catch((err) => {
265
+ btn.disabled = false;
266
+ btn.lastChild.textContent = 'Stop';
267
+ $('.w-status b', node).textContent = `${agentName(s.agent)} is working`;
268
+ toastError(err);
269
+ });
270
+ };
271
+ $('.peek', node).onclick = (e) => {
272
+ const log = $('.log', node);
273
+ log.hidden = !log.hidden;
274
+ e.target.textContent = log.hidden ? 'Show all activity' : 'Hide activity';
275
+ if (!log.hidden) log.innerHTML = t.activity.map((a) => actItem(a).outerHTML).join('');
276
+ };
277
+ return node;
278
+ }
279
+
280
+ function actItem(a) {
281
+ return el(`<li class="act k-${a.kind}">${icon(KIND_ICON[a.kind] || 'tool')}<span>${esc(a.label || '')}</span></li>`);
282
+ }
283
+
284
+ function renderAskCard(s) {
285
+ const a = s.ask;
286
+ const p = a.permission;
287
+ // A live approval: the agent is paused on a tool call its permissions don't allow by themselves.
288
+ const node = p
289
+ ? el(`
290
+ <div class="ask-card perm">
291
+ <div class="q-head">${icon('lock')}<span>Needs your approval</span></div>
292
+ <p class="q-text">${inlineMd(a.question)}</p>
293
+ ${p.detail && !a.question.includes(p.detail) ? `<pre class="perm-detail">${esc(p.detail)}</pre>` : ''}
294
+ ${a.context ? `<p class="q-context">${esc(a.context)}</p>` : ''}
295
+ <div class="q-opts">${a.options.map((o, i) => `<button class="opt ${i === 0 ? 'go' : i === a.options.length - 1 ? 'no' : ''}" data-i="${i}"><span class="k">${i + 1}</span>${esc(o)}</button>`).join('')}</div>
296
+ <form class="q-own"><input class="input" placeholder="Or say what to do instead (denies this call)…"><button class="ghost sm">Send</button></form>
297
+ </div>`)
298
+ : el(`
299
+ <div class="ask-card">
300
+ <div class="q-head">${icon('ask')}<span>Needs your call</span></div>
301
+ <p class="q-text">${esc(a.question)}</p>
302
+ ${a.context ? `<div class="prose q-context">${md(a.context)}</div>` : ''}
303
+ ${a.options?.length ? `<div class="q-opts">${a.options.map((o, i) => `<button class="opt" data-i="${i}"><span class="k">${i + 1}</span>${esc(o)}</button>`).join('')}</div>` : ''}
304
+ <form class="q-own"><input class="input" placeholder="${a.options?.length ? 'Or answer in your own words…' : 'Your answer…'}"><button class="primary sm">Answer</button></form>
305
+ </div>`);
306
+ const answer = async (text) => {
307
+ if (!text.trim()) return;
308
+ node.classList.add('sent');
309
+ const undo = startOptimistic(s.id, text, null, { answering: true });
310
+ try {
311
+ await api(`/sessions/${s.id}/answer`, { method: 'POST', body: { text } });
312
+ if (undo) scheduleRefetch(s.id);
313
+ } catch (e) {
314
+ node.classList.remove('sent');
315
+ undo?.();
316
+ toastError(e);
317
+ }
318
+ };
319
+ node.addEventListener('click', (e) => {
320
+ const b = e.target.closest('.opt');
321
+ if (b) answer(a.options[b.dataset.i]);
322
+ });
323
+ $('form', node).onsubmit = (e) => (e.preventDefault(), answer($('input', node).value));
324
+ return node;
325
+ }
326
+
327
+ function renderTrail(s, t) {
328
+ const acts = t.activity || [];
329
+ const count = (k) => acts.filter((a) => a.kind === k).length;
330
+ const parts = [
331
+ ['read', 'read', count('read')],
332
+ ['pen', 'edited', count('edit')],
333
+ ['terminal', 'ran', count('run')],
334
+ ['search', 'searched', count('search')],
335
+ ['web', 'web', count('web')],
336
+ ].filter((p) => p[2]);
337
+ const totals = (t.changes || []).reduce((acc, c) => {
338
+ const n = countChanges(c.edits);
339
+ return { add: acc.add + n.add, del: acc.del + n.del };
340
+ }, { add: 0, del: 0 });
341
+ const st = t.stats || {};
342
+ const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (t.changes?.length || t.checkpoint?.commit);
343
+ const rolledBack = !!t.rolledBackAt;
344
+ const rated = t.feedback?.rating;
345
+ const node = el(`
346
+ <div class="trail">
347
+ <div class="trail-bar">
348
+ ${acts.length ? `<button class="tb-acts" data-tab="activity" title="Show activity">${parts.length ? parts.map(([ic, l, n]) => `<span class="tb" title="${n} ${l}">${icon(ic)}${n}</span>`).join('') : 'Activity'}</button>` : ''}
349
+ ${t.endedAt ? `<span class="tb">${fmtDuration(t.endedAt - t.startedAt)}</span>` : ''}
350
+ ${st.costUsd ? `<span class="tb">$${st.costUsd.toFixed(2)}</span>` : ''}
351
+ <span class="grow"></span>
352
+ ${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>` : ''}
353
+ <span class="tb-tools">
354
+ ${t.presentation?.notes ? `<button class="tb-btn" data-tab="notes" title="Agent notes" aria-label="Agent notes">${icon('text')}</button>` : ''}
355
+ ${canRollback ? (rolledBack
356
+ ? `<button class="link xs cp-rollback rolled-back" data-a="rollback" title="Re-apply changes from this turn">${icon('rerun')}Re-apply</button>`
357
+ : `<button class="tb-btn cp-rollback" data-a="rollback" title="Restore working tree to before this turn${t.checkpoint?.head ? ` (${t.checkpoint.head.slice(0, 7)})` : ''}" aria-label="Restore to before this turn">${icon('replay')}</button>`) : ''}
358
+ ${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>` : ''}
359
+ </span>
360
+ </div>
361
+ <div class="trail-body" hidden></div>
362
+ </div>`);
363
+ const body = $('.trail-body', node);
364
+ let open = null;
365
+ node.addEventListener('click', (e) => {
366
+ if (e.target.closest('[data-a="rollback"]')) {
367
+ return rollbackTurn(s.id, t.id, rolledBack);
368
+ }
369
+ const rb = e.target.closest('[data-rate]');
370
+ if (rb) {
371
+ const next = t.feedback?.rating === rb.dataset.rate ? null : rb.dataset.rate;
372
+ rateTurn(s, t, node, next);
373
+ if (next !== 'down' && open === 'rate') ((open = null), (body.hidden = true));
374
+ if (next === 'down') {
375
+ open = 'rate';
376
+ $$('[data-tab]', node).forEach((x) => x.classList.remove('on'));
377
+ body.hidden = false;
378
+ body.innerHTML = '';
379
+ body.append(whyForm(s, t, () => ((open = null), (body.hidden = true))));
380
+ }
381
+ return;
382
+ }
383
+ const b = e.target.closest('[data-tab]');
384
+ if (!b) return;
385
+ const tab = b.dataset.tab;
386
+ $$('[data-tab]', node).forEach((x) => x.classList.toggle('on', x === b && open !== tab));
387
+ if (open === tab) {
388
+ open = null;
389
+ body.hidden = true;
390
+ return;
391
+ }
392
+ open = tab;
393
+ body.hidden = false;
394
+ body.innerHTML = '';
395
+ if (tab === 'activity') body.append(el(`<ul class="log">${acts.map((a) => actItem(a).outerHTML).join('')}</ul>`));
396
+ if (tab === 'notes') body.append(el(`<div class="prose">${md(t.presentation.notes)}</div>`));
397
+ if (tab === 'changes') {
398
+ body.append(renderPresentation({ blocks: t.changes.map((c) => ({ type: 'diff', file: c.file })) }, ctxFor(s, t, false)));
399
+ }
400
+ });
401
+ return node;
402
+ }
403
+
404
+ // Live activity without re-rendering the whole turn.
405
+ export function onActivity({ sessionId, turnId, item }) {
406
+ const s = S.full.get(sessionId);
407
+ if (!s) return;
408
+ const turn = s.turns.find((t) => t.id === turnId);
409
+ if (turn && !turn.activity.includes(item)) turn.activity.push(item);
410
+ const node = S.nodes.get(sessionId)?.turnNodes.get(turnId)?.node;
411
+ if (!node || item.kind === 'text') return;
412
+ const ticker = $('.ticker', node);
413
+ if (ticker) {
414
+ const li = actItem(item);
415
+ li.classList.add('new');
416
+ ticker.prepend(li);
417
+ while (ticker.children.length > 5) ticker.lastElementChild.remove();
418
+ }
419
+ const log = $('.log', node);
420
+ if (log && !log.hidden) log.append(actItem(item));
421
+ const c = $('.w-count', node);
422
+ if (c && turn) c.textContent = `${turn.activity.filter((a) => a.kind !== 'text').length} actions`;
423
+ }
424
+
425
+ export function replay(id) {
426
+ const n = S.nodes.get(id);
427
+ if (!n?.body) return;
428
+ for (const v of n.turnNodes.values()) v.node.remove();
429
+ n.turnNodes.clear();
430
+ renderBody(id, { fresh: false });
431
+ $('.turns', n.body).lastElementChild?.scrollIntoView({ block: 'start', behavior: 'smooth' });
432
+ }
@@ -0,0 +1,48 @@
1
+ // Self-update: the server polls npm; when a newer wisp-agents is out, a pill appears next to Search.
2
+
3
+ import { copy } from '../blocks.js';
4
+ import { esc } from '../md.js';
5
+ import { api } from '../core/api.js';
6
+ import { $ } from '../core/dom.js';
7
+ import { S } from '../core/state.js';
8
+ import { toast } from '../ui/toast.js';
9
+
10
+ // The server polls npm; when a newer wisp-agents is out, a pill appears next to Search.
11
+ let update = null;
12
+ export const loadUpdate = () => api('/update').then(renderUpdate).catch(() => {});
13
+ export function renderUpdate(u) {
14
+ update = u;
15
+ const b = $('#updateBtn');
16
+ const busy = u.status === 'installing' || u.status === 'restarting';
17
+ b.hidden = !u.available && !busy;
18
+ b.disabled = busy;
19
+ b.classList.toggle('busy', busy);
20
+ b.querySelector('span').textContent = u.status === 'installing' ? 'Updating…' : u.status === 'restarting' ? 'Restarting…' : `Update to ${u.latest}`;
21
+ b.title = u.auto ? `wisp ${u.current} → ${u.latest}: install and restart` : `wisp ${u.latest} is out. Run \`${u.command}\` to update`;
22
+ }
23
+
24
+ export async function installUpdate() {
25
+ if (!update?.available) return;
26
+ if (!update.auto) return toast(`wisp <b>${esc(update.latest)}</b> is out. Run <code>${esc(update.command)}</code>`, { action: { label: 'Copy', run: () => copy(update.command) }, timeout: 8000 });
27
+ const post = (force) => api('/update', { method: 'POST', body: { force } });
28
+ try {
29
+ await post(false).catch(async (err) => {
30
+ const running = [...S.sessions.values()].filter((s) => s.state === 'running' || s.state === 'waiting').length;
31
+ if (err.message !== 'agents are running') throw err;
32
+ if (!confirm(`Update to wisp ${update.latest} and restart? ${running || 'Some'} running agent${running === 1 ? '' : 's'} will be stopped.`)) throw null;
33
+ return post(true);
34
+ });
35
+ } catch (err) {
36
+ if (err) toast(esc(err.message), { tone: 'bad', timeout: 10_000, action: { label: 'Copy command', run: () => copy(update.command) } });
37
+ return;
38
+ }
39
+ renderUpdate({ ...update, status: 'restarting' });
40
+ // The server exits and a new one takes the port: reload once it answers with the new version.
41
+ const from = update.current;
42
+ const t0 = Date.now();
43
+ const poll = setInterval(async () => {
44
+ const h = await api('/health').catch(() => null);
45
+ if (h?.version && h.version !== from) return clearInterval(poll), location.reload();
46
+ if (Date.now() - t0 > 120_000) clearInterval(poll), toast('wisp did not come back. Start it again from your terminal.', { tone: 'bad', timeout: 15_000 });
47
+ }, 1000);
48
+ }
@@ -0,0 +1,75 @@
1
+ // Usage: spend and outcomes.
2
+
3
+ import { fmtDuration } from '../blocks.js';
4
+ import { esc } from '../md.js';
5
+ import { api } from '../core/api.js';
6
+ import { $, $$, el, icon } from '../core/dom.js';
7
+ import { money, pct } from '../core/format.js';
8
+ import { present } from '../ui/overlay.js';
9
+ import { toastError } from '../ui/toast.js';
10
+ import { agentName, harnessLogo } from './runners.js';
11
+
12
+ export async function openUsage({ days = 30, host } = {}) {
13
+ const node = el(`<div class="modal usage-modal">
14
+ <header><h2>Usage</h2><div class="mem-tabs us-range">${[7, 30, 90].map((d) => `<button data-days="${d}">${d} days</button>`).join('')}</div><button class="icon-btn close">${icon('x')}</button></header>
15
+ <div class="us-body"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
16
+ </div>`);
17
+ const close = present(node, host);
18
+ const render = async () => {
19
+ $$('.us-range button', node).forEach((b) => b.classList.toggle('on', Number(b.dataset.days) === days));
20
+ const u = await api(`/usage?days=${days}`).catch((err) => (toastError(err), null));
21
+ if (!u || !node.isConnected) return;
22
+ const t = u.total;
23
+ const unpriced = t.turns - t.priced;
24
+ // Cost when any harness reports it, else turns: one measure per chart, never two axes.
25
+ const byCost = t.priced > 0;
26
+ const val = (d) => (byCost ? d.cost : d.turns);
27
+ const max = Math.max(...u.daily.map(val), 0) || 1;
28
+ const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString([], { month: 'short', day: 'numeric' });
29
+ const table = (rows, label) =>
30
+ rows.length
31
+ ? `<table class="us-table"><thead><tr><th></th><th>Spend</th><th>Turns</th><th>Worked</th><th>Asked</th><th>Avg time</th></tr></thead><tbody>${rows
32
+ .map((r) => `<tr><td>${label(r)}</td><td>${r.priced ? money(r.cost) : '—'}</td><td>${r.turns}</td><td>${pct(r.successRate)}</td><td>${r.questions}</td><td>${fmtDuration(r.avgMs)}</td></tr>`)
33
+ .join('')}</tbody></table>`
34
+ : '<p class="mem-empty">Nothing in this range.</p>';
35
+ $('.us-body', node).innerHTML = `
36
+ <div class="us-tiles">
37
+ <div><span>Spend</span><b>${byCost ? money(t.cost) : '—'}</b><small>${unpriced ? `${unpriced} turn${unpriced > 1 ? 's' : ''} without a price` : 'all turns priced'}</small></div>
38
+ <div><span>Turns</span><b>${t.turns}</b><small>in ${t.sessions} session${t.sessions === 1 ? '' : 's'}</small></div>
39
+ <div><span>Worked</span><b>${pct(t.successRate)}</b><small>finished, not partial or blocked</small></div>
40
+ <div><span>Questions</span><b>${t.questions}</b><small>times an agent needed you</small></div>
41
+ </div>
42
+ <h4>${byCost ? 'Spend per day' : 'Turns per day'}</h4>
43
+ <div class="us-chart" role="img" aria-label="${byCost ? 'Spend' : 'Turns'} per day">${u.daily
44
+ .map((d) => `<div class="us-bar" data-tip="${esc(fmtDay(d.date))}: ${byCost ? money(d.cost) : ''}${byCost ? ' · ' : ''}${d.turns} turn${d.turns === 1 ? '' : 's'}"><i style="height:${val(d) ? Math.max(2, (val(d) / max) * 100) : 0}%"></i></div>`)
45
+ .join('')}</div>
46
+ <div class="us-axis"><span>${esc(fmtDay(u.daily[0].date))}</span><span>${byCost ? `max ${money(max)}/day` : `max ${max}/day`}</span><span>today</span></div>
47
+ <h4>By project</h4>${table(u.projects, (r) => `<span class="us-proj c-${esc(r.color)}"><i class="p-dot"></i>${esc(r.name)}</span>`)}
48
+ <h4>By harness · model</h4>${table(u.runners, (r) => `<span class="us-runner"><span class="model-logo">${harnessLogo(r.agent)}</span>${esc(agentName(r.agent))} · ${esc(r.model || 'default')}</span>`)}
49
+ ${unpriced ? '<p class="hint">Codex, Cursor and Antigravity report tokens but not cost, so their turns count in turns and outcomes but not in spend.</p>' : ''}`;
50
+ };
51
+ // One floating tooltip for the bars (they are too thin to label).
52
+ const tip = el('<div class="us-tip" hidden></div>');
53
+ node.append(tip);
54
+ node.addEventListener('mousemove', (e) => {
55
+ const b = e.target.closest('.us-bar');
56
+ tip.hidden = !b;
57
+ if (!b) return;
58
+ tip.textContent = b.dataset.tip;
59
+ const r = b.getBoundingClientRect();
60
+ const m = node.getBoundingClientRect();
61
+ tip.style.left = `${Math.min(r.left - m.left + r.width / 2, m.width - 90)}px`;
62
+ tip.style.top = `${r.top - m.top + node.scrollTop - 6}px`;
63
+ });
64
+ node.addEventListener('mouseleave', () => (tip.hidden = true));
65
+ $('.us-range', node).onclick = (e) => {
66
+ const d = Number(e.target.closest('[data-days]')?.dataset.days);
67
+ if (d) (days = d), render();
68
+ };
69
+ render();
70
+ }
71
+
72
+ function usageSummary(u) {
73
+ if (!u) return '';
74
+ return u.total.turns ? `${u.total.priced ? `${money(u.total.cost)} · ` : ''}${u.total.turns} turns · ${pct(u.total.successRate)} worked, last 7 days` : 'Nothing in the last 7 days';
75
+ }
@@ -0,0 +1,59 @@
1
+ // Task mentions in prompts, without the DOM. A prompt stores one as `@[Title](wisp:<id>)`; the
2
+ // prompt box shows `@Title` and keeps the ids aside (mentions = [{ id, title }]). The server reads
3
+ // the same form (server/mentions.js).
4
+
5
+ export const MENTION = /@\[([^\]\n]{1,200})\]\(wisp:([a-z0-9]{6,40})\)/g;
6
+
7
+ // Titles go inside [...], so no ] or newlines.
8
+ export const cleanTitle = (t) => String(t || 'Untitled task').replace(/[\]\n\r]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || 'Untitled task';
9
+
10
+ // Stored form → what the box shows, plus the mentions in it.
11
+ export function decode(text = '') {
12
+ const mentions = [];
13
+ const shown = String(text).replace(MENTION, (_, title, id) => {
14
+ if (!mentions.some((m) => m.id === id)) mentions.push({ id, title });
15
+ return `@${title}`;
16
+ });
17
+ return { text: shown, mentions };
18
+ }
19
+
20
+ // Where each mention sits in the shown text: every `@Title` of a known mention, longest titles
21
+ // first so one title that starts another doesn't steal it. Sorted, non-overlapping.
22
+ export function spans(text = '', mentions = []) {
23
+ const out = [];
24
+ for (const m of [...mentions].sort((a, b) => b.title.length - a.title.length)) {
25
+ const needle = `@${m.title}`;
26
+ for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + needle.length)) {
27
+ const end = i + needle.length;
28
+ if (!out.some((s) => i < s.end && end > s.start)) out.push({ start: i, end, id: m.id, title: m.title });
29
+ }
30
+ }
31
+ return out.sort((a, b) => a.start - b.start);
32
+ }
33
+
34
+ // Shown text → stored form. A mention the user edited into something else is just text again.
35
+ export function encode(text = '', mentions = []) {
36
+ let out = '';
37
+ let at = 0;
38
+ for (const s of spans(text, mentions)) {
39
+ out += text.slice(at, s.start) + `@[${s.title}](wisp:${s.id})`;
40
+ at = s.end;
41
+ }
42
+ return out + text.slice(at);
43
+ }
44
+
45
+ // For people: titles, no ids (card titles while a launch is pending).
46
+ export const plain = (text = '') => String(text).replace(MENTION, '@$1');
47
+
48
+ // Already-escaped HTML of a stored prompt → mentions as chips. link: chips open the task.
49
+ export const mentionHtml = (html, { link = true } = {}) =>
50
+ html.replace(MENTION, (_, title, id) => (link ? `<a class="mn" href="#/s/${id}" title="Open this task">@${title}</a>` : `<span class="mn">@${title}</span>`));
51
+
52
+ // Pasted text that is only task references (ids, `wisp:<id>`, `#/s/<id>` links), each a task
53
+ // `known(id)` finds → those ids; anything else → null (pasted as ordinary text).
54
+ export function pastedTasks(text, known) {
55
+ const parts = String(text || '').trim().split(/\s+/).filter(Boolean);
56
+ if (!parts.length || parts.length > 10) return null;
57
+ const ids = parts.map((p) => (p.match(/^(?:wisp:)?([a-f0-9]{12})$/) || p.match(/#\/s\/([a-z0-9]{6,40})\/?$/))?.[1]);
58
+ return ids.every((id) => id && known(id)) ? [...new Set(ids)] : null;
59
+ }