wisp-agents 0.8.0 → 0.10.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
@@ -22,6 +22,7 @@ import { createWorktree, worktreeSource, ensureWorktree, removeWorktree, worktre
22
22
  import { normalizeRecipe, findRecipe, recipeView, fill, positional } from './recipes.js';
23
23
  import { Triggers, normalizeTrigger, triggerView } from './triggers.js';
24
24
  import { Remote } from './remote.js';
25
+ import { Machines, splitId, qualify, sshHosts, discoverHosts } from './machines.js';
25
26
  import { usage } from './usage.js';
26
27
  import { normalizeTeam, normalizePreset, delegationReport } from './subagents.js';
27
28
 
@@ -53,6 +54,7 @@ export const API_VERSION = 1;
53
54
  const settled = (s) => s.state === 'waiting' || (s.state !== 'running' && !s.queue?.length);
54
55
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
55
56
  const PR_POLL_MS = 60_000;
57
+ const SHIP_RECHECK_MS = 5_000;
56
58
  const MAX_COMPARE = 4;
57
59
  const MODES = new Set(['plan', 'edits', 'auto']);
58
60
  const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
@@ -87,6 +89,30 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
87
89
  runner.on('finished', (f) => broadcast('finished', f));
88
90
  runner.on('finished', ({ session }) => notifyWebhooks(store, session));
89
91
  runner.on('finished', ({ session }) => remote.notify(store.get(session.id), session).catch((err) => console.warn(`wisp: phone: ${err.message}`)));
92
+
93
+ // Machines: other computers running their own wisp, reached over SSH. Their sessions show up
94
+ // here as <id>@<machine>; their events are relayed into this stream.
95
+ const machines = new Machines({
96
+ store,
97
+ version: VERSION,
98
+ apiVersion: API_VERSION,
99
+ onChange: () => broadcast('machines', machines.view()),
100
+ onEvent: (event, data) => {
101
+ broadcast(event, data);
102
+ if (event === 'finished' && remote.on) notifyFromMachine(data.session).catch((err) => console.warn(`wisp: phone: ${err.message}`));
103
+ },
104
+ });
105
+ async function notifyFromMachine(summary) {
106
+ const at = splitId(summary.id);
107
+ const s = machines.session(at.machine, await machines.json(at.machine, 'GET', `/sessions/${at.id}`));
108
+ await remote.notify(s, summary);
109
+ }
110
+ // Attachments are uploaded here; a machine gets its own copies before a launch or reply.
111
+ const pushUploads = async (mid, ids) => {
112
+ const out = [];
113
+ for (const u of resolveAttachments(ids)) out.push((await machines.upload(mid, u)).id);
114
+ return out;
115
+ };
90
116
  setInterval(() => broadcast('ping', {}), 25_000).unref();
91
117
 
92
118
  const telemetry = new Telemetry(store);
@@ -150,7 +176,8 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
150
176
  // runs need one each, or the harnesses would edit the same files.
151
177
  const comparing = runners.length > 1;
152
178
  const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
153
- const groupId = comparing ? newId() : null;
179
+ // groupId: one launch sent to several machines (or here and there) shows as one task.
180
+ const groupId = /^[a-f0-9]{12}$/.test(body.groupId || '') ? body.groupId : comparing ? newId() : null;
154
181
  const attachments = resolveAttachments(body.attachments);
155
182
  const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
156
183
  if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
@@ -177,6 +204,27 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
177
204
  return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
178
205
  }
179
206
 
207
+ // Where a launch runs: body.machine, else a project folder that only exists on a machine
208
+ // (recipes and schedules keep just the path). null = here.
209
+ function machineFor(body) {
210
+ if (body.machine) return machines.get(String(body.machine)).id;
211
+ const cwd = String(body.cwd || '');
212
+ if (!cwd || fs.existsSync(expand(cwd))) return null;
213
+ return store.config.projects.find((p) => p.machine && (cwd === p.path || cwd.startsWith(`${p.path}/`)))?.machine || null;
214
+ }
215
+
216
+ // launch(), here or on a machine. Resolves with summaries (a compare launch has several).
217
+ async function launchAny(body, origin = null) {
218
+ const mid = machineFor(body);
219
+ if (!mid) return (await launch(body, origin)).map((x) => summarize(x, store));
220
+ const out = await machines.launch(mid, { ...body, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
221
+ const known = store.projectFor(out.cwd, mid);
222
+ (known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
223
+ store.saveConfig();
224
+ if (!known) broadcast('config', {});
225
+ return (out.group || [out]).map((x) => machines.summary(mid, x));
226
+ }
227
+
180
228
  // A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
181
229
  async function replyTo(s, text, attachments = [], queue) {
182
230
  await ensureWorktree(s.worktree);
@@ -194,29 +242,31 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
194
242
  };
195
243
 
196
244
  // Recipes and triggers.
197
- const runRecipe = async (r, { params = {}, words, cwd, agent, model, effort, mode } = {}) => {
245
+ const runRecipe = async (r, { params = {}, words, cwd, machine, agent, model, effort, mode } = {}) => {
198
246
  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 });
247
+ 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 });
200
248
  r.runs = (r.runs || 0) + 1;
201
249
  r.lastRunAt = Date.now();
202
250
  store.saveConfig();
203
251
  broadcast('config', {});
204
252
  return s;
205
253
  };
