wisp-agents 0.24.0 → 0.25.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 (43) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +44 -4
  3. package/bin/wisp.js +55 -1
  4. package/package.json +2 -2
  5. package/server/adapters/codex.js +3 -0
  6. package/server/client.js +2 -2
  7. package/server/index.js +17 -2
  8. package/server/mcp-ask.js +44 -2
  9. package/server/openapi.js +39 -1
  10. package/server/protocol.js +32 -3
  11. package/server/render.js +16 -1
  12. package/server/routes/automations.js +8 -5
  13. package/server/routes/internal.js +61 -0
  14. package/server/routes/meta.js +5 -1
  15. package/server/routes/settings.js +17 -3
  16. package/server/routes/spells.js +147 -0
  17. package/server/runner.js +10 -3
  18. package/server/services/launch.js +7 -2
  19. package/server/services/spell-hooks.js +99 -0
  20. package/server/spell-guide.js +135 -0
  21. package/server/spells-data.js +60 -0
  22. package/server/spells.js +647 -0
  23. package/server/update.js +50 -16
  24. package/web/css/feed.css +1 -1
  25. package/web/css/spells.css +205 -0
  26. package/web/index.html +4 -0
  27. package/web/js/app.js +3 -0
  28. package/web/js/blocks.js +7 -1
  29. package/web/js/features/actions.js +3 -0
  30. package/web/js/features/commands.js +11 -0
  31. package/web/js/features/composer.js +12 -0
  32. package/web/js/features/feed.js +3 -0
  33. package/web/js/features/keys.js +15 -0
  34. package/web/js/features/launcher.js +56 -11
  35. package/web/js/features/live.js +9 -1
  36. package/web/js/features/settings.js +21 -0
  37. package/web/js/features/slash.js +2 -1
  38. package/web/js/features/spell-actions.js +64 -0
  39. package/web/js/features/spell-ui.js +398 -0
  40. package/web/js/features/spells.js +715 -0
  41. package/web/js/features/turns.js +2 -0
  42. package/web/js/features/update.js +1 -2
  43. package/web/js/spell-schema.js +246 -0
@@ -27,6 +27,8 @@ import { needsSetup, nudgeSetup, renderSetup } from './setup.js';
27
27
  import { agentName, agentsAt, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, MAX_RUNNERS, modeMenu, MODES, paintRunnerBtn, runnerTip, savedEffort, savedModel } from './runners.js';
28
28
  import { feedAnchor, keepInPlace, stage, topInset } from './scroll.js';
29
29
  import { canLead, paintTeamBtn, teamEditor } from './team.js';
30
+ import { activeSpells, launcherParts, runAction, spellProp } from './spells.js';
31
+ import { cast } from './spell-ui.js';
30
32
 
31
33
  // "No folder": a general ask with no project. The server gives it an empty scratch directory.
32
34
  const NO_FOLDER = 'none';
@@ -72,6 +74,19 @@ function syncWhere() {
72
74
  launch.project = storedPick() || null;
73
75
  }
74
76
 
