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
@@ -20,6 +20,7 @@ import { scrollByPx, topInset, watching } from './scroll.js';
20
20
  import { renderPrStrip, shipDialog } from './ship.js';
21
21
  import { copiesOf, memberLabel, renderSubs, subKey, teamLiveHtml, teamSize } from './team.js';
22
22
  import { appendStep, KIND_ICON, pickView, renderLiveTranscript, renderTranscript, sameStep, transcriptOn, updateStep } from './transcript.js';
23
+ import { paintCard } from './spells.js';
23
24
 
24
25
  export function ctxFor(s, turn, isLatest) {
25
26
  return {
@@ -98,6 +99,7 @@ export function renderBody(id, { fresh }) {
98
99
  }
99
100
 
100
101
  const body = n.body;
102
+ paintCard(id); // spell panels and buttons in the card
101
103
  const proj = projectById(summary.projectId);
102
104
  // The head already shows the harness, model and project, and the task head has Compare. This bar
103
105
  // says how the run was set up: a mode worth noticing (Plan or Edits, as on the launcher), the
@@ -18,12 +18,11 @@ export function renderUpdate(u) {
18
18
  b.disabled = busy;
19
19
  b.classList.toggle('busy', busy);
20
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`;
21
+ b.title = `wisp ${u.current} → ${u.latest}: install and restart`;
22
22
  }
23
23
 
24
24
  export async function installUpdate() {
25
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
26
  const post = (force) => api('/update', { method: 'POST', body: { force } });
28
27
  try {
29
28
  await post(false).catch(async (err) => {
@@ -0,0 +1,246 @@
1
+ // Spells: what a spell may add to wisp, change in it or hide, in one DOM-free table that the
2
+ // server (validation, the Cast a spell agent's schema tool) and the page (applying spells) share.
3
+ //
4
+ // A spell never sees wisp's DOM. It names slots (places to add things) and parts (things to
5
+ // restyle or hide) from this list, and wisp maps them to its markup. Renaming a class in wisp
6
+ // means updating the selector here, not breaking every spell.
7
+
8
+ export const SPELL_VERSION = 1;
9
+
10
+ // Where buttons, chips and panels can be added.
11
+ export const SLOTS = {
12
+ topbar: 'The top bar, next to Automations and Search: a labelled button with an optional count.',
13
+ float: 'Floating buttons, stacked in a bottom corner of the window. Buttons with the same group fold into one that fans out.',
14
+ palette: 'An item in the ⌘K palette.',
15
+ 'feed.top': 'Above the feed: a panel pinned there (panels only).',
16
+ 'launcher.bar': 'In the prompt box bar, before Send.',
17
+ 'launcher.chips': 'Chips under the prompt box (chips and buttons).',
18
+ 'composer.bar': "In a card's follow-up box bar, before Send.",
19
+ 'composer.chips': 'Chips above a card\'s follow-up box (quick replies).',
20
+ 'card.head': "A chip in a card's head, before the time.",
21
+ 'card.hover': 'A small icon button on a card head, shown on hover.',
22
+ 'card.menu': "An item in a card's right-click menu.",
23
+ 'card.top': 'A panel at the top of an open card (panels only).',
24
+ 'card.bottom': 'A panel at the bottom of an open card, above the follow-up box (panels only).',
25
+ };
26
+
27
+ export const CARD_SLOTS = new Set(['composer.bar', 'composer.chips', 'card.head', 'card.hover', 'card.menu', 'card.top', 'card.bottom']);
28
+
29
+ // Parts: things a spell can restyle, give props or hide. Selectors are wisp's own markup.
30
+ // Never here: questions, approvals, waiting pills, the spell review and Settings → Spells.
31
+ export const PARTS = {
32
+ topbar: '#topbar',
33
+ 'topbar.brand': '#topbar .brand',
34
+ 'topbar.projects': '#projects',
35
+ 'topbar.machines': '#machinesBtn',
36
+ 'topbar.automations': '#autoBtn',
37
+ 'topbar.search': '#searchBtn',
38
+ 'topbar.settings': '#settingsBtn',
39
+ feed: '#feed',
40
+ 'section.done': '#secDone',
41
+ 'section.archived': '#secArchived',
42
+ toTop: '#toTop',
43
+ launcher: '#launcher',
44
+ 'launcher.input': '#prompt',
45
+ 'launcher.projects': '#launcher .l-projects',
46
+ 'launcher.attach': '#attach',
47
+ 'launcher.where': '#whereBtn',
48
+ 'launcher.mode': '#mode',
49
+ 'launcher.harness': '#runner',
50
+ 'launcher.compare': '#compareBtn',
51
+ 'launcher.team': '#teamBtn',
52
+ 'launcher.worktree': '#wtBtn',
53
+ 'launcher.send': '#launch',
54
+ 'launcher.recipes': '#launcher .l-recipes',
55
+ composer: '.composer',
56
+ 'composer.input': '.composer .reply',
57
+ 'composer.attach': '.composer .c-attach',
58
+ 'composer.mode': '.composer .c-mode',
59
+ 'composer.harness': '.composer .c-runner',
60
+ 'composer.place': '.composer .c-place',
61
+ 'composer.hint': '.composer .c-hint',
62
+ 'composer.ship': '.composer .c-ship',
63
+ 'composer.done': '.composer .c-done',
64
+ 'composer.send': '.composer .send',
65
+ 'composer.btwTip': '.w-tip',
66
+ card: '#feed .item',
67
+ 'card.head': '#feed .it-head',
68
+ 'card.dot': '#feed .it-head .q-dot',
69
+ 'card.title': '#feed .it-title',
70
+ 'card.sub': '#feed .it-sub',
71
+ 'card.live': '#feed .q-live',
72
+ 'card.side': '#feed .it-side',
73
+ 'card.pr': '#feed .it-side .pill.pr',
74
+ 'card.model': '#feed .it-side .model-logo',
75
+ 'card.machine': '#feed .it-side .q-machine',
76
+ 'card.project': '#feed .it-side .q-proj',
77
+ 'card.time': '#feed .it-side .q-time',
78
+ 'card.quickDone': '#feed .it-quick',
79
+ 'card.body': '#feed .it-body',
80
+ };
81
+
82
+ // The pieces of a card head, for `parts["card.head"].order`.
83
+ export const CARD_HEAD_ORDER = { pill: '.pill:not(.pr)', pr: '.pill.pr', model: '.model-logo', machine: '.q-machine', project: '.q-proj', time: '.q-time', spells: '.sp-chip' };
84
+
85
+ // Props a part publishes (anything else is refused).
86
+ export const PART_PROPS = {
87
+ 'launcher.input': { placeholder: 'string', rows: 'int', maxRows: 'int', monospace: 'bool', fontSize: 'int' },
88
+ 'composer.input': { placeholder: 'string', rows: 'int', maxRows: 'int', monospace: 'bool', fontSize: 'int' },
89
+ card: { density: ['compact', 'comfortable'] },
90
+ 'card.sub': { lines: 'int' },
91
+ };
92
+
93
+ // CSS a spell may set on a part (camelCase or kebab-case), and what values look like.
94
+ export const STYLE_PROPS = [
95
+ 'color', 'background', 'background-color', 'border', 'border-color', 'border-width', 'border-style', 'border-radius',
96
+ 'padding', 'padding-top', 'padding-bottom', 'padding-left', 'padding-right', 'margin', 'margin-top', 'margin-bottom',
97
+ 'gap', 'font-size', 'font-weight', 'font-family', 'font-style', 'line-height', 'letter-spacing', 'text-transform',
98
+ 'box-shadow', 'opacity', 'order', 'display', 'flex-direction', 'align-items', 'justify-content', 'min-height', 'max-height', 'width', 'max-width',
99
+ ];
100
+ const STYLE_VALUE = /^[#a-z0-9\s.,%()+\-/'"]*$/i;
101
+ const BANNED_VALUE = /url\s*\(|image-set|@import|expression\s*\(|\\|;|[{}<>]|attr\s*\(/i;
102
+
103
+ export const kebab = (k) => String(k).replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
104
+
105
+ // '' when the declaration is fine, else why not.
106
+ export function styleProblem(prop, value) {
107
+ const p = kebab(prop);
108
+ if (!STYLE_PROPS.includes(p)) return `${p} isn't a property spells can set`;
109
+ const v = String(value ?? '');
110
+ if (v.length > 120) return `${p}: value too long`;
111
+ if (BANNED_VALUE.test(v) || !STYLE_VALUE.test(v)) return `${p}: value not allowed (no url(), imports or special characters)`;
112
+ if (p === 'display' && !['none', 'flex', 'grid', 'block', 'inline-flex', 'inline-block'].includes(v.trim())) return 'display must be none, flex, grid, block, inline-flex or inline-block';
113
+ return '';
114
+ }
115
+
116
+ // Design tokens from web/css/tokens.css a spell may set.
117
+ export const TOKENS = ['--accent', '--accent-ink', '--accent-soft', '--bg', '--bg-2', '--surface', '--surface-2', '--ink', '--ink-2', '--muted', '--faint', '--line', '--line-2', '--good', '--bad', '--warn', '--info', '--radius', '--sans', '--mono', '--display', '--shadow-1', '--shadow-2'];
118
+
119
+ // Icons from the page's sprite (#i-<name>).
120
+ export const ICONS = ['check', 'x', 'skip', 'minus', 'plus', 'chevron', 'arrow', 'external', 'file', 'folder', 'dots', 'copy', 'branch', 'github', 'info', 'warn', 'ask', 'lock', 'pen', 'spark', 'stop', 'merge', 'send', 'enter', 'queue', 'clip', 'terminal', 'done', 'archive', 'trash', 'settings', 'read', 'search', 'web', 'agent', 'plan', 'tool', 'stats', 'rerun', 'replay', 'transcript', 'text', 'back', 'update', 'menu', 'bolt', 'clock', 'laptop', 'server', 'thumb-up', 'thumb-down', 'play'];
121
+
122
+ // What a button, chip, command, slash command or hook can do. kind: what the review says about it.
123
+ // safe: changes nothing outside the page · acts: does something for you, only when you click
124
+ // runs: runs a command on your computer
125
+ export const ACTIONS = {
126
+ open: { kind: 'safe', doc: '{ "open": "https://…/{pr.number}" } open one http(s) link (a {field} template)' },
127
+ openAll: { kind: 'safe', doc: '{ "openAll": { "data": "prs", "field": "url" } } open every link in a data source (at most 20). Or { "fn": "name" }: a script function returning links, each of which must appear in the spell\'s data' },
128
+ panel: { kind: 'safe', doc: '{ "panel": "id" } open one of this spell\'s panels' },
129
+ insert: { kind: 'safe', doc: '{ "insert": "text" } put text into the box at the caret (prompt box or follow-up box)' },
130
+ copy: { kind: 'safe', doc: '{ "copy": "{branch}" } copy text' },
131
+ toast: { kind: 'safe', doc: '{ "toast": "text" } show a short message' },
132
+ setLaunch: { kind: 'safe', doc: '{ "setLaunch": { "agent": "codex", "model": "", "effort": "high", "mode": "plan" } } set the prompt box pickers' },
133
+ send: { kind: 'acts', doc: '{ "send": "text" } send a follow-up to the card\'s task right away' },
134
+ reply: { kind: 'acts', doc: '{ "reply": "text" } same as send, used by hooks' },
135
+ launchWith: { kind: 'acts', doc: '{ "launchWith": { "agent": "codex", "mode": "plan", "effort": "high", "text": "optional prefix" } } launch what is in the prompt box with these settings' },
136
+ launch: { kind: 'acts', doc: '{ "launch": { "prompt": "…", "agent": "", "mode": "plan", "cwd": "" } } start a new task (cwd empty = the current project)' },
137
+ rerun: { kind: 'acts', doc: '{ "rerun": { "agent": "codex", "model": "", "effort": "", "mode": "plan", "prompt": "optional, defaults to the task\'s first request" } } run the card\'s task again as a new task on another harness' },
138
+ recipe: { kind: 'acts', doc: '{ "recipe": "name" } run a recipe' },
139
+ ship: { kind: 'acts', doc: '{ "ship": true } open Ship it for the card\'s task' },
140
+ done: { kind: 'acts', doc: '{ "done": true } mark the card\'s task done' },
141
+ archive: { kind: 'acts', doc: '{ "archive": true } archive the card\'s task' },
142
+ notify: { kind: 'acts', doc: '{ "notify": "text" } a desktop notification / toast (hooks: your phone, if linked)' },
143
+ webhook: { kind: 'runs', doc: '{ "webhook": "https://…" } POST the task summary as JSON to a URL (hooks)' },
144
+ exec: { kind: 'runs', doc: '{ "exec": "npm run e2e" } run a shell command in the task\'s folder (asks you the first time)' },
145
+ };
146
+
147
+ // Hook events (server side), with what `if` can test.
148
+ export const EVENTS = {
149
+ 'turn.finished': 'A turn ended normally (the agent reported).',
150
+ 'turn.failed': 'A turn ended with an error.',
151
+ 'session.started': 'A new task started.',
152
+ 'session.waiting': 'A task is waiting for your answer.',
153
+ };
154
+ export const HOOK_IF = ['status', 'testsFailed', 'agent', 'project', 'hasPr', 'mode'];
155
+ export const HOOK_ACTIONS = new Set(['reply', 'done', 'archive', 'notify', 'webhook']);
156
+
157
+ // Settings a spell may set (while on). Telemetry, feedback and anything secret are not here.
158
+ export const SETTINGS = {
159
+ editor: ['vscode', 'cursor', 'windsurf', 'zed', 'idea', 'none'],
160
+ transcript: ['on', 'off'],
161
+ worktree: ['on', 'off'],
162
+ followUp: ['queue', 'interrupt'],
163
+ mode: ['plan', 'edits', 'auto'],
164
+ agent: 'string',
165
+ model: 'string',
166
+ effort: 'string',
167
+ learn: ['on', 'off'],
168
+ fixConflicts: ['on', 'off'],
169
+ notify: ['on', 'off'],
170
+ };
171
+
172
+ // GET routes a `wisp` data source may read (patterns, :x = one segment).
173
+ export const WISP_DATA = ['/sessions', '/sessions/:id', '/usage', '/usage/tools', '/projects', '/recipes', '/triggers', '/machines', '/agents', '/preferences', '/spells'];
174
+
175
+ // `gh` data sources: read-only subcommands only.
176
+ export const GH_READ = { pr: ['list', 'view', 'status', 'checks', 'diff'], search: ['prs', 'issues', 'repos', 'commits'], issue: ['list', 'view', 'status'], run: ['list', 'view'], repo: ['view', 'list'], release: ['list', 'view'], workflow: ['list', 'view'], api: null };
177
+ const GH_WRITE_FLAGS = /^(-X|--method|-f|-F|--field|--raw-field|--input)(=|$)/;
178
+
179
+ // '' when the gh args are read-only, else why not.
180
+ export function ghProblem(args) {
181
+ if (!Array.isArray(args) || !args.length || args.some((a) => typeof a !== 'string')) return 'gh needs a list of string arguments';
182
+ const [cmd, sub] = args;
183
+ if (!(cmd in GH_READ)) return `gh ${cmd} isn't allowed (read-only commands only)`;
184
+ if (cmd === 'api') {
185
+ if (args.some((a) => GH_WRITE_FLAGS.test(a))) return 'gh api may only read (no -X, -f, -F or --input)';
186
+ return '';
187
+ }
188
+ if (!GH_READ[cmd].includes(sub)) return `gh ${cmd} ${sub || ''} isn't allowed (read-only commands only)`.replace(/\s+\(/, ' (');
189
+ return '';
190
+ }
191
+
192
+ // '' when the path is a readable wisp route, else why not.
193
+ export function wispPathProblem(p) {
194
+ const s = String(p || '');
195
+ if (!s.startsWith('/')) return 'a wisp data path starts with / (e.g. /sessions)';
196
+ const route = s.split('?')[0].replace(/^\/api(?=\/)/, '');
197
+ const parts = route.split('/').filter(Boolean);
198
+ const ok = WISP_DATA.some((pat) => {
199
+ const segs = pat.split('/').filter(Boolean);
200
+ return segs.length === parts.length && segs.every((x, i) => x.startsWith(':') || x === parts[i]);
201
+ });
202
+ return ok ? '' : `${route} isn't readable by spells (one of ${WISP_DATA.join(', ')})`;
203
+ }
204
+
205
+ // Built-in presentation blocks (a spell's agent block type can't reuse these names).
206
+ export const BUILTIN_BLOCKS = ['hero', 'markdown', 'steps', 'stats', 'files', 'diff', 'tests', 'commits', 'graph', 'tree', 'code', 'compare', 'flow', 'chart', 'terminal', 'checklist', 'callout', 'image', 'question', 'blocked', 'actions', 'next', 'spell'];
207
+
208
+ // {field} templates over a context object (dotted paths): "PR #{pr.number}". Missing → ''.
209
+ export function fillTemplate(tpl, ctx = {}) {
210
+ return String(tpl ?? '').replace(/\{([a-zA-Z_][\w.]*)\}/g, (_, key) => {
211
+ const v = key.split('.').reduce((o, k) => (o === null || o === undefined ? undefined : o[k]), ctx);
212
+ return v === undefined || v === null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
213
+ });
214
+ }
215
+
216
+ // Does a card-scoped `when` match a session summary?
217
+ // { hasPr, state: [...], status: [...], agent: [...], project: [...], unread, running }
218
+ export function whenMatches(when, s) {
219
+ if (!when) return true;
220
+ if (!s) return false;
221
+ const list = (v) => (Array.isArray(v) ? v : [v]);
222
+ if (when.hasPr !== undefined && !!s.pr !== !!when.hasPr) return false;
223
+ if (when.prState !== undefined && !list(when.prState).includes(s.pr?.state)) return false;
224
+ if (when.state !== undefined && !list(when.state).includes(s.state)) return false;
225
+ if (when.status !== undefined && !list(when.status).includes(s.status)) return false;
226
+ if (when.agent !== undefined && !list(when.agent).includes(s.agent)) return false;
227
+ if (when.project !== undefined && !list(when.project).some((p) => p === s.project || p === s.projectId)) return false;
228
+ if (when.unread !== undefined && !!s.unread !== !!when.unread) return false;
229
+ if (when.hasBranch !== undefined && !!s.branch !== !!when.hasBranch) return false;
230
+ return true;
231
+ }
232
+
233
+ // The function names a spell's script exports (`export function name`, `export const name =`).
234
+ export function scriptExports(src = '') {
235
+ const out = new Set();
236
+ for (const m of String(src).matchAll(/export\s+(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/g)) out.add(m[1]);
237
+ for (const m of String(src).matchAll(/export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) out.add(m[1]);
238
+ return [...out];
239
+ }
240
+
241
+ // The script as a classic script defining its functions on `__spell` (what the sandbox runs).
242
+ export function scriptBody(src = '') {
243
+ const names = scriptExports(src);
244
+ const body = String(src).replace(/export\s+(?=(?:async\s+)?function|const|let|var)/g, '');
245
+ return `${body}\n;return { ${names.join(', ')} };`;
246
+ }