wisp-agents 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +29 -13
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +7 -3
  6. package/server/adapters/claude.js +79 -55
  7. package/server/adapters/codex.js +5 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +4 -2
  10. package/server/adapters/grok.js +17 -2
  11. package/server/adapters/index.js +25 -2
  12. package/server/adapters/opencode.js +78 -12
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +6 -4
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +71 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-ask.js +113 -8
  25. package/server/mcp-wisp.js +17 -5
  26. package/server/mentions.js +39 -0
  27. package/server/openapi.js +36 -4
  28. package/server/paths.js +32 -0
  29. package/server/preferences.js +2 -6
  30. package/server/proc.js +54 -0
  31. package/server/protocol.js +8 -1
  32. package/server/recipes.js +2 -4
  33. package/server/remote.js +14 -14
  34. package/server/render.js +3 -1
  35. package/server/router.js +7 -3
  36. package/server/routes/auto.js +29 -0
  37. package/server/routes/automations.js +92 -0
  38. package/server/routes/internal.js +176 -0
  39. package/server/routes/machines.js +43 -0
  40. package/server/routes/meta.js +76 -0
  41. package/server/routes/proxy.js +47 -0
  42. package/server/routes/sessions.js +351 -0
  43. package/server/routes/settings.js +144 -0
  44. package/server/routes/ship.js +61 -0
  45. package/server/routes/uploads.js +19 -0
  46. package/server/runner.js +216 -49
  47. package/server/services/auto.js +67 -0
  48. package/server/services/feedback.js +53 -0
  49. package/server/services/install.js +69 -0
  50. package/server/services/launch.js +127 -0
  51. package/server/services/phone.js +65 -0
  52. package/server/services/ship.js +82 -0
  53. package/server/services/telemetry.js +34 -0
  54. package/server/services/uploads.js +59 -0
  55. package/server/services/webhooks.js +28 -0
  56. package/server/store.js +13 -5
  57. package/server/subagents.js +5 -4
  58. package/server/telemetry.js +1 -1
  59. package/server/terminal.js +2 -1
  60. package/server/triggers.js +5 -7
  61. package/server/update.js +4 -3
  62. package/server/usage.js +94 -0
  63. package/server/util.js +42 -0
  64. package/server/welcome.js +3 -3
  65. package/web/css/auto.css +29 -0
  66. package/web/css/automations.css +71 -0
  67. package/web/css/base.css +83 -0
  68. package/web/css/blocks.css +461 -0
  69. package/web/css/composer.css +84 -0
  70. package/web/css/feed.css +235 -0
  71. package/web/css/launcher.css +204 -0
  72. package/web/css/layout.css +61 -0
  73. package/web/css/machines.css +125 -0
  74. package/web/css/modals.css +176 -0
  75. package/web/css/palette.css +79 -0
  76. package/web/css/responsive.css +35 -0
  77. package/web/css/ship.css +95 -0
  78. package/web/css/subagents.css +130 -0
  79. package/web/css/tokens.css +115 -0
  80. package/web/index.html +17 -1
  81. package/web/js/app.js +28 -5237
  82. package/web/js/blocks.js +46 -0
  83. package/web/js/core/api.js +25 -0
  84. package/web/js/core/bus.js +28 -0
  85. package/web/js/core/dom.js +7 -0
  86. package/web/js/core/format.js +27 -0
  87. package/web/js/core/state.js +43 -0
  88. package/web/js/features/actions.js +344 -0
  89. package/web/js/features/attachments.js +115 -0
  90. package/web/js/features/auto.js +54 -0
  91. package/web/js/features/automations.js +305 -0
  92. package/web/js/features/commands.js +123 -0
  93. package/web/js/features/compare.js +105 -0
  94. package/web/js/features/composer.js +315 -0
  95. package/web/js/features/feed.js +400 -0
  96. package/web/js/features/feedback.js +127 -0
  97. package/web/js/features/keys.js +83 -0
  98. package/web/js/features/launcher.js +561 -0
  99. package/web/js/features/live.js +91 -0
  100. package/web/js/features/machines.js +313 -0
  101. package/web/js/features/memory.js +154 -0
  102. package/web/js/features/mentions.js +125 -0
  103. package/web/js/features/notify.js +36 -0
  104. package/web/js/features/phone.js +124 -0
  105. package/web/js/features/places.js +38 -0
  106. package/web/js/features/projects.js +284 -0
  107. package/web/js/features/runner-picker.js +233 -0
  108. package/web/js/features/runners.js +163 -0
  109. package/web/js/features/scroll.js +75 -0
  110. package/web/js/features/settings.js +390 -0
  111. package/web/js/features/setup.js +94 -0
  112. package/web/js/features/ship.js +122 -0
  113. package/web/js/features/team.js +386 -0
  114. package/web/js/features/turns.js +432 -0
  115. package/web/js/features/update.js +48 -0
  116. package/web/js/features/usage.js +75 -0
  117. package/web/js/mention-text.js +59 -0
  118. package/web/js/ui/kbd.js +46 -0
  119. package/web/js/ui/overlay.js +38 -0
  120. package/web/js/ui/popover.js +52 -0
  121. package/web/js/ui/toast.js +23 -0
  122. package/web/css/app.css +0 -1910
