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
@@ -9,6 +9,9 @@ import { summarize } from '../runner.js';
9
9
  import { delegationReport } from '../subagents.js';
10
10
  import { toolStats, toolStatsText } from '../usage.js';
11
11
  import { clip } from '../util.js';
12
+ import { describeSpell, normalizeSpell } from '../spells.js';
13
+ import { spellReference } from '../spell-guide.js';
14
+ import { ghProblem, wispPathProblem } from '../../web/js/spell-schema.js';
12
15
 
13
16
  export function register(r, app) {
14
17
  const { store, runner, telemetry } = app;
@@ -76,6 +79,64 @@ export function register(r, app) {
76
79
  opts,
77
80
  );
78
81
 
82
+ // Cast a spell: the tools a spell task writes its spell with. Only that task's token works.
83
+ const spellTask = (fn) =>
84
+ guarded(async (c) => {
85
+ const s = lead(c);
86
+ if (!s) return c.send(404, { error: 'no such session' });
87
+ if (s.kind !== 'spell') return c.send(403, { error: 'only a Cast a spell task can use this' });
88
+ return fn(c, s);
89
+ });
90
+ r.route(
91
+ 'GET',
92
+ '/internal/spells/:id',
93
+ spellTask((c) => {
94
+ const { spells } = app.spells.list();
95
+ const one = (x) => [`### ${x.name} (\`${x.id}\` v${x.version}, ${x.enabled ? 'on' : 'off'})`, ...x.summary.map((l) => `- ${l}`), '', '```json', JSON.stringify(app.spells.exported(x.id), null, 2), '```'].join('\n');
96
+ const text = spells.length ? spells.map(one).join('\n\n') : 'The user has no spells yet.';
97
+ c.send(200, { text });
98
+ }),
99
+ opts,
100
+ );
101
+ r.route('GET', '/internal/spell-schema/:id', spellTask((c) => c.send(200, { text: JSON.stringify(spellReference(), null, 1) })), opts);
102
+ r.route(
103
+ 'POST',
104
+ '/internal/spell-data/:id',
105
+ spellTask(async (c) => {
106
+ const { source, task } = await c.body();
107
+ const src = source && typeof source === 'object' ? source : {};
108
+ const problem = src.wisp !== undefined ? wispPathProblem(src.wisp) : src.gh !== undefined ? ghProblem(src.gh) : 'source is { "wisp": "/path" } or { "gh": [args] }';
109
+ if (problem) return c.send(200, { text: `Not allowed: ${problem}` });
110
+ const session = task ? store.get(String(task)) : null;
111
+ try {
112
+ const value = await app.spellData.get(`check-${c.params.id}`, 'sample', { ...src, refresh: 10 }, { session, summary: session ? summarize(session, store) : null, fresh: true });
113
+ const json = JSON.stringify(value, null, 1);
114
+ c.send(200, { text: json.length > 12_000 ? `${json.slice(0, 12_000)}\n… (cut; ${json.length} characters in all)` : json });
115
+ } catch (err) {
116
+ c.send(200, { text: `The source failed: ${err.message}` });
117
+ }
118
+ }),
119
+ opts,
120
+ );
121
+ r.route(
122
+ 'POST',
123
+ '/internal/spell-check/:id',
124
+ spellTask(async (c) => {
125
+ const { spell } = await c.body();
126
+ try {
127
+ const { spell: n, warnings } = normalizeSpell(spell);
128
+ const d = describeSpell(n);
129
+ const was = app.spells.get(n.id);
130
+ c.send(200, {
131
+ text: [`ok — “${n.name}” (${d.kind}) would:`, ...d.lines.map((l) => `- ${l}`), ...(warnings.length ? ['', 'Warnings:', ...warnings.map((w) => `- ${w}`)] : []), ...(was ? ['', `The user already has \`${n.id}\` v${was.version}; yours replaces it${n.version === was.version ? ' (bump version)' : ''}.`] : []), '', 'End your report with {"type":"spell","spell":{…}} holding exactly this spell.'].join('\n'),
132
+ });
133
+ } catch (err) {
134
+ c.send(200, { text: err.problems ? `Not ok. Fix:\n${err.problems.map((p) => `- ${p}`).join('\n')}` : `Not ok: ${err.message}` });
135
+ }
136
+ }),
137
+ opts,
138
+ );
139
+
79
140
  // wisp_stats: read-only tool counts, spend and success across sessions. project=current is the
80
141
  // asking session's project.
81
142
  r.route(
@@ -4,7 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { openapi } from '../openapi.js';
7
- import { usage } from '../usage.js';
7
+ import { toolStats, usage } from '../usage.js';
8
8
  import { expandHome, VERSION } from '../util.js';
9
9
 
10
10
  export const API_VERSION = 1;
@@ -70,6 +70,10 @@ export function register(r, app) {
70
70
 
71
71
  // Spend and outcomes. ?days=7|30|90
72
72
  r.route('GET', '/usage', (c) => c.send(200, usage(store.all(), { days: Number(c.query.get('days')) || 30, project: (s) => store.projectFor(s.cwd) })));
73
+ // Tool calls by kind, harness, project and day (what wisp_stats tells agents).
74
+ r.route('GET', '/usage/tools', (c) =>
75
+ c.send(200, toolStats(store.all(), { days: Math.min(Math.max(Number(c.query.get('days')) || 30, 1), 365), project: (s) => store.projectFor(s.cwd), filter: { agent: c.query.get('agent') || '', project: c.query.get('project') || '' } })),
76
+ );
73
77
 
74
78
  // Feedback for the wisp team: thumbs on a turn and/or a note. { rating?, text?, sessionId?, turnId? }
75
79
  r.route('POST', '/feedback', async (c) => c.send(200, await app.sendFeedback(await c.body())));
@@ -18,19 +18,33 @@ export { cloneRoot };
18
18
  export function register(r, app) {
19
19
  const { store, telemetry, prefs, retro, remote, machines } = app;
20
20
 
21
+ // defaults are what's in effect: yours, with what spells set on top (lockedBy says which spell).
22
+ // spells: every spell that's on, for the page to apply. recipes: yours and the spells'.
23
+ const view = () => ({
24
+ ...store.config,
25
+ remote: undefined,
26
+ defaults: { ...store.config.defaults },
27
+ lockedBy: app.spells.settings().lockedBy,
28
+ recipes: [...store.config.recipes, ...app.spells.recipes()],
29
+ spells: app.spells.forPage(),
30
+ spellsSafe: app.spells.safe,
31
+ });
21
32
  r.route('GET', '/config', (c) =>
22
- c.send(200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), cloneDir: cloneRoot(store.config), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
33
+ c.send(200, { ...view(), home: os.homedir(), cwd: process.cwd(), cloneDir: cloneRoot(store.config), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
23
34
  );
24
35
  r.route('PATCH', '/config', async (c) => {
25
36
  const body = await c.body();
26
- if (body.defaults) Object.assign(store.config.defaults, body.defaults);
37
+ const locked = app.spells.settings().lockedBy;
38
+ const clash = Object.keys(body.defaults || {}).find((k) => locked[k] && body.defaults[k] !== store.config.defaults[k]);
39
+ if (clash) return c.send(409, { error: `${clash} is set by the spell ${locked[clash].name}. Turn it off in Settings → Spells to change it.`, spell: locked[clash] });
40
+ if (body.defaults) Object.assign(store.config.defaults, Object.fromEntries(Object.entries(body.defaults).filter(([k]) => !locked[k])));
27
41
  if (body.webhooks !== undefined) {
28
42
  const { hooks, error } = normalizeWebhooks(body.webhooks);
29
43
  if (error) return c.send(400, { error });
30
44
  store.config.webhooks = hooks;
31
45
  }
32
46
  app.configChanged();
33
- c.send(200, { ...store.config, remote: undefined });
47
+ c.send(200, view());
34
48
  });
35
49
 
36
50
  // What wisp learned: preferences and lessons, global and per project (scope = 'global' or a project path).
@@ -0,0 +1,147 @@
1
+ // Spells: install, check, turn on and off, export, and the data and links spells use.
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { describeSpell, normalizeSpell } from '../spells.js';
5
+ import { summarize } from '../runner.js';
6
+ import { bad } from '../util.js';
7
+
8
+ const MAX_SPELL_BYTES = 512 * 1024;
9
+ const MAX_OPEN = 20;
10
+
11
+ // Fetch a spell from a link: any https URL to the JSON, or gist:<id> (the first *.spell.json in it).
12
+ export async function fetchSpell(src) {
13
+ const s = String(src || '').trim();
14
+ const gist = /^gist:([a-f0-9]{6,64})$/i.exec(s) || /^https:\/\/gist\.github\.com\/(?:[\w-]+\/)?([a-f0-9]{6,64})\/?$/i.exec(s);
15
+ const get = async (url, json) => {
16
+ const res = await fetch(url, { headers: { accept: json ? 'application/vnd.github+json' : 'application/json', 'user-agent': 'wisp' }, signal: AbortSignal.timeout(15_000) });
17
+ if (!res.ok) throw bad(`Couldn't fetch ${url}: ${res.status}`);
18
+ const text = await res.text();
19
+ if (text.length > MAX_SPELL_BYTES) throw bad('That spell is bigger than 512 KB.');
20
+ return text;
21
+ };
22
+ if (gist) {
23
+ const g = JSON.parse(await get(`https://api.github.com/gists/${gist[1]}`, true));
24
+ const file = Object.values(g.files || {}).find((f) => /\.spell\.json$/i.test(f.filename)) || Object.values(g.files || {}).find((f) => /\.json$/i.test(f.filename));
25
+ if (!file) throw bad('That gist has no .spell.json file.');
26
+ return { spell: JSON.parse(file.truncated ? await get(file.raw_url) : file.content), source: { kind: 'gist', url: `gist:${gist[1]}`, owner: g.owner?.login || '' } };
27
+ }
28
+ if (!/^https:\/\//i.test(s)) throw bad('A spell link is an https URL or gist:<id>.');
29
+ return { spell: JSON.parse(await get(s)), source: { kind: 'url', url: s } };
30
+ }
31
+
32
+ // The body's spell: { spell: {…} }, { text: '<json>' } (pasted), or { url } (fetched).
33
+ async function spellOf(body) {
34
+ if (body.url) return fetchSpell(body.url);
35
+ if (typeof body.text === 'string') {
36
+ try {
37
+ return { spell: JSON.parse(body.text), source: { kind: 'file', name: String(body.name || '') } };
38
+ } catch (err) {
39
+ throw bad(`That isn't JSON: ${err.message}`);
40
+ }
41
+ }
42
+ if (body.spell && typeof body.spell === 'object') return { spell: body.spell, source: body.source && typeof body.source === 'object' ? { kind: String(body.source.kind || 'local'), ...(body.source.session && { session: String(body.source.session) }) } : null };
43
+ throw bad('Send { spell }, { text } or { url }.');
44
+ }
45
+
46
+ // Open links in the default browser (a page can open only one popup per click; this can open several).
47
+ export function openUrls(urls) {
48
+ const list = (Array.isArray(urls) ? urls : [urls]).map(String).filter((u) => /^https?:\/\/[^\s"']+$/i.test(u));
49
+ if (!list.length) throw bad('Nothing to open: only http(s) links.');
50
+ for (const url of list.slice(0, MAX_OPEN)) {
51
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]] : ['xdg-open', [url]];
52
+ if (process.env.WISP_NO_OPEN === '1') continue; // tests
53
+ try {
54
+ spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true }).on('error', () => {}).unref();
55
+ } catch {}
56
+ }
57
+ return list.slice(0, MAX_OPEN);
58
+ }
59
+
60
+ export function register(r, app) {
61
+ const { spells, store } = app;
62
+ const one = (fn) => async (c) => {
63
+ const s = spells.get(c.params.id);
64
+ if (!s) return c.send(404, { error: 'no such spell' });
65
+ return fn(c, s);
66
+ };
67
+
68
+ r.route('GET', '/spells', (c) => c.send(200, spells.list()));
69
+
70
+ // Validate without installing: what it does, its riskiest kind, problems or warnings.
71
+ r.route('POST', '/spells/check', async (c) => {
72
+ const { spell: raw } = await spellOf(await c.body());
73
+ try {
74
+ const { spell, warnings } = normalizeSpell(raw);
75
+ const d = describeSpell(spell);
76
+ const installed = spells.get(spell.id);
77
+ c.send(200, { ok: true, spell, summary: d.lines, kind: d.kind, warnings, installed: installed ? { version: installed.version, enabled: spells.isOn(spell.id), same: JSON.stringify(installed) === JSON.stringify(spell) } : null });
78
+ } catch (err) {
79
+ if (!err.problems) throw err;
80
+ c.send(200, { ok: false, problems: err.problems, error: err.message });
81
+ }
82
+ });
83
+
84
+ // Install (or update). Off unless enable: true, which only the user's own click sends.
85
+ r.route('POST', '/spells', async (c) => {
86
+ const body = await c.body();
87
+ const { spell, source } = await spellOf(body);
88
+ const out = spells.install(spell, { source, enable: body.enable === true });
89
+ app.telemetry?.track('spell_installed', { source: source?.kind || 'local', enabled: body.enable === true, kind: out.spell.kind });
90
+ c.send(out.updated ? 200 : 201, out);
91
+ });
92
+
93
+ // Turn every spell off (the way out when one makes the page unusable).
94
+ r.route('POST', '/spells/off', (c) => {
95
+ for (const s of spells.enabled()) spells.setEnabled(s.id, false, { quiet: true });
96
+ spells.onChange();
97
+ c.send(200, spells.list());
98
+ });
99
+
100
+ r.route('GET', '/spells/:id', one((c, s) => c.send(200, spells.view(s))));
101
+ r.route(
102
+ 'PATCH',
103
+ '/spells/:id',
104
+ one(async (c, s) => {
105
+ const body = await c.body();
106
+ if (typeof body.enabled !== 'boolean') return c.send(400, { error: 'enabled must be true or false' });
107
+ c.send(200, spells.setEnabled(s.id, body.enabled));
108
+ }),
109
+ );
110
+ r.route('DELETE', '/spells/:id', (c) => {
111
+ spells.remove(c.params.id);
112
+ c.send(200, { ok: true });
113
+ });
114
+ r.route('GET', '/spells/:id/export', one((c, s) => c.send(200, spells.exported(s.id))));
115
+
116
+ // A spell's data source, fetched for it. session: the card's task, for per-task sources.
117
+ r.route(
118
+ 'POST',
119
+ '/spells/:id/data/:name',
120
+ one(async (c, s) => {
121
+ const body = await c.body();
122
+ const source = s.data?.[c.params.name];
123
+ if (!source) return c.send(404, { error: `${s.name} has no data source ${c.params.name}` });
124
+ const session = body.session ? store.get(String(body.session)) : null;
125
+ const value = await app.spellData.get(s.id, c.params.name, source, { session, summary: session ? summarize(session, store) : null, fresh: body.fresh === true });
126
+ c.send(200, { value });
127
+ }),
128
+ );
129
+
130
+ // Try it: a data source of a spell that isn't installed (or is, in another version), checked first.
131
+ r.route('POST', '/spells/try-data', async (c) => {
132
+ const body = await c.body();
133
+ const { spell } = normalizeSpell(body.spell);
134
+ const name = String(body.name || '');
135
+ const source = spell.data?.[name];
136
+ if (!source) return c.send(404, { error: `${spell.name} has no data source ${name}` });
137
+ const session = body.session ? store.get(String(body.session)) : null;
138
+ const value = await app.spellData.get(`try-${spell.id}`, name, source, { session, summary: session ? summarize(session, store) : null });
139
+ c.send(200, { value });
140
+ });
141
+
142
+ // Open http(s) links in the default browser.
143
+ r.route('POST', '/open', async (c) => {
144
+ const { urls } = await c.body();
145
+ c.send(200, { opened: openUrls(urls) });
146
+ });
147
+ }
package/server/runner.js CHANGED
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { adapters } from './adapters/index.js';
10
10
  import { systemPrompt, parsePresentation, heroOf } from './protocol.js';
11
+ import { spellGuide } from './spell-guide.js';
11
12
  import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js';
12
13
  import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
13
14
  import { handoffNote } from './handoff.js';
@@ -145,6 +146,7 @@ export function summarize(s, store) {
145
146
  conflictFix: s.conflictFix ? { step: s.conflictFix.step, why: s.conflictFix.why || '' } : null,
146
147
  groupId: s.groupId || null,
147
148
  origin: s.origin || null, // { kind: 'recipe' | 'trigger' | 'telegram', id, name }
149
+ ...(s.kind && { kind: s.kind }), // 'spell': a Cast a spell task
148
150
  moved: s.moved || null, // { from }: carried on here from another place (services/move.js)
149
151
  movedTo: s.movedTo || null, // { id, place }: this one carried on there, and was archived
150
152
  // The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
@@ -187,6 +189,7 @@ export class Runner extends EventEmitter {
187
189
  this.answers = new Map(); // askId -> answer, until the MCP server picks it up
188
190
  this.sides = new Map(); // /btw id -> { sessionId, handle }: side questions being answered
189
191
  this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
192
+ this.spellBlocks = () => []; // (session) => block types spells add, for the protocol
190
193
  this.routeAuto = null; // (prompt, { cwd }) => { agent, model, route } for subagents set to Auto
191
194
  this.pipeline = null; // Auto's check / repair / hand-over steps (server/pipeline.js)
192
195
 
@@ -209,7 +212,7 @@ export class Runner extends EventEmitter {
209
212
  // subagents: the team a lead may delegate to. parentId/member: this session is one of them.
210
213
  // also: the task's other folders, [{ cwd, worktree }] (services/launch.js).
211
214
  // history / title / moved: a task moved here from another place, with the turns it had there.
212
- create({ id = newId(), prompt, cwd, also = [], agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null, auto = null, scout = null, history = [], title = '', moved = null }) {
215
+ create({ id = newId(), prompt, cwd, also = [], agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null, auto = null, scout = null, history = [], title = '', moved = null, kind = '' }) {
213
216
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
214
217
  const now = Date.now();
215
218
  const session = {
@@ -230,6 +233,7 @@ export class Runner extends EventEmitter {
230
233
  queue: [],
231
234
  turns: [...history],
232
235
  ...(moved && { moved }),
236
+ ...(kind && { kind }), // 'spell': a Cast a spell task (server/spell-guide.js)
233
237
  ...(worktree && { worktree }),
234
238
  ...(also.length && { also }),
235
239
  ...(groupId && { groupId }),
@@ -625,10 +629,12 @@ export class Runner extends EventEmitter {
625
629
 
626
630
  // The wisp protocol for this run: a lead hears about its team, a subagent that it can't ask.
627
631
  #systemPrompt(session, adapter, team) {
632
+ const extraBlocks = session.parentId ? [] : this.spellBlocks(session);
628
633
  const base = session.parentId
629
634
  ? systemPrompt({ ask: SUBAGENT_ASK })
630
- : systemPrompt({ askTool: adapter.askTool }) + teamNote(team, adapters, delegateTool(adapter.askTool ?? 'mcp__wisp__ask_user'));
631
- return base + workspaceNote(session) + this.promptExtra(session);
635
+ : systemPrompt({ askTool: adapter.askTool, extraBlocks }) + teamNote(team, adapters, delegateTool(adapter.askTool ?? 'mcp__wisp__ask_user'));
636
+ const spell = session.kind === 'spell' ? spellGuide(adapter.askTool ?? 'mcp__wisp__ask_user') : '';
637
+ return base + spell + workspaceNote(session) + this.promptExtra(session);
632
638
  }
633
639
 
634
640
  // wisp's own MCP server (ask_user, delegate), for harnesses that take one. Subagents get none.
@@ -646,6 +652,7 @@ export class Runner extends EventEmitter {
646
652
  WISP_TOKEN: this.bridge.token,
647
653
  WISP_SESSION: session.id,
648
654
  ...(adapter.approveTool && { WISP_APPROVE: '1' }),
655
+ ...(session.kind === 'spell' && { WISP_SPELLS: '1' }),
649
656
  ...(team.length && { WISP_TEAM: JSON.stringify(team.map((m) => ({ name: m.name, agent: adapters[m.agent]?.name || m.agent, model: m.model, effort: m.effort, use: m.use, count: m.count || 1, copies: copies(m) }))) }),
650
657
  },
651
658
  },
@@ -42,6 +42,11 @@ export function createLaunch(app) {
42
42
  // subagents: [{ name, agent, model, effort, use }] | preset: teamId, moved: { from, title, turns, packs } }
43
43
  async function launch(body, origin = null) {
44
44
  if (!String(body.prompt || '').trim()) throw bad('prompt required');
45
+ // kind 'spell': Cast a spell. The agent changes wisp, not a project: an empty scratch folder of
46
+ // its own, one run. Edits mode, not Plan: Claude's Plan mode refuses MCP tools (the spell tools),
47
+ // and there is nothing in the folder to edit; wisp installs the spell only when you keep it.
48
+ const kind = body.kind === 'spell' ? 'spell' : '';
49
+ if (kind) body = { ...body, scratch: true, mode: 'edits', compare: undefined, subagents: undefined, preset: undefined, worktree: false };
45
50
  // scratch: no project folder (general questions); the task gets an empty directory of its own.
46
51
  const scratch = body.scratch === true;
47
52
  const cwd = scratch ? store.scratchDir(newId()) : expand(body.cwd || process.cwd());
@@ -109,7 +114,7 @@ export function createLaunch(app) {
109
114
  return made.map((worktree) => ({ cwd: dir, ...(worktree && { worktree }) }));
110
115
  }),
111
116
  );
112
- return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, also: extras.map((x) => x[i]), ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents, ...(r.route && { auto: { strategy }, scout: resolved.scout || null }) }));
117
+ return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, also: extras.map((x) => x[i]), ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents, kind, ...(kind && { title: `Spell: ${String(body.prompt).trim().split('\n')[0].slice(0, 60)}` }), ...(r.route && { auto: { strategy }, scout: resolved.scout || null }) }));
113
118
  }
114
119
 
115
120
  // Where a launch runs: body.machine ('' = here), else a project folder that only exists on a
@@ -131,7 +136,7 @@ export function createLaunch(app) {
131
136
 
132
137
  // launch(), here or on a machine. Resolves with summaries (a compare launch has several).
133
138
  async function launchAny(body, origin = null) {
134
- const mid = machineFor(body);
139
+ const mid = body.kind === 'spell' ? null : machineFor(body); // spells change this wisp, so they're cast here
135
140
  // Every project runs anywhere: its folders become the ones on that place, cloned there first if need be.
136
141
  body = await app.places.placeBody(body, mid);
137
142
  if (!mid) return (await launch(body, origin)).map((x) => summarize(x, store));
@@ -0,0 +1,99 @@
1
+ // Spell hooks: what spells do on their own when something happens to a task. A hook fires at most
2
+ // its `max` times per task (default once), so a spell that replies to failed tests can't loop.
3
+ //
4
+ // turn.finished a turn ended with the agent's report turn.failed a turn ended in an error
5
+ // session.started a new task started session.waiting a task asks you something
6
+
7
+ import { sessionToMarkdown } from '../render.js';
8
+ import { summarize } from '../runner.js';
9
+ import { fillTemplate } from '../../web/js/spell-schema.js';
10
+ import { VERSION, clip } from '../util.js';
11
+
12
+ // Did the turn's tests fail (its own tests block, or Auto's check)?
13
+ export function testsFailed(session, turn) {
14
+ const blocks = turn?.presentation?.blocks || [];
15
+ if (blocks.some((b) => b.type === 'tests' && Number(b.failed) > 0)) return true;
16
+ return session.auto?.checks?.at(-1)?.verdict === 'fail';
17
+ }
18
+
19
+ export function hookMatches(cond = {}, { session, turn, summary }) {
20
+ const list = (v) => (Array.isArray(v) ? v : [v]);
21
+ if (cond.status !== undefined && !list(cond.status).includes(summary.status) && !list(cond.status).includes(turn?.status)) return false;
22
+ if (cond.testsFailed !== undefined && testsFailed(session, turn) !== !!cond.testsFailed) return false;
23
+ if (cond.agent !== undefined && !list(cond.agent).includes(session.agent)) return false;
24
+ if (cond.project !== undefined && !list(cond.project).some((p) => p === summary.project || p === summary.projectId)) return false;
25
+ if (cond.hasPr !== undefined && !!session.pr !== !!cond.hasPr) return false;
26
+ if (cond.mode !== undefined && !list(cond.mode).includes(session.mode)) return false;
27
+ return true;
28
+ }
29
+
30
+ export function createSpellHooks(app) {
31
+ const { runner, store, spells } = app;
32
+ const waiting = new Set(); // sessions already reported as waiting, until they move on
33
+
34
+ const skip = (s) => !s || s.agent === 'wisp' || s.parentId || s.kind === 'spell';
35
+
36
+ async function act(spell, hook, s) {
37
+ const [k, v] = Object.entries(hook.do || {})[0] || [];
38
+ const summary = summarize(s, store);
39
+ const text = typeof v === 'string' ? fillTemplate(v, summary) : v;
40
+ switch (k) {
41
+ case 'reply':
42
+ runner.reply(s, `${text}`, [], { by: 'wisp' });
43
+ break;
44
+ case 'done':
45
+ app.launcher.markDone(s);
46
+ break;
47
+ case 'archive':
48
+ if (s.state === 'running' || s.state === 'waiting') return;
49
+ s.archived = true;
50
+ s.unread = false;
51
+ app.sessionChanged(s);
52
+ break;
53
+ case 'notify':
54
+ app.broadcast('spell', { toast: clip(text, 200), spell: spell.name, session: s.id });
55
+ await app.remote?.send?.({ title: `✨ ${spell.name}`, text, actions: [] }).catch(() => {});
56
+ break;
57
+ case 'webhook':
58
+ await fetch(v, {
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/json', 'user-agent': `wisp/${VERSION}` },
61
+ body: JSON.stringify({ event: hook.on, spell: spell.id, session: summary, markdown: sessionToMarkdown(s), at: Date.now() }),
62
+ signal: AbortSignal.timeout(10_000),
63
+ });
64
+ break;
65
+ default:
66
+ return;
67
+ }
68
+ app.telemetry?.track('spell_hook', { on: hook.on, action: k });
69
+ }
70
+
71
+ function fire(event, s, turn = s?.turns?.at(-1)) {
72
+ if (skip(s)) return;
73
+ const summary = summarize(s, store);
74
+ for (const { spell, hook } of spells.hooks(event)) {
75
+ if (spell.harnesses && !spell.harnesses.includes('*') && !spell.harnesses.includes(s.agent)) continue;
76
+ if (spells.hookCount(spell.id, hook.id, s.id) >= hook.max) continue;
77
+ if (!hookMatches(hook.if, { session: s, turn, summary })) continue;
78
+ spells.countHook(spell.id, hook.id, s.id);
79
+ // After the event's own handlers: a reply must not start inside the turn that's ending.
80
+ setTimeout(() => act(spell, hook, s).catch((err) => console.warn(`wisp: spell ${spell.id} hook ${hook.id}: ${err.message}`)), 50);
81
+ }
82
+ }
83
+
84
+ runner.on('ended', ({ session, turn, interrupted }) => {
85
+ if (interrupted || turn.status === 'stopped') return;
86
+ fire(turn.status === 'error' ? 'turn.failed' : 'turn.finished', session, turn);
87
+ });
88
+ runner.on('started', ({ session }) => {
89
+ if (session.turns.length === 1) fire('session.started', session);
90
+ });
91
+ runner.on('summary', (sum) => {
92
+ if (sum.state !== 'waiting') return void waiting.delete(sum.id);
93
+ if (waiting.has(sum.id)) return;
94
+ waiting.add(sum.id);
95
+ fire('session.waiting', store.get(sum.id));
96
+ });
97
+
98
+ return { fire };
99
+ }
@@ -0,0 +1,135 @@
1
+ // What a Cast a spell agent is told: how spells work, what it may use, and how to hand one back.
2
+ // The reference (slots, parts, actions, …) comes from web/js/spell-schema.js, so it never drifts
3
+ // from what wisp accepts.
4
+
5
+ import { ACTIONS, CARD_HEAD_ORDER, EVENTS, HOOK_ACTIONS, HOOK_IF, ICONS, PART_PROPS, PARTS, SETTINGS, SLOTS, STYLE_PROPS, TOKENS, WISP_DATA, GH_READ } from '../web/js/spell-schema.js';
6
+
7
+ export const SPELL_EXAMPLES = [
8
+ {
9
+ ask: 'A floating button that opens all my open PRs',
10
+ spell: {
11
+ wispSpell: 1, id: 'my-prs', name: 'My PRs', version: '1.0.0', description: 'Open every PR you have open on GitHub, from a floating button.',
12
+ data: { prs: { gh: ['search', 'prs', '--author', '@me', '--state', 'open', '--json', 'url,title,repository'], refresh: 120 } },
13
+ buttons: [{ slot: 'float', icon: 'github', label: 'My PRs', badge: { count: 'prs' }, do: { openAll: { data: 'prs', field: 'url' } } }],
14
+ },
15
+ },
16
+ {
17
+ ask: 'A top bar button that shows my wisp tool stats',
18
+ spell: {
19
+ wispSpell: 1, id: 'tool-stats', name: 'Tool stats', version: '1.0.0',
20
+ data: { tools: { wisp: '/usage/tools?days=30' } },
21
+ buttons: [{ slot: 'topbar', icon: 'stats', label: 'Tools', do: { panel: 'tools' } }],
22
+ panels: [{ id: 'tools', title: 'Tool calls · last 30 days', as: 'dialog', data: ['tools'], render: 'tools' }],
23
+ script: "export function tools({ tools: t }) {\n return [\n { type: 'stats', items: [{ label: 'tool calls', value: t.total.calls }, { label: 'turns', value: t.total.turns }, { label: 'spend', value: Math.round(t.total.cost * 100) / 100, unit: '$' }] },\n { type: 'chart', title: 'By kind', items: t.kinds.map((k) => ({ label: k.kind, value: k.calls })) },\n { type: 'chart', title: 'By harness', items: t.agents.map((a) => ({ label: a.agent, value: a.calls })) },\n ];\n}",
24
+ },
25
+ },
26
+ {
27
+ ask: 'A deploy block agents use after deploying',
28
+ spell: {
29
+ wispSpell: 1, id: 'deploy-block', name: 'Deploy block', version: '1.0.0',
30
+ blocks: [{ type: 'deploy', doc: '{"type":"deploy","env":"staging","url":"https://…","status":"ok|failed","checks":[{"label":"health","ok":true}]} After you deploy: where it went and whether it is healthy.', summary: 'Deployed {env} → {url} ({status})', render: 'deploy' }],
31
+ script: "export function deploy(b) {\n return [\n { type: 'callout', tone: b.status === 'ok' ? 'success' : 'danger', title: `${b.env} · ${b.status}`, text: b.url },\n { type: 'checklist', items: (b.checks || []).map((c) => ({ text: c.label, done: !!c.ok })) },\n ];\n}",
32
+ },
33
+ },
34
+ {
35
+ ask: 'Compact cards, bold titles, PR first, no machine or project, and Open PR on hover',
36
+ spell: {
37
+ wispSpell: 1, id: 'compact-cards', name: 'Compact cards', version: '1.0.0',
38
+ parts: { card: { props: { density: 'compact' } }, 'card.title': { style: { fontWeight: '650' } }, 'card.head': { order: ['pr', 'pill', 'time'] } },
39
+ ui: { hide: ['card.machine', 'card.project'] },
40
+ buttons: [{ slot: 'card.hover', icon: 'github', label: 'Open PR', when: { hasPr: true }, do: { open: '{pr.url}' } }],
41
+ },
42
+ },
43
+ {
44
+ ask: 'Quick replies above the follow-up box, and a second-opinion button',
45
+ spell: {
46
+ wispSpell: 1, id: 'quick-replies', name: 'Quick replies', version: '1.0.0',
47
+ buttons: [
48
+ { slot: 'composer.chips', label: 'Run the tests', do: { send: 'Run the tests and show me the output.' } },
49
+ { slot: 'composer.chips', label: 'Explain why…', do: { insert: 'Explain why ' } },
50
+ { slot: 'composer.bar', icon: 'agent', label: 'Second opinion', do: { rerun: { agent: 'codex', mode: 'plan', prompt: 'Review the changes on branch {branch} and say what you would do differently.' } } },
51
+ ],
52
+ },
53
+ },
54
+ {
55
+ ask: 'When tests fail, send the task back once',
56
+ spell: {
57
+ wispSpell: 1, id: 'fix-failing-tests', name: 'Fix failing tests', version: '1.0.0',
58
+ hooks: [{ on: 'turn.finished', if: { testsFailed: true }, max: 1, do: { reply: 'Some tests fail. Fix them, rerun the suite and report again.' } }],
59
+ },
60
+ },
61
+ ];
62
+
63
+ // The full reference, as JSON (wisp_spell_schema).
64
+ export function spellReference() {
65
+ return {
66
+ format: 'One JSON object. Required: wispSpell (1), id (lowercase-dashes), name. Everything else is optional.',
67
+ fields: {
68
+ version: '"1.0.0" — bump it when you change a spell the user already has',
69
+ description: 'one sentence',
70
+ harnesses: '["*"] or e.g. ["claude","codex"]: which harnesses its prompt rules, blocks and hooks apply to',
71
+ when: '{ project: [names|ids|paths], agent: [...], mode: [...] } scope for prompt rules, blocks and hooks',
72
+ data: '{ name: { wisp: "/path?query" } | { gh: [args], cwd?: "task", refresh?: seconds } } — fetched by wisp; script functions receive them as their first argument { name: value }. gh args may use {field} templates over the card\'s task (e.g. "{pr.number}").',
73
+ buttons: '[{ id?, slot, label, icon?, title?, badge?: { count: "<data>" } | { text: "{field}" } | { fn, with: [data] }, group?: "name" (float only: folds into one button), corner?: "bottom-left", when?: {hasPr, prState, state, status, agent, project, unread, hasBranch} (card slots), do }]',
74
+ chips: '[{ slot: "card.head" | "launcher.chips" | "composer.chips", text: "{field} template" | fn: "<script fn>" (returns a string), with?: [data], tone?: good|bad|warn|info|neutral, icon?, title?, when?, do? }]',
75
+ panels: '[{ id, title, icon?, as: dialog|drawer|feed|card, slot?: card.top|card.bottom (as card), data: [names], render: "<script fn>", refresh?: { every: seconds, on: [summary, finished] } }] — render returns an array of wisp presentation blocks (stats, chart, markdown, checklist, files, callout, steps, tree, flow, terminal, code, actions with url…)',
76
+ blocks: '[{ type, doc: "one-line JSON example + when to use it", summary: "{field} template for text-only places", render: "<script fn>" }] — new presentation blocks agents can use; render(block) returns wisp blocks',
77
+ parts: '{ "<part>": { style?: { cssProp: value }, props?: {...}, order?: [...] (card.head only) } }',
78
+ ui: '{ hide: [parts], tokens: { "--accent": "#7c5cff" }, dark: { … tokens for dark mode } }',
79
+ prompt: '["text", { text, when }] — added to agents\' system prompts while the spell is on',
80
+ settings: '{ key: value } — wins over the user\'s setting while the spell is on',
81
+ slash: '[{ name, args?, hint?, do }] — /name in a follow-up box; {args} in do\'s text is what the user typed after it',
82
+ commands: '[{ label, keywords?, icon?, key?, do }] — ⌘K items (key: a single key like "g" or "shift+g", only when no built-in uses it)',
83
+ hooks: `[{ on, if?, max?, do }] — on: ${Object.keys(EVENTS).join(' | ')}; if: { ${HOOK_IF.join(', ')} }; do: one of ${[...HOOK_ACTIONS].join(', ')}; max: times per task (default 1)`,
84
+ recipes: '[{ name, prompt with {params}, agent?, mode? }] — saved prompts',
85
+ script: 'JS with `export function name(data, ctx) { return … }` functions. It runs in a sandbox with no network, no DOM and no wisp API: it can only compute and return values (blocks, strings, numbers, lists of links). ctx: { session } (the card\'s task summary, when there is one), { block } for agent blocks (the block is also the first argument).',
86
+ },
87
+ templates: '{field} in text, labels, links and gh args is filled from the card\'s task summary: id, title, state, status, agent, model, mode, project, branch, cwd, preview, pr.number, pr.url, pr.state, pr.checks …; in slash commands {args} too. Missing fields become empty.',
88
+ slots: SLOTS,
89
+ parts: Object.keys(PARTS),
90
+ partProps: PART_PROPS,
91
+ cardHeadOrder: Object.keys(CARD_HEAD_ORDER),
92
+ styleProps: STYLE_PROPS,
93
+ tokens: TOKENS,
94
+ icons: ICONS,
95
+ actions: Object.fromEntries(Object.entries(ACTIONS).map(([k, v]) => [k, v.doc])),
96
+ settings: SETTINGS,
97
+ wispData: WISP_DATA,
98
+ ghReadOnly: Object.fromEntries(Object.entries(GH_READ).map(([k, v]) => [k, v || 'GET only'])),
99
+ examples: SPELL_EXAMPLES,
100
+ };
101
+ }
102
+
103
+ // The system-prompt section for a Cast a spell task (kind: 'spell').
104
+ export function spellGuide(tool = 'mcp__wisp__ask_user') {
105
+ const t = (name) => tool.replace(/ask_user$/, name);
106
+ return `
107
+
108
+ ## This task: cast a spell on wisp
109
+
110
+ The user wants to change wisp itself — add a button, a panel, a block agents can use, restyle or hide
111
+ part of the UI, change how agents behave — without anyone editing wisp's code. You do it by writing a
112
+ **spell**: one JSON object wisp layers over itself at runtime. You don't edit files and you don't run
113
+ commands; wisp installs the spell when the user clicks **Keep** on your report.
114
+
115
+ How to work:
116
+ 1. Call \`wisp_spell_schema\` (${t('wisp_spell_schema')}) for the full reference: slots, parts, actions,
117
+ icons, tokens, data sources, examples. Use only what it lists.
118
+ 2. Call \`wisp_spells\` (${t('wisp_spells')}) to see what the user has already. To change one, keep its id and
119
+ bump its version; to remove one, end with \`{"type":"spell","remove":"<id>"}\` (remove at the top
120
+ level of the block, no spell object).
121
+ 3. If you read data (a \`wisp\` or \`gh\` source), call \`wisp_spell_data\` (${t('wisp_spell_data')}) with the
122
+ source to see the real shape before writing script that reads it.
123
+ 4. Call \`wisp_spell_check\` (${t('wisp_spell_check')}) with your spell until it says ok. It validates and
124
+ compiles the script (without running it) and says in plain words what the spell does.
125
+ 5. End with a report whose blocks include exactly one \`spell\` block:
126
+ {"type":"spell","spell":{ …the whole spell… }}
127
+ Put a hero first (what the spell does, in a line), then the spell block, then anything worth
128
+ knowing (what you assumed, what it can't do). Keep it short: the spell block itself shows the user
129
+ a preview and what changes.
130
+
131
+ Rules: prefer the smallest spell that does what was asked. Never invent slots, parts, actions or
132
+ fields. Script functions are pure: no fetch, no DOM, no globals — they get data and return values.
133
+ If the ask needs something spells can't do yet, say so in a callout and do the closest thing they can.
134
+ `;
135
+ }