wisp-agents 0.7.0 → 0.9.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,15 @@ 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 { Machines, splitId, qualify, sshHosts } from './machines.js';
26
+ import { usage } from './usage.js';
27
+ import { normalizeTeam, normalizePreset, delegationReport } from './subagents.js';
19
28
 
20
29
  const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
21
30
 
@@ -44,6 +53,11 @@ export const API_VERSION = 1;
44
53
  // with follow-ups still queued is about to start the next one, so it doesn't count yet.
45
54
  const settled = (s) => s.state === 'waiting' || (s.state !== 'running' && !s.queue?.length);
46
55
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
56
+ const PR_POLL_MS = 60_000;
57
+ const MAX_COMPARE = 4;
58
+ const MODES = new Set(['plan', 'edits', 'auto']);
59
+ const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
60
+ const expand = (p) => path.resolve(String(p).replace(/^~(?=$|[\\/])/, os.homedir()));
47
61
 
48
62
  export async function startServer({ port = 4477, host = '127.0.0.1', handleSignals = true } = {}) {
49
63
  const store = new Store();
@@ -73,6 +87,31 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
73
87
  runner.on('turn', (t) => broadcast('turn', t));
74
88
  runner.on('finished', (f) => broadcast('finished', f));
75
89
  runner.on('finished', ({ session }) => notifyWebhooks(store, session));
90
+ runner.on('finished', ({ session }) => remote.notify(store.get(session.id), session).catch((err) => console.warn(`wisp: phone: ${err.message}`)));
91
+
92
+ // Machines: other computers running their own wisp, reached over SSH. Their sessions show up
93
+ // here as <id>@<machine>; their events are relayed into this stream.
94
+ const machines = new Machines({
95
+ store,
96
+ version: VERSION,
97
+ apiVersion: API_VERSION,
98
+ onChange: () => broadcast('machines', machines.view()),
99
+ onEvent: (event, data) => {
100
+ broadcast(event, data);
101
+ if (event === 'finished' && remote.on) notifyFromMachine(data.session).catch((err) => console.warn(`wisp: phone: ${err.message}`));
102
+ },
103
+ });
104
+ async function notifyFromMachine(summary) {
105
+ const at = splitId(summary.id);
106
+ const s = machines.session(at.machine, await machines.json(at.machine, 'GET', `/sessions/${at.id}`));
107
+ await remote.notify(s, summary);
108
+ }
109
+ // Attachments are uploaded here; a machine gets its own copies before a launch or reply.
110
+ const pushUploads = async (mid, ids) => {
111
+ const out = [];
112
+ for (const u of resolveAttachments(ids)) out.push((await machines.upload(mid, u)).id);
113
+ return out;
114
+ };
76
115
  setInterval(() => broadcast('ping', {}), 25_000).unref();
77
116
 
78
117
  const telemetry = new Telemetry(store);
@@ -117,6 +156,188 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
117
156
  });
118
157
  if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
119
158
 
