wisp-agents 0.15.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.
- package/CHANGELOG.md +39 -1
- package/README.md +28 -12
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +2 -2
- package/server/adapters/claude.js +31 -50
- package/server/adapters/codex.js +2 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +2 -2
- package/server/adapters/grok.js +2 -2
- package/server/adapters/index.js +16 -1
- package/server/adapters/opencode.js +47 -6
- package/server/checkpoint.js +12 -68
- package/server/client.js +5 -3
- package/server/git.js +9 -4
- package/server/handoff.js +70 -0
- package/server/http/respond.js +59 -0
- package/server/http/router.js +64 -0
- package/server/http/static.js +36 -0
- package/server/index.js +138 -1216
- package/server/machines/bootstrap.js +56 -0
- package/server/machines/hosts.js +127 -0
- package/server/machines.js +7 -179
- package/server/mcp-wisp.js +17 -5
- package/server/openapi.js +33 -2
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/router.js +7 -3
- package/server/routes/auto.js +29 -0
- package/server/routes/automations.js +92 -0
- package/server/routes/internal.js +56 -0
- package/server/routes/machines.js +43 -0
- package/server/routes/meta.js +76 -0
- package/server/routes/proxy.js +47 -0
- package/server/routes/sessions.js +339 -0
- package/server/routes/settings.js +144 -0
- package/server/routes/ship.js +61 -0
- package/server/routes/uploads.js +19 -0
- package/server/runner.js +87 -36
- package/server/services/auto.js +67 -0
- package/server/services/feedback.js +53 -0
- package/server/services/install.js +69 -0
- package/server/services/launch.js +127 -0
- package/server/services/phone.js +65 -0
- package/server/services/ship.js +82 -0
- package/server/services/telemetry.js +34 -0
- package/server/services/uploads.js +59 -0
- package/server/services/webhooks.js +28 -0
- package/server/store.js +5 -3
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +4 -6
- package/server/update.js +4 -3
- package/server/util.js +42 -0
- package/server/welcome.js +1 -1
- package/web/css/auto.css +29 -0
- package/web/css/automations.css +71 -0
- package/web/css/base.css +83 -0
- package/web/css/blocks.css +461 -0
- package/web/css/composer.css +62 -0
- package/web/css/feed.css +208 -0
- package/web/css/launcher.css +204 -0
- package/web/css/layout.css +61 -0
- package/web/css/machines.css +125 -0
- package/web/css/modals.css +176 -0
- package/web/css/palette.css +79 -0
- package/web/css/responsive.css +35 -0
- package/web/css/ship.css +95 -0
- package/web/css/subagents.css +130 -0
- package/web/css/tokens.css +115 -0
- package/web/index.html +16 -1
- package/web/js/app.js +28 -5237
- package/web/js/core/api.js +25 -0
- package/web/js/core/bus.js +28 -0
- package/web/js/core/dom.js +7 -0
- package/web/js/core/format.js +27 -0
- package/web/js/core/state.js +43 -0
- package/web/js/features/actions.js +339 -0
- package/web/js/features/attachments.js +115 -0
- package/web/js/features/auto.js +54 -0
- package/web/js/features/automations.js +305 -0
- package/web/js/features/commands.js +123 -0
- package/web/js/features/compare.js +104 -0
- package/web/js/features/composer.js +311 -0
- package/web/js/features/feed.js +400 -0
- package/web/js/features/feedback.js +127 -0
- package/web/js/features/keys.js +83 -0
- package/web/js/features/launcher.js +559 -0
- package/web/js/features/live.js +91 -0
- package/web/js/features/machines.js +313 -0
- package/web/js/features/memory.js +154 -0
- package/web/js/features/notify.js +36 -0
- package/web/js/features/phone.js +124 -0
- package/web/js/features/places.js +38 -0
- package/web/js/features/projects.js +284 -0
- package/web/js/features/runner-picker.js +233 -0
- package/web/js/features/runners.js +161 -0
- package/web/js/features/scroll.js +75 -0
- package/web/js/features/settings.js +390 -0
- package/web/js/features/setup.js +94 -0
- package/web/js/features/ship.js +122 -0
- package/web/js/features/team.js +386 -0
- package/web/js/features/turns.js +401 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/ui/kbd.js +46 -0
- package/web/js/ui/overlay.js +38 -0
- package/web/js/ui/popover.js +52 -0
- package/web/js/ui/toast.js +23 -0
- package/web/css/app.css +0 -1910
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 './
|
|
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
|
|
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 &&
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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 “${
|
|
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: ${
|
|
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(
|
|
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
|
|
346
|
-
if (n.topic && !/^[\w-]{1,64}$/.test(n.topic)) throw
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|