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/CHANGELOG.md +35 -1
- package/README.md +28 -12
- package/bin/wisp.js +1 -1
- package/package.json +2 -2
- package/server/adapters/claude.js +3 -1
- package/server/adapters/grok.js +251 -0
- package/server/adapters/index.js +2 -1
- package/server/adapters/opencode.js +3 -1
- package/server/git.js +6 -3
- package/server/index.js +212 -19
- package/server/machines.js +736 -0
- package/server/mcp-ask.js +1 -1
- package/server/mcp-wisp.js +2 -2
- package/server/openapi.js +18 -2
- package/server/remote.js +8 -4
- package/server/runner.js +3 -0
- package/server/store.js +11 -6
- package/server/subagents.js +2 -1
- package/server/update.js +8 -4
- package/skills/wisp/SKILL.md +2 -2
- package/web/css/app.css +173 -39
- package/web/index.html +5 -0
- package/web/js/app.js +649 -86
- package/web/logos/grok.svg +1 -0
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
|
-
|
|
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
|
|
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
|
|
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
|
|
214
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
490
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
627
|
-
|
|
628
|
-
|
|
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')
|
|
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));
|