159
+ // One way in for every launch: the launcher, the CLI, recipes, triggers and compare runs.
160
+ // body: { prompt, cwd, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
161
+ // subagents: [{ name, agent, model, effort, use }] | preset: teamId }
162
+ async function launch(body, origin = null) {
163
+ if (!String(body.prompt || '').trim()) throw bad('prompt required');
164
+ const cwd = expand(body.cwd || process.cwd());
165
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
166
+ const d = store.config.defaults;
167
+ // compare: more { agent, model, effort } to run the same task on, side by side.
168
+ const runners = [{ agent: body.agent, model: body.model, effort: body.effort }, ...(Array.isArray(body.compare) ? body.compare : [])].slice(0, MAX_COMPARE).map((r, i) => {
169
+ const agent = r.agent || (i ? d.agent : '') || 'claude';
170
+ if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
171
+ return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '' };
172
+ });
173
+ const mode = MODES.has(body.mode) ? body.mode : 'edits';
174
+ // Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
175
+ // runs need one each, or the harnesses would edit the same files.
176
+ const comparing = runners.length > 1;
177
+ const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
178
+ // groupId: one launch sent to several machines (or here and there) shows as one task.
179
+ const groupId = /^[a-f0-9]{12}$/.test(body.groupId || '') ? body.groupId : comparing ? newId() : null;
180
+ const attachments = resolveAttachments(body.attachments);
181
+ const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
182
+ if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
183
+ const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
184
+ const ids = runners.map(() => newId());
185
+ let trees = ids.map(() => null);
186
+ if (wantTree) {
187
+ const must = body.worktree === true || (comparing && mode !== 'plan');
188
+ // One fetch for the whole launch, then the checkouts side by side: compare runs used to
189
+ // fetch and check out one after the other before anything showed up in the feed.
190
+ const results = await worktreeSource(cwd).then(
191
+ (source) => source && Promise.allSettled(ids.map((id) => createWorktree({ cwd, home: store.home, id, title: body.prompt, source }))),
192
+ (err) => [{ status: 'rejected', reason: err }],
193
+ );
194
+ const failed = results?.find((x) => x.status === 'rejected')?.reason;
195
+ trees = ids.map((_, i) => (results?.[i]?.status === 'fulfilled' ? results[i].value : null));
196
+ if (must && (failed || trees.some((t) => !t))) {
197
+ await Promise.all(trees.map((t) => t && removeWorktree(t, { force: true, branch: 'force' }).catch(() => {})));
198
+ if (failed) throw bad(`Could not create a worktree: ${failed.message}`);
199
+ 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.');
200
+ }
201
+ if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
202
+ }
203
+ return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
204
+ }
205
+
206
+ // Where a launch runs: body.machine, else a project folder that only exists on a machine
207
+ // (recipes and schedules keep just the path). null = here.
208
+ function machineFor(body) {
209
+ if (body.machine) return machines.get(String(body.machine)).id;
210
+ const cwd = String(body.cwd || '');
211
+ if (!cwd || fs.existsSync(expand(cwd))) return null;
212
+ return store.config.projects.find((p) => p.machine && (cwd === p.path || cwd.startsWith(`${p.path}/`)))?.machine || null;
213
+ }
214
+
215
+ // launch(), here or on a machine. Resolves with summaries (a compare launch has several).
216
+ async function launchAny(body, origin = null) {
217
+ const mid = machineFor(body);
218
+ if (!mid) return (await launch(body, origin)).map((x) => summarize(x, store));
219
+ const out = await machines.launch(mid, { ...body, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
220
+ const known = store.projectFor(out.cwd, mid);
221
+ (known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
222
+ store.saveConfig();
223
+ if (!known) broadcast('config', {});
224
+ return (out.group || [out]).map((x) => machines.summary(mid, x));
225
+ }
226
+
227
+ // A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
228
+ async function replyTo(s, text, attachments = [], queue) {
229
+ await ensureWorktree(s.worktree);
230
+ return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue' });
231
+ }
232
+
233
+ const markDone = (s) => {
234
+ if (s.state === 'running' || s.state === 'waiting') return false;
235
+ s.state = 'done';
236
+ s.unread = false;
237
+ retro.schedule(s, 0);
238
+ store.save(s);
239
+ broadcast('summary', summarize(s, store));
240
+ return true;
241
+ };
242
+
243
+ // Recipes and triggers.
244
+ const runRecipe = async (r, { params = {}, words, cwd, machine, agent, model, effort, mode } = {}) => {
245
+ const values = words ? { ...positional(r.prompt, words), ...params } : params;
246
+ const [s] = await launchAny({ prompt: fill(r.prompt, values), cwd: r.cwd || cwd, machine, 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 });
247
+ r.runs = (r.runs || 0) + 1;
248
+ r.lastRunAt = Date.now();
249
+ store.saveConfig();
250
+ broadcast('config', {});
251
+ return s;
252
+ };
253
+ const triggers = new Triggers({ store, launch: async (body, origin) => (await launchAny(body, origin))[0], onChange: () => broadcast('config', {}) });
254
+
255
+ // Phone: answers and follow-ups coming back from ntfy / Telegram.
256
+ const remote = new Remote({
257
+ store,
258
+ onChange: () => broadcast('remote', {}),
259
+ act: async ({ sessionId, kind, ref, index, text }) => {
260
+ const ops = await sessionOps(sessionId).catch((err) => ({ error: err.message }));
261
+ if (ops?.error) return `Can’t reach it: ${ops.error}`;
262
+ if (!ops) return 'That session is gone.';
263
+ const { s } = ops;
264
+ if (kind === 'answer') {
265
+ if (!s.ask || s.ask.id !== ref) return 'That question was already answered.';
266
+ const answer = s.ask.options[index];
267
+ if (answer === undefined) return 'Unknown option.';
268
+ await ops.answer(answer);
269
+ return `✓ Answered: ${answer}`;
270
+ }
271
+ if (kind === 'reply') {
272
+ const turn = s.turns.at(-1);
273
+ if (!turn || turn.id !== ref || s.state === 'running') return 'That was already answered.';
274
+ const q = turn.presentation?.blocks?.findLast((b) => b?.type === 'question');
275
+ const o = q?.options?.[index];
276
+ const msg = typeof o === 'string' ? o : o?.reply || o?.label;
277
+ if (!msg) return 'Unknown option.';
278
+ await ops.reply(msg);
279
+ return `✓ Sent: ${typeof o === 'string' ? o : o.label || msg}`;
280
+ }
281
+ if (kind === 'done') {
282
+ if (s.turns.at(-1)?.id !== ref && ref) return 'There is newer work on that session; open wisp to review it.';
283
+ return (await ops.done()) ? '✓ Marked done' : 'It is still working.';
284
+ }
285
+ if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
286
+ const r = await ops.reply(String(text || '').trim());
287
+ return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
288
+ },
289
+ });
290
+
291
+ // What the phone can do to a session, here or on a machine: { s, answer, reply, done } or null.
292
+ async function sessionOps(sessionId) {
293
+ const at = splitId(sessionId);
294
+ if (!at) {
295
+ const s = store.get(sessionId);
296
+ return s && { s, answer: (a) => runner.answer(s, a), reply: (t) => replyTo(s, t), done: () => markDone(s) };
297
+ }
298
+ const call = (method, p, body) => machines.json(at.machine, method, `/sessions/${at.id}${p}`, body);
299
+ const s = await call('GET', '').catch((err) => {
300
+ if (err.status === 404) return null;
301
+ throw err;
302
+ });
303
+ const live = s && (s.state === 'running' || s.state === 'waiting');
304
+ return s && { s: machines.session(at.machine, s), answer: (a) => call('POST', '/answer', { text: a }), reply: (t) => call('POST', '/reply', { text: t }), done: async () => !live && !!(await call('PATCH', '', { state: 'done' })) };
305
+ }
306
+
307
+ // Ship it: follow open PRs (checks, reviews, merges) so their cards stay current.
308
+ const refreshPr = async (s) => {
309
+ if (!s.pr || !s.worktree) return s.pr;
310
+ const prev = s.pr;
311
+ const dir = fs.existsSync(s.worktree.path) ? s.worktree.path : s.worktree.root;
312
+ const pr = await prView(dir, prev.number);
313
+ const changed = (k) => JSON.stringify(k(pr)) !== JSON.stringify(k(prev));
314
+ s.pr = pr;
315
+ // Things worth coming back for: CI finished either way, a review landed, it merged or closed.
316
+ const settledChecks = (x) => (x.checks.pending ? 'pending' : x.checks.fail ? 'fail' : 'pass');
317
+ if (changed((x) => x.state) || changed((x) => x.review) || (changed(settledChecks) && settledChecks(pr) !== 'pending')) s.unread = true;
318
+ store.save(s);
319
+ broadcast('summary', summarize(s, store));
320
+ return pr;
321
+ };
322
+ const prTimer = setInterval(async () => {
323
+ for (const s of store.all()) if (s.pr?.state === 'OPEN') await refreshPr(s).catch(() => {});
324
+ }, PR_POLL_MS);
325
+ prTimer.unref();
326
+
327
+ // Default commit message and PR text, from the session's latest presentation.
328
+ const shipDraft = (s) => {
329
+ const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
330
+ const blocks = turn?.presentation?.blocks || [];
331
+ const hero = blocks.find((b) => b?.type === 'hero');
332
+ const text = `${hero?.title || s.title}`.trim();
333
+ const conventional = /^[a-z]+(\([\w./-]+\))?!?: /.test(text);
334
+ 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';
335
+ const title = conventional ? text : `${type}: ${text.charAt(0).toLowerCase()}${text.slice(1)}`;
336
+ const keep = blocks.filter((b) => !['hero', 'next', 'question', 'diff', 'code', 'image', 'terminal'].includes(b?.type));
337
+ const body = [hero?.summary, blocksToMarkdown(keep, turn)].filter(Boolean).join('\n\n').trim();
338
+ return { title: title.slice(0, 100), body: `${body}\n\n_Shipped from [wisp](https://github.com/M0xoo/wisp)._`.trim() };
339
+ };
340
+
120
341
  const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
121
342
 
122
343
  const server = http.createServer(async (req, res) => {
@@ -188,6 +409,24 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
188
409
  return send(res, 200, { id: runner.ask(s, body) });
189
410
  }
190
411
  if (id === 'ask' && action && m === 'GET') return send(res, 200, runner.pollAnswer(action));
412
+ // delegate: POST starts tasks on the lead's subagents; GET reports on them (done once all finished).
413
+ if (id === 'delegate' && action) {
414
+ const lead = store.get(action);
415
+ if (!lead) return send(res, 404, { error: 'no such session' });
416
+ if (m === 'POST') {
417
+ const body = await readJson(req);
418
+ const delegations = runner.delegate(lead, body.tasks);
419
+ telemetry.track('delegate', { agent: lead.agent, tasks: delegations.length, harnesses: [...new Set(delegations.filter((d) => d.agent).map((d) => d.agent))] });
420
+ return send(res, 200, { delegations });
421
+ }
422
+ if (m === 'GET') {
423
+ const ids = (url.searchParams.get('ids') || '').split(',').filter(Boolean);
424
+ const list = runner.delegations(lead, ids);
425
+ if (!list.length) return send(res, 200, { done: true, text: 'No delegated tasks to report on.' });
426
+ const running = ({ d, child }) => child?.turns.find((t) => t.id === d.turnId)?.status === 'running';
427
+ return send(res, 200, { done: !list.some(running), text: list.map(({ d, child }) => delegationReport(d, child, adapters)).join('\n\n---\n\n') });
428
+ }
429
+ }
191
430
  }
192
431
 
193
432
  // Attachments: raw body streamed to disk, referenced by id from launches and replies.
@@ -217,6 +456,10 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
217
456
  fs.writeFileSync(path.join(uploadsDir, `${uid}.json`), JSON.stringify(meta));
218
457
  return send(res, 201, meta);
219
458
  }
459
+ if (id && m === 'GET' && splitId(id)) {
460
+ const at = splitId(id);
461
+ return pipeFrom(res, await machines.stream(at.machine, 'GET', `/uploads/${at.id}`));
462
+ }
220
463
  if (id && m === 'GET') {
221
464
  const u = uploads.get(id);
222
465
  if (!u || !fs.existsSync(u.path)) return send(res, 404, { error: 'not found' });
@@ -226,7 +469,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
226
469
  }
227
470
 
228
471
  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 } });
