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/CHANGELOG.md +57 -1
- package/README.md +99 -8
- package/bin/wisp.js +53 -6
- package/package.json +2 -2
- package/server/adapters/claude.js +7 -1
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/grok.js +251 -0
- package/server/adapters/index.js +4 -2
- package/server/adapters/opencode.js +7 -1
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +559 -20
- package/server/machines.js +672 -0
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +9 -3
- package/server/openapi.js +164 -5
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +283 -0
- package/server/render.js +18 -0
- package/server/runner.js +154 -13
- package/server/store.js +20 -7
- package/server/subagents.js +86 -0
- package/server/triggers.js +268 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +5 -4
- package/web/css/app.css +702 -34
- package/web/index.html +15 -3
- package/web/js/app.js +2298 -134
- package/web/js/blocks.js +396 -22
- package/web/logos/grok.svg +1 -0
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
|
-
|
|
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
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
479
|
-
if (!(p.startsWith(
|
|
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));
|