@@ -0,0 +1,39 @@
1
+ // Task mentions: a prompt can point at another wisp task. The web app stores one as
2
+ // `@[Title](wisp:<id>)` (pasting a copied task id in a prompt box makes one); the agent gets
3
+ // `@Title (wisp task <id>)` plus a note on how to read it: the wisp_session tool, or the task's
4
+ // report inline for runs that can't take wisp's MCP tools.
5
+
6
+ import { sessionToMarkdown } from './render.js';
7
+ import { truncate } from './util.js';
8
+
9
+ export const MENTION = /@\[([^\]\n]{1,200})\]\(wisp:([a-z0-9]{6,40})\)/g;
10
+ const INLINE_MAX = 8_000; // characters of a mentioned task's report, when it goes in the prompt
11
+
12
+ export const mentionsIn = (text = '') => [...new Map([...String(text).matchAll(MENTION)].map((m) => [m[2], { id: m[2], title: m[1] }])).values()];
13
+
14
+ // What a person reads: titles, no ids (session titles, notifications).
15
+ export const plainMentions = (text = '') => String(text).replace(MENTION, '@$1');
16
+
17
+ // What an agent reads in place of each mention.
18
+ export const textMentions = (text = '') => String(text).replace(MENTION, '@$1 (wisp task $2)');
19
+
20
+ // The prompt for an agent run. tool: how this harness names the wisp_session tool, or null when
21
+ // it has none (then each mentioned task's latest report goes in the prompt).
22
+ export function agentPrompt(prompt, store, { tool = 'wisp_session', self } = {}) {
23
+ const found = mentionsIn(prompt).filter((m) => m.id !== self);
24
+ if (!found.length) return textMentions(prompt);
25
+ const line = (m) => {
26
+ const s = store.get(m.id);
27
+ if (!s) return `- \`${m.id}\` (${m.title}): not on this computer; it may have been deleted or run on another machine.`;
28
+ return `- \`${s.id}\` **${s.title}**: ${s.state}, ${s.agent}, ${s.turns.length} turn${s.turns.length === 1 ? '' : 's'}, in \`${s.worktree?.path || s.cwd}\``;
29
+ };
30
+ const note = tool
31
+ ? `The user mentions other wisp tasks. Read one with \`wisp_session\` (${tool}) before relying on it; \`wisp_sessions\` lists the rest.\n${found.map(line).join('\n')}`
32
+ : `The user mentions other wisp tasks. Their latest reports:\n\n${found
33
+ .map((m) => {
34
+ const s = store.get(m.id);
35
+ return s ? `### ${s.title} (\`${s.id}\`)\n\n${truncate(sessionToMarkdown(s), INLINE_MAX)}` : line(m);
36
+ })
37
+ .join('\n\n')}`;
38
+ return `${textMentions(prompt)}\n\n<wisp-tasks>\n${note}\n</wisp-tasks>`;
39
+ }
package/server/openapi.js CHANGED
@@ -50,6 +50,13 @@ export function openapi({ version, port }) {
50
50
  patch: { summary: 'Auto settings', requestBody: body({ type: 'object', properties: { goal: { enum: ['cheap', 'balanced', 'best'] }, explore: { type: 'boolean', description: 'Let Auto try new models on a few tasks' }, allowed: { type: 'array', items: { type: 'string' }, description: 'Harness ids Auto may pick; empty or null = all' } } }), responses: { 200: json({ type: 'object' }) } },
51
51
  },
52
52
  '/router/reset': { post: { summary: 'Forget what Auto learned here (the shipped starting point stays)', responses: { 200: json({ type: 'object' }) } } },
53
+ '/agents/install': {
54
+ post: {
55
+ summary: 'Install a harness into ~/.wisp/tools with npm (only opencode: its free models need no account), then detect again',
56
+ requestBody: body({ type: 'object', required: ['id'], properties: { id: { type: 'string', enum: ['opencode'] } } }),
57
+ responses: { 200: json({ type: 'array', items: ref('Agent') }), 400: err[400] },
58
+ },
59
+ },
53
60
  '/agents/recheck': { post: { summary: 'Detect harnesses and models again, here and on every online machine', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
54
61
  '/feedback': {
55
62
  post: {
@@ -129,6 +136,16 @@ export function openapi({ version, port }) {
129
136
  parameters: [id],
130
137
  post: { summary: 'Mark the PR ready if draft, squash-merge it with --match-head-commit, fast-forward the local base branch, remove the worktree', responses: { 200: json({ type: 'object', properties: { pr: ref('PullRequest'), sync: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' } } } } }), 400: err[400] } },
131
138
  },
139
+ '/teams': {
140
+ get: { summary: 'Saved subagent presets: a team the lead can delegate to', responses: { 200: json({ type: 'array', items: ref('Team') }) } },
141
+ post: { summary: 'Save a subagent preset', requestBody: body(ref('Team')), responses: { 201: json(ref('Team')), 400: err[400] } },
142
+ },
143
+ '/teams/{tid}': {
144
+ parameters: [{ name: 'tid', in: 'path', required: true, schema: { type: 'string' } }],
145
+ get: { summary: 'One subagent preset', responses: { 200: json(ref('Team')), 404: err[404] } },
146
+ patch: { summary: 'Edit a subagent preset', requestBody: body(ref('Team')), responses: { 200: json(ref('Team')), 400: err[400], 404: err[404] } },
147
+ delete: { summary: 'Delete a subagent preset', responses: { 200: ok, 404: err[404] } },
148
+ },
132
149
  '/recipes': {
133
150
  get: { summary: 'Saved recipes, with the {params} found in each prompt', responses: { 200: json({ type: 'array', items: ref('Recipe') }) } },
134
151
  post: { summary: 'Save a recipe', requestBody: body(ref('Recipe')), responses: { 201: json(ref('Recipe')), 400: err[400] } },
@@ -205,11 +222,12 @@ export function openapi({ version, port }) {
205
222
  parameters: [id],
206
223
  post: {
207
224
  summary: 'Follow up (answers a pending ask_user question if there is one)',
208
- requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' }, mode: { enum: ['plan', 'edits', 'auto'], description: 'Switch the session to this mode from this follow-up on' } } }),
225
+ requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' }, mode: { enum: ['plan', 'edits', 'auto'], description: 'Switch the session to this mode from this follow-up on' }, agent: { type: 'string', description: 'Switch to this harness from this follow-up on; it is handed the turns it has not seen' }, model: { type: 'string', description: 'Switch model (empty: the harness default)' }, effort: { type: 'string', description: 'Switch thinking level (empty: the default)' } } }),
209
226
  responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
210
227
  },
211
228
  },
212
- '/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
229
+ '/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question (or approval: Allow, Always allow in this task, Deny, or what to do instead)', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
230
+ '/sessions/{id}/approve': { parameters: [id], post: { summary: 'Allow and retry (or deny) what a finished turn’s permissions blocked', requestBody: body({ type: 'object', properties: { turnId: { type: 'string' }, allow: { type: 'boolean', description: 'Default true' } } }), responses: { 200: ok } } },
213
231
  '/sessions/{id}/revert': {
214
232
  parameters: [id],
215
233
  post: {
@@ -249,9 +267,13 @@ export function openapi({ version, port }) {
249
267
  responses: { 201: json({ type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, size: { type: 'integer' }, mime: { type: 'string' } } }) },
250
268
  },
251
269
  },
270
+ '/uploads/{uid}': {
271
+ parameters: [{ name: 'uid', in: 'path', required: true, schema: { type: 'string' }, description: 'Upload id (<id>@<machine> for one on a machine)' }],
272
+ get: { summary: 'The uploaded file, with its MIME type', responses: { 200: { description: 'The file' }, 404: err[404] } },
273
+ },
252
274
  '/events': {
253
275
  get: {
254
- summary: 'Server-sent events: summary, activity, turn, finished, removed, config, preferences, ping',
276
+ summary: 'Server-sent events: summary, activity, turn, finished, removed, config, preferences, agents, ping',
255
277
  responses: { 200: { description: 'text/event-stream', content: { 'text/event-stream': { schema: { type: 'string' } } } } },
256
278
  },
257
279
  },
@@ -271,7 +293,7 @@ export function openapi({ version, port }) {
271
293
  cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
272
294
  agent: { enum: ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'auto', description: 'auto: wisp picks the harness and model from what worked before; model is then the goal (cheap, balanced, best)' },
273
295
  important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
274
- mode: { enum: ['plan', 'edits', 'auto'], default: 'edits' },
296
+ mode: { enum: ['plan', 'edits', 'auto'], default: 'auto' },
275
297
  model: { type: 'string' },
276
298
  effort: { type: 'string', description: 'Thinking level: low, medium, high, xhigh, max (per harness)' },
277
299
  attachments: { type: 'array', items: { type: 'string' }, description: 'Upload ids' },
@@ -294,6 +316,16 @@ export function openapi({ version, port }) {
294
316
  base: { type: 'string' },
295
317
  },
296
318
  },
319
+ Team: {
320
+ type: 'object',
321
+ required: ['name', 'members'],
322
+ properties: {
323
+ id: { type: 'string', readOnly: true },
324
+ name: { type: 'string' },
325
+ members: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, agent: { type: 'string', description: 'Harness id, or auto' }, model: { type: 'string' }, effort: { type: 'string' }, use: { type: 'string', description: 'What the lead should use it for' }, count: { type: 'integer', minimum: 1 } } } },
326
+ updatedAt: { type: 'integer', readOnly: true },
327
+ },
328
+ },
297
329
  Recipe: {
298
330
  type: 'object',
299
331
  required: ['name', 'prompt'],
@@ -0,0 +1,32 @@
1
+ // Project-relative paths and the size cap for the file contents wisp keeps (diffs, checkpoints).
2
+ // Shared by the adapters (what a harness changed) and checkpoint.js (reverting it).
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ // Larger files are tracked as changed, but their contents aren't kept.
8
+ export const MAX_SNAPSHOT = 256 * 1024;
9
+
10
+ const realDirs = new Map();
11
+ function realDir(dir) {
12
+ if (!realDirs.has(dir)) {
13
+ try {
14
+ realDirs.set(dir, fs.realpathSync(dir));
15
+ } catch {
16
+ realDirs.set(dir, dir);
17
+ }
18
+ }
19
+ return realDirs.get(dir);
20
+ }
21
+
22
+ // Path relative to cwd, or the path as given if it lies outside. Harnesses may report paths
23
+ // through the resolved cwd (/private/tmp vs /tmp on macOS), so both spellings count.
24
+ export function rel(file, cwd) {
25
+ if (!file) return '';
26
+ const abs = path.resolve(cwd, file);
27
+ for (const base of [cwd, realDir(cwd)]) {
28
+ const r = path.relative(base, abs);
29
+ if (r && !r.startsWith('..') && !path.isAbsolute(r)) return r;
30
+ }
31
+ return file;
32
+ }
@@ -14,6 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { adapters } from './adapters/index.js';
16
16
  import { heroOf } from './protocol.js';
17
+ import { clip, writeJsonAtomic } from './util.js';
17
18
 
18
19
  export const KINDS = ['preferences', 'lessons'];
19
20
  export const MAX_ITEMS = 10; // per list
@@ -23,10 +24,6 @@ const MAX_HISTORY = 30; // retro runs kept for Settings, newest last
23
24
  const RETRO_TIMEOUT_MS = 3 * 60_000;
24
25
  const IDLE_MS = Number(process.env.WISP_RETRO_IDLE_MS ?? 90_000); // quiet time before a retro runs
25
26
 
26
- const clip = (s, n) => {
27
- s = String(s ?? '').replace(/\s+/g, ' ').trim();
28
- return s.length > n ? `${s.slice(0, n - 1)}…` : s;
29
- };
30
27
  const emptyScope = () => ({ preferences: [], lessons: [], updatedAt: 0 });
31
28
 
32
29
  // Trim, dedupe (case-insensitively), cap length and count. Newest items win the cap.
@@ -66,8 +63,7 @@ export class Preferences {
66
63
  }
67
64
 
68
65
  #save() {
69
- fs.writeFileSync(`${this.file}.tmp`, JSON.stringify(this.data, null, 2));
70
- fs.renameSync(`${this.file}.tmp`, this.file);
66
+ writeJsonAtomic(this.file, this.data, 2);
71
67
  }
72
68
 
73
69
  // scope: 'global' or a project path; kind: 'preferences' | 'lessons'.
package/server/proc.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { spawn as nodeSpawn, execFile as nodeExecFile, spawnSync } from 'node:child_process';
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
+ import { bad } from './util.js';
10
11
 
11
12
  // Find `bin` on PATH the way cmd.exe would (PATHEXT). Returns an absolute path or null.
12
13
  // A copied env object loses Windows' case-insensitive keys, so PATH may be spelled "Path".
@@ -125,3 +126,56 @@ export function onPath(bin, env = process.env) {
125
126
  }
126
127
  });
127
128
  }
129
+
130
+ const MAX_EXEC_OUTPUT = 32 * 1024;
131
+ const EXEC_TIMEOUT_MS = 60_000;
132
+
133
+ // Run a shell command from an actionable presentation block inside `cwd`.
134
+ export function execCommand(cwd, command, { timeoutMs = EXEC_TIMEOUT_MS } = {}) {
135
+ const cmd = String(command || '').trim();
136
+ if (!cmd) return Promise.reject(bad('command required'));
137
+ const t0 = Date.now();
138
+
139
+ return new Promise((resolve) => {
140
+ const child = nodeSpawn(cmd, {
141
+ cwd,
142
+ env: { ...process.env, PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', CI: '1' },
143
+ shell: true,
144
+ windowsHide: true,
145
+ stdio: ['ignore', 'pipe', 'pipe'],
146
+ });
147
+ let out = '';
148
+ const append = (chunk) => {
149
+ if (out.length < MAX_EXEC_OUTPUT) out += chunk.toString('utf8').slice(0, MAX_EXEC_OUTPUT - out.length);
150
+ };
151
+ child.stdout.on('data', append);
152
+ child.stderr.on('data', append);
153
+
154
+ let timedOut = false;
155
+ const timer = setTimeout(() => {
156
+ timedOut = true;
157
+ killTree(child);
158
+ }, timeoutMs);
159
+
160
+ child.on('error', (err) => {
161
+ clearTimeout(timer);
162
+ resolve({
163
+ command: cmd,
164
+ output: (out ? `${out}\n` : '') + err.message,
165
+ exitCode: 1,
166
+ durationMs: Date.now() - t0,
167
+ });
168
+ });
169
+
170
+ child.on('close', (code) => {
171
+ clearTimeout(timer);
172
+ const suffix = timedOut ? `\n[timed out after ${Math.round(timeoutMs / 1000)}s]` : '';
173
+ resolve({
174
+ command: cmd,
175
+ output: (out.replace(/\s+$/, '') + suffix).trim(),
176
+ exitCode: timedOut ? 124 : (code ?? 1),
177
+ durationMs: Date.now() - t0,
178
+ });
179
+ });
180
+ });
181
+ }
@@ -69,6 +69,9 @@ diff, the test run, the command output — over prose. Use file paths relative t
69
69
  A screenshot or generated image on disk.
70
70
  - {"type":"question","question":"…","options":[{"label":"Short choice","reply":"full message sent back to you"}]}
71
71
  When you need a decision. The user's click resumes you with the reply.
72
+ - {"type":"blocked","items":[{"tool":"shell","detail":"npm publish","why":"needs network access"}]}
73
+ Actions your permissions or sandbox refused (a denied tool call, a command that failed for lack of
74
+ network or write access). wisp shows them with an Allow & retry button. Not for ordinary failures.
72
75
  - {"type":"actions","title":"…","items":[{"label":"Run tests","command":"npm test"},{"label":"Follow up","reply":"…"}]}
73
76
  One-click shell commands (executed in the project root) or follow-up actions the user may want to trigger.
74
77
  - {"type":"next","items":["Suggested follow-up the user might send", "…"]}
@@ -89,7 +92,11 @@ Strings must be valid JSON (escape newlines as \\n and quotes as \\"). Example:
89
92
 
90
93
  const ASK_TOOL = (tool) => `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