472
+ 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
473
  if (m === 'PATCH') {
231
474
  const body = await readJson(req);
232
475
  if (body.defaults) Object.assign(store.config.defaults, body.defaults);
@@ -238,7 +481,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
238
481
  }
239
482
  store.saveConfig();
240
483
  broadcast('config', {});
241
- return send(res, 200, store.config);
484
+ return send(res, 200, { ...store.config, remote: undefined });
242
485
  }
243
486
  }
244
487
 
@@ -264,10 +507,117 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
264
507
  }
265
508
  }
266
509
 
510
+ // Subagent presets: a saved team (members with harness, model, thinking and "use it for").
511
+ if (root === 'teams') {
512
+ const list = store.config.teams;
513
+ const saved = (status, body) => (store.saveConfig(), broadcast('config', {}), send(res, status, body));
514
+ if (!id && m === 'GET') return send(res, 200, list);
515
+ if (!id && m === 'POST') {
516
+ const t = normalizePreset(await readJson(req), adapters);
517
+ list.push(t);
518
+ return saved(201, t);
519
+ }
520
+ const t = list.find((x) => x.id === id);
521
+ if (!t) return send(res, 404, { error: 'no such preset' });
522
+ if (!action && m === 'GET') return send(res, 200, t);
523
+ if (!action && m === 'PATCH') return saved(200, Object.assign(t, normalizePreset(await readJson(req), adapters, t)));
524
+ if (!action && m === 'DELETE') {
525
+ store.config.teams = list.filter((x) => x !== t);
526
+ return saved(200, { ok: true });
527
+ }
528
+ }
529
+
530
+ // Recipes: saved prompts with {params}. POST /recipes/:id/run { params | words, cwd, … }.
531
+ if (root === 'recipes') {
532
+ const list = store.config.recipes;
533
+ if (!id && m === 'GET') return send(res, 200, list.map(recipeView));
534
+ if (!id && m === 'POST') {
535
+ const r = normalizeRecipe(await readJson(req));
536
+ list.push(r);
537
+ store.saveConfig();
538
+ broadcast('config', {});
539
+ return send(res, 201, recipeView(r));
540
+ }
541
+ const r = findRecipe(list, decodeURIComponent(id || ''));
542
+ if (!r) return send(res, 404, { error: 'no such recipe' });
543
+ if (!action && m === 'GET') return send(res, 200, recipeView(r));
544
+ if (!action && m === 'PATCH') {
545
+ Object.assign(r, normalizeRecipe(await readJson(req), r));
546
+ store.saveConfig();
547
+ broadcast('config', {});
548
+ return send(res, 200, recipeView(r));
549
+ }
550
+ if (!action && m === 'DELETE') {
551
+ store.config.recipes = list.filter((x) => x !== r);
552
+ store.saveConfig();
553
+ broadcast('config', {});
554
+ return send(res, 200, { ok: true });
555
+ }
556
+ if (action === 'run' && m === 'POST') {
557
+ const body = await readJson(req);
558
+ // machine: run it in a project there (r.cwd empty, or a path on that machine).
559
+ const s = await runRecipe(r, { params: body.params || {}, words: Array.isArray(body.words) ? body.words.map(String) : undefined, cwd: body.cwd, machine: body.machine || undefined, agent: body.agent, model: body.model, effort: body.effort, mode: body.mode });
560
+ return send(res, 201, s);
561
+ }
562
+ }
563
+
564
+ // Triggers: schedules and GitHub watchers that launch sessions on their own.
565
+ if (root === 'triggers') {
566
+ const list = store.config.triggers;
567
+ if (!id && m === 'GET') return send(res, 200, list.map(triggerView));
568
+ if (!id && m === 'POST') {
569
+ const t = normalizeTrigger(await readJson(req), null, { recipes: store.config.recipes });
570
+ list.push(t);
571
+ store.saveConfig();
572
+ broadcast('config', {});
573
+ return send(res, 201, triggerView(t));
574
+ }
575
+ const t = list.find((x) => x.id === id);
576
+ if (!t) return send(res, 404, { error: 'no such trigger' });
577
+ if (!action && m === 'PATCH') {
578
+ Object.assign(t, normalizeTrigger(await readJson(req), t, { recipes: store.config.recipes }));
579
+ store.saveConfig();
580
+ broadcast('config', {});
581
+ return send(res, 200, triggerView(t));
582
+ }
583
+ if (!action && m === 'DELETE') {
584
+ store.config.triggers = list.filter((x) => x !== t);
585
+ store.saveConfig();
586
+ broadcast('config', {});
587
+ return send(res, 200, { ok: true });
588
+ }
589
+ if (action === 'run' && m === 'POST') {
590
+ const r = await triggers.runNow(t);
591
+ const started = Array.isArray(r) ? r : r ? [r] : [];
592
+ return send(res, 200, { started, trigger: triggerView(t) });
593
+ }
594
+ }
595
+
596
+ // Phone: ntfy / Telegram settings (secrets masked), and a test message.
597
+ if (root === 'remote') {
598
+ if (!id && m === 'GET') return send(res, 200, remote.view());
599
+ if (!id && m === 'PUT') return send(res, 200, remote.update(await readJson(req)));
600
+ if (id === 'test' && m === 'POST') {
601
+ const sent = await remote.send({ title: '✨ wisp', text: 'Test from wisp: questions and finished tasks will show up like this.', actions: [] });
602
+ if (!sent) return send(res, 400, { error: 'Turn on ntfy or link Telegram first.' });
603
+ return send(res, 200, remote.view());
604
+ }
605
+ }
606
+
607
+ // Spend and outcomes. ?days=7|30|90
608
+ if (root === 'usage' && m === 'GET') {
609
+ return send(res, 200, usage(store.all(), { days: Number(url.searchParams.get('days')) || 30, project: (s) => store.projectFor(s.cwd) }));
610
+ }
611
+
267
612
  if (root === 'projects') {
268
613
  if (!id && m === 'GET') return send(res, 200, store.config.projects);
269
614
  if (!id && m === 'POST') {
270
615
  const body = await readJson(req);
616
+ if (body.machine) {
617
+ const p = await machines.addProject(String(body.machine), String(body.path || '').trim(), body.name);
618
+ broadcast('config', {});
619
+ return send(res, 201, p);
620
+ }
271
621
  const dir = path.resolve(String(body.path || '').replace(/^~(?=$|[\\/])/, os.homedir()));
272
622
  if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return send(res, 400, { error: `Not a folder: ${dir}` });
273
623
  const p = store.addProject(dir, { name: body.name });
@@ -332,12 +682,18 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
332
682
  return send(res, 200, entries);
333
683
  }
334
684
 
685
+ if (root === 'machines') return machinesApi(req, res, url, id, action);
686
+
335
687
  if (root === 'sessions') {
688
+ if (id && splitId(id)) return machineSessionApi(req, res, url, splitId(id), action);
336
689
  if (!id) {
337
690
  if (m === 'GET') {
338
691
  const q = Object.fromEntries(url.searchParams);
339
692
  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);
693
+ // Subagent sessions belong to their lead; ?children=1 lists them too.
694
+ // Sessions on machines too (the last ones seen, for a machine that's offline).
695
+ const here = store.all().filter((x) => !x.parentId || q.children).map((x) => summarize(x, store));
696
+ let list = [...here, ...(await machines.sessions(q))].sort((a, b) => b.updatedAt - a.updatedAt);
341
697
  if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
342
698
  if (q.agent) list = list.filter((x) => x.agent === q.agent);
343
699
  if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
@@ -348,12 +704,11 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
348
704
  }
349
705
  if (m === 'POST') {
350
706
  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));
707
+ // A machine's wisp hands on where a recipe or trigger launch came from.
708
+ const o = body.origin;
709
+ const origin = o && (o.kind === 'recipe' || o.kind === 'trigger') ? { kind: o.kind, id: String(o.id || ''), name: String(o.name || '') } : null;
710
+ const out = await launchAny(body, origin);
711
+ return send(res, 201, out.length > 1 ? { ...out[0], group: out } : out[0]);
357
712
  }
