wisp-agents 0.6.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/server/index.js CHANGED
@@ -7,13 +7,23 @@ 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';
14
14
  import { Telemetry, envDisabled } from './telemetry.js';
15
15
  import { blocksToMarkdown, sessionToMarkdown } from './render.js';
16
16
  import { openapi } from './openapi.js';
17
+ import { Updater } from './update.js';
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';
17
27
 
18
28
  const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
19
29
 
@@ -42,6 +52,11 @@ export const API_VERSION = 1;
42
52
  // with follow-ups still queued is about to start the next one, so it doesn't count yet.
43
53
  const settled = (s) => s.state === 'waiting' || (s.state !== 'running' && !s.queue?.length);
44
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()));
45
60
 
46
61
  export async function startServer({ port = 4477, host = '127.0.0.1', handleSignals = true } = {}) {
47
62
  const store = new Store();
@@ -71,9 +86,29 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
71
86
  runner.on('turn', (t) => broadcast('turn', t));
72
87
  runner.on('finished', (f) => broadcast('finished', f));
73
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}`)));
74
90
  setInterval(() => broadcast('ping', {}), 25_000).unref();
75
91
 
76
92
  const telemetry = new Telemetry(store);
93
+ const updater = new Updater({ port, onChange: (u) => broadcast('update', u) });
94
+
95
+ // Learned preferences: injected into every turn, refreshed by a background retro per session.
96
+ const prefs = new Preferences(store.home);
97
+ const learning = () => store.config.defaults.learn !== 'off';
98
+ const projectOf = (cwd) => store.projectFor(cwd)?.path || cwd;
99
+ runner.promptExtra = (s) => (learning() && s.agent !== 'wisp' ? prefs.prompt(projectOf(s.cwd)) : '');
100
+ const retro = new Retro({
101
+ store,
102
+ runner,
103
+ prefs,
104
+ projectOf,
105
+ enabled: learning,
106
+ onStart: () => broadcast('preferences', {}),
107
+ onDone: ({ session, ok, added = [], removed = [] }) => {
108
+ telemetry.track('retro', { agent: session.agent, ok, added: added.length, removed: removed.length });
109
+ broadcast('preferences', { added: added.length, removed: removed.length });
110
+ },
111
+ });
77
112
  const run = (s, t) => ({ agent: s.agent, model: s.model || 'default', mode: s.mode, effort: s.effort || 'default', followUp: s.turns.indexOf(t) > 0 });
78
113
  runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...run(session, turn), attachments: turn.attachments.length }));
79
114
  runner.on('ended', ({ session, turn, interrupted, errorText }) => {
@@ -96,6 +131,148 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
96
131
  });
97
132
  if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
98
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
+
99
276
  const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
100
277
 
101
278
  const server = http.createServer(async (req, res) => {
@@ -134,6 +311,24 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
134
311
  }
135
312
 
136
313
  if (root === 'health' && m === 'GET') return send(res, 200, { ok: true, name: 'wisp', version: VERSION, apiVersion: API_VERSION, pid: process.pid, url: `http://127.0.0.1:${port}` });
314
+ // New wisp-agents on npm? POST installs it and relaunches; running agents are stopped, so the
315
+ // client must pass force once it has confirmed that with the user.
316
+ if (root === 'update') {
317
+ if (m === 'GET') {
318
+ if (url.searchParams.has('check')) return send(res, 200, await updater.check());
319
+ updater.maybeCheck(); // the UI asks on focus and reconnect: a day-old check gets refreshed
320
+ return send(res, 200, updater.view());
321
+ }
322
+ if (m === 'POST') {
323
+ const body = await readJson(req);
324
+ if (runner.live.size && !body.force) return send(res, 409, { error: 'agents are running', running: runner.live.size });
325
+ await updater.install();
326
+ send(res, 202, updater.view());
327
+ telemetry.track('update', { from: updater.state.current, to: updater.state.latest, method: updater.method });
328
+ setTimeout(() => (shutdown(), process.exit(0)), 300);
329
+ return;
330
+ }
331
+ }
137
332
  if (root === 'openapi.json' && m === 'GET') return send(res, 200, openapi({ version: VERSION, port }));
138
333
 
139
334
  if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents());
@@ -149,6 +344,24 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
149
344
  return send(res, 200, { id: runner.ask(s, body) });
150
345
  }
151
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
+ }
152
365
  }
153
366
 
154
367
  // Attachments: raw body streamed to disk, referenced by id from launches and replies.
@@ -187,7 +400,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
187
400
  }
188
401
 