206
- const triggers = new Triggers({ store, launch: async (body, origin) => (await launch(body, origin))[0], onChange: () => broadcast('config', {}) });
254
+ const triggers = new Triggers({ store, launch: async (body, origin) => (await launchAny(body, origin))[0], onChange: () => broadcast('config', {}) });
207
255
 
208
256
  // Phone: answers and follow-ups coming back from ntfy / Telegram.
209
257
  const remote = new Remote({
210
258
  store,
211
259
  onChange: () => broadcast('remote', {}),
212
260
  act: async ({ sessionId, kind, ref, index, text }) => {
213
- const s = store.get(sessionId);
214
- if (!s) return 'That session is gone.';
261
+ const ops = await sessionOps(sessionId).catch((err) => ({ error: err.message }));
262
+ if (ops?.error) return `Can’t reach it: ${ops.error}`;
263
+ if (!ops) return 'That session is gone.';
264
+ const { s } = ops;
215
265
  if (kind === 'answer') {
216
266
  if (!s.ask || s.ask.id !== ref) return 'That question was already answered.';
217
267
  const answer = s.ask.options[index];
218
268
  if (answer === undefined) return 'Unknown option.';
219
- runner.answer(s, answer);
269
+ await ops.answer(answer);
220
270
  return `✓ Answered: ${answer}`;
221
271
  }
222
272
  if (kind === 'reply') {
@@ -226,19 +276,67 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
226
276
  const o = q?.options?.[index];
227
277
  const msg = typeof o === 'string' ? o : o?.reply || o?.label;
228
278
  if (!msg) return 'Unknown option.';
229
- await replyTo(s, msg);
279
+ await ops.reply(msg);
230
280
  return `✓ Sent: ${typeof o === 'string' ? o : o.label || msg}`;
231
281
  }
232
282
  if (kind === 'done') {
233
283
  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.';
284
+ return (await ops.done()) ? '✓ Marked done' : 'It is still working.';
235
285
  }
236
286
  if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
237
- const r = await replyTo(s, String(text || '').trim());
287
+ const r = await ops.reply(String(text || '').trim());
238
288
  return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
239
289
  },
240
290
  });
241
291
 
292
+ // What the phone can do to a session, here or on a machine: { s, answer, reply, done } or null.
293
+ async function sessionOps(sessionId) {
294
+ const at = splitId(sessionId);
295
+ if (!at) {
296
+ const s = store.get(sessionId);
297
+ return s && { s, answer: (a) => runner.answer(s, a), reply: (t) => replyTo(s, t), done: () => markDone(s) };
298
+ }
299
+ const call = (method, p, body) => machines.json(at.machine, method, `/sessions/${at.id}${p}`, body);
300
+ const s = await call('GET', '').catch((err) => {
301
+ if (err.status === 404) return null;
302
+ throw err;
303
+ });
304
+ const live = s && (s.state === 'running' || s.state === 'waiting');
305
+ 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' })) };
306
+ }
307
+
308
+ // Ship it shows only when the worktree has something to ship: uncommitted changes, or commits
309
+ // not yet pushed. Rechecked whenever wisp might have changed the worktree.
310
+ const checkShip = async (s) => {
311
+ if (!s?.worktree || s.parentId) return;
312
+ const { dirty, ahead, unpushed } = await worktreeStatus(s.worktree);
313
+ const next = { dirty, ahead, unpushed };
314
+ if (JSON.stringify(next) === JSON.stringify(s.toShip)) return;
315
+ s.toShip = next;
316
+ store.save(s);
317
+ broadcast('summary', summarize(s, store));
318
+ };
319
+ // One check at a time per session (one asked for meanwhile runs right after); `lazy` (a card
320
+ // fetching its session) skips running sessions and runs at most every few seconds.
321
+ const shipChecks = new Map(); // id → true while checking, 'again' if another check is due
322
+ const shipCheckedAt = new Map();
323
+ const recheckShip = (s, { lazy = false } = {}) => {
324
+ if (!s?.worktree || s.parentId) return;
325
+ if (lazy && (runner.live.has(s.id) || shipChecks.has(s.id) || Date.now() - (shipCheckedAt.get(s.id) || 0) < SHIP_RECHECK_MS)) return;
326
+ if (shipChecks.has(s.id)) return void shipChecks.set(s.id, 'again');
327
+ shipChecks.set(s.id, true);
328
+ shipCheckedAt.set(s.id, Date.now());
329
+ checkShip(s)
330
+ .catch(() => {})
331
+ .finally(() => {
332
+ const again = shipChecks.get(s.id) === 'again';
333
+ shipChecks.delete(s.id);
334
+ if (again) recheckShip(s);
335
+ });
336
+ };
337
+ runner.on('ended', ({ session }) => recheckShip(store.get(session.id)));
338
+ for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheckShip(s);
339
+
242
340
  // Ship it: follow open PRs (checks, reviews, merges) so their cards stay current.
