wisp-agents 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +29 -13
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +2 -2
  6. package/server/adapters/claude.js +31 -50
  7. package/server/adapters/codex.js +2 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +2 -2
  10. package/server/adapters/grok.js +2 -2
  11. package/server/adapters/index.js +16 -1
  12. package/server/adapters/opencode.js +47 -6
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +5 -3
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +70 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-wisp.js +17 -5
  25. package/server/openapi.js +33 -2
  26. package/server/paths.js +32 -0
  27. package/server/preferences.js +2 -6
  28. package/server/proc.js +54 -0
  29. package/server/recipes.js +2 -4
  30. package/server/remote.js +14 -14
  31. package/server/router.js +7 -3
  32. package/server/routes/auto.js +29 -0
  33. package/server/routes/automations.js +92 -0
  34. package/server/routes/internal.js +56 -0
  35. package/server/routes/machines.js +43 -0
  36. package/server/routes/meta.js +76 -0
  37. package/server/routes/proxy.js +47 -0
  38. package/server/routes/sessions.js +339 -0
  39. package/server/routes/settings.js +144 -0
  40. package/server/routes/ship.js +61 -0
  41. package/server/routes/uploads.js +19 -0
  42. package/server/runner.js +87 -36
  43. package/server/services/auto.js +67 -0
  44. package/server/services/feedback.js +53 -0
  45. package/server/services/install.js +69 -0
  46. package/server/services/launch.js +127 -0
  47. package/server/services/phone.js +65 -0
  48. package/server/services/ship.js +82 -0
  49. package/server/services/telemetry.js +34 -0
  50. package/server/services/uploads.js +59 -0
  51. package/server/services/webhooks.js +28 -0
  52. package/server/store.js +5 -3
  53. package/server/subagents.js +5 -4
  54. package/server/telemetry.js +1 -1
  55. package/server/terminal.js +2 -1
  56. package/server/triggers.js +4 -6
  57. package/server/update.js +4 -3
  58. package/server/util.js +42 -0
  59. package/server/welcome.js +1 -1
  60. package/web/css/auto.css +29 -0
  61. package/web/css/automations.css +71 -0
  62. package/web/css/base.css +83 -0
  63. package/web/css/blocks.css +461 -0
  64. package/web/css/composer.css +62 -0
  65. package/web/css/feed.css +208 -0
  66. package/web/css/launcher.css +204 -0
  67. package/web/css/layout.css +61 -0
  68. package/web/css/machines.css +125 -0
  69. package/web/css/modals.css +176 -0
  70. package/web/css/palette.css +79 -0
  71. package/web/css/responsive.css +35 -0
  72. package/web/css/ship.css +95 -0
  73. package/web/css/subagents.css +130 -0
  74. package/web/css/tokens.css +115 -0
  75. package/web/index.html +16 -1
  76. package/web/js/app.js +28 -5232
  77. package/web/js/core/api.js +25 -0
  78. package/web/js/core/bus.js +28 -0
  79. package/web/js/core/dom.js +7 -0
  80. package/web/js/core/format.js +27 -0
  81. package/web/js/core/state.js +43 -0
  82. package/web/js/features/actions.js +339 -0
  83. package/web/js/features/attachments.js +115 -0
  84. package/web/js/features/auto.js +54 -0
  85. package/web/js/features/automations.js +305 -0
  86. package/web/js/features/commands.js +123 -0
  87. package/web/js/features/compare.js +104 -0
  88. package/web/js/features/composer.js +311 -0
  89. package/web/js/features/feed.js +400 -0
  90. package/web/js/features/feedback.js +127 -0
  91. package/web/js/features/keys.js +83 -0
  92. package/web/js/features/launcher.js +559 -0
  93. package/web/js/features/live.js +91 -0
  94. package/web/js/features/machines.js +313 -0
  95. package/web/js/features/memory.js +154 -0
  96. package/web/js/features/notify.js +36 -0
  97. package/web/js/features/phone.js +124 -0
  98. package/web/js/features/places.js +38 -0
  99. package/web/js/features/projects.js +284 -0
  100. package/web/js/features/runner-picker.js +233 -0
  101. package/web/js/features/runners.js +161 -0
  102. package/web/js/features/scroll.js +75 -0
  103. package/web/js/features/settings.js +390 -0
  104. package/web/js/features/setup.js +94 -0
  105. package/web/js/features/ship.js +122 -0
  106. package/web/js/features/team.js +386 -0
  107. package/web/js/features/turns.js +401 -0
  108. package/web/js/features/update.js +48 -0
  109. package/web/js/features/usage.js +75 -0
  110. package/web/js/ui/kbd.js +46 -0
  111. package/web/js/ui/overlay.js +38 -0
  112. package/web/js/ui/popover.js +52 -0
  113. package/web/js/ui/toast.js +23 -0
  114. package/web/css/app.css +0 -1901
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
+ }
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/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
+ }
@@ -0,0 +1,56 @@
1
+ // Bridge for the MCP servers running inside an agent process (ask_user, delegate). Every call
2
+ // carries the per-run token wisp handed the agent; nothing else may use these.
3
+
4
+ import { adapters } from '../adapters/index.js';
5
+ import { delegationReport } from '../subagents.js';
6
+
7
+ export function register(r, app) {
8
+ const { store, runner, telemetry } = app;
9
+ // The token check, then the handler; a wrong token is a 403 whatever the route.
10
+ const guarded = (fn) => (c) => (c.req.headers['x-wisp-token'] !== app.token ? c.send(403, { error: 'bad token' }) : fn(c));
11
+ const opts = { doc: false };
12
+ const lead = (c) => store.get(c.params.id);
13
+
14
+ r.route(
15
+ 'POST',
16
+ '/internal/ask',
17
+ guarded(async (c) => {
18
+ const body = await c.body();
19
+ const s = store.get(body.session);
20
+ if (!s) return c.send(404, { error: 'no such session' });
21
+ telemetry.track('ask_user', { agent: s.agent });
22
+ c.send(200, { id: runner.ask(s, body) });
23
+ }),
24
+ opts,
25
+ );
26
+ r.route('GET', '/internal/ask/:id', guarded((c) => c.send(200, runner.pollAnswer(c.params.id))), opts);
27
+
28
+ // delegate: POST starts tasks on the lead's subagents; GET reports on them (done once all finished).
29
+ r.route(
30
+ 'POST',
31
+ '/internal/delegate/:id',
32
+ guarded(async (c) => {
33
+ const s = lead(c);
34
+ if (!s) return c.send(404, { error: 'no such session' });
35
+ const body = await c.body();
36
+ const delegations = runner.delegate(s, body.tasks);
37
+ telemetry.track('delegate', { agent: s.agent, tasks: delegations.length, harnesses: [...new Set(delegations.filter((d) => d.agent).map((d) => d.agent))] });
38
+ c.send(200, { delegations });
39
+ }),
40
+ opts,
41
+ );
42
+ r.route(
43
+ 'GET',
44
+ '/internal/delegate/:id',
45
+ guarded((c) => {
46
+ const s = lead(c);
47
+ if (!s) return c.send(404, { error: 'no such session' });
48
+ const ids = (c.query.get('ids') || '').split(',').filter(Boolean);
49
+ const list = runner.delegations(s, ids);
50
+ if (!list.length) return c.send(200, { done: true, text: 'No delegated tasks to report on.' });
51
+ const running = ({ d, child }) => child?.turns.find((t) => t.id === d.turnId)?.status === 'running';
52
+ c.send(200, { done: !list.some(running), text: list.map(({ d, child }) => delegationReport(d, child, adapters)).join('\n\n---\n\n') });
53
+ }),
54
+ opts,
55
+ );
56
+ }
@@ -0,0 +1,43 @@
1
+ // /api/machines: add, connect, and browse folders on other computers.
2
+
3
+ import { sshHosts, discoverHosts } from '../machines.js';
4
+
5
+ export function register(r, app) {
6
+ const { store, machines } = app;
7
+ const one = (mid) => machines.view().find((x) => x.id === mid);
8
+ // A machine route: 404s for an unknown id before doing anything.
9
+ const known = (fn) => (c) => (machines.get(c.params.id), fn(c, c.params.id));
10
+
11
+ r.route('GET', '/machines', (c) => c.send(200, machines.view()));
12
+ r.route('POST', '/machines', async (c) => {
13
+ const body = await c.body();
14
+ const x = machines.add(body);
15
+ if (body.connect !== false) await machines.connect(x.id);
16
+ c.send(201, one(x.id));
17
+ });
18
+ r.route('GET', '/machines/ssh-hosts', (c) => c.send(200, sshHosts()));
19
+ r.route('GET', '/machines/discover', async (c) => c.send(200, await discoverHosts()));
20
+ r.route('PATCH', '/machines/:id', known(async (c, id) => c.send(200, (await machines.update(id, await c.body()), one(id)))));
21
+ r.route(
22
+ 'DELETE',
23
+ '/machines/:id',
24
+ known((c, id) => {
25
+ machines.remove(id);
26
+ app.broadcast('config', {});
27
+ c.send(200, { ok: true });
28
+ }),
29
+ );
30
+ r.route('POST', '/machines/:id/connect', known(async (c, id) => c.send(200, await machines.connect(id))));
31
+ r.route('POST', '/machines/:id/disconnect', known((c, id) => c.send(200, (machines.disconnect(id), one(id)))));
32
+ // Stop wisp over there (its agents too); restart starts this version in its place.
33
+ r.route('POST', '/machines/:id/stop', known(async (c, id) => c.send(200, (await machines.stopRemote(id, { restart: !!(await c.body()).restart }), one(id)))));
34
+ r.route('GET', '/machines/:id/fs', known(async (c, id) => c.send(200, await machines.json(id, 'GET', `/fs?q=${encodeURIComponent(c.query.get('q') || '')}`))));
35
+ r.route(
36
+ 'GET',
37
+ '/machines/:id/discover',
38
+ known(async (c, id) => {
39
+ const have = new Set(store.config.projects.filter((p) => p.machine === id).map((p) => p.path));
40
+ c.send(200, (await machines.json(id, 'GET', '/projects/discover')).filter((f) => !have.has(f.path)));
41
+ }),
42
+ );
43
+ }
@@ -0,0 +1,76 @@
1
+ // The server itself: live events, health, self-update, the API description, installed
2
+ // harnesses, folder suggestions, usage and feedback.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { openapi } from '../openapi.js';
7
+ import { usage } from '../usage.js';
8
+ import { expandHome, VERSION } from '../util.js';
9
+
10
+ export const API_VERSION = 1;
11
+
12
+ export function register(r, app) {
13
+ const { store, runner, updater, telemetry, machines, port } = app;
14
+
15
+ r.route('GET', '/events', (c) => app.events.attach(c.req, c.res));
16
+
17
+ r.route('GET', '/health', (c) => c.send(200, { ok: true, name: 'wisp', version: VERSION, apiVersion: API_VERSION, pid: process.pid, url: `http://127.0.0.1:${port}` }));
18
+
19
+ // New wisp-agents on npm? POST installs it and relaunches; running agents are stopped, so the
20
+ // client must pass force once it has confirmed that with the user.
21
+ r.route('GET', '/update', async (c) => {
22
+ if (c.query.has('check')) return c.send(200, await updater.check());
23
+ updater.maybeCheck(); // the UI asks on focus and reconnect: a day-old check gets refreshed
24
+ c.send(200, updater.view());
25
+ });
26
+ r.route('POST', '/update', async (c) => {
27
+ const body = await c.body();
28
+ if (runner.live.size && !body.force) return c.send(409, { error: 'agents are running', running: runner.live.size });
29
+ await updater.install();
30
+ c.send(202, updater.view());
31
+ telemetry.track('update', { from: updater.state.current, to: updater.state.latest, method: updater.method });
32
+ setTimeout(() => (app.shutdown(), process.exit(0)), 300);
33
+ });
34
+
35
+ r.route('GET', '/openapi.json', (c) => c.send(200, openapi({ version: VERSION, port })), { doc: false });
36
+
37
+ r.route('GET', '/agents', async (c) => c.send(200, await app.auto.detect({ fresh: c.query.has('fresh') })));
38
+ // Re-check: detect harnesses and models again, here and on every online machine.
39
+ r.route('POST', '/agents/recheck', async (c) => {
40
+ const [agents] = await Promise.all([app.auto.detect({ fresh: true }), machines.recheckAgents()]);
41
+ c.send(200, agents);
42
+ });
43
+ // No harness yet: install one (opencode) without leaving wisp. Answers once it's ready.
44
+ r.route('POST', '/agents/install', async (c) => {
45
+ const { id } = await c.body();
46
+ c.send(200, await app.installer.run(String(id || '')));
47
+ });
48
+
49
+ // Directory suggestions for the launcher's project picker.
50
+ r.route(
51
+ 'GET',
52
+ '/fs',
53
+ (c) => {
54
+ const raw = expandHome(c.query.get('q') || '');
55
+ const trailing = /[\\/]$/.test(raw); // a trailing backslash counts too (Windows)
56
+ const dir = trailing ? raw : path.dirname(raw);
57
+ const prefix = trailing ? '' : path.basename(raw).toLowerCase();
58
+ let entries = [];
59
+ try {
60
+ entries = fs
61
+ .readdirSync(dir, { withFileTypes: true })
62
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name.toLowerCase().startsWith(prefix))
63
+ .slice(0, 12)
64
+ .map((e) => path.join(dir, e.name));
65
+ } catch {}
66
+ c.send(200, entries);
67
+ },
68
+ { doc: false },
69
+ );
70
+
71
+ // Spend and outcomes. ?days=7|30|90
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
+
74
+ // Feedback for the wisp team: thumbs on a turn and/or a note. { rating?, text?, sessionId?, turnId? }
75
+ r.route('POST', '/feedback', async (c) => c.send(200, await app.sendFeedback(await c.body())));
76
+ }
@@ -0,0 +1,47 @@
1
+ // /api/sessions/<id>@<machine>/…: passed through to that machine's wisp, ids rewritten.
2
+ // Registered before the session routes, so a qualified id never reaches them.
3
+
4
+ import os from 'node:os';
5
+ import { pipeFrom } from '../http/respond.js';
6
+ import { splitId, qualify } from '../machines.js';
7
+ import { openInTerminal, shq } from '../terminal.js';
8
+ import { MAX_WAIT_S } from './sessions.js';
9
+
10
+ const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
11
+
12
+ export function register(r, app) {
13
+ const { store, machines, telemetry } = app;
14
+ const remote = { when: (p) => !!splitId(p.id), doc: false };
15
+
16
+ async function proxy(c) {
17
+ const at = splitId(c.params.id);
18
+ const { action } = c.params;
19
+ const m = c.req.method;
20
+ const mid = at.machine;
21
+ const sub = `/sessions/${at.id}${action ? `/${action}` : ''}${c.url.search}`;
22
+ if ((action === 'file' && m === 'GET') || (action === 'presentation' && c.query.get('format') === 'markdown')) return pipeFrom(c.res, await machines.stream(mid, 'GET', sub));
23
+ // Take over: a terminal here, signed in over there, resuming the agent in its folder.
24
+ if (action === 'takeover') {
25
+ const { command } = await machines.json(mid, 'GET', `/sessions/${at.id}/takeover`);
26
+ const argv = machines.terminalArgv(mid, command);
27
+ if (m === 'POST') {
28
+ const s = await machines.json(mid, 'GET', `/sessions/${at.id}`);
29
+ if (s.state === 'running' || s.state === 'waiting') await machines.json(mid, 'POST', `/sessions/${at.id}/stop`, {});
30
+ const d = store.config.defaults;
31
+ openInTerminal({ cwd: os.homedir(), argv, terminal: d.terminal || 'auto', custom: d.terminalCommand });
32
+ telemetry.track('takeover', { agent: s.agent, terminal: d.terminal || 'auto', machine: true });
33
+ }
34
+ return c.send(200, { command: argv.map(shq).join(' ') });
35
+ }
36
+ const body = m === 'GET' ? undefined : await c.body();
37
+ if (action === 'reply' && body?.attachments?.length) body.attachments = await app.launcher.pushUploads(mid, body.attachments);
38
+ const out = await machines.json(mid, m, sub, body, action === 'wait' ? { timeout: (MAX_WAIT_S + 30) * 1000 } : undefined);
39
+ if (!action && m === 'GET') return c.send(200, machines.session(mid, out));
40
+ if ((!action && m === 'PATCH') || action === 'wait') return c.send(200, machines.summary(mid, out));
41
+ if (out && typeof out === 'object' && out.sessionId) out.sessionId = qualify(out.sessionId, mid);
42
+ c.send(200, out);
43
+ }
44
+
45
+ r.route(METHODS, '/sessions/:id', proxy, remote);
46
+ r.route(METHODS, '/sessions/:id/:action', proxy, remote);
47
+ }