91
94
  two very different approaches), call the \`ask_user\` tool (${tool}). It pauses you until
92
- the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options.`;
95
+ the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options.
96
+ - If the user asks about their past wisp tasks (which tools or harnesses they use, cost, success rate),
97
+ call \`wisp_stats\` (${tool.replace(/ask_user$/, 'wisp_stats')}) instead of digging through files.
98
+ - If the prompt mentions another wisp task ("@Title (wisp task <id>)") or the user asks what another task
99
+ did, read it with \`wisp_session\` (${tool.replace(/ask_user$/, 'wisp_session')}); \`wisp_sessions\` lists them.`;
93
100
  const ASK_BLOCK = `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
94
101
  two very different approaches), stop before acting on it and end with a "question" block (2–4 options).
95
102
  The user's answer comes back as a follow-up in this same session.`;
package/server/recipes.js CHANGED
@@ -2,10 +2,9 @@
2
2
  // parameter, filled in when the recipe runs (the run dialog, `wisp run release 0.8.0`, a trigger).
3
3
 
4
4
  import path from 'node:path';
5
- import { newId } from './runner.js';
5
+ import { bad, MODES, newId } from './util.js';
6
6
 
7
7
  const PARAM = /\{([a-zA-Z][\w-]{0,30})\}/g;