243
341
  const refreshPr = async (s) => {
244
342
  if (!s.pr || !s.worktree) return s.pr;
@@ -391,6 +489,10 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
391
489
  fs.writeFileSync(path.join(uploadsDir, `${uid}.json`), JSON.stringify(meta));
392
490
  return send(res, 201, meta);
393
491
  }
492
+ if (id && m === 'GET' && splitId(id)) {
493
+ const at = splitId(id);
494
+ return pipeFrom(res, await machines.stream(at.machine, 'GET', `/uploads/${at.id}`));
495
+ }
394
496
  if (id && m === 'GET') {
395
497
  const u = uploads.get(id);
396
498
  if (!u || !fs.existsSync(u.path)) return send(res, 404, { error: 'not found' });
@@ -486,8 +588,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
486
588
  }
487
589
  if (action === 'run' && m === 'POST') {
488
590
  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));
591
+ // machine: run it in a project there (r.cwd empty, or a path on that machine).
592
+ 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 });
593
+ return send(res, 201, s);
491
594
  }
492
595
  }
493
596
 
@@ -518,7 +621,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
518
621
  }
519
622
  if (action === 'run' && m === 'POST') {
520
623
  const r = await triggers.runNow(t);
521
- const started = (Array.isArray(r) ? r : r ? [r] : []).map((x) => summarize(x, store));
624
+ const started = Array.isArray(r) ? r : r ? [r] : [];
522
625
  return send(res, 200, { started, trigger: triggerView(t) });
523
626
  }
524
627
  }
@@ -543,6 +646,11 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
543
646
  if (!id && m === 'GET') return send(res, 200, store.config.projects);
544
647
  if (!id && m === 'POST') {
545
648
  const body = await readJson(req);
649
+ if (body.machine) {
650
+ const p = await machines.addProject(String(body.machine), String(body.path || '').trim(), body.name);
651
+ broadcast('config', {});
652
+ return send(res, 201, p);
653
+ }
546
654
  const dir = path.resolve(String(body.path || '').replace(/^~(?=$|[\\/])/, os.homedir()));
547
655
  if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return send(res, 400, { error: `Not a folder: ${dir}` });
548
656
  const p = store.addProject(dir, { name: body.name });
@@ -607,13 +715,18 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
607
715
  return send(res, 200, entries);
608
716
  }
609
717
 
718
+ if (root === 'machines') return machinesApi(req, res, url, id, action);
719
+
610
720
  if (root === 'sessions') {
721
+ if (id && splitId(id)) return machineSessionApi(req, res, url, splitId(id), action);
611
722
  if (!id) {
612
723
  if (m === 'GET') {
613
724
  const q = Object.fromEntries(url.searchParams);
614
725
  const cwd = q.cwd && path.resolve(q.cwd.replace(/^~(?=$|[\\/])/, os.homedir()));
615
726
  // 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);
727
+ // Sessions on machines too (the last ones seen, for a machine that's offline).
728
+ const here = store.all().filter((x) => !x.parentId || q.children).map((x) => summarize(x, store));
729
+ let list = [...here, ...(await machines.sessions(q))].sort((a, b) => b.updatedAt - a.updatedAt);
617
730
  if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
618
731
  if (q.agent) list = list.filter((x) => x.agent === q.agent);
619
732
  if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
@@ -623,9 +736,12 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
623
736
  return send(res, 200, list);
624
737
  }
625
738
  if (m === 'POST') {
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]);
739
+ const body = await readJson(req);
740
+ // A machine's wisp hands on where a recipe or trigger launch came from.
741
+ const o = body.origin;
742
+ const origin = o && (o.kind === 'recipe' || o.kind === 'trigger') ? { kind: o.kind, id: String(o.id || ''), name: String(o.name || '') } : null;
743
+ const out = await launchAny(body, origin);
744
+ return send(res, 201, out.length > 1 ? { ...out[0], group: out } : out[0]);
629
745
  }