77
+ // A spell's slash command at the start of `text`: { spell, cmd, args } or null.
78
+ export function spellSlash(text) {
79
+ const m = /^\/([a-z][a-z0-9-]*)(?:\s+([\s\S]*))?$/i.exec(String(text || '').trim());
80
+ if (!m) return null;
81
+ for (const spell of activeSpells()) {
82
+ const cmd = (spell.slash || []).find((c) => c.name === m[1].toLowerCase());
83
+ if (cmd) return { spell, cmd, args: (m[2] || '').trim() };
84
+ }
85
+ return null;
86
+ }
87
+
88
+ bus.on('spells:launcher', () => $('#launcher:not(.skel)') && renderLauncher());
89
+
75
90
  export function renderLauncher() {
76
91
  const box = $('#launcher');
77
92
  const mode = S.config.defaults.mode || DEFAULT_MODE.id;
@@ -104,18 +119,19 @@ export function renderLauncher() {
104
119
  <div class="l-recipes"></div>`;
105
120
  renderLaunchChips();
106
121
  renderSetup();
122
+ launcherParts(box); // what spells add to the prompt box
107
123
 
108
124
  const ta = mentionBox($('#prompt', box));
109
125
  const tray = attachTray();
110
126
  ta.parentNode.before(tray.node); // before the mention box's wrapper
111
127
  acceptFiles(box, ta, tray);
112
- autosize(ta);
128
+ autosize(ta, (spellProp('launcher.input', 'maxRows') || 0) * 24 || undefined);
113
129
  ta.value = S.drafts.get('home') || '';
114
130
  ta.addEventListener('input', () => (S.drafts.set('home', ta.value), paintSaveRecipe()));
115
131
  // While focus is elsewhere, the placeholder advertises the shortcut that brings you back.
116
- const hint = () => (ta.placeholder = document.activeElement === ta
132
+ const hint = () => (ta.placeholder = spellProp('launcher.input', 'placeholder') || (document.activeElement === ta
117
133
  ? 'What should I go do? Paste screenshots, drop logs or videos…'
118
- : `What should I go do? Press ${combo('L')} to start typing`);
134
+ : `What should I go do? Press ${combo('L')} to start typing`));
119
135
  ta.addEventListener('focus', hint);
120
136
  ta.addEventListener('blur', hint);
121
137
  hint();
@@ -247,8 +263,7 @@ export function renderLauncher() {
247
263
  });
248
264
  });
249
265
  paintCompare();
250
- runnerBtn.onclick = () =>
251
- runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launch.where, auto: true }, (id, model, effort) => {
266
+ const pickRun = (id, model, effort) => {
252
267
  agent = harnessesAt(launch.where, { auto: true }).find((a) => a.id === id);
253
268
  curModel = model;
254
269
  curEffort = effort;
@@ -260,11 +275,38 @@ export function renderLauncher() {
260
275
  const efforts = effort || effortsFor(id, model).length ? { ...d.efforts, [id]: effort } : d.efforts;
261
276
  Object.assign(d, { agent: id, model, models, effort, efforts });
262
277
  api('/config', { method: 'PATCH', body: { defaults: { agent: id, model, models, effort, efforts } } }).catch(() => {});
263
- });
278
+ };
279
+ runnerBtn.onclick = () => runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launch.where, auto: true }, pickRun);
280
+ // Spells: set the pickers (setLaunch), or launch once with other settings (launchWith).
281
+ box.spellSet = (v) => {
282
+ if (v.agent && harnessesAt(launch.where, { auto: true }).some((a) => a.id === v.agent)) pickRun(v.agent, v.model ?? savedModel(v.agent), v.effort ?? savedEffort(v.agent, v.model ?? savedModel(v.agent)));
283
+ else if (v.agent) toast(`${esc(agentName(v.agent))} isn’t installed here`, { tone: 'bad' });
284
+ if (v.mode) setMode(v.mode);
285
+ };
286
+ box.spellLaunch = (v) => go(v);
264
287
 
265
- const go = async () => {
288
+ // over: a spell's launchWith ({ agent, model, effort, mode, text }) for this one launch.
289
+ const go = async (over = null) => {
290
+ if (over && !(over instanceof Event) && over.text && !ta.value.trim().startsWith(over.text)) ta.value = `${over.text}${ta.value.trim() ? `\n\n${ta.value.trim()}` : ''}`;
291
+ if (over instanceof Event) over = null;
266
292
  const prompt = ta.value.trim();
267
293
  if (!prompt) return ta.focus();
294
+ // /spell <what to change>: cast a spell instead of starting a task.
295
+ const spellAsk = /^\/spell(?:\s+|$)([\s\S]*)$/i.exec(prompt);
296
+ if (spellAsk) {
297
+ if (!spellAsk[1].trim()) return toast('Say what to change after /spell, like “/spell a floating button that opens my PRs”');
298
+ ta.value = '';
299
+ S.drafts.delete('home');
300
+ ta.dispatchEvent(new Event('input'));
301
+ return cast(spellAsk[1]).catch((err) => (toastError(err), (ta.value = prompt), ta.dispatchEvent(new Event('input'))));
302
+ }
303
+ // A spell's slash command, typed in the prompt box.
304
+ const slash = spellSlash(prompt);
305
+ if (slash) {
306
+ ta.value = '';
307
+ ta.dispatchEvent(new Event('input'));
308
+ return runAction(slash.spell, slash.cmd.do, { args: slash.args, input: ta });
309
+ }
268
310
  if (isFeedback(prompt)) {
269
311
  ta.value = '';
270
312
  ta.dispatchEvent(new Event('input'));
@@ -277,7 +319,7 @@ export function renderLauncher() {
277
319
  const off = targets.find((t) => t.m && t.m.state !== 'online');
278
320
  if (off) return offlineToast(off.k);
279
321
  if (needsSetup()) return nudgeSetup();
280
- const wanted = [agent?.id, ...compare.map((c) => c.agent)].filter((id) => id !== 'auto'); // Auto picks from what's there
322
+ const wanted = [over?.agent || agent?.id, ...compare.map((c) => c.agent)].filter((id) => id !== 'auto'); // Auto picks from what's there
281
323
  for (const t of targets) {
282
324
  const list = agentsAt(t.k);
283
325
  const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
@@ -290,7 +332,8 @@ export function renderLauncher() {
290
332
  const attachments = await tray.ids();
291
333
  const files = tray.take();
292
334
  const cmp = compare;
293
- const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
335
+ const runner = over?.agent ? { agent: over.agent, model: over.model ?? savedModel(over.agent), effort: over.effort ?? '' } : { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
336
+ const launchMode = over?.mode || curMode;
294
337
  // A repo still cloning gets a stand-in project; its card says so until the clone lands.
295
338
  const fetching = (t) => t.k && [t.project, ...t.also].some((p) => p && !p.scratch && !pathAt(p, t.k)); // not on that machine yet
296
339
  const pending = targets.map((t) => addPending(prompt, t.project ? t : { ...t, project: { id: t.clone.id, name: t.clone.name, path: '', color: 'slate' } }, runner, t.clone ? `Cloning ${t.clone.label}` : fetching(t) ? `Cloning ${t.project.name} on ${placeName(t.k)}` : undefined));
@@ -320,7 +363,7 @@ export function renderLauncher() {
320
363
  targets.map((t) =>
321
364
  api('/sessions', {
322
365
  method: 'POST',
323
- body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, also: t.also.length ? t.also.map((p) => p.path) : undefined, scratch: t.project.scratch || undefined, machine: t.k, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
366
+ body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, also: t.also.length ? t.also.map((p) => p.path) : undefined, scratch: t.project.scratch || undefined, machine: t.k, groupId, agent: runner.agent, mode: launchMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
324
367
  }),
325
368
  ),
326
369
  );
@@ -347,7 +390,9 @@ export function renderLauncher() {
347
390
  tray.restore(files);
348
391
  }
349
392
  };
350
- $('#launch', box).onclick = go;
393
+ $('#launch', box).onclick = () => {
394
+ go();
395
+ };
351
396
  // Picking other places (or a machine coming online) changes which harnesses exist: keep the
352
397
  // pick if one of them still has it, else move to one that's installed there.
353
398
  box.placesChanged = () => {
@@ -17,6 +17,7 @@ import { openSettings } from './settings.js';
17
17
  import { renderPrStrip } from './ship.js';
18
18
  import { onChildSummary } from './team.js';
19
19
  import { onActivity } from './turns.js';
20
+ import { applySpells, spellEvent } from './spells.js';
20
21
  import { loadUpdate, renderUpdate } from './update.js';
21
22
 
22
23
  export function connect() {
@@ -37,10 +38,16 @@ export function connect() {
37
38
  keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id), renderFeed);
38
39
  if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
39
40
  if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state, prev?.shipping]) !== JSON.stringify([s.pr, s.toShip, s.state, s.shipping])) renderPrStrip(s.id);
41
+ if (prev?.state !== s.state || prev?.pr?.state !== s.pr?.state) spellEvent('summary');
40
42
  });
41
43
  es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
42
44
  es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
43
- es.addEventListener('finished', (e) => notifyFinished(JSON.parse(e.data).session));
45
+ es.addEventListener('finished', (e) => (notifyFinished(JSON.parse(e.data).session), spellEvent('finished')));
46
+ // A spell hook's notify, from the server.
47
+ es.addEventListener('spell', (e) => {
48
+ const m = JSON.parse(e.data);
49
+ if (m.toast) toast(`<b>${esc(m.spell)}</b> · ${esc(m.toast)}`);
50
+ });
44
51
  es.addEventListener('removed', (e) => {
45
52
  const { id } = JSON.parse(e.data);
46
53
  S.sessions.delete(id);
@@ -80,6 +87,7 @@ export async function loadSessions() {
80
87
 
81
88
  export async function loadConfig() {
82
89
  S.config = await api('/config');
90
+ applySpells();
83
91
  renderProjects();
84
92
  renderLaunchChips();
85
93
  paintAutoBtn();
@@ -23,6 +23,7 @@ import { copySetupCommand, INSTALLABLE, installHarness, isInstalling, setupComma
23
23
  import { clearPicks } from './transcript.js';
24
24
  import { repaintViews } from './turns.js';
25
25
  import { openUsage } from './usage.js';
26
+ import { spellsPane } from './spell-ui.js';
26
27
 
27
28
  // A sidebar of sections and one pane at a time, like every other app's settings. Big lists
28
29
  // (memory, automations, usage history) keep their own views; a pane only summarises and links.
@@ -34,6 +35,7 @@ const SETTINGS_PANES = [
34
35
  { id: 'machines', label: 'Machines', icon: 'server', lede: 'Other computers that run agents for you, over SSH.' },
35
36
  { id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
36
37
  { id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
38
+ { id: 'spells', label: 'Spells', icon: 'spark', lede: 'Changes to wisp itself: buttons, panels, blocks, looks and behaviour. Cast one, or add one a colleague sent.' },
37
39
  { id: 'usage', label: 'Usage', icon: 'stats', lede: 'Spend and outcomes over the last 7 days.' },
38
40
  { id: 'privacy', label: 'Privacy', icon: 'info', lede: 'What wisp sends about itself. Never prompts, paths or code.' },
39
41
  ];
@@ -135,6 +137,7 @@ export function openSettings(opts) {
135
137
  ${row('Triggers', '<span class="trigger-hint"></span>', '<button class="ghost sm" data-auto="triggers">Manage</button>')}
136
138
  </div>
137
139
  </section>
140
+ <section class="sx-pane sp-pane" data-pane="spells"></section>
138
141
  <section class="sx-pane" data-pane="usage">
139
142
  <div class="us-tiles sx-usage"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
140
143
  <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>
@@ -149,6 +152,7 @@ export function openSettings(opts) {
149
152
  </div>`);
150
153
  const close = overlay(node);
151
154
  $('.close', node).onclick = close;
155
+ spellsPane($('[data-pane="spells"].sx-pane', node));
152
156
  const paintAgents = () => {
153
157
  $('.sx-agents', node).innerHTML = S.agents.length
154
158
  ? S.agents.map((a) => {
@@ -441,6 +445,23 @@ export function openSettings(opts) {
441
445
  $('#openRouter', node).onclick = () => openRouter({ host: host('auto') });
442
446
  $('#openMemory', node).onclick = () => openMemory({ host: host('memory') });
443
447
  $$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
448
+ // Settings a spell sets are locked while it's on: the row says which spell, and links to Spells.
449
+ const LOCKABLE = { transcript: '#txDefault', worktree: '#wtDefault', fixConflicts: '#fixConflicts', learn: '#learn', editor: 'select[name="editor"]', notify: '#notif' };
450
+ const paintLocks = () => {
451
+ for (const n of $$('.sp-lock', node)) n.remove();
452
+ for (const [key, sel] of Object.entries(LOCKABLE)) {
453
+ const ctl = $(sel, node);
454
+ if (!ctl) continue;
455
+ const by = S.config.lockedBy?.[key];
456
+ ctl.disabled = !!by;
457
+ if (by) $('.sx-text', ctl.closest('.sx-row'))?.insertAdjacentHTML('beforeend', `<small class="sp-lock">${icon('spark')}Set by the spell <b>${esc(by.name)}</b> · <button class="link xs" data-pane-go="spells">Spells</button></small>`);
458
+ }
459
+ };
460
+ paintLocks();
461
+ bus.on('config', paintLocks, { node });
462
+ node.addEventListener('click', (e) => {
463
+ if (e.target.closest('[data-pane-go]')) show(e.target.closest('[data-pane-go]').dataset.paneGo);
464
+ });
444
465
  $('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
445
466
  $('#openPhone', node).onclick = () => openPhone({ host: host('notifications') });
446
467
  $('#openMachines', node).onclick = () => openMachines({ host: host('machines') });
@@ -5,6 +5,7 @@ import { esc } from '../md.js';
5
5
  import { el } from '../core/dom.js';
6
6
  import { SLASH, slashMatches } from '../slash-text.js';
7
7
  import { feedbackOn } from './feedback.js';
8
+ import { activeSpells } from './spells.js';
8
9
 
9
10
  // Register before the box's own Enter handler: picking a command must not send it.
10
11
  export function slashMenu(ta, host) {
@@ -13,7 +14,7 @@ export function slashMenu(ta, host) {
13
14
  let items = [];
14
15
  let sel = 0;
15
16
  let closed = false; // Esc: hidden until the text changes
16
- const commands = () => SLASH.filter((c) => c.name !== 'feedback' || feedbackOn());
17
+ const commands = () => [...SLASH.filter((c) => c.name !== 'feedback' || feedbackOn()), ...activeSpells().flatMap((s) => (s.slash || []).map((c) => ({ name: c.name, args: c.args || '', hint: c.hint || `From ${s.name}` })))];
17
18
  const paint = () => {
18
19
  const next = !closed && document.activeElement === ta ? slashMatches(ta.value, commands()) : [];
19
20
  if (next.map((c) => c.name).join() !== items.map((c) => c.name).join()) sel = 0;
@@ -0,0 +1,64 @@
1
+ // What spell buttons do to tasks, through the same functions the rest of the page uses. Loaded on
2
+ // first use, so spells.js doesn't import half the app.
3
+
4
+ import { esc } from '../md.js';
5
+ import { api } from '../core/api.js';
6
+ import { $, el, icon } from '../core/dom.js';
7
+ import { S } from '../core/state.js';
8
+ import { overlay } from '../ui/overlay.js';
9
+ import { toast } from '../ui/toast.js';
10
+ import { setArchived, toggleDone } from './actions.js';
11
+ import { recipeParams, runRecipe } from './automations.js';
12
+ import { sendReply } from './composer.js';
13
+ import { shipDialog } from './ship.js';
14
+
15
+ // A follow-up: through the card's box when it's open (optimistic, like typing it), else straight to the API.
16
+ export async function sendTo(id, text) {
17
+ if (S.full.has(id) && S.nodes.get(id)?.body) return sendReply(id, text);
18
+ await api(`/sessions/${id}/reply`, { method: 'POST', body: { text } });
19
+ toast('Sent');
20
+ }
21
+
22
+ const project = () => S.config.projects.find((p) => p.id === S.filter) || null;
23
+
24
+ export async function launchTask(v, s) {
25
+ const cwd = v.cwd || s?.cwd || project()?.path || '';
26
+ const out = await api('/sessions', { method: 'POST', body: { prompt: v.prompt, ...(cwd ? { cwd } : { scratch: true }), agent: v.agent || undefined, model: v.model || undefined, effort: v.effort || undefined, mode: v.mode || undefined } });
27
+ toast(`Started <b>${esc(out.title)}</b>`);
28
+ }
29
+
30
+ // Rerun a task as a new one (its first request unless the spell gives a prompt), on another harness.
31
+ export async function rerunOn(s, v) {
32
+ const full = S.full.get(s.id) || (await api(`/sessions/${s.id}`));
33
+ const prompt = v.prompt || full.turns?.[0]?.prompt || s.title;
34
+ const out = await api('/sessions', { method: 'POST', body: { prompt, cwd: s.scratch ? undefined : s.cwd, scratch: s.scratch || undefined, agent: v.agent || undefined, model: v.model ?? undefined, effort: v.effort || undefined, mode: v.mode || undefined } });
35
+ toast(`Started <b>${esc(out.title)}</b> on ${esc(out.agent)}`);
36
+ }
37
+
38
+ export function recipe(name) {
39
+ const loose = (x) => String(x).toLowerCase().replace(/[^a-z0-9]+/g, '');
40
+ const r = (S.config.recipes || []).find((x) => x.id === name || loose(x.name) === loose(name));
41
+ if (!r) throw new Error(`No recipe named ${name}`);
42
+ runRecipe({ ...r, params: recipeParams(r.prompt) });
43
+ }
44
+
45
+ export const ship = (id) => shipDialog(id);
46
+ export const done = (id) => toggleDone(id);
47
+ export const archive = (id) => setArchived(id, true);
48
+
49
+ export async function exec(id, command) {
50
+ const out = await api(`/sessions/${id}/exec`, { method: 'POST', body: { command, key: `spell:${command}` } });
51
+ const ok = out.exitCode === 0;
52
+ toast(`${icon(ok ? 'check' : 'x')} <code>${esc(command)}</code> ${ok ? 'finished' : `exited ${esc(out.exitCode)}`}`, {
53
+ tone: ok ? '' : 'bad',
54
+ action: {
55
+ label: 'Output',
56
+ run: () => {
57
+ const node = el(`<div class="modal sp-panel"><header><h2>${icon('terminal')} ${esc(command)}</h2><button class="icon-btn close">${icon('x')}</button></header><pre class="code sp-out"><code>${esc(out.output || '(no output)')}</code></pre></div>`);
58
+ const close = overlay(node);
59
+ $('.close', node).onclick = close;
60
+ },
61
+ },
62
+ timeout: 8000,
63
+ });
64
+ }