358
713
  }
359
714
 
@@ -361,22 +716,39 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
361
716
  if (!s) return send(res, 404, { error: 'no such session' });
362
717
 
363
718
  if (!action) {
364
- if (m === 'GET') return send(res, 200, s);
719
+ // A lead comes with its subagents' sessions, so its cards can show their reports and diffs.
720
+ 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
721
  if (m === 'PATCH') {
366
722
  const body = await readJson(req);
367
723
  if (typeof body.title === 'string' && body.title.trim()) Object.assign(s, { title: body.title.trim(), titleAuto: false });
368
724
  if (body.state === 'done' && s.state !== 'running') s.state = 'done';
369
725
  if (body.state === 'review' && s.state === 'done') s.state = 'review';
370
726
  if (typeof body.unread === 'boolean') s.unread = body.unread;
727
+ // The team can change between turns; the next turn's lead sees the new roster.
728
+ if (body.subagents !== undefined && !s.parentId) {
729
+ s.subagents = normalizeTeam(body.subagents, adapters);
730
+ s.children ||= {};
731
+ }
371
732
  if (body.archived === true && s.state !== 'running' && s.state !== 'waiting') Object.assign(s, { archived: true, unread: false });
372
733
  if (body.archived === false) delete s.archived;
373
734
  if (s.state === 'done' || s.archived) retro.schedule(s, 0); // finished with it: learn now
735
+ // Archived: a clean worktree goes (its branch stays, a follow-up brings it back).
736
+ // discardWorktree: a compare run you didn't keep; its changes go too.
737
+ 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
738
  store.save(s);
375
739
  broadcast('summary', summarize(s, store));
376
740
  return send(res, 200, summarize(s, store));
377
741
  }
378
742
  if (m === 'DELETE') {
379
743
  runner.stop(s);
744
+ await runner.live.get(s.id)?.done;
745
+ cleanupCheckpoints(workdir(s), s.id);
746
+ for (const cid of Object.values(s.children || {})) {
747
+ await runner.live.get(cid)?.done;
748
+ if (store.get(cid)) (store.delete(cid), broadcast('removed', { id: cid }));
749
+ }
750
+ // Its worktree goes with it; the branch only if it holds nothing beyond its base.
751
+ if (s.worktree) await removeWorktree(s.worktree, { force: true, branch: true }).catch(() => {});
380
752
  store.delete(id);
381
753
  broadcast('removed', { id });
382
754
  return send(res, 200, { ok: true });
@@ -415,7 +787,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
415
787
  blocks: turn.presentation?.blocks || [],
416
788
  notes: turn.presentation?.notes,
417
789
  fallback: !!turn.presentation?.fallback,
418
- changes: turn.changes.map((c) => ({ file: c.file, op: c.op, edits: c.edits.length })),
790
+ checkpoint: turn.checkpoint ? { commit: turn.checkpoint.commit, head: turn.checkpoint.head, branch: turn.checkpoint.branch, dirty: turn.checkpoint.dirty, at: turn.checkpoint.at } : null,
791
+ rolledBackAt: turn.rolledBackAt || null,
792
+ changes: turn.changes.map((c) => ({ file: c.file, op: c.op, edits: c.edits.length, reverted: !!c.reverted })),
419
793
  stats: turn.stats,
420
794
  ask: latest ? s.ask || null : null,
421
795
  markdown,
@@ -426,7 +800,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
426
800
  const { text, attachments, queue } = await readJson(req);
427
801
  if (!text?.trim()) return send(res, 400, { error: 'text required' });
428
802
  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' }));
803
+ return send(res, 200, await replyTo(s, text.trim(), resolveAttachments(attachments), queue));
430
804
  }
431
805
 
432
806
  if (action === 'queue' && m === 'DELETE') {
@@ -440,19 +814,78 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
440
814
  return send(res, 200, { ok: runner.answer(s, text.trim()) });
441
815
  }
442
816
 
817
+ // Revert (or re-apply) a single file's edits from a turn.
818
+ if (action === 'revert' && m === 'POST') {
819
+ if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to revert files.' });
820
+ if (runner.live.has(s.id)) return send(res, 409, { error: 'Stop the running turn before reverting files.' });
821
+ const { turnId, file, restore } = await readJson(req);
822
+ const turn = turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1);
823
+ if (!turn) return send(res, 404, { error: 'no such turn' });
824
+ await ensureWorktree(s.worktree);
825
+ const result = revertFileChange(s, turn, file, { restore: !!restore });
826
+ turn.activity.push({ t: Date.now(), kind: 'edit', label: `${result.reverted ? 'Reverted' : 'Re-applied'} ${result.file}`, file: result.file });
827
+ s.updatedAt = Date.now();
828
+ store.save(s);
829
+ broadcast('summary', summarize(s, store));
830
+ broadcast('turn', { sessionId: s.id, turnId: turn.id });
831
+ telemetry.track('revert_file', { agent: s.agent, restore: !!restore });
832
+ return send(res, 200, { ok: true, turnId: turn.id, ...result });
833
+ }
834
+
835
+ // Roll back (or re-apply) the project working tree to the checkpoint before a turn.
836
+ if (action === 'rollback' && m === 'POST') {
837
+ if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to roll back turns.' });
838
+ if (runner.live.has(s.id)) return send(res, 409, { error: 'Stop the running turn before rolling back.' });
839
+ const { turnId, restore } = await readJson(req);
840
+ const turn = turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1);
841
+ if (!turn) return send(res, 404, { error: 'no such turn' });
842
+ await ensureWorktree(s.worktree);
843
+ const result = rollbackToTurn(s, turn, { restore: !!restore });
844
+ turn.activity.push({ t: Date.now(), kind: 'edit', label: result.rolledBack ? 'Restored checkpoint before this turn' : 'Re-applied turn changes' });
845
+ s.updatedAt = Date.now();
846
+ store.save(s);
847
+ broadcast('summary', summarize(s, store));
848
+ broadcast('turn', { sessionId: s.id, turnId: turn.id });
849
+ telemetry.track('rollback_turn', { agent: s.agent, restore: !!restore });
850
+ return send(res, 200, { ok: true, turnId: turn.id, ...result });
851
+ }
852
+
853
+ // Run a command from an actionable presentation block (actions, tests, terminal).
854
+ if (action === 'exec' && m === 'POST') {
855
+ const { command, turnId, key } = await readJson(req);
856
+ const cmd = String(command || '').trim();
857
+ if (!cmd) return send(res, 400, { error: 'command required' });
858
+ const turn = (turnId && s.turns.find((t) => t.id === turnId)) || s.turns.at(-1);
859
+ if (s.agent === 'wisp') {
860
+ 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 });
861
+ }
862
+ await ensureWorktree(s.worktree);
863
+ const resExec = await execCommand(workdir(s), cmd);
864
+ if (turn) {
865
+ turn.actionRuns ||= {};
866
+ turn.actionRuns[key || cmd] = { ...resExec, ranAt: Date.now() };
867
+ turn.activity.push({ t: Date.now(), kind: 'run', label: `Ran ${clip(cmd, 60)}`, detail: cmd });
868
+ store.save(s);
869
+ broadcast('turn', { sessionId: s.id, turnId: turn.id });
870
+ }
871
+ telemetry.track('exec_action', { agent: s.agent, exitCode: resExec.exitCode });
872
+ return send(res, 200, resExec);
873
+ }
874
+
443
875
  // Take over: continue this exact agent session interactively in a terminal.
