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/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
- 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);
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 body = await readJson(req);
351
- if (!body.prompt?.trim()) return send(res, 400, { error: 'prompt required' });
352
- const cwd = path.resolve((body.cwd || process.cwd()).replace(/^~(?=$|[\\/])/, os.homedir()));
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
- 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);
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
- 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 })),
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, 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));
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
- 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(' ')}`;
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.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 });
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
- 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') || '');
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(s.cwd, v) === p;
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(s.cwd, kept.path);
479
- 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' });
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).