8
- const MODES = new Set(['plan', 'edits', 'auto']);
9
8
 
10
9
  export const params = (prompt) => [...new Set([...String(prompt || '').matchAll(PARAM)].map((m) => m[1]))];
11
10
 
@@ -14,7 +13,7 @@ export const params = (prompt) => [...new Set([...String(prompt || '').matchAll(
14
13
  export function fill(prompt, values = {}, { strict = true } = {}) {
15
14
  const has = (k) => values[k] !== undefined && values[k] !== null && String(values[k]).trim() !== '';
16
15
  const missing = params(prompt).filter((k) => !has(k));
17
- if (strict && missing.length) throw Object.assign(new Error(`Missing ${missing.map((k) => `{${k}}`).join(', ')}`), { status: 400 });
16
+ if (strict && missing.length) throw bad(`Missing ${missing.map((k) => `{${k}}`).join(', ')}`);
18
17
  return String(prompt).replace(PARAM, (_, k) => (has(k) ? String(values[k]) : ''));
19
18
  }
20
19
 
@@ -25,7 +24,6 @@ export function positional(prompt, words = []) {
25
24
  return out;
26
25
  }
27
26
 
28
- const bad = (msg) => Object.assign(new Error(msg), { status: 400 });
29
27
 
30
28
  export function normalizeRecipe(body, prev = null) {
31
29
  const name = String(body.name ?? prev?.name ?? '').trim().slice(0, 60);
package/server/remote.js CHANGED
@@ -8,13 +8,13 @@
8
8
  // /new [@project] <task> starts a task and /projects lists where it can run.
9
9
 
10
10
  import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
11
+ import { bad, truncate } from './util.js';
11
12
 
12
13
  const KIND = { a: 'answer', r: 'reply', d: 'done' };
13
14
  const CODE = { answer: 'a', reply: 'r', done: 'd' };
14
15
  const MAX_TG_IDS = 20;
15
16
  const STALE_S = 10 * 60;
16
17
  const sleep = (ms, signal) => new Promise((r) => (signal?.aborted ? r() : (setTimeout(r, ms).unref?.(), signal?.addEventListener('abort', r, { once: true }))));
17
- const clip = (s, n) => (String(s || '').length > n ? `${String(s).slice(0, n - 1)}…` : String(s || ''));
18
18
  // /new @name: exact name, then name prefix, then anywhere in the name or path.
19
19
  export function findProject(projects, q) {
20
20
  const n = String(q || '').toLowerCase();
@@ -76,7 +76,7 @@ export class Remote {
76
76
  if (s.ask) {
77
77
  return {
78
78
  title: `❓ ${where}`,
79
- text: [s.ask.question, s.ask.context && clip(s.ask.context, 600)].filter(Boolean).join('\n\n'),
79
+ text: [s.ask.question, s.ask.context && truncate(s.ask.context, 600)].filter(Boolean).join('\n\n'),
80
80
  actions: (s.ask.options || []).map((label, index) => ({ label, kind: 'answer', ref: s.ask.id, index })),
81
81
  };
82
82
  }
@@ -139,12 +139,12 @@ export class Remote {
139
139
  const { server, topic, headers } = this.#ntfy();
140
140
  const actions = (s ? msg.actions : []).slice(0, 3).map((a) => {
141
141
  const k = CODE[a.kind];
142
- return { action: 'http', label: clip(a.label, 40), url: `${server}/${topic}-reply`, method: 'POST', headers, body: JSON.stringify({ s: s.id, k, r: a.ref, i: a.index, sig: this.sign(s.id, a.kind, a.ref, a.index) }), clear: true };
142
+ return { action: 'http', label: truncate(a.label, 40), url: `${server}/${topic}-reply`, method: 'POST', headers, body: JSON.stringify({ s: s.id, k, r: a.ref, i: a.index, sig: this.sign(s.id, a.kind, a.ref, a.index) }), clear: true };
143
143
  });
144
144
  const r = await fetch(server, {
145
145
  method: 'POST',
146
146
  headers: { 'content-type': 'application/json', ...headers },
147
- body: JSON.stringify({ topic, title: clip(msg.title, 200), message: clip(msg.text, 3000), tags: ['wisp'], ...(actions.length && { actions }) }),
147
+ body: JSON.stringify({ topic, title: truncate(msg.title, 200), message: truncate(msg.text, 3000), tags: ['wisp'], ...(actions.length && { actions }) }),
148
148
  signal: AbortSignal.timeout(10_000),
149
149
  });
150
150
  if (!r.ok) throw new Error(`ntfy: ${r.status} ${(await r.text().catch(() => '')).slice(0, 120)}`);
@@ -194,10 +194,10 @@ export class Remote {
194
194
  }
195
195
 
196
196
  async #tgPublish(msg, s) {
197
- const buttons = (s ? msg.actions : []).map((a) => [{ text: clip(a.label, 60), callback_data: `${CODE[a.kind]}:${s.id}:${a.ref}:${a.index}` }]);
197
+ const buttons = (s ? msg.actions : []).map((a) => [{ text: truncate(a.label, 60), callback_data: `${CODE[a.kind]}:${s.id}:${a.ref}:${a.index}` }]);
198
198
  const sent = await this.#tg('sendMessage', {
199
199
  chat_id: this.cfg.telegram.chatId,
200
- text: clip(`${msg.title}\n\n${msg.text}${s ? '\n\n↩ Reply to this message to answer or follow up.' : ''}`, 4000),
200
+ text: truncate(`${msg.title}\n\n${msg.text}${s ? '\n\n↩ Reply to this message to answer or follow up.' : ''}`, 4000),
201
201
  ...(buttons.length && { reply_markup: { inline_keyboard: buttons } }),
202
202
  });
203
203
  if (s && sent?.message_id) this.#tgLink(s.id, sent.message_id);
@@ -231,7 +231,7 @@ export class Remote {
231
231
  if (!t.chatId || String(q.message?.chat?.id) !== String(t.chatId)) return;
232
232
  const [k, sessionId, ref, i] = String(q.data || '').split(':');
233
233
  const result = KIND[k] ? await this.act({ sessionId, kind: KIND[k], ref, index: Number(i) }) : 'Unknown button.';
234
- await this.#tg('answerCallbackQuery', { callback_query_id: q.id, text: clip(result, 190) }).catch(() => {});
234
+ await this.#tg('answerCallbackQuery', { callback_query_id: q.id, text: truncate(result, 190) }).catch(() => {});
235
235
  await this.#tg('editMessageReplyMarkup', { chat_id: t.chatId, message_id: q.message.message_id, reply_markup: { inline_keyboard: [] } }).catch(() => {});
236
236
  return;
237
237
  }
@@ -274,14 +274,14 @@ export class Remote {
274
274
  const def = this.defaultProject();
275
275
  const where = (id) => (this.store.config.machines || []).find((x) => x.id === id)?.name || id;
276
276
  const lines = projects.map((p) => `• ${p.name}${p.machine ? ` (on ${where(p.machine)})` : ''}${p === def ? ' ← default' : ''}`);
277
- return say(clip(`Projects:\n${lines.join('\n')}\n\n/new @name <task> runs in one of them.`, 4000));
277
+ return say(truncate(`Projects:\n${lines.join('\n')}\n\n/new @name <task> runs in one of them.`, 4000));
278
278
  }
279
279
  let prompt = arg;
280
280
  let project = this.defaultProject();
281
281
  const at = arg.match(/^@(\S+)\s*([\s\S]*)$/);
282
282
  if (at) {
283
283
  project = findProject(projects, at[1]);
284
- if (!project) return say(`No project matches “${clip(at[1], 60)}”. Send /projects to see them.`);
284
+ if (!project) return say(`No project matches “${truncate(at[1], 60)}”. Send /projects to see them.`);
285
285
  prompt = at[2].trim();
286
286
  }
287
287
  if (!prompt) return say('What should it do? Send /new <task>, or /new @project <task>.');
@@ -290,9 +290,9 @@ export class Remote {
290
290
  try {
291
291
  s = await this.launch({ project, prompt });
292
292
  } catch (err) {
293
- return say(`Couldn’t start it: ${clip(err.message, 300)}`, { reply_to_message_id: m.message_id });
293
+ return say(`Couldn’t start it: ${truncate(err.message, 300)}`, { reply_to_message_id: m.message_id });
294
294
  }
295
- const sent = await say(clip(`▶ Started in ${project.name}: ${s?.title || prompt}\n\nwisp will message you when it needs you or is done. Reply to this message to follow up.`, 4000), { reply_to_message_id: m.message_id });
295
+ const sent = await say(truncate(`▶ Started in ${project.name}: ${s?.title || prompt}\n\nwisp will message you when it needs you or is done. Reply to this message to follow up.`, 4000), { reply_to_message_id: m.message_id });
296
296
  if (s?.id && sent?.message_id) this.#tgLink(s.id, sent.message_id);
297
297
  }
298
298
 
@@ -342,8 +342,8 @@ export class Remote {
342
342
  if (body.ntfy) {
343
343
  const n = { ...c.ntfy, ...body.ntfy };
344
344
  n.token = keep(body.ntfy.token, c.ntfy?.token) || '';
345
- if (n.server && !/^https?:\/\/\S+$/.test(n.server)) throw Object.assign(new Error('ntfy server must be an http(s) URL'), { status: 400 });
346
- if (n.topic && !/^[\w-]{1,64}$/.test(n.topic)) throw Object.assign(new Error('ntfy topic: letters, digits, - and _ only'), { status: 400 });
345
+ if (n.server && !/^https?:\/\/\S+$/.test(n.server)) throw bad('ntfy server must be an http(s) URL');
346
+ if (n.topic && !/^[\w-]{1,64}$/.test(n.topic)) throw bad('ntfy topic: letters, digits, - and _ only');
347
347
  c.ntfy = { enabled: !!n.enabled, server: n.server || 'https://ntfy.sh', topic: n.topic || '', token: n.token };
348
348
  }
349
349
  if (body.telegram) {
@@ -352,7 +352,7 @@ export class Remote {
352
352
  const relink = body.telegram.unlink || token !== prev.token;
353
353
  if (token !== prev.token) this.tgOffset = 0; // another bot, another update counter
354
354
  const project = body.telegram.project ?? prev.project ?? '';
355
- if (project && !this.store.config.projects.some((p) => p.id === project)) throw Object.assign(new Error('Unknown project'), { status: 400 });
355
+ if (project && !this.store.config.projects.some((p) => p.id === project)) throw bad('Unknown project');
356
356
  c.telegram = {
357
357
  ...prev,
358
358
  enabled: !!(body.telegram.enabled ?? prev.enabled),
package/server/render.js CHANGED
@@ -80,6 +80,8 @@ const R = {
80
80
  callout: (b) => `> **${b.tone === 'danger' ? '⛔' : b.tone === 'warn' ? '⚠️' : b.tone === 'success' ? '✅' : 'ℹ️'} ${str(b.title)}**\n> ${str(b.text).replace(/\n/g, '\n> ')}`,
81
81
  image: (b) => `🖼️ \`${str(b.path || b.src)}\`${b.caption ? ` — ${b.caption}` : ''}`,
82
82
  question: (b) => `**❓ ${str(b.question)}**\n${list(b.options).map((o, i) => `${i + 1}. ${str(o.label || o)}`).join('\n')}`,
83
+ blocked: (b, turn) =>
84
+ `**🔒 Blocked by permissions**${turn?.approval ? ` _(${turn.approval})_` : ''}\n${list(b.items).map((i) => `- ${str(i.tool)}${i.detail ? `: \`${str(i.detail)}\`` : ''}${i.why ? ` — ${i.why}` : ''}`).join('\n')}`,
83
85
  actions: (b) =>
84
86
  `${b.title ? `**${b.title}**\n` : '**Actions**\n'}${list(b.items)
85
87
  .map((a) => (typeof a === 'string' ? `- ${a}` : `- **${str(a.label || a.command)}**${a.command ? ` — \`$ ${str(a.command)}\`` : a.reply ? ` — _${str(a.reply)}_` : ''}${a.detail ? ` (${str(a.detail)})` : ''}`))
@@ -109,7 +111,7 @@ export function sessionToMarkdown(session) {
109
111
  if (turn?.status === 'running') out.push(`_Working… (${turn.activity?.findLast?.((a) => a.kind !== 'text')?.label || 'starting up'})_`);
110
112
  else if (turn?.presentation) out.push(blocksToMarkdown(turn.presentation.blocks, turn));
111
113
  if (session.ask) {
112
- out.push(`**❓ The agent is waiting for your answer:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
114
+ out.push(`**${session.ask.permission ? '🔒 The agent needs your approval' : '❓ The agent is waiting for your answer'}:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
113
115
  if (session.ask.options?.length) out.push(session.ask.options.map((o, i) => `${i + 1}. ${o}`).join('\n'));
114
116
  }
115
117
  // Worktree sessions: say where the changes are, since they aren't in the caller's checkout.
package/server/router.js CHANGED
@@ -7,7 +7,8 @@
7
7
  // sampling: arms with little data sometimes draw high and get tried) and picks the best value
8
8
  // for the goal: value × chance − expected $ − a price on your waiting time.
9
9
  //
10
- // What happened afterwards is the reward, strongest signal first: merged (1) / PR closed (0.2),
10
+ // What happened afterwards is the reward, strongest signal first: switched to another harness or
11
+ // model in a follow-up (0.1), merged (1) / PR closed (0.2),
11
12
  // 👍 (1) / 👎 (0), rolled back (0.1), else the presentation's hero (done 0.6, partial 0.3,
12
13
  // blocked 0.1, error 0) less 0.1 per follow-up. A later, stronger signal replaces an earlier one.
13
14
  //
@@ -17,6 +18,7 @@
17
18
 
18
19
  import fs from 'node:fs';
19
20
  import path from 'node:path';
21
+ import { writeJsonAtomic } from './util.js';
20
22
 
21
23
  const PRIORS = JSON.parse(fs.readFileSync(new URL('./router-priors.json', import.meta.url), 'utf8'));
22
24
 
@@ -97,7 +99,7 @@ export class Router {
97
99
 
98
100
  save() {
99
101
  try {
100
- fs.writeFileSync(this.file, JSON.stringify(this.state));
102
+ writeJsonAtomic(this.file, this.state); // a cut-off file would load as nothing learned
101
103
  } catch (err) {
102
104
  console.warn(`wisp: router: ${err.message}`);
103
105
  }
@@ -226,7 +228,7 @@ export class Router {
226
228
  }
227
229
 
228
230
  // Something happened to a routed session: { hero, error, followups, feedback, merged, closed,
229
- // rolledBack, costUsd, ms }. The reward is recomputed and the stats move by the difference.
231
+ // rolledBack, switched, costUsd, ms }. The reward is recomputed and the stats move by the difference.
230
232
  signal(id, patch) {
231
233
  const route = this.state.routes[id];
232
234
  if (!route) return null;
@@ -291,6 +293,8 @@ export class Router {
291
293
  }
292
294
 
293
295
  export function reward(sig) {
296
+ // The user moved the task to another harness or model: whatever happens next isn't this arm's.
297
+ if (sig.switched) return 0.1;
294
298
  if (sig.merged) return 1;
295
299
  if (sig.feedback === 'up') return 1;
296
300
  if (sig.feedback === 'down') return 0;
@@ -0,0 +1,29 @@
1
+ // Auto: what the router learned, and its settings (goal, trying new models, allowed harnesses).
2
+
3
+ import { adapters } from '../adapters/index.js';
4
+ import { GOALS } from '../router.js';
5
+
6
+ export function register(r, app) {
7
+ const { store, auto } = app;
8
+
9
+ r.route('GET', '/router', async (c) => c.send(200, await auto.view()));
10
+ r.route('PATCH', '/router', async (c) => {
11
+ const body = await c.body();
12
+ const d = store.config.defaults;
13
+ if (body.goal !== undefined) {
14
+ if (!GOALS[body.goal]) return c.send(400, { error: `goal must be one of ${Object.keys(GOALS).join(', ')}` });
15
+ d.models = { ...d.models, auto: body.goal };
16
+ if (d.agent === 'auto') d.model = body.goal;
17
+ }
18
+ d.router = { ...d.router };
19
+ if (body.explore !== undefined) d.router.explore = body.explore ? 'on' : 'off';
20
+ if (body.allowed !== undefined) d.router.allowed = Array.isArray(body.allowed) ? body.allowed.filter((a) => adapters[a]) : null;
21
+ app.configChanged();
22
+ c.send(200, await auto.view());
23
+ });
24
+ r.route('POST', '/router/reset', async (c) => {
25
+ app.router.reset();
26
+ app.broadcast('router', {});
27
+ c.send(200, await auto.view());
28
+ });
29
+ }
@@ -0,0 +1,92 @@
1
+ // Recipes (saved prompts with {params}) and triggers (schedules and GitHub watchers).
2
+
3
+ import { normalizeRecipe, findRecipe, recipeView } from '../recipes.js';
4
+ import { normalizeTrigger, triggerView } from '../triggers.js';
5
+
6
+ export function register(r, app) {
7
+ const { store } = app;
8
+
9
+ r.route('GET', '/recipes', (c) => c.send(200, store.config.recipes.map(recipeView)));
10
+ r.route('POST', '/recipes', async (c) => {
11
+ const x = normalizeRecipe(await c.body());
12
+ store.config.recipes.push(x);
13
+ app.configChanged();
14
+ c.send(201, recipeView(x));
15
+ });
16
+ // A recipe by id or by name (loosely: "Fix Bug" finds fix-bug).
17
+ const recipe = (fn) => async (c) => {
18
+ const x = findRecipe(store.config.recipes, c.params.key);
19
+ if (!x) return c.send(404, { error: 'no such recipe' });
20
+ return fn(c, x);
21
+ };
22
+ r.route('GET', '/recipes/:key', recipe((c, x) => c.send(200, recipeView(x))));
23
+ r.route(
24
+ 'PATCH',
25
+ '/recipes/:key',
26
+ recipe(async (c, x) => {
27
+ Object.assign(x, normalizeRecipe(await c.body(), x));
28
+ app.configChanged();
29
+ c.send(200, recipeView(x));
30
+ }),
31
+ );
32
+ r.route(
33
+ 'DELETE',
34
+ '/recipes/:key',
35
+ recipe((c, x) => {
36
+ store.config.recipes = store.config.recipes.filter((y) => y !== x);
37
+ app.configChanged();
38
+ c.send(200, { ok: true });
39
+ }),
40
+ );
41
+ // POST /recipes/:key/run { params | words, cwd, … }. machine: run it in a project there
42
+ // (r.cwd empty, or a path on that machine).
43
+ r.route(
44
+ 'POST',
45
+ '/recipes/:key/run',
46
+ recipe(async (c, x) => {
47
+ const body = await c.body();
48
+ const s = await app.launcher.runRecipe(x, { params: body.params || {}, words: Array.isArray(body.words) ? body.words.map(String) : undefined, cwd: body.cwd, machine: body.machine || undefined, agent: body.agent, model: body.model, effort: body.effort, mode: body.mode });
49
+ c.send(201, s);
50
+ }),
51
+ );
52
+
53
+ r.route('GET', '/triggers', (c) => c.send(200, store.config.triggers.map(triggerView)));
54
+ r.route('POST', '/triggers', async (c) => {
55
+ const t = normalizeTrigger(await c.body(), null, { recipes: store.config.recipes });
56
+ store.config.triggers.push(t);
57
+ app.configChanged();
58
+ c.send(201, triggerView(t));
59
+ });
60
+ const trigger = (fn) => async (c) => {
61
+ const t = store.config.triggers.find((x) => x.id === c.params.id);
62
+ if (!t) return c.send(404, { error: 'no such trigger' });
63
+ return fn(c, t);
64
+ };
65
+ r.route(
66
+ 'PATCH',
67
+ '/triggers/:id',
68
+ trigger(async (c, t) => {
69
+ Object.assign(t, normalizeTrigger(await c.body(), t, { recipes: store.config.recipes }));
70
+ app.configChanged();
71
+ c.send(200, triggerView(t));
72
+ }),
73
+ );
74
+ r.route(
75
+ 'DELETE',
76
+ '/triggers/:id',
77
+ trigger((c, t) => {
78
+ store.config.triggers = store.config.triggers.filter((x) => x !== t);
79
+ app.configChanged();
80
+ c.send(200, { ok: true });
81
+ }),
82
+ );
83
+ r.route(
84
+ 'POST',
85
+ '/triggers/:id/run',
86
+ trigger(async (c, t) => {
87
+ const out = await app.triggers.runNow(t);
88
+ const started = Array.isArray(out) ? out : out ? [out] : [];
89
+ c.send(200, { started, trigger: triggerView(t) });
90
+ }),
91
+ );
92
+ }