444
876
  if (action === 'takeover') {
445
877
  const adapter = adapters[s.agent];
446
878
  if (!adapter?.resumeCommand || !s.agentSessionId) return send(res, 400, { error: 'This session cannot be resumed in a terminal.' });
447
879
  const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
448
- const command = `cd ${shq(s.cwd)} && ${argv.map(shq).join(' ')}`;
880
+ await ensureWorktree(s.worktree);
881
+ const command = `cd ${shq(workdir(s))} && ${argv.map(shq).join(' ')}`;
449
882
  if (m === 'GET') return send(res, 200, { command });
450
883
  if (m === 'POST') {
451
884
  if (runner.live.has(s.id)) {
452
885
  runner.stop(s);
453
886
  await runner.live.get(s.id)?.done;
454
887
  }
455
- openInTerminal({ cwd: s.cwd, argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
888
+ openInTerminal({ cwd: workdir(s), argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
456
889
  s.takenOverAt = Date.now();
457
890
  telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto' });
458
891
  store.save(s);
@@ -465,18 +898,51 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
465
898
  return send(res, 200, { ok: true });
466
899
  }
467
900
 
901
+ // Ship it. GET: what would ship (branch, changes, draft message). POST { title, body, draft }:
902
+ // commit, push, open or update the PR.
903
+ if (action === 'ship') {
904
+ if (!s.worktree) return send(res, 400, { error: 'Ship it works on worktree sessions: launch with a worktree to get one.' });
905
+ 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) });
906
+ if (m === 'POST') {
907
+ if (runner.live.has(s.id)) return send(res, 409, { error: 'Still working: wait for it to finish, or stop it.' });
908
+ const body = await readJson(req);
909
+ const draft = shipDraft(s);
910
+ s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr });
911
+ telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
912
+ store.save(s, { now: true });
913
+ broadcast('summary', summarize(s, store));
914
+ return send(res, 200, s.pr);
915
+ }
916
+ }
917
+ if (action === 'pr' && m === 'POST') {
918
+ if (!s.pr) return send(res, 404, { error: 'No pull request yet.' });
919
+ return send(res, 200, await refreshPr(s));
920
+ }
921
+ // Squash-merge the reviewed head, sync the user's base branch, clean up the worktree.
922
+ if (action === 'merge' && m === 'POST') {
923
+ if (!s.pr || !s.worktree) return send(res, 400, { error: 'No pull request to merge.' });
924
+ const { pr, sync } = await merge(s.worktree, s.pr.number);
925
+ s.pr = pr;
926
+ markDone(s);
927
+ telemetry.track('merge', { agent: s.agent });
928
+ store.save(s, { now: true });
929
+ broadcast('summary', summarize(s, store));
930
+ return send(res, 200, { pr, sync });
931
+ }
932
+
468
933
  // Images the agent pointed at (screenshots etc.). Served if inside the session's project,
