wisp-agents 0.8.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
@@ -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 } from './machines.js';
25
26
  import { usage } from './usage.js';
26
27
  import { normalizeTeam, normalizePreset, delegationReport } from './subagents.js';
27
28
 
@@ -87,6 +88,30 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
87
88
  runner.on('finished', (f) => broadcast('finished', f));
88
89
  runner.on('finished', ({ session }) => notifyWebhooks(store, session));
89
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
+ };
90
115
  setInterval(() => broadcast('ping', {}), 25_000).unref();
91
116
 
92
117
  const telemetry = new Telemetry(store);
@@ -150,7 +175,8 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
150
175
  // runs need one each, or the harnesses would edit the same files.
151
176
  const comparing = runners.length > 1;
152
177
  const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
153
- const groupId = comparing ? newId() : null;
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;
154
180
  const attachments = resolveAttachments(body.attachments);
155
181
  const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
156
182
  if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
@@ -177,6 +203,27 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
177
203
  return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
178
204
  }
179
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
+
180
227
  // A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
181
228
  async function replyTo(s, text, attachments = [], queue) {
182
229
  await ensureWorktree(s.worktree);
@@ -194,29 +241,31 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
194
241
  };
195
242
 
196
243
  // Recipes and triggers.
197
- const runRecipe = async (r, { params = {}, words, cwd, agent, model, effort, mode } = {}) => {
244
+ const runRecipe = async (r, { params = {}, words, cwd, machine, agent, model, effort, mode } = {}) => {
198
245
  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 });
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 });
200
247
  r.runs = (r.runs || 0) + 1;
201
248
  r.lastRunAt = Date.now();
202
249
  store.saveConfig();
203
250
  broadcast('config', {});
204
251
  return s;
205
252
  };
206
- const triggers = new Triggers({ store, launch: async (body, origin) => (await launch(body, origin))[0], onChange: () => broadcast('config', {}) });
253
+ const triggers = new Triggers({ store, launch: async (body, origin) => (await launchAny(body, origin))[0], onChange: () => broadcast('config', {}) });
207
254
 
208
255
  // Phone: answers and follow-ups coming back from ntfy / Telegram.
209
256
  const remote = new Remote({
210
257
  store,
211
258
  onChange: () => broadcast('remote', {}),
212
259
  act: async ({ sessionId, kind, ref, index, text }) => {
213
- const s = store.get(sessionId);
214
- if (!s) return 'That session is gone.';
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;
215
264
  if (kind === 'answer') {
216
265
  if (!s.ask || s.ask.id !== ref) return 'That question was already answered.';
217
266
  const answer = s.ask.options[index];
218
267
  if (answer === undefined) return 'Unknown option.';
219
- runner.answer(s, answer);
268
+ await ops.answer(answer);
220
269
  return `✓ Answered: ${answer}`;
221
270
  }
222
271
  if (kind === 'reply') {
@@ -226,19 +275,35 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
226
275
  const o = q?.options?.[index];
227
276
  const msg = typeof o === 'string' ? o : o?.reply || o?.label;
228
277
  if (!msg) return 'Unknown option.';
229
- await replyTo(s, msg);
278
+ await ops.reply(msg);
230
279
  return `✓ Sent: ${typeof o === 'string' ? o : o.label || msg}`;
231
280
  }
232
281
  if (kind === 'done') {
233
282
  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.';
283
+ return (await ops.done()) ? '✓ Marked done' : 'It is still working.';
235
284
  }
236
285
  if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
237
- const r = await replyTo(s, String(text || '').trim());
286
+ const r = await ops.reply(String(text || '').trim());
238
287
  return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
239
288
  },
240
289
  });
241
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
+
242
307
  // Ship it: follow open PRs (checks, reviews, merges) so their cards stay current.
