wisp-agents 0.7.0 → 0.8.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 +38 -1
- package/README.md +76 -1
- package/bin/wisp.js +52 -5
- package/package.json +2 -2
- package/server/adapters/claude.js +4 -0
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/index.js +2 -1
- package/server/adapters/opencode.js +4 -0
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +409 -21
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +7 -1
- package/server/openapi.js +147 -3
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +279 -0
- package/server/render.js +18 -0
- package/server/runner.js +152 -13
- package/server/store.js +9 -1
- package/server/subagents.js +85 -0
- package/server/triggers.js +268 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +3 -2
- package/web/css/app.css +557 -16
- package/web/index.html +10 -3
- package/web/js/app.js +1727 -86
- package/web/js/blocks.js +396 -22
package/server/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import os from 'node:os';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { randomBytes } from 'node:crypto';
|
|
9
9
|
import { Store } from './store.js';
|
|
10
|
-
import { Runner, summarize, newId } from './runner.js';
|
|
10
|
+
import { Runner, summarize, newId, workdir } from './runner.js';
|
|
11
11
|
import { adapters, listAgents } from './adapters/index.js';
|
|
12
12
|
import { welcomeSession } from './welcome.js';
|
|
13
13
|
import { openInTerminal, TERMINALS, shq } from './terminal.js';
|
|
@@ -16,6 +16,14 @@ import { blocksToMarkdown, sessionToMarkdown } from './render.js';
|
|
|
16
16
|
import { openapi } from './openapi.js';
|
|
17
17
|
import { Updater } from './update.js';
|
|
18
18
|
import { Preferences, Retro, KINDS } from './preferences.js';
|
|
19
|
+
import { cleanupCheckpoints, revertFileChange, rollbackToTurn, execCommand } from './checkpoint.js';
|
|
20
|
+
import { clip } from './adapters/common.js';
|
|
21
|
+
import { createWorktree, worktreeSource, ensureWorktree, removeWorktree, worktreeStatus, ship, merge, prView, repoRoot } from './git.js';
|
|
22
|
+
import { normalizeRecipe, findRecipe, recipeView, fill, positional } from './recipes.js';
|
|
23
|
+
import { Triggers, normalizeTrigger, triggerView } from './triggers.js';
|
|
24
|
+
import { Remote } from './remote.js';
|
|
25
|
+
import { usage } from './usage.js';
|
|
26
|
+
import { normalizeTeam, normalizePreset, delegationReport } from './subagents.js';
|
|
19
27
|
|
|
20
28
|
const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
|
|
21
29
|
|
|
@@ -44,6 +52,11 @@ export const API_VERSION = 1;
|
|
|
44
52
|
// with follow-ups still queued is about to start the next one, so it doesn't count yet.
|
|
45
53
|
const settled = (s) => s.state === 'waiting' || (s.state !== 'running' && !s.queue?.length);
|
|
46
54
|
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
55
|
+
const PR_POLL_MS = 60_000;
|
|
56
|
+
const MAX_COMPARE = 4;
|
|
57
|
+
const MODES = new Set(['plan', 'edits', 'auto']);
|
|
58
|
+
const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
|
59
|
+
const expand = (p) => path.resolve(String(p).replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
47
60
|
|
|
48
61
|
export async function startServer({ port = 4477, host = '127.0.0.1', handleSignals = true } = {}) {
|
|
49
62
|
const store = new Store();
|
|
@@ -73,6 +86,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
73
86
|
runner.on('turn', (t) => broadcast('turn', t));
|
|
74
87
|
runner.on('finished', (f) => broadcast('finished', f));
|
|
75
88
|
runner.on('finished', ({ session }) => notifyWebhooks(store, session));
|
|
89
|
+
runner.on('finished', ({ session }) => remote.notify(store.get(session.id), session).catch((err) => console.warn(`wisp: phone: ${err.message}`)));
|
|
76
90
|
setInterval(() => broadcast('ping', {}), 25_000).unref();
|
|
77
91
|
|
|
78
92
|
const telemetry = new Telemetry(store);
|
|
@@ -117,6 +131,148 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
117
131
|
});
|
|
118
132
|
if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
|
|
119
133
|
|
|
134
|
+
// One way in for every launch: the launcher, the CLI, recipes, triggers and compare runs.
|
|
135
|
+
// body: { prompt, cwd, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
|
|
136
|
+
// subagents: [{ name, agent, model, effort, use }] | preset: teamId }
|
|
137
|
+
async function launch(body, origin = null) {
|
|
138
|
+
if (!String(body.prompt || '').trim()) throw bad('prompt required');
|
|
139
|
+
const cwd = expand(body.cwd || process.cwd());
|
|
140
|
+
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
|
|
141
|
+
const d = store.config.defaults;
|
|
142
|
+
// compare: more { agent, model, effort } to run the same task on, side by side.
|
|
143
|
+
const runners = [{ agent: body.agent, model: body.model, effort: body.effort }, ...(Array.isArray(body.compare) ? body.compare : [])].slice(0, MAX_COMPARE).map((r, i) => {
|
|
144
|
+
const agent = r.agent || (i ? d.agent : '') || 'claude';
|
|
145
|
+
if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
|
|
146
|
+
return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '' };
|
|
147
|
+
});
|
|
148
|
+
const mode = MODES.has(body.mode) ? body.mode : 'edits';
|
|
149
|
+
// Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
|
|
150
|
+
// runs need one each, or the harnesses would edit the same files.
|
|
151
|
+
const comparing = runners.length > 1;
|
|
152
|
+
const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
|
|
153
|
+
const groupId = comparing ? newId() : null;
|
|
154
|
+
const attachments = resolveAttachments(body.attachments);
|
|
155
|
+
const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
|
|
156
|
+
if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
|
|
157
|
+
const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
|
|
158
|
+
const ids = runners.map(() => newId());
|
|
159
|
+
let trees = ids.map(() => null);
|
|
160
|
+
if (wantTree) {
|
|
161
|
+
const must = body.worktree === true || (comparing && mode !== 'plan');
|
|
162
|
+
// One fetch for the whole launch, then the checkouts side by side: compare runs used to
|
|
163
|
+
// fetch and check out one after the other before anything showed up in the feed.
|
|
164
|
+
const results = await worktreeSource(cwd).then(
|
|
165
|
+
(source) => source && Promise.allSettled(ids.map((id) => createWorktree({ cwd, home: store.home, id, title: body.prompt, source }))),
|
|
166
|
+
(err) => [{ status: 'rejected', reason: err }],
|
|
167
|
+
);
|
|
168
|
+
const failed = results?.find((x) => x.status === 'rejected')?.reason;
|
|
169
|
+
trees = ids.map((_, i) => (results?.[i]?.status === 'fulfilled' ? results[i].value : null));
|
|
170
|
+
if (must && (failed || trees.some((t) => !t))) {
|
|
171
|
+
await Promise.all(trees.map((t) => t && removeWorktree(t, { force: true, branch: 'force' }).catch(() => {})));
|
|
172
|
+
if (failed) throw bad(`Could not create a worktree: ${failed.message}`);
|
|
173
|
+
throw bad(comparing ? 'Compare needs a git repo with at least one commit, so each harness gets its own worktree.' : 'Not a git repo with commits, so there is nothing to make a worktree from.');
|
|
174
|
+
}
|
|
175
|
+
if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
|
|
176
|
+
}
|
|
177
|
+
return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
|
|
181
|
+
async function replyTo(s, text, attachments = [], queue) {
|
|
182
|
+
await ensureWorktree(s.worktree);
|
|
183
|
+
return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue' });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const markDone = (s) => {
|
|
187
|
+
if (s.state === 'running' || s.state === 'waiting') return false;
|
|
188
|
+
s.state = 'done';
|
|
189
|
+
s.unread = false;
|
|
190
|
+
retro.schedule(s, 0);
|
|
191
|
+
store.save(s);
|
|
192
|
+
broadcast('summary', summarize(s, store));
|
|
193
|
+
return true;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Recipes and triggers.
|
|
197
|
+
const runRecipe = async (r, { params = {}, words, cwd, agent, model, effort, mode } = {}) => {
|
|
198
|
+
const values = words ? { ...positional(r.prompt, words), ...params } : params;
|
|
199
|
+
const [s] = await launch({ prompt: fill(r.prompt, values), cwd: r.cwd || cwd, agent: agent || r.agent || undefined, model: model ?? (r.model || undefined), effort: effort || r.effort || undefined, mode: mode || r.mode || undefined }, { kind: 'recipe', id: r.id, name: r.name });
|
|
200
|
+
r.runs = (r.runs || 0) + 1;
|
|
201
|
+
r.lastRunAt = Date.now();
|
|
202
|
+
store.saveConfig();
|
|
203
|
+
broadcast('config', {});
|
|
204
|
+
return s;
|
|
205
|
+
};
|
|
206
|
+
const triggers = new Triggers({ store, launch: async (body, origin) => (await launch(body, origin))[0], onChange: () => broadcast('config', {}) });
|
|
207
|
+
|
|
208
|
+
// Phone: answers and follow-ups coming back from ntfy / Telegram.
|
|
209
|
+
const remote = new Remote({
|
|
210
|
+
store,
|
|
211
|
+
onChange: () => broadcast('remote', {}),
|
|
212
|
+
act: async ({ sessionId, kind, ref, index, text }) => {
|
|
213
|
+
const s = store.get(sessionId);
|
|
214
|
+
if (!s) return 'That session is gone.';
|
|
215
|
+
if (kind === 'answer') {
|
|
216
|
+
if (!s.ask || s.ask.id !== ref) return 'That question was already answered.';
|
|
217
|
+
const answer = s.ask.options[index];
|
|
218
|
+
if (answer === undefined) return 'Unknown option.';
|
|
219
|
+
runner.answer(s, answer);
|
|
220
|
+
return `✓ Answered: ${answer}`;
|
|
221
|
+
}
|
|
222
|
+
if (kind === 'reply') {
|
|
223
|
+
const turn = s.turns.at(-1);
|
|
224
|
+
if (!turn || turn.id !== ref || s.state === 'running') return 'That was already answered.';
|
|
225
|
+
const q = turn.presentation?.blocks?.findLast((b) => b?.type === 'question');
|
|
226
|
+
const o = q?.options?.[index];
|
|
227
|
+
const msg = typeof o === 'string' ? o : o?.reply || o?.label;
|
|
228
|
+
if (!msg) return 'Unknown option.';
|
|
229
|
+
await replyTo(s, msg);
|
|
230
|
+
return `✓ Sent: ${typeof o === 'string' ? o : o.label || msg}`;
|
|
231
|
+
}
|
|
232
|
+
if (kind === 'done') {
|
|
233
|
+
if (s.turns.at(-1)?.id !== ref && ref) return 'There is newer work on that session; open wisp to review it.';
|
|
234
|
+
return markDone(s) ? '✓ Marked done' : 'It is still working.';
|
|
235
|
+
}
|
|
236
|
+
if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
|
|
237
|
+
const r = await replyTo(s, String(text || '').trim());
|
|
238
|
+
return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Ship it: follow open PRs (checks, reviews, merges) so their cards stay current.
|
|
243
|
+
const refreshPr = async (s) => {
|
|
244
|
+
if (!s.pr || !s.worktree) return s.pr;
|
|
245
|
+
const prev = s.pr;
|
|
246
|
+
const dir = fs.existsSync(s.worktree.path) ? s.worktree.path : s.worktree.root;
|
|
247
|
+
const pr = await prView(dir, prev.number);
|
|
248
|
+
const changed = (k) => JSON.stringify(k(pr)) !== JSON.stringify(k(prev));
|
|
249
|
+
s.pr = pr;
|
|
250
|
+
// Things worth coming back for: CI finished either way, a review landed, it merged or closed.
|
|
251
|
+
const settledChecks = (x) => (x.checks.pending ? 'pending' : x.checks.fail ? 'fail' : 'pass');
|
|
252
|
+
if (changed((x) => x.state) || changed((x) => x.review) || (changed(settledChecks) && settledChecks(pr) !== 'pending')) s.unread = true;
|
|
253
|
+
store.save(s);
|
|
254
|
+
broadcast('summary', summarize(s, store));
|
|
255
|
+
return pr;
|
|
256
|
+
};
|
|
257
|
+
const prTimer = setInterval(async () => {
|
|
258
|
+
for (const s of store.all()) if (s.pr?.state === 'OPEN') await refreshPr(s).catch(() => {});
|
|
259
|
+
}, PR_POLL_MS);
|
|
260
|
+
prTimer.unref();
|
|
261
|
+
|
|
262
|
+
// Default commit message and PR text, from the session's latest presentation.
|
|
263
|
+
const shipDraft = (s) => {
|
|
264
|
+
const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
|
|
265
|
+
const blocks = turn?.presentation?.blocks || [];
|
|
266
|
+
const hero = blocks.find((b) => b?.type === 'hero');
|
|
267
|
+
const text = `${hero?.title || s.title}`.trim();
|
|
268
|
+
const conventional = /^[a-z]+(\([\w./-]+\))?!?: /.test(text);
|
|
269
|
+
const type = /\b(fix|bug|broken|crash|flaky|regression)/i.test(`${text} ${s.turns[0]?.prompt || ''}`) ? 'fix' : /\b(docs?|readme|changelog)\b/i.test(text) ? 'docs' : /\b(refactor|clean ?up|simplif)/i.test(text) ? 'refactor' : /\btests?\b/i.test(text) ? 'test' : 'feat';
|
|
270
|
+
const title = conventional ? text : `${type}: ${text.charAt(0).toLowerCase()}${text.slice(1)}`;
|
|
271
|
+
const keep = blocks.filter((b) => !['hero', 'next', 'question', 'diff', 'code', 'image', 'terminal'].includes(b?.type));
|
|
272
|
+
const body = [hero?.summary, blocksToMarkdown(keep, turn)].filter(Boolean).join('\n\n').trim();
|
|
273
|
+
return { title: title.slice(0, 100), body: `${body}\n\n_Shipped from [wisp](https://github.com/M0xoo/wisp)._`.trim() };
|
|
274
|
+
};
|
|
275
|
+
|
|
120
276
|
const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
121
277
|
|
|
122
278
|
const server = http.createServer(async (req, res) => {
|
|
@@ -188,6 +344,24 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
188
344
|
return send(res, 200, { id: runner.ask(s, body) });
|
|
189
345
|
}
|
|
190
346
|
if (id === 'ask' && action && m === 'GET') return send(res, 200, runner.pollAnswer(action));
|
|
347
|
+
// delegate: POST starts tasks on the lead's subagents; GET reports on them (done once all finished).
|
|
348
|
+
if (id === 'delegate' && action) {
|
|
349
|
+
const lead = store.get(action);
|
|
350
|
+
if (!lead) return send(res, 404, { error: 'no such session' });
|
|
351
|
+
if (m === 'POST') {
|
|
352
|
+
const body = await readJson(req);
|
|
353
|
+
const delegations = runner.delegate(lead, body.tasks);
|
|
354
|
+
telemetry.track('delegate', { agent: lead.agent, tasks: delegations.length, harnesses: [...new Set(delegations.filter((d) => d.agent).map((d) => d.agent))] });
|
|
355
|
+
return send(res, 200, { delegations });
|
|
356
|
+
}
|
|
357
|
+
if (m === 'GET') {
|
|
358
|
+
const ids = (url.searchParams.get('ids') || '').split(',').filter(Boolean);
|
|
359
|
+
const list = runner.delegations(lead, ids);
|
|
360
|
+
if (!list.length) return send(res, 200, { done: true, text: 'No delegated tasks to report on.' });
|
|
361
|
+
const running = ({ d, child }) => child?.turns.find((t) => t.id === d.turnId)?.status === 'running';
|
|
362
|
+
return send(res, 200, { done: !list.some(running), text: list.map(({ d, child }) => delegationReport(d, child, adapters)).join('\n\n---\n\n') });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
191
365
|
}
|
|
192
366
|
|
|
193
367
|
// Attachments: raw body streamed to disk, referenced by id from launches and replies.
|
|
@@ -226,7 +400,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
226
400
|
}
|
|
227
401
|
|
|
228
402
|
if (root === 'config') {
|
|
229
|
-
if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled } });
|
|
403
|
+
if (m === 'GET') return send(res, 200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled } });
|
|
230
404
|
if (m === 'PATCH') {
|
|
231
405
|
const body = await readJson(req);
|
|
232
406
|
if (body.defaults) Object.assign(store.config.defaults, body.defaults);
|
|
@@ -238,7 +412,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
238
412
|
}
|
|
239
413
|
store.saveConfig();
|
|
240
414
|
broadcast('config', {});
|
|
241
|
-
return send(res, 200, store.config);
|
|
415
|
+
return send(res, 200, { ...store.config, remote: undefined });
|
|
242
416
|
}
|
|
243
417
|
}
|
|
244
418
|
|
|
@@ -264,6 +438,107 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
264
438
|
}
|
|
265
439
|
}
|
|
266
440
|
|
|
441
|
+
// Subagent presets: a saved team (members with harness, model, thinking and "use it for").
|
|
442
|
+
if (root === 'teams') {
|
|
443
|
+
const list = store.config.teams;
|
|
444
|
+
const saved = (status, body) => (store.saveConfig(), broadcast('config', {}), send(res, status, body));
|
|
445
|
+
if (!id && m === 'GET') return send(res, 200, list);
|
|
446
|
+
if (!id && m === 'POST') {
|
|
447
|
+
const t = normalizePreset(await readJson(req), adapters);
|
|
448
|
+
list.push(t);
|
|
449
|
+
return saved(201, t);
|
|
450
|
+
}
|
|
451
|
+
const t = list.find((x) => x.id === id);
|
|
452
|
+
if (!t) return send(res, 404, { error: 'no such preset' });
|
|
453
|
+
if (!action && m === 'GET') return send(res, 200, t);
|
|
454
|
+
if (!action && m === 'PATCH') return saved(200, Object.assign(t, normalizePreset(await readJson(req), adapters, t)));
|
|
455
|
+
if (!action && m === 'DELETE') {
|
|
456
|
+
store.config.teams = list.filter((x) => x !== t);
|
|
457
|
+
return saved(200, { ok: true });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Recipes: saved prompts with {params}. POST /recipes/:id/run { params | words, cwd, … }.
|
|
462
|
+
if (root === 'recipes') {
|
|
463
|
+
const list = store.config.recipes;
|
|
464
|
+
if (!id && m === 'GET') return send(res, 200, list.map(recipeView));
|
|
465
|
+
if (!id && m === 'POST') {
|
|
466
|
+
const r = normalizeRecipe(await readJson(req));
|
|
467
|
+
list.push(r);
|
|
468
|
+
store.saveConfig();
|
|
469
|
+
broadcast('config', {});
|
|
470
|
+
return send(res, 201, recipeView(r));
|
|
471
|
+
}
|
|
472
|
+
const r = findRecipe(list, decodeURIComponent(id || ''));
|
|
473
|
+
if (!r) return send(res, 404, { error: 'no such recipe' });
|
|
474
|
+
if (!action && m === 'GET') return send(res, 200, recipeView(r));
|
|
475
|
+
if (!action && m === 'PATCH') {
|
|
476
|
+
Object.assign(r, normalizeRecipe(await readJson(req), r));
|
|
477
|
+
store.saveConfig();
|
|
478
|
+
broadcast('config', {});
|
|
479
|
+
return send(res, 200, recipeView(r));
|
|
480
|
+
}
|
|
481
|
+
if (!action && m === 'DELETE') {
|
|
482
|
+
store.config.recipes = list.filter((x) => x !== r);
|
|
483
|
+
store.saveConfig();
|
|
484
|
+
broadcast('config', {});
|
|
485
|
+
return send(res, 200, { ok: true });
|
|
486
|
+
}
|
|
487
|
+
if (action === 'run' && m === 'POST') {
|
|
488
|
+
const body = await readJson(req);
|
|
489
|
+
const s = await runRecipe(r, { params: body.params || {}, words: Array.isArray(body.words) ? body.words.map(String) : undefined, cwd: body.cwd, agent: body.agent, model: body.model, effort: body.effort, mode: body.mode });
|
|
490
|
+
return send(res, 201, summarize(s, store));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Triggers: schedules and GitHub watchers that launch sessions on their own.
|
|
495
|
+
if (root === 'triggers') {
|
|
496
|
+
const list = store.config.triggers;
|
|
497
|
+
if (!id && m === 'GET') return send(res, 200, list.map(triggerView));
|
|
498
|
+
if (!id && m === 'POST') {
|
|
499
|
+
const t = normalizeTrigger(await readJson(req), null, { recipes: store.config.recipes });
|
|
500
|
+
list.push(t);
|
|
501
|
+
store.saveConfig();
|
|
502
|
+
broadcast('config', {});
|
|
503
|
+
return send(res, 201, triggerView(t));
|
|
504
|
+
}
|
|
505
|
+
const t = list.find((x) => x.id === id);
|
|
506
|
+
if (!t) return send(res, 404, { error: 'no such trigger' });
|
|
507
|
+
if (!action && m === 'PATCH') {
|
|
508
|
+
Object.assign(t, normalizeTrigger(await readJson(req), t, { recipes: store.config.recipes }));
|
|
509
|
+
store.saveConfig();
|
|
510
|
+
broadcast('config', {});
|
|
511
|
+
return send(res, 200, triggerView(t));
|
|
512
|
+
}
|
|
513
|
+
if (!action && m === 'DELETE') {
|
|
514
|
+
store.config.triggers = list.filter((x) => x !== t);
|
|
515
|
+
store.saveConfig();
|
|
516
|
+
broadcast('config', {});
|
|
517
|
+
return send(res, 200, { ok: true });
|
|
518
|
+
}
|
|
519
|
+
if (action === 'run' && m === 'POST') {
|
|
520
|
+
const r = await triggers.runNow(t);
|
|
521
|
+
const started = (Array.isArray(r) ? r : r ? [r] : []).map((x) => summarize(x, store));
|
|
522
|
+
return send(res, 200, { started, trigger: triggerView(t) });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Phone: ntfy / Telegram settings (secrets masked), and a test message.
|
|
527
|
+
if (root === 'remote') {
|
|
528
|
+
if (!id && m === 'GET') return send(res, 200, remote.view());
|
|
529
|
+
if (!id && m === 'PUT') return send(res, 200, remote.update(await readJson(req)));
|
|
530
|
+
if (id === 'test' && m === 'POST') {
|
|
531
|
+
const sent = await remote.send({ title: '✨ wisp', text: 'Test from wisp: questions and finished tasks will show up like this.', actions: [] });
|
|
532
|
+
if (!sent) return send(res, 400, { error: 'Turn on ntfy or link Telegram first.' });
|
|
533
|
+
return send(res, 200, remote.view());
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Spend and outcomes. ?days=7|30|90
|
|
538
|
+
if (root === 'usage' && m === 'GET') {
|
|
539
|
+
return send(res, 200, usage(store.all(), { days: Number(url.searchParams.get('days')) || 30, project: (s) => store.projectFor(s.cwd) }));
|
|
540
|
+
}
|
|
541
|
+
|
|
267
542
|
if (root === 'projects') {
|
|
268
543
|
if (!id && m === 'GET') return send(res, 200, store.config.projects);
|
|
269
544
|
if (!id && m === 'POST') {
|
|
@@ -337,7 +612,8 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
337
612
|
if (m === 'GET') {
|
|
338
613
|
const q = Object.fromEntries(url.searchParams);
|
|
339
614
|
const cwd = q.cwd && path.resolve(q.cwd.replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
340
|
-
|
|
615
|
+
// Subagent sessions belong to their lead; ?children=1 lists them too.
|
|
616
|
+
let list = store.all().filter((x) => !x.parentId || q.children).map((x) => summarize(x, store)).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
341
617
|
if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
|
|
342
618
|
if (q.agent) list = list.filter((x) => x.agent === q.agent);
|
|
343
619
|
if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
|
|
@@ -347,13 +623,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
347
623
|
return send(res, 200, list);
|
|
348
624
|
}
|
|
349
625
|
if (m === 'POST') {
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) return send(res, 400, { error: `Not a directory: ${cwd}` });
|
|
354
|
-
if (!adapters[body.agent || 'claude']) return send(res, 400, { error: 'unknown agent' });
|
|
355
|
-
const s = runner.create({ prompt: body.prompt, cwd, agent: body.agent, mode: body.mode, model: body.model, effort: /^[\w-]{1,20}$/.test(body.effort || '') ? body.effort : '', attachments: resolveAttachments(body.attachments) });
|
|
356
|
-
return send(res, 201, summarize(s, store));
|
|
626
|
+
const made = await launch(await readJson(req));
|
|
627
|
+
const out = made.map((x) => summarize(x, store));
|
|
628
|
+
return send(res, 201, made.length > 1 ? { ...out[0], group: out } : out[0]);
|
|
357
629
|
}
|
|
358
630
|
}
|
|
359
631
|
|
|
@@ -361,22 +633,39 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
361
633
|
if (!s) return send(res, 404, { error: 'no such session' });
|
|
362
634
|
|
|
363
635
|
if (!action) {
|
|
364
|
-
|
|
636
|
+
// A lead comes with its subagents' sessions, so its cards can show their reports and diffs.
|
|
637
|
+
if (m === 'GET') return send(res, 200, s.children ? { ...s, childSessions: Object.fromEntries(Object.values(s.children).map((cid) => [cid, store.get(cid)]).filter(([, c]) => c)) } : s);
|
|
365
638
|
if (m === 'PATCH') {
|
|
366
639
|
const body = await readJson(req);
|
|
367
640
|
if (typeof body.title === 'string' && body.title.trim()) Object.assign(s, { title: body.title.trim(), titleAuto: false });
|
|
368
641
|
if (body.state === 'done' && s.state !== 'running') s.state = 'done';
|
|
369
642
|
if (body.state === 'review' && s.state === 'done') s.state = 'review';
|
|
370
643
|
if (typeof body.unread === 'boolean') s.unread = body.unread;
|
|
644
|
+
// The team can change between turns; the next turn's lead sees the new roster.
|
|
645
|
+
if (body.subagents !== undefined && !s.parentId) {
|
|
646
|
+
s.subagents = normalizeTeam(body.subagents, adapters);
|
|
647
|
+
s.children ||= {};
|
|
648
|
+
}
|
|
371
649
|
if (body.archived === true && s.state !== 'running' && s.state !== 'waiting') Object.assign(s, { archived: true, unread: false });
|
|
372
650
|
if (body.archived === false) delete s.archived;
|
|
373
651
|
if (s.state === 'done' || s.archived) retro.schedule(s, 0); // finished with it: learn now
|
|
652
|
+
// Archived: a clean worktree goes (its branch stays, a follow-up brings it back).
|
|
653
|
+
// discardWorktree: a compare run you didn't keep; its changes go too.
|
|
654
|
+
if (s.worktree && !runner.live.has(s.id) && (s.archived || body.discardWorktree)) await removeWorktree(s.worktree, { force: !!body.discardWorktree, branch: !!body.discardWorktree && !s.pr && 'force' });
|
|
374
655
|
store.save(s);
|
|
375
656
|
broadcast('summary', summarize(s, store));
|
|
376
657
|
return send(res, 200, summarize(s, store));
|
|
377
658
|
}
|
|
378
659
|
if (m === 'DELETE') {
|
|
379
660
|
runner.stop(s);
|
|
661
|
+
await runner.live.get(s.id)?.done;
|
|
662
|
+
cleanupCheckpoints(workdir(s), s.id);
|
|
663
|
+
for (const cid of Object.values(s.children || {})) {
|
|
664
|
+
await runner.live.get(cid)?.done;
|
|
665
|
+
if (store.get(cid)) (store.delete(cid), broadcast('removed', { id: cid }));
|
|
666
|
+
}
|
|
667
|
+
// Its worktree goes with it; the branch only if it holds nothing beyond its base.
|
|
668
|
+
if (s.worktree) await removeWorktree(s.worktree, { force: true, branch: true }).catch(() => {});
|
|
380
669
|
store.delete(id);
|
|
381
670
|
broadcast('removed', { id });
|
|
382
671
|
return send(res, 200, { ok: true });
|
|
@@ -415,7 +704,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
415
704
|
blocks: turn.presentation?.blocks || [],
|
|
416
705
|
notes: turn.presentation?.notes,
|
|
417
706
|
fallback: !!turn.presentation?.fallback,
|
|
418
|
-
|
|
707
|
+
checkpoint: turn.checkpoint ? { commit: turn.checkpoint.commit, head: turn.checkpoint.head, branch: turn.checkpoint.branch, dirty: turn.checkpoint.dirty, at: turn.checkpoint.at } : null,
|
|
708
|
+
rolledBackAt: turn.rolledBackAt || null,
|
|
709
|
+
changes: turn.changes.map((c) => ({ file: c.file, op: c.op, edits: c.edits.length, reverted: !!c.reverted })),
|
|
419
710
|
stats: turn.stats,
|
|
420
711
|
ask: latest ? s.ask || null : null,
|
|
421
712
|
markdown,
|
|
@@ -426,7 +717,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
426
717
|
const { text, attachments, queue } = await readJson(req);
|
|
427
718
|
if (!text?.trim()) return send(res, 400, { error: 'text required' });
|
|
428
719
|
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to follow up.' });
|
|
429
|
-
return send(res, 200,
|
|
720
|
+
return send(res, 200, await replyTo(s, text.trim(), resolveAttachments(attachments), queue));
|
|
430
721
|
}
|
|
431
722
|
|
|
432
723
|
if (action === 'queue' && m === 'DELETE') {
|
|
@@ -440,19 +731,78 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
440
731
|
return send(res, 200, { ok: runner.answer(s, text.trim()) });
|
|
441
732
|
}
|
|
442
733
|
|
|
734
|
+
// Revert (or re-apply) a single file's edits from a turn.
|
|
735
|
+
if (action === 'revert' && m === 'POST') {
|
|
736
|
+
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to revert files.' });
|
|
737
|
+
if (runner.live.has(s.id)) return send(res, 409, { error: 'Stop the running turn before reverting files.' });
|
|
738
|
+
const { turnId, file, restore } = await readJson(req);
|
|
739
|
+
const turn = turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1);
|
|
740
|
+
if (!turn) return send(res, 404, { error: 'no such turn' });
|
|
741
|
+
await ensureWorktree(s.worktree);
|
|
742
|
+
const result = revertFileChange(s, turn, file, { restore: !!restore });
|
|
743
|
+
turn.activity.push({ t: Date.now(), kind: 'edit', label: `${result.reverted ? 'Reverted' : 'Re-applied'} ${result.file}`, file: result.file });
|
|
744
|
+
s.updatedAt = Date.now();
|
|
745
|
+
store.save(s);
|
|
746
|
+
broadcast('summary', summarize(s, store));
|
|
747
|
+
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
748
|
+
telemetry.track('revert_file', { agent: s.agent, restore: !!restore });
|
|
749
|
+
return send(res, 200, { ok: true, turnId: turn.id, ...result });
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Roll back (or re-apply) the project working tree to the checkpoint before a turn.
|
|
753
|
+
if (action === 'rollback' && m === 'POST') {
|
|
754
|
+
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to roll back turns.' });
|
|
755
|
+
if (runner.live.has(s.id)) return send(res, 409, { error: 'Stop the running turn before rolling back.' });
|
|
756
|
+
const { turnId, restore } = await readJson(req);
|
|
757
|
+
const turn = turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1);
|
|
758
|
+
if (!turn) return send(res, 404, { error: 'no such turn' });
|
|
759
|
+
await ensureWorktree(s.worktree);
|
|
760
|
+
const result = rollbackToTurn(s, turn, { restore: !!restore });
|
|
761
|
+
turn.activity.push({ t: Date.now(), kind: 'edit', label: result.rolledBack ? 'Restored checkpoint before this turn' : 'Re-applied turn changes' });
|
|
762
|
+
s.updatedAt = Date.now();
|
|
763
|
+
store.save(s);
|
|
764
|
+
broadcast('summary', summarize(s, store));
|
|
765
|
+
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
766
|
+
telemetry.track('rollback_turn', { agent: s.agent, restore: !!restore });
|
|
767
|
+
return send(res, 200, { ok: true, turnId: turn.id, ...result });
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Run a command from an actionable presentation block (actions, tests, terminal).
|
|
771
|
+
if (action === 'exec' && m === 'POST') {
|
|
772
|
+
const { command, turnId, key } = await readJson(req);
|
|
773
|
+
const cmd = String(command || '').trim();
|
|
774
|
+
if (!cmd) return send(res, 400, { error: 'command required' });
|
|
775
|
+
const turn = (turnId && s.turns.find((t) => t.id === turnId)) || s.turns.at(-1);
|
|
776
|
+
if (s.agent === 'wisp') {
|
|
777
|
+
return send(res, 200, { command: cmd, output: '✓ Tour preview — in a real project, wisp runs this command in your repo folder and streams the exit code and output here.', exitCode: 0, durationMs: 18 });
|
|
778
|
+
}
|
|
779
|
+
await ensureWorktree(s.worktree);
|
|
780
|
+
const resExec = await execCommand(workdir(s), cmd);
|
|
781
|
+
if (turn) {
|
|
782
|
+
turn.actionRuns ||= {};
|
|
783
|
+
turn.actionRuns[key || cmd] = { ...resExec, ranAt: Date.now() };
|
|
784
|
+
turn.activity.push({ t: Date.now(), kind: 'run', label: `Ran ${clip(cmd, 60)}`, detail: cmd });
|
|
785
|
+
store.save(s);
|
|
786
|
+
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
787
|
+
}
|
|
788
|
+
telemetry.track('exec_action', { agent: s.agent, exitCode: resExec.exitCode });
|
|
789
|
+
return send(res, 200, resExec);
|
|
790
|
+
}
|
|
791
|
+
|
|
443
792
|
// Take over: continue this exact agent session interactively in a terminal.
|
|
444
793
|
if (action === 'takeover') {
|
|
445
794
|
const adapter = adapters[s.agent];
|
|
446
795
|
if (!adapter?.resumeCommand || !s.agentSessionId) return send(res, 400, { error: 'This session cannot be resumed in a terminal.' });
|
|
447
796
|
const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
|
|
448
|
-
|
|
797
|
+
await ensureWorktree(s.worktree);
|
|
798
|
+
const command = `cd ${shq(workdir(s))} && ${argv.map(shq).join(' ')}`;
|
|
449
799
|
if (m === 'GET') return send(res, 200, { command });
|
|
450
800
|
if (m === 'POST') {
|
|
451
801
|
if (runner.live.has(s.id)) {
|
|
452
802
|
runner.stop(s);
|
|
453
803
|
await runner.live.get(s.id)?.done;
|
|
454
804
|
}
|
|
455
|
-
openInTerminal({ cwd: s
|
|
805
|
+
openInTerminal({ cwd: workdir(s), argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
|
|
456
806
|
s.takenOverAt = Date.now();
|
|
457
807
|
telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto' });
|
|
458
808
|
store.save(s);
|
|
@@ -465,18 +815,51 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
465
815
|
return send(res, 200, { ok: true });
|
|
466
816
|
}
|
|
467
817
|
|
|
818
|
+
// Ship it. GET: what would ship (branch, changes, draft message). POST { title, body, draft }:
|
|
819
|
+
// commit, push, open or update the PR.
|
|
820
|
+
if (action === 'ship') {
|
|
821
|
+
if (!s.worktree) return send(res, 400, { error: 'Ship it works on worktree sessions: launch with a worktree to get one.' });
|
|
822
|
+
if (m === 'GET') return send(res, 200, { branch: s.worktree.branch, base: s.worktree.base, remote: s.worktree.remote, ...(await worktreeStatus(s.worktree)), pr: s.pr || null, ...shipDraft(s) });
|
|
823
|
+
if (m === 'POST') {
|
|
824
|
+
if (runner.live.has(s.id)) return send(res, 409, { error: 'Still working: wait for it to finish, or stop it.' });
|
|
825
|
+
const body = await readJson(req);
|
|
826
|
+
const draft = shipDraft(s);
|
|
827
|
+
s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr });
|
|
828
|
+
telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
|
|
829
|
+
store.save(s, { now: true });
|
|
830
|
+
broadcast('summary', summarize(s, store));
|
|
831
|
+
return send(res, 200, s.pr);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (action === 'pr' && m === 'POST') {
|
|
835
|
+
if (!s.pr) return send(res, 404, { error: 'No pull request yet.' });
|
|
836
|
+
return send(res, 200, await refreshPr(s));
|
|
837
|
+
}
|
|
838
|
+
// Squash-merge the reviewed head, sync the user's base branch, clean up the worktree.
|
|
839
|
+
if (action === 'merge' && m === 'POST') {
|
|
840
|
+
if (!s.pr || !s.worktree) return send(res, 400, { error: 'No pull request to merge.' });
|
|
841
|
+
const { pr, sync } = await merge(s.worktree, s.pr.number);
|
|
842
|
+
s.pr = pr;
|
|
843
|
+
markDone(s);
|
|
844
|
+
telemetry.track('merge', { agent: s.agent });
|
|
845
|
+
store.save(s, { now: true });
|
|
846
|
+
broadcast('summary', summarize(s, store));
|
|
847
|
+
return send(res, 200, { pr, sync });
|
|
848
|
+
}
|
|
849
|
+
|
|
468
850
|
// Images the agent pointed at (screenshots etc.). Served if inside the session's project,
|
|
469
851
|
// or if the agent named that exact path in an image block (screenshots often land in /tmp).
|
|
470
852
|
if (action === 'file' && m === 'GET') {
|
|
471
|
-
|
|
853
|
+
const dir = workdir(s);
|
|
854
|
+
let p = path.resolve(dir, url.searchParams.get('path') || '');
|
|
472
855
|
const ext = path.extname(p).toLowerCase();
|
|
473
856
|
const images = s.turns.flatMap((t) => (t.presentation?.blocks || []).filter((b) => b?.type === 'image'));
|
|
474
|
-
const at = (v) => typeof v === 'string' && path.resolve(
|
|
857
|
+
const at = (v) => typeof v === 'string' && path.resolve(dir, v) === p;
|
|
475
858
|
const named = images.find((b) => at(b.path || b.src));
|
|
476
859
|
// A page rendered before keepImages() rewrote the block still asks for the original path.
|
|
477
860
|
const kept = !named && images.find((b) => at(b.source) && typeof b.path === 'string');
|
|
478
|
-
if (kept) p = path.resolve(
|
|
479
|
-
if (!(p.startsWith(
|
|
861
|
+
if (kept) p = path.resolve(dir, kept.path);
|
|
862
|
+
if (!(p.startsWith(dir + path.sep) || named || kept) || !IMAGE_EXT.has(ext) || !fs.existsSync(p)) return send(res, 404, { error: 'not found' });
|
|
480
863
|
res.writeHead(200, { 'content-type': MIME[ext] });
|
|
481
864
|
return fs.createReadStream(p).pipe(res);
|
|
482
865
|
}
|
|
@@ -503,6 +886,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
503
886
|
if (JSON.parse(fs.readFileSync(serverFile, 'utf8')).pid === process.pid) fs.rmSync(serverFile, { force: true });
|
|
504
887
|
} catch {}
|
|
505
888
|
retro.stop();
|
|
889
|
+
triggers.stop();
|
|
890
|
+
remote.stop();
|
|
891
|
+
clearInterval(prTimer);
|
|
506
892
|
for (const s of store.all()) runner.stop(s);
|
|
507
893
|
store.flush();
|
|
508
894
|
telemetry.persist();
|
|
@@ -519,6 +905,8 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
519
905
|
});
|
|
520
906
|
fs.writeFileSync(serverFile, JSON.stringify({ url: `http://127.0.0.1:${port}`, port, pid: process.pid, version: VERSION, apiVersion: API_VERSION, startedAt: Date.now() }, null, 2));
|
|
521
907
|
updater.start();
|
|
908
|
+
triggers.start();
|
|
909
|
+
remote.start();
|
|
522
910
|
|
|
523
911
|
if (telemetry.enabled) {
|
|
524
912
|
if (store.config.defaults.telemetryNotice !== 'seen') console.log('wisp sends anonymous usage stats and error reports (no prompts, paths or code). Opt out: Settings, or WISP_TELEMETRY=0 / DO_NOT_TRACK=1.');
|
|
@@ -527,7 +915,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
527
915
|
.catch(() => {});
|
|
528
916
|
}
|
|
529
917
|
|
|
530
|
-
return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry };
|
|
918
|
+
return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry, triggers, remote };
|
|
531
919
|
}
|
|
532
920
|
|
|
533
921
|
// POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).
|