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.
- package/CHANGELOG.md +55 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +7 -3
- package/server/adapters/claude.js +79 -55
- package/server/adapters/codex.js +5 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +4 -2
- package/server/adapters/grok.js +17 -2
- package/server/adapters/index.js +25 -2
- package/server/adapters/opencode.js +78 -12
- package/server/checkpoint.js +12 -68
- package/server/client.js +6 -4
- package/server/git.js +9 -4
- package/server/handoff.js +71 -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-ask.js +113 -8
- package/server/mcp-wisp.js +17 -5
- package/server/mentions.js +39 -0
- package/server/openapi.js +36 -4
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/protocol.js +8 -1
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/render.js +3 -1
- 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 +176 -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 +351 -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 +216 -49
- 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 +13 -5
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +5 -7
- package/server/update.js +4 -3
- package/server/usage.js +94 -0
- package/server/util.js +42 -0
- package/server/welcome.js +3 -3
- 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 +84 -0
- package/web/css/feed.css +235 -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 +17 -1
- package/web/js/app.js +28 -5237
- package/web/js/blocks.js +46 -0
- 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 +344 -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 +105 -0
- package/web/js/features/composer.js +315 -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 +561 -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/mentions.js +125 -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 +163 -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 +432 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/mention-text.js +59 -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
|
@@ -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: '
|
|
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'],
|
package/server/paths.js
ADDED
|
@@ -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
|
+
}
|
package/server/preferences.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/server/protocol.js
CHANGED
|
@@ -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 './
|
|
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/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(
|
|
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:
|
|
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
|
+
}
|