189
402
  if (root === 'config') {
190
- 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 } });
191
404
  if (m === 'PATCH') {
192
405
  const body = await readJson(req);
193
406
  if (body.defaults) Object.assign(store.config.defaults, body.defaults);
@@ -199,10 +412,133 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
199
412
  }
200
413
  store.saveConfig();
201
414
  broadcast('config', {});
202
- return send(res, 200, store.config);
415
+ return send(res, 200, { ...store.config, remote: undefined });
416
+ }
417
+ }
418
+
419
+ // What wisp learned: preferences and lessons, global and per project (scope = 'global' or a project path).
420
+ if (root === 'preferences') {
421
+ const view = () => ({
422
+ enabled: learning(),
423
+ global: prefs.data.global,
424
+ projects: Object.entries(prefs.data.projects).map(([p, v]) => ({ path: p, name: store.projectFor(p)?.name || path.basename(p), ...v })),
425
+ history: prefs.data.history.slice().reverse(),
426
+ running: retro.running,
427
+ });
428
+ if (m === 'GET') return send(res, 200, view());
429
+ if (m === 'PUT') {
430
+ const body = await readJson(req);
431
+ const scope = String(body.scope || '');
432
+ if (scope !== 'global' && !path.isAbsolute(scope)) return send(res, 400, { error: 'scope must be "global" or a project path' });
433
+ if (!KINDS.includes(body.kind)) return send(res, 400, { error: `kind must be one of ${KINDS.join(', ')}` });
434
+ if (!Array.isArray(body.items)) return send(res, 400, { error: 'items must be an array of strings' });
435
+ prefs.set(scope, body.kind, body.items);
436
+ broadcast('preferences', {});
437
+ return send(res, 200, view());
438
+ }
439
+ }
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());
203
534
  }
204
535
  }
205
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
+
206
542
  if (root === 'projects') {
207
543
  if (!id && m === 'GET') return send(res, 200, store.config.projects);
208
544
  if (!id && m === 'POST') {
@@ -276,7 +612,8 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
276
612
  if (m === 'GET') {
277
613
  const q = Object.fromEntries(url.searchParams);
278
614
  const cwd = q.cwd && path.resolve(q.cwd.replace(/^~(?=$|[\\/])/, os.homedir()));
279
- let list = store.all().map((x) => summarize(x, store)).sort((a, b) => b.updatedAt - a.updatedAt);
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);
280
617
  if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
281
618
  if (q.agent) list = list.filter((x) => x.agent === q.agent);
282
619
  if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
@@ -286,13 +623,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
286
623
  return send(res, 200, list);
287
624
  }
288
625
  if (m === 'POST') {
289
- const body = await readJson(req);
290
- if (!body.prompt?.trim()) return send(res, 400, { error: 'prompt required' });
291
- const cwd = path.resolve((body.cwd || process.cwd()).replace(/^~(?=$|[\\/])/, os.homedir()));
292
- if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) return send(res, 400, { error: `Not a directory: ${cwd}` });
293
- if (!adapters[body.agent || 'claude']) return send(res, 400, { error: 'unknown agent' });
294
- 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) });
295
- 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]);
296
629
  }
297
630
  }
298
631
 
@@ -300,21 +633,39 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
300
633
  if (!s) return send(res, 404, { error: 'no such session' });
301
634
 
302
635
  if (!action) {
303
- if (m === 'GET') return send(res, 200, s);
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);
304
638
  if (m === 'PATCH') {
305
639
  const body = await readJson(req);
306
640
  if (typeof body.title === 'string' && body.title.trim()) Object.assign(s, { title: body.title.trim(), titleAuto: false });
307
641
  if (body.state === 'done' && s.state !== 'running') s.state = 'done';
308
642
  if (body.state === 'review' && s.state === 'done') s.state = 'review';
309
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
+ }
310
649
  if (body.archived === true && s.state !== 'running' && s.state !== 'waiting') Object.assign(s, { archived: true, unread: false });
311
650
  if (body.archived === false) delete s.archived;
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' });
312
655
  store.save(s);
313
656
  broadcast('summary', summarize(s, store));
314
657
  return send(res, 200, summarize(s, store));
315
658
  }
316
659
  if (m === 'DELETE') {
317
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(() => {});
318
669
  store.delete(id);
319
670
  broadcast('removed', { id });
320
671
  return send(res, 200, { ok: true });
@@ -353,7 +704,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
353
704
  blocks: turn.presentation?.blocks || [],
354
705
  notes: turn.presentation?.notes,
355
706
  fallback: !!turn.presentation?.fallback,
356
- changes: turn.changes.map((c) => ({ file: c.file, op: c.op, edits: c.edits.length })),
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 })),
357
710
  stats: turn.stats,