630
746
  }
631
747
 
@@ -634,6 +750,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
634
750
 
635
751
  if (!action) {
636
752
  // A lead comes with its subagents' sessions, so its cards can show their reports and diffs.
753
+ if (m === 'GET') recheckShip(s, { lazy: true }); // edits made outside wisp (in an editor) show up too
637
754
  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);
638
755
  if (m === 'PATCH') {
639
756
  const body = await readJson(req);
@@ -746,6 +863,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
746
863
  broadcast('summary', summarize(s, store));
747
864
  broadcast('turn', { sessionId: s.id, turnId: turn.id });
748
865
  telemetry.track('revert_file', { agent: s.agent, restore: !!restore });
866
+ recheckShip(s);
749
867
  return send(res, 200, { ok: true, turnId: turn.id, ...result });
750
868
  }
751
869
 
@@ -764,6 +882,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
764
882
  broadcast('summary', summarize(s, store));
765
883
  broadcast('turn', { sessionId: s.id, turnId: turn.id });
766
884
  telemetry.track('rollback_turn', { agent: s.agent, restore: !!restore });
885
+ recheckShip(s);
767
886
  return send(res, 200, { ok: true, turnId: turn.id, ...result });
768
887
  }
769
888
 
@@ -786,6 +905,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
786
905
  broadcast('turn', { sessionId: s.id, turnId: turn.id });
787
906
  }
788
907
  telemetry.track('exec_action', { agent: s.agent, exitCode: resExec.exitCode });
908
+ recheckShip(s);
789
909
  return send(res, 200, resExec);
790
910
  }
791
911
 
@@ -819,7 +939,10 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
819
939
  // commit, push, open or update the PR.
820
940
  if (action === 'ship') {
821
941
  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) });
942
+ if (m === 'GET') {
943
+ recheckShip(s);
944
+ 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) });
945
+ }
823
946
  if (m === 'POST') {
824
947
  if (runner.live.has(s.id)) return send(res, 409, { error: 'Still working: wait for it to finish, or stop it.' });
825
948
  const body = await readJson(req);
@@ -828,6 +951,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
828
951
  telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
829
952
  store.save(s, { now: true });
830
953
  broadcast('summary', summarize(s, store));
954
+ recheckShip(s);
831
955
  return send(res, 200, s.pr);
832
956
  }
833
957
  }
@@ -868,6 +992,65 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
868
992
  send(res, 404, { error: 'not found' });
869
993
  }
870
994
 
