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,311 @@
1
+ // The composer at the bottom of an expanded card: follow-ups, answers, queued follow-ups.
2
+
3
+ import { esc } from '../md.js';
4
+ import { api } from '../core/api.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 { toast, toastError } from '../ui/toast.js';
9
+ import { toggleDone } from './actions.js';
10
+ import { acceptFiles, attachTray, autosize } from './attachments.js';
11
+ import { renderFeed, scheduleRefetch, setFocus } from './feed.js';
12
+ import { feedbackCommand, isFeedback } from './feedback.js';
13
+ import { placeName } from './places.js';
14
+ import { runnerPicker } from './runner-picker.js';
15
+ import { agentById, agentName, effortLabel, effortsFor, harnessesAt, harnessLogo, modeMenu, MODES, runnerTip, savedEffort, savedModel } from './runners.js';
16
+ import { feedAnchor, keepInPlace, watching } from './scroll.js';
17
+ import { shipDialog } from './ship.js';
18
+ import { renderBody } from './turns.js';
19
+
20
+ export function draftInto(id, text) {
21
+ const ta = $('.reply', S.nodes.get(id)?.card);
22
+ if (!ta) return;
23
+ ta.value = text;
24
+ ta.dispatchEvent(new Event('input'));
25
+ ta.focus();
26
+ ta.setSelectionRange(text.length, text.length);
27
+ }
28
+
29
+ export function renderDock(id) {
30
+ const n = S.nodes.get(id);
31
+ const dock = $('.it-dock', n.body);
32
+ const comp = el(`
33
+ <div class="composer">
34
+ <ul class="c-queued"></ul>
35
+ <textarea class="reply" rows="1" spellcheck="false"></textarea>
36
+ <div class="c-bar">
37
+ <button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
38
+ <button class="mode-btn c-mode" aria-haspopup="menu"></button>
39
+ <button class="c-runner" aria-haspopup="dialog"></button>
40
+ <span class="c-hint"></span>
41
+ <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>
42
+ <button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
43
+ <button class="ghost xs c-queue" hidden>${icon('queue')}<span>Queue</span></button>
44
+ <button class="send" title="Send (Enter · Shift+Enter for newline)">${icon('enter')}</button>
45
+ </div>
46
+ </div>`);
47
+ dock.append(comp);
48
+ n.tray = attachTray();
49
+ comp.prepend(n.tray.node);
50
+ const ta = $('.reply', comp);
51
+ autosize(ta, 260);
52
+ acceptFiles(comp, ta, n.tray);
53
+ $('.c-attach', comp).onclick = n.tray.pick;
54
+ $('.c-done', comp).onclick = () => toggleDone(id);
55
+ $('.c-ship', comp).onclick = () => shipDialog(id);
56
+ $('.c-queue', comp).onclick = toggleQueue;
57
+ const modeBtn = $('.c-mode', comp);
58
+ modeBtn.onclick = () => modeMenu(modeBtn, replyMode(id), (m) => (setReplyMode(id, m), ta.focus()));
59
+ const runnerBtn = $('.c-runner', comp);
60
+ runnerBtn.onclick = () => {
61
+ pickReplyRunner(id, runnerBtn);
62
+ };
63
+ ta.value = S.drafts.get(id) || '';
64
+ ta.addEventListener('input', () => S.drafts.set(id, ta.value));
65
+ ta.addEventListener('focus', () => setFocus(id, { scroll: false }));
66
+ ta.addEventListener('keydown', (e) => {
67
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
68
+ e.preventDefault();
69
+ sendReply(id, ta.value);
70
+ }
71
+ if (e.key === 'Tab' && e.shiftKey) {
72
+ e.preventDefault();
73
+ setReplyMode(id, MODES[(MODES.findIndex((m) => m.id === replyMode(id)) + 1) % MODES.length].id);
74
+ }
75
+ });
76
+ $('.send', comp).onclick = () => sendReply(id, ta.value);
77
+ }
78
+
79
+ // The mode the next follow-up runs in: the session's own until the user picks another here.
80
+ // The pick goes out with that follow-up and stays from then on.
81
+ const replyMode = (id) => S.replyModes.get(id) || S.full.get(id)?.mode || 'edits';
82
+
83
+ function setReplyMode(id, mode) {
84
+ if (mode === S.full.get(id)?.mode) S.replyModes.delete(id);
85
+ else S.replyModes.set(id, mode);
86
+ updateDock(id);
87
+ }
88
+
89
+ function paintReplyMode(id, btn) {
90
+ const s = S.full.get(id);
91
+ const m = MODES.find((x) => x.id === replyMode(id)) || MODES[1];
92
+ const from = s && S.replyModes.has(id) && MODES.find((x) => x.id === s.mode);
93
+ btn.className = `mode-btn c-mode m-${m.id}${from ? ' changed' : ''}`;
94
+ btn.innerHTML = `${icon(m.icon)}${m.id === 'edits' && !from ? '' : `<span>${m.label}</span>`}`;
95
+ btn.title = from
96
+ ? `${m.label}: ${m.hint}\nYour next follow-up switches this task from ${from.label} to ${m.label}. Shift+Tab to switch`
97
+ : `${m.label}: ${m.hint}\nPick another mode for your next follow-up. Shift+Tab to switch`;
98
+ // Subagents run in their lead's mode; the tour has nothing to run.
99
+ btn.hidden = !s || s.agent === 'wisp' || !!s.parentId;
100
+ }
101
+
102
+ // The harness, model and thinking level the next follow-up runs on: the session's own until the
103
+ // user picks another here. Like the mode, the pick goes out with that follow-up and stays.
104
+ const replyRunner = (id) => {
105
+ const s = S.full.get(id);
106
+ return S.replyRunners.get(id) || { agent: s?.agent, model: s?.model || '', effort: s?.effort || '' };
107
+ };
108
+
109
+ function setReplyRunner(id, run) {
110
+ const s = S.full.get(id);
111
+ if (s && run.agent === s.agent && run.model === (s.model || '') && run.effort === (s.effort || '')) S.replyRunners.delete(id);
112
+ else S.replyRunners.set(id, run);
113
+ updateDock(id);
114
+ }
115
+
116
+ function pickReplyRunner(id, btn) {
117
+ const s = S.full.get(id);
118
+ if (!s) return;
119
+ const cur = replyRunner(id);
120
+ // The session's own harness offers its own model first; other harnesses their remembered one.
121
+ const model = (a) => (a === s.agent && (agentById(a)?.models || []).some((m) => m.id === s.model) ? s.model : savedModel(a));
122
+ const effort = (a, m) => (a === s.agent && m === s.model && effortsFor(a, m).includes(s.effort) ? s.effort : savedEffort(a, m));
123
+ runnerPicker(btn, { agent: cur.agent, model: cur.model, effort: cur.effort, savedModel: model, savedEffort: effort, places: [s.machine?.id || ''] }, (agent, m, e) => {
124
+ setReplyRunner(id, { agent, model: m, effort: e });
125
+ });
126
+ }
127
+
128
+ function paintReplyRunner(id, btn) {
129
+ const s = S.full.get(id);
130
+ const run = replyRunner(id);
131
+ const changed = !!s && S.replyRunners.has(id);
132
+ const harness = agentName(run.agent);
133
+ btn.className = `c-runner${changed ? ' changed' : ''}`;
134
+ // One quiet logo until it changes; then it says where the next follow-up goes.
135
+ const model = (run.model && agentById(run.agent)?.models?.find((m) => m.id === run.model)?.label) || run.model || 'Default';
136
+ const label = run.agent === s?.agent ? model : `${harness} · ${model}`;
137
+ btn.innerHTML = `<span class="rb-logo">${harnessLogo(run.agent)}</span>${changed ? `<span class="cr-label">${esc(label)}</span>${run.effort ? `<span class="rb-e">${esc(effortLabel(run.effort))}</span>` : ''}` : ''}`;
138
+ const now = runnerTip(s?.agent, s?.model, s?.effort);
139
+ const where = s?.machine ? ` (on ${placeName(s.machine.id)})` : '';
140
+ btn.title = !changed
141
+ ? `${now}\nPick another harness or model for your next follow-up${where}`
142
+ : run.agent !== s.agent
143
+ ? `${runnerTip(run.agent, run.model, run.effort)}\nYour next follow-up moves this task from ${agentName(s.agent)} to ${harness}, with the conversation so far. Click to change`
144
+ : `${runnerTip(run.agent, run.model, run.effort)}\nYour next follow-up switches from ${now}. Same conversation. Click to change`;
145
+ btn.hidden = !s || s.agent === 'wisp' || !!s.parentId || !harnessesAt([s.machine?.id || '']).some((a) => a.available);
146
+ }
147
+
148
+ export function updateDock(id) {
149
+ const s = S.full.get(id);
150
+ const n = S.nodes.get(id);
151
+ const ta = n && $('.reply', n.card);
152
+ if (!s || !ta) return;
153
+ const name = agentName(s.agent);
154
+ const next = agentName(replyRunner(id).agent); // where the next follow-up goes, if it was switched
155
+ const tour = s.agent === 'wisp';
156
+ ta.disabled = tour;
157
+ ta.placeholder = tour
158
+ ? 'This is the tour — launch a real wisp from the box at the top'
159
+ : s.ask
160
+ ? `Answer ${name}…`
161
+ : s.state === 'running'
162
+ ? queueing()
163
+ ? `Queue a follow-up — sent when ${name} finishes`
164
+ : `Steer ${name} — interrupts and sends now`
165
+ : s.state === 'done'
166
+ ? next !== name
167
+ ? `Follow up with ${next} to reopen…`
168
+ : 'Follow up to reopen…'
169
+ : `Follow up with ${next}…`;
170
+ $('.c-hint', n.card).innerHTML = s.queue?.length ? `${s.queue.length} queued` : '';
171
+ paintReplyMode(id, $('.c-mode', n.card));
172
+ paintReplyRunner(id, $('.c-runner', n.card));
173
+ renderQueued(s, $('.c-queued', n.card), name);
174
+ const q = $('.c-queue', n.card);
175
+ q.hidden = tour || s.state !== 'running';
176
+ q.classList.toggle('on', queueing());
177
+ q.title = queueing()
178
+ ? `Queueing: follow-ups wait until ${name} finishes. Click to send now instead.`
179
+ : `Sending now: follow-ups interrupt ${name}. Click to queue them instead.`;
180
+ }
181
+
182
+ // Follow-ups waiting their turn, shown above the composer so it's clear what goes out next.
183
+ function renderQueued(s, list, name) {
184
+ const key = (s.queue || []).map((q) => `${q.id}${q.run ? `:${q.run.agent}/${q.run.model}` : ''}`).join();
185
+ if (list.dataset.key === key) return;
186
+ list.dataset.key = key;
187
+ list.innerHTML = '';
188
+ (s.queue || []).forEach((q, i) => {
189
+ const li = el(`
190
+ <li class="cq">
191
+ <span class="cq-tag">${i === 0 ? `Next · after ${esc(name)} finishes` : `Queued #${i + 1}`}${q.run ? ` · on ${esc(runnerTip(q.run.agent, q.run.model, ''))}` : ''}</span>
192
+ <span class="cq-text">${esc(q.text)}</span>
193
+ <button class="att-x" title="Cancel this follow-up">${icon('x')}</button>
194
+ </li>`);
195
+ const cancel = () => {
196
+ // Items queued by a wisp older than queue ids get one when wisp restarts.
197
+ if (!q.id) return toast('Restart wisp to cancel this follow-up.', { tone: 'bad' });
198
+ const short = q.text.length > 80 ? `${q.text.slice(0, 80)}…` : q.text;
199
+ if (!confirm(`Cancel this queued follow-up?\n\n“${short}”`)) return;
200
+ api(`/sessions/${s.id}/queue?item=${encodeURIComponent(q.id)}`, { method: 'DELETE' }).catch(toastError);
201
+ };
202
+ $('.att-x', li).onclick = cancel;
203
+ li.addEventListener('contextmenu', (e) => {
204
+ e.preventDefault();
205
+ e.stopPropagation();
206
+ queuedMenu(e, cancel);
207
+ });
208
+ list.append(li);
209
+ });
210
+ }
211
+
212
+ // Right-click on a queued follow-up.
213
+ function queuedMenu(e, cancel) {
214
+ $('.menu.queued')?.close();
215
+ const menu = el(`
216
+ <div class="menu queued" style="top:${e.clientY}px;left:${e.clientX}px">
217
+ <button data-a="cancel" class="danger">${icon('x')}Cancel follow-up</button>
218
+ </div>`);
219
+ menu.onclick = (ev) => {
220
+ const a = ev.target.closest('button')?.dataset.a;
221
+ close();
222
+ if (a === 'cancel') cancel();
223
+ };
224
+ const close = popover(menu);
225
+ const r = menu.getBoundingClientRect();
226
+ if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
227
+ if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
228
+ }
229
+
230
+ // Follow-ups sent while an agent works interrupt it, unless the user flipped this on.
231
+ const queueing = () => S.config?.defaults?.followUp === 'queue';
232
+
233
+ async function toggleQueue() {
234
+ const followUp = queueing() ? 'now' : 'queue';
235
+ S.config.defaults.followUp = followUp;
236
+ S.nodes.forEach((_, id) => updateDock(id));
237
+ await api('/config', { method: 'PATCH', body: { defaults: { followUp } } }).catch(toastError);
238
+ }
239
+
240
+ export async function sendReply(id, text) {
241
+ const s = S.full.get(id);
242
+ const n = S.nodes.get(id);
243
+ text = (text || '').trim();
244
+ if (!s || !text) return;
245
+ const ta = n && $('.reply', n.card);
246
+ if (isFeedback(text)) {
247
+ if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
248
+ S.drafts.delete(id);
249
+ return feedbackCommand(text, { sessionId: id, restore: () => ta?.isConnected && !ta.value && ((ta.value = text), ta.dispatchEvent(new Event('input'))) });
250
+ }
251
+ if (n?.tray?.busy) toast('Waiting for uploads…');
252
+ const attachments = n?.tray ? await n.tray.ids() : [];
253
+ const mode = S.replyModes.get(id);
254
+ const run = S.replyRunners.get(id);
255
+ // An answer goes to the run that asked; a new mode or harness needs a new run, so that's a follow-up.
256
+ const answering = !!s.ask && !attachments.length && !mode && !run;
257
+ // Optimistic: the composer empties and the card goes to Working before the server answers
258
+ // (it may first have to bring back an archived worktree, or reach another machine).
259
+ if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
260
+ S.drafts.delete(id);
261
+ S.replyModes.delete(id);
262
+ S.replyRunners.delete(id);
263
+ const files = n?.tray?.take();
264
+ const undo = startOptimistic(id, text, files, { answering, mode, run });
265
+ try {
266
+ const r = answering
267
+ ? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
268
+ : await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing(), mode, ...run } });
269
+ if (undo) scheduleRefetch(id); // the summary that follows says "running" too, so it won't refetch by itself
270
+ if (r.queued)
271
+ 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.');
272
+ } catch (err) {
273
+ undo?.();
274
+ // Give the message back, unless the user has already started typing another one.
275
+ if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
276
+ if (mode && !S.replyModes.has(id)) setReplyMode(id, mode);
277
+ if (run && !S.replyRunners.has(id)) setReplyRunner(id, run);
278
+ n?.tray?.restore(files);
279
+ toastError(err);
280
+ }
281
+ }
282
+
283
+ // Show a follow-up as started right away: the card moves to Working with the prompt as a live
284
+ // turn. Only when it will start now, not when it queues or interrupts a run already working.
285
+ // Returns an undo for a failed send, or null when nothing changed.
286
+ export function startOptimistic(id, text, files, { answering, mode, run }) {
287
+ const full = S.full.get(id);
288
+ const sum = S.sessions.get(id);
289
+ if (!full || !sum) return null;
290
+ const live = full.state === 'running' || full.state === 'waiting';
291
+ if (live && !answering) return null;
292
+ const now = Date.now();
293
+ 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 })) };
294
+ if (mode) turn.mode = mode;
295
+ if (run) Object.assign(turn, run);
296
+ const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn], ...(mode && { mode }), ...run };
297
+ const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : run && run.agent !== full.agent ? `Handing over to ${agentName(run.agent)}` : 'Starting up', updatedAt: now, ...(mode && { mode }), ...run };
298
+ const apply = (f, sm) => {
299
+ S.full.set(id, f);
300
+ S.sessions.set(id, sm);
301
+ const card = S.nodes.get(id)?.card;
302
+ keepInPlace(watching(id) ? card : feedAnchor(id), renderFeed);
303
+ renderBody(id, { fresh: false });
304
+ };
305
+ apply(nextFull, nextSum);
306
+ return () => {
307
+ // A live update may already have replaced the guess; only roll back what is still ours.
308
+ if (S.full.get(id) !== nextFull || S.sessions.get(id) !== nextSum) return scheduleRefetch(id);
309
+ apply(full, sum);
310
+ };
311
+ }