469
934
  // or if the agent named that exact path in an image block (screenshots often land in /tmp).
470
935
  if (action === 'file' && m === 'GET') {
471
- let p = path.resolve(s.cwd, url.searchParams.get('path') || '');
936
+ const dir = workdir(s);
937
+ let p = path.resolve(dir, url.searchParams.get('path') || '');
472
938
  const ext = path.extname(p).toLowerCase();
473
939
  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;
940
+ const at = (v) => typeof v === 'string' && path.resolve(dir, v) === p;
475
941
  const named = images.find((b) => at(b.path || b.src));
476
942
  // A page rendered before keepImages() rewrote the block still asks for the original path.
477
943
  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' });
944
+ if (kept) p = path.resolve(dir, kept.path);
945
+ if (!(p.startsWith(dir + path.sep) || named || kept) || !IMAGE_EXT.has(ext) || !fs.existsSync(p)) return send(res, 404, { error: 'not found' });
480
946
  res.writeHead(200, { 'content-type': MIME[ext] });
481
947
  return fs.createReadStream(p).pipe(res);
482
948
  }
@@ -485,6 +951,64 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
485
951
  send(res, 404, { error: 'not found' });
486
952
  }
487
953
 
954
+ // /api/machines: add, connect, and browse folders on other computers.
955
+ async function machinesApi(req, res, url, id, action) {
956
+ const m = req.method;
957
+ const one = (mid) => machines.view().find((x) => x.id === mid);
958
+ if (!id && m === 'GET') return send(res, 200, machines.view());
959
+ if (!id && m === 'POST') {
960
+ const body = await readJson(req);
961
+ const x = machines.add(body);
962
+ if (body.connect !== false) await machines.connect(x.id);
963
+ return send(res, 201, one(x.id));
964
+ }
965
+ if (id === 'ssh-hosts' && m === 'GET') return send(res, 200, sshHosts());
966
+ machines.get(id); // 404s
967
+ if (!action && m === 'PATCH') return send(res, 200, (await machines.update(id, await readJson(req)), one(id)));
968
+ if (!action && m === 'DELETE') {
969
+ machines.remove(id);
970
+ broadcast('config', {});
971
+ return send(res, 200, { ok: true });
972
+ }
973
+ if (action === 'connect' && m === 'POST') return send(res, 200, await machines.connect(id));
974
+ if (action === 'disconnect' && m === 'POST') return send(res, 200, (machines.disconnect(id), one(id)));
975
+ // Stop wisp over there (its agents too); restart starts this version in its place.
976
+ if (action === 'stop' && m === 'POST') return send(res, 200, (await machines.stopRemote(id, { restart: !!(await readJson(req)).restart }), one(id)));
977
+ if (action === 'fs' && m === 'GET') return send(res, 200, await machines.json(id, 'GET', `/fs?q=${encodeURIComponent(url.searchParams.get('q') || '')}`));
978
+ if (action === 'discover' && m === 'GET') {
979
+ const known = new Set(store.config.projects.filter((p) => p.machine === id).map((p) => p.path));
980
+ return send(res, 200, (await machines.json(id, 'GET', '/projects/discover')).filter((f) => !known.has(f.path)));
981
+ }
982
+ send(res, 404, { error: 'not found' });
983
+ }
984
+
985
+ // /api/sessions/<id>@<machine>/…: passed through to that machine's wisp, ids rewritten.
986
+ async function machineSessionApi(req, res, url, at, action) {
987
+ const m = req.method;
988
+ const mid = at.machine;
989
+ const sub = `/sessions/${at.id}${action ? `/${action}` : ''}${url.search}`;
990
+ if ((action === 'file' && m === 'GET') || (action === 'presentation' && url.searchParams.get('format') === 'markdown')) return pipeFrom(res, await machines.stream(mid, 'GET', sub));
991
+ // Take over: a terminal here, signed in over there, resuming the agent in its folder.
992
+ if (action === 'takeover') {
993
+ const { command } = await machines.json(mid, 'GET', `/sessions/${at.id}/takeover`);
994
+ const argv = machines.terminalArgv(mid, command);
995
+ if (m === 'POST') {
996
+ const s = await machines.json(mid, 'GET', `/sessions/${at.id}`);
997
+ if (s.state === 'running' || s.state === 'waiting') await machines.json(mid, 'POST', `/sessions/${at.id}/stop`, {});
998
+ openInTerminal({ cwd: os.homedir(), argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
999
+ telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto', machine: true });
1000
+ }
1001
+ return send(res, 200, { command: argv.map(shq).join(' ') });
1002
+ }
1003
+ const body = m === 'GET' ? undefined : await readJson(req);
1004
+ if (action === 'reply' && body?.attachments?.length) body.attachments = await pushUploads(mid, body.attachments);
1005
+ const out = await machines.json(mid, m, sub, body, action === 'wait' ? { timeout: (MAX_WAIT_S + 30) * 1000 } : undefined);
1006
+ if (!action && m === 'GET') return send(res, 200, machines.session(mid, out));
1007
+ if ((!action && m === 'PATCH') || action === 'wait') return send(res, 200, machines.summary(mid, out));
1008
+ if (out && typeof out === 'object' && out.sessionId) out.sessionId = qualify(out.sessionId, mid);
1009
+ return send(res, 200, out);
1010
+ }
1011
+
488
1012
  function serveStatic(res, pathname) {
489
1013
  const p = path.normalize(path.join(WEB, pathname === '/' ? 'index.html' : pathname));
490
1014
  if (!p.startsWith(WEB) || !fs.existsSync(p) || fs.statSync(p).isDirectory()) {
@@ -503,6 +1027,10 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
503
1027
  if (JSON.parse(fs.readFileSync(serverFile, 'utf8')).pid === process.pid) fs.rmSync(serverFile, { force: true });
504
1028
  } catch {}
505
1029
  retro.stop();
1030
+ triggers.stop();
1031
+ remote.stop();
1032
+ machines.stop(); // the tunnels go; wisp over there keeps running, agents and all
1033
+ clearInterval(prTimer);
506
1034
  for (const s of store.all()) runner.stop(s);
507
1035
  store.flush();
508
1036
  telemetry.persist();
@@ -519,6 +1047,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
519
1047
  });
520
1048
  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
1049
  updater.start();
1050
+ triggers.start();
1051
+ remote.start();
1052
+ machines.start();
522
1053
 
523
1054
  if (telemetry.enabled) {
524
1055
  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 +1058,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
527
1058
  .catch(() => {});
528
1059
  }
529
1060
 
530
- return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry };
1061
+ return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry, triggers, remote, machines };
531
1062
  }
532
1063
 
533
1064
  // POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).
@@ -547,6 +1078,14 @@ function notifyWebhooks(store, summary) {
547
1078
  }
548
1079
  }
549
1080
 
1081
+ // Pass a machine's raw response (images, markdown, uploads) straight through.
1082
+ function pipeFrom(res, r) {
1083
+ const headers = { 'content-type': r.headers['content-type'] || 'application/octet-stream' };
1084
+ if (r.headers['content-length']) headers['content-length'] = r.headers['content-length'];
1085
+ res.writeHead(r.statusCode, headers);
1086
+ r.pipe(res);
1087
+ }
1088
+
550
1089
  function send(res, status, body) {
551
1090
  res.writeHead(status, { 'content-type': 'application/json' });
552
1091
  res.end(JSON.stringify(body));