995
+ // /api/machines: add, connect, and browse folders on other computers.
996
+ async function machinesApi(req, res, url, id, action) {
997
+ const m = req.method;
998
+ const one = (mid) => machines.view().find((x) => x.id === mid);
999
+ if (!id && m === 'GET') return send(res, 200, machines.view());
1000
+ if (!id && m === 'POST') {
1001
+ const body = await readJson(req);
1002
+ const x = machines.add(body);
1003
+ if (body.connect !== false) await machines.connect(x.id);
1004
+ return send(res, 201, one(x.id));
1005
+ }
1006
+ if (id === 'ssh-hosts' && m === 'GET') return send(res, 200, sshHosts());
1007
+ if (id === 'discover' && m === 'GET') return send(res, 200, await discoverHosts());
1008
+ machines.get(id); // 404s
1009
+ if (!action && m === 'PATCH') return send(res, 200, (await machines.update(id, await readJson(req)), one(id)));
1010
+ if (!action && m === 'DELETE') {
1011
+ machines.remove(id);
1012
+ broadcast('config', {});
1013
+ return send(res, 200, { ok: true });
1014
+ }
1015
+ if (action === 'connect' && m === 'POST') return send(res, 200, await machines.connect(id));
1016
+ if (action === 'disconnect' && m === 'POST') return send(res, 200, (machines.disconnect(id), one(id)));
1017
+ // Stop wisp over there (its agents too); restart starts this version in its place.
1018
+ if (action === 'stop' && m === 'POST') return send(res, 200, (await machines.stopRemote(id, { restart: !!(await readJson(req)).restart }), one(id)));
1019
+ if (action === 'fs' && m === 'GET') return send(res, 200, await machines.json(id, 'GET', `/fs?q=${encodeURIComponent(url.searchParams.get('q') || '')}`));
1020
+ if (action === 'discover' && m === 'GET') {
1021
+ const known = new Set(store.config.projects.filter((p) => p.machine === id).map((p) => p.path));
1022
+ return send(res, 200, (await machines.json(id, 'GET', '/projects/discover')).filter((f) => !known.has(f.path)));
1023
+ }
1024
+ send(res, 404, { error: 'not found' });
1025
+ }
1026
+
1027
+ // /api/sessions/<id>@<machine>/…: passed through to that machine's wisp, ids rewritten.
1028
+ async function machineSessionApi(req, res, url, at, action) {
1029
+ const m = req.method;
1030
+ const mid = at.machine;
1031
+ const sub = `/sessions/${at.id}${action ? `/${action}` : ''}${url.search}`;
1032
+ if ((action === 'file' && m === 'GET') || (action === 'presentation' && url.searchParams.get('format') === 'markdown')) return pipeFrom(res, await machines.stream(mid, 'GET', sub));
1033
+ // Take over: a terminal here, signed in over there, resuming the agent in its folder.
1034
+ if (action === 'takeover') {
1035
+ const { command } = await machines.json(mid, 'GET', `/sessions/${at.id}/takeover`);
1036
+ const argv = machines.terminalArgv(mid, command);
1037
+ if (m === 'POST') {
1038
+ const s = await machines.json(mid, 'GET', `/sessions/${at.id}`);
1039
+ if (s.state === 'running' || s.state === 'waiting') await machines.json(mid, 'POST', `/sessions/${at.id}/stop`, {});
1040
+ openInTerminal({ cwd: os.homedir(), argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
1041
+ telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto', machine: true });
1042
+ }
1043
+ return send(res, 200, { command: argv.map(shq).join(' ') });
1044
+ }
1045
+ const body = m === 'GET' ? undefined : await readJson(req);
1046
+ if (action === 'reply' && body?.attachments?.length) body.attachments = await pushUploads(mid, body.attachments);
1047
+ const out = await machines.json(mid, m, sub, body, action === 'wait' ? { timeout: (MAX_WAIT_S + 30) * 1000 } : undefined);
1048
+ if (!action && m === 'GET') return send(res, 200, machines.session(mid, out));
1049
+ if ((!action && m === 'PATCH') || action === 'wait') return send(res, 200, machines.summary(mid, out));
1050
+ if (out && typeof out === 'object' && out.sessionId) out.sessionId = qualify(out.sessionId, mid);
1051
+ return send(res, 200, out);
1052
+ }
1053
+
871
1054
  function serveStatic(res, pathname) {
872
1055
  const p = path.normalize(path.join(WEB, pathname === '/' ? 'index.html' : pathname));
873
1056
  if (!p.startsWith(WEB) || !fs.existsSync(p) || fs.statSync(p).isDirectory()) {
@@ -888,6 +1071,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
888
1071
  retro.stop();
889
1072
  triggers.stop();
890
1073
  remote.stop();
1074
+ machines.stop(); // the tunnels go; wisp over there keeps running, agents and all
891
1075
  clearInterval(prTimer);
892
1076
  for (const s of store.all()) runner.stop(s);
893
1077
  store.flush();
@@ -907,6 +1091,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
907
1091
  updater.start();
908
1092
  triggers.start();
909
1093
  remote.start();
1094
+ machines.start();
910
1095
 
911
1096
  if (telemetry.enabled) {
912
1097
  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.');
@@ -915,7 +1100,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
915
1100
  .catch(() => {});
916
1101
  }
917
1102
 
918
- return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry, triggers, remote };
1103
+ return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry, triggers, remote, machines };
919
1104
  }
920
1105
 
921
1106
  // POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).
@@ -935,6 +1120,14 @@ function notifyWebhooks(store, summary) {
935
1120
  }
936
1121
  }
937
1122
 
1123
+ // Pass a machine's raw response (images, markdown, uploads) straight through.
1124
+ function pipeFrom(res, r) {
1125
+ const headers = { 'content-type': r.headers['content-type'] || 'application/octet-stream' };
1126
+ if (r.headers['content-length']) headers['content-length'] = r.headers['content-length'];
1127
+ res.writeHead(r.statusCode, headers);
1128
+ r.pipe(res);
1129
+ }
1130
+
938
1131
  function send(res, status, body) {
939
1132
  res.writeHead(status, { 'content-type': 'application/json' });
940
1133
  res.end(JSON.stringify(body));