243
308
  const refreshPr = async (s) => {
244
309
  if (!s.pr || !s.worktree) return s.pr;
@@ -391,6 +456,10 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
391
456
  fs.writeFileSync(path.join(uploadsDir, `${uid}.json`), JSON.stringify(meta));
392
457
  return send(res, 201, meta);
393
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
+ }
394
463
  if (id && m === 'GET') {
395
464
  const u = uploads.get(id);
396
465
  if (!u || !fs.existsSync(u.path)) return send(res, 404, { error: 'not found' });
@@ -486,8 +555,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
486
555
  }
487
556
  if (action === 'run' && m === 'POST') {
488
557
  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));
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);
491
561
  }
492
562
  }
493
563
 
@@ -518,7 +588,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
518
588
  }
519
589
  if (action === 'run' && m === 'POST') {
520
590
  const r = await triggers.runNow(t);
521
- const started = (Array.isArray(r) ? r : r ? [r] : []).map((x) => summarize(x, store));
591
+ const started = Array.isArray(r) ? r : r ? [r] : [];
522
592
  return send(res, 200, { started, trigger: triggerView(t) });
523
593
  }
524
594
  }
@@ -543,6 +613,11 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
543
613
  if (!id && m === 'GET') return send(res, 200, store.config.projects);
544
614
  if (!id && m === 'POST') {
545
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
+ }
546
621
  const dir = path.resolve(String(body.path || '').replace(/^~(?=$|[\\/])/, os.homedir()));
547
622
  if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return send(res, 400, { error: `Not a folder: ${dir}` });
548
623
  const p = store.addProject(dir, { name: body.name });
@@ -607,13 +682,18 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
607
682
  return send(res, 200, entries);
608
683
  }
609
684
 
685
+ if (root === 'machines') return machinesApi(req, res, url, id, action);
686
+
610
687
  if (root === 'sessions') {
688
+ if (id && splitId(id)) return machineSessionApi(req, res, url, splitId(id), action);
611
689
  if (!id) {
612
690
  if (m === 'GET') {
613
691
  const q = Object.fromEntries(url.searchParams);
614
692
  const cwd = q.cwd && path.resolve(q.cwd.replace(/^~(?=$|[\\/])/, os.homedir()));
615
693
  // 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);
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);
617
697
  if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
618
698
  if (q.agent) list = list.filter((x) => x.agent === q.agent);
619
699
  if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
@@ -623,9 +703,12 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
623
703
  return send(res, 200, list);
624
704
  }
625
705
  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]);
706
+ const body = await readJson(req);
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]);
629
712
  }
630
713
  }
631
714
 
@@ -868,6 +951,64 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
868
951
  send(res, 404, { error: 'not found' });
869
952
  }
870
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
+
871
1012
  function serveStatic(res, pathname) {
872
1013
  const p = path.normalize(path.join(WEB, pathname === '/' ? 'index.html' : pathname));
873
1014
  if (!p.startsWith(WEB) || !fs.existsSync(p) || fs.statSync(p).isDirectory()) {
@@ -888,6 +1029,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
888
1029
  retro.stop();
889
1030
  triggers.stop();
890
1031
  remote.stop();
1032
+ machines.stop(); // the tunnels go; wisp over there keeps running, agents and all
891
1033
  clearInterval(prTimer);
892
1034
  for (const s of store.all()) runner.stop(s);
893
1035
  store.flush();
@@ -907,6 +1049,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
907
1049
  updater.start();
908
1050
  triggers.start();
909
1051
  remote.start();
1052
+ machines.start();
910
1053
 
911
1054
  if (telemetry.enabled) {
912
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.');
@@ -915,7 +1058,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
915
1058
  .catch(() => {});
916
1059
  }
917
1060
 
918
- return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry, triggers, remote };
1061
+ return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry, triggers, remote, machines };
919
1062
  }
920
1063
 
921
1064
  // POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).
@@ -935,6 +1078,14 @@ function notifyWebhooks(store, summary) {
935
1078
  }
936
1079
  }
937
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
+
938
1089
  function send(res, status, body) {
939
1090
  res.writeHead(status, { 'content-type': 'application/json' });
940
1091
  res.end(JSON.stringify(body));