358
711
  ask: latest ? s.ask || null : null,
359
712
  markdown,
@@ -364,7 +717,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
364
717
  const { text, attachments, queue } = await readJson(req);
365
718
  if (!text?.trim()) return send(res, 400, { error: 'text required' });
366
719
  if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to follow up.' });
367
- return send(res, 200, runner.reply(s, text.trim(), resolveAttachments(attachments), { queue: queue ?? store.config.defaults.followUp === 'queue' }));
720
+ return send(res, 200, await replyTo(s, text.trim(), resolveAttachments(attachments), queue));
368
721
  }
369
722
 
370
723
  if (action === 'queue' && m === 'DELETE') {
@@ -378,19 +731,78 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
378
731
  return send(res, 200, { ok: runner.answer(s, text.trim()) });
379
732
  }
380
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
+
381
792
  // Take over: continue this exact agent session interactively in a terminal.
382
793
  if (action === 'takeover') {
383
794
  const adapter = adapters[s.agent];
384
795
  if (!adapter?.resumeCommand || !s.agentSessionId) return send(res, 400, { error: 'This session cannot be resumed in a terminal.' });
385
796
  const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
386
- const command = `cd ${shq(s.cwd)} && ${argv.map(shq).join(' ')}`;
797
+ await ensureWorktree(s.worktree);
798
+ const command = `cd ${shq(workdir(s))} && ${argv.map(shq).join(' ')}`;
387
799
  if (m === 'GET') return send(res, 200, { command });
388
800
  if (m === 'POST') {
389
801
  if (runner.live.has(s.id)) {
390
802
  runner.stop(s);
391
803
  await runner.live.get(s.id)?.done;
392
804
  }
393
- openInTerminal({ cwd: s.cwd, argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
805
+ openInTerminal({ cwd: workdir(s), argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
394
806
  s.takenOverAt = Date.now();
395
807
  telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto' });
396
808
  store.save(s);
@@ -403,18 +815,51 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
403
815
  return send(res, 200, { ok: true });
404
816
  }
405
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
+
406
850
  // Images the agent pointed at (screenshots etc.). Served if inside the session's project,
407
851
  // or if the agent named that exact path in an image block (screenshots often land in /tmp).
408
852
  if (action === 'file' && m === 'GET') {
409
- let p = path.resolve(s.cwd, url.searchParams.get('path') || '');
853
+ const dir = workdir(s);
854
+ let p = path.resolve(dir, url.searchParams.get('path') || '');
410
855
  const ext = path.extname(p).toLowerCase();
411
856
  const images = s.turns.flatMap((t) => (t.presentation?.blocks || []).filter((b) => b?.type === 'image'));
412
- const at = (v) => typeof v === 'string' && path.resolve(s.cwd, v) === p;
857
+ const at = (v) => typeof v === 'string' && path.resolve(dir, v) === p;
413
858
  const named = images.find((b) => at(b.path || b.src));
414
859
  // A page rendered before keepImages() rewrote the block still asks for the original path.
415
860
  const kept = !named && images.find((b) => at(b.source) && typeof b.path === 'string');
416
- if (kept) p = path.resolve(s.cwd, kept.path);
417
- if (!(p.startsWith(s.cwd + path.sep) || named || kept) || !IMAGE_EXT.has(ext) || !fs.existsSync(p)) return send(res, 404, { error: 'not found' });
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' });
418
863
  res.writeHead(200, { 'content-type': MIME[ext] });
419
864
  return fs.createReadStream(p).pipe(res);
420
865
  }
@@ -440,6 +885,10 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
440
885
  try {
441
886
  if (JSON.parse(fs.readFileSync(serverFile, 'utf8')).pid === process.pid) fs.rmSync(serverFile, { force: true });
442
887
  } catch {}
888
+ retro.stop();
889
+ triggers.stop();
890
+ remote.stop();
891
+ clearInterval(prTimer);
443
892
  for (const s of store.all()) runner.stop(s);
444
893
  store.flush();
445
894
  telemetry.persist();
@@ -455,6 +904,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
455
904
  server.listen(port, host, resolve);
456
905
  });
457
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));
907
+ updater.start();
908
+ triggers.start();
909
+ remote.start();
458
910
 
459
911
  if (telemetry.enabled) {
460
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.');
@@ -463,7 +915,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
463
915
  .catch(() => {});
464
916
  }
465
917
 
466
- return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry };
918
+ return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry, triggers, remote };
467
919
  }
468
920
 
469
921
  // POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).