wisp-agents 0.6.0 → 0.8.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 +52 -1
- package/README.md +90 -1
- package/bin/wisp.js +52 -5
- package/package.json +2 -2
- package/server/adapters/claude.js +4 -0
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/index.js +2 -1
- package/server/adapters/opencode.js +4 -0
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +473 -21
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +7 -1
- package/server/openapi.js +168 -4
- package/server/preferences.js +296 -0
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +279 -0
- package/server/render.js +18 -0
- package/server/runner.js +153 -13
- package/server/store.js +9 -1
- package/server/subagents.js +85 -0
- package/server/triggers.js +268 -0
- package/server/update.js +136 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +3 -2
- package/web/css/app.css +637 -14
- package/web/index.html +15 -3
- package/web/js/app.js +2060 -87
- package/web/js/blocks.js +396 -22
package/server/index.js
CHANGED
|
@@ -7,13 +7,23 @@ 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';
|
|
14
14
|
import { Telemetry, envDisabled } from './telemetry.js';
|
|
15
15
|
import { blocksToMarkdown, sessionToMarkdown } from './render.js';
|
|
16
16
|
import { openapi } from './openapi.js';
|
|
17
|
+
import { Updater } from './update.js';
|
|
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 { usage } from './usage.js';
|
|
26
|
+
import { normalizeTeam, normalizePreset, delegationReport } from './subagents.js';
|
|
17
27
|
|
|
18
28
|
const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
|
|
19
29
|
|
|
@@ -42,6 +52,11 @@ export const API_VERSION = 1;
|
|
|
42
52
|
// with follow-ups still queued is about to start the next one, so it doesn't count yet.
|
|
43
53
|
const settled = (s) => s.state === 'waiting' || (s.state !== 'running' && !s.queue?.length);
|
|
44
54
|
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
55
|
+
const PR_POLL_MS = 60_000;
|
|
56
|
+
const MAX_COMPARE = 4;
|
|
57
|
+
const MODES = new Set(['plan', 'edits', 'auto']);
|
|
58
|
+
const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
|
59
|
+
const expand = (p) => path.resolve(String(p).replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
45
60
|
|
|
46
61
|
export async function startServer({ port = 4477, host = '127.0.0.1', handleSignals = true } = {}) {
|
|
47
62
|
const store = new Store();
|
|
@@ -71,9 +86,29 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
71
86
|
runner.on('turn', (t) => broadcast('turn', t));
|
|
72
87
|
runner.on('finished', (f) => broadcast('finished', f));
|
|
73
88
|
runner.on('finished', ({ session }) => notifyWebhooks(store, session));
|
|
89
|
+
runner.on('finished', ({ session }) => remote.notify(store.get(session.id), session).catch((err) => console.warn(`wisp: phone: ${err.message}`)));
|
|
74
90
|
setInterval(() => broadcast('ping', {}), 25_000).unref();
|
|
75
91
|
|
|
76
92
|
const telemetry = new Telemetry(store);
|
|
93
|
+
const updater = new Updater({ port, onChange: (u) => broadcast('update', u) });
|
|
94
|
+
|
|
95
|
+
// Learned preferences: injected into every turn, refreshed by a background retro per session.
|
|
96
|
+
const prefs = new Preferences(store.home);
|
|
97
|
+
const learning = () => store.config.defaults.learn !== 'off';
|
|
98
|
+
const projectOf = (cwd) => store.projectFor(cwd)?.path || cwd;
|
|
99
|
+
runner.promptExtra = (s) => (learning() && s.agent !== 'wisp' ? prefs.prompt(projectOf(s.cwd)) : '');
|
|
100
|
+
const retro = new Retro({
|
|
101
|
+
store,
|
|
102
|
+
runner,
|
|
103
|
+
prefs,
|
|
104
|
+
projectOf,
|
|
105
|
+
enabled: learning,
|
|
106
|
+
onStart: () => broadcast('preferences', {}),
|
|
107
|
+
onDone: ({ session, ok, added = [], removed = [] }) => {
|
|
108
|
+
telemetry.track('retro', { agent: session.agent, ok, added: added.length, removed: removed.length });
|
|
109
|
+
broadcast('preferences', { added: added.length, removed: removed.length });
|
|
110
|
+
},
|
|
111
|
+
});
|
|
77
112
|
const run = (s, t) => ({ agent: s.agent, model: s.model || 'default', mode: s.mode, effort: s.effort || 'default', followUp: s.turns.indexOf(t) > 0 });
|
|
78
113
|
runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...run(session, turn), attachments: turn.attachments.length }));
|
|
79
114
|
runner.on('ended', ({ session, turn, interrupted, errorText }) => {
|
|
@@ -96,6 +131,148 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
96
131
|
});
|
|
97
132
|
if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
|
|
98
133
|
|
|
134
|
+
// One way in for every launch: the launcher, the CLI, recipes, triggers and compare runs.
|
|
135
|
+
// body: { prompt, cwd, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
|
|
136
|
+
// subagents: [{ name, agent, model, effort, use }] | preset: teamId }
|
|
137
|
+
async function launch(body, origin = null) {
|
|
138
|
+
if (!String(body.prompt || '').trim()) throw bad('prompt required');
|
|
139
|
+
const cwd = expand(body.cwd || process.cwd());
|
|
140
|
+
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
|
|
141
|
+
const d = store.config.defaults;
|
|
142
|
+
// compare: more { agent, model, effort } to run the same task on, side by side.
|
|
143
|
+
const runners = [{ agent: body.agent, model: body.model, effort: body.effort }, ...(Array.isArray(body.compare) ? body.compare : [])].slice(0, MAX_COMPARE).map((r, i) => {
|
|
144
|
+
const agent = r.agent || (i ? d.agent : '') || 'claude';
|
|
145
|
+
if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
|
|
146
|
+
return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '' };
|
|
147
|
+
});
|
|
148
|
+
const mode = MODES.has(body.mode) ? body.mode : 'edits';
|
|
149
|
+
// Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
|
|
150
|
+
// runs need one each, or the harnesses would edit the same files.
|
|
151
|
+
const comparing = runners.length > 1;
|
|
152
|
+
const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
|
|
153
|
+
const groupId = comparing ? newId() : null;
|
|
154
|
+
const attachments = resolveAttachments(body.attachments);
|
|
155
|
+
const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
|
|
156
|
+
if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
|
|
157
|
+
const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
|
|
158
|
+
const ids = runners.map(() => newId());
|
|
159
|
+
let trees = ids.map(() => null);
|
|
160
|
+
if (wantTree) {
|
|
161
|
+
const must = body.worktree === true || (comparing && mode !== 'plan');
|
|
162
|
+
// One fetch for the whole launch, then the checkouts side by side: compare runs used to
|
|
163
|
+
// fetch and check out one after the other before anything showed up in the feed.
|
|
164
|
+
const results = await worktreeSource(cwd).then(
|
|
165
|
+
(source) => source && Promise.allSettled(ids.map((id) => createWorktree({ cwd, home: store.home, id, title: body.prompt, source }))),
|
|
166
|
+
(err) => [{ status: 'rejected', reason: err }],
|
|
167
|
+
);
|
|
168
|
+
const failed = results?.find((x) => x.status === 'rejected')?.reason;
|
|
169
|
+
trees = ids.map((_, i) => (results?.[i]?.status === 'fulfilled' ? results[i].value : null));
|
|
170
|
+
if (must && (failed || trees.some((t) => !t))) {
|
|
171
|
+
await Promise.all(trees.map((t) => t && removeWorktree(t, { force: true, branch: 'force' }).catch(() => {})));
|
|
172
|
+
if (failed) throw bad(`Could not create a worktree: ${failed.message}`);
|
|
173
|
+
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.');
|
|
174
|
+
}
|
|
175
|
+
if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
|
|
176
|
+
}
|
|
177
|
+
return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
|
|
181
|
+
async function replyTo(s, text, attachments = [], queue) {
|
|
182
|
+
await ensureWorktree(s.worktree);
|
|
183
|
+
return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue' });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const markDone = (s) => {
|
|
187
|
+
if (s.state === 'running' || s.state === 'waiting') return false;
|
|
188
|
+
s.state = 'done';
|
|
189
|
+
s.unread = false;
|
|
190
|
+
retro.schedule(s, 0);
|
|
191
|
+
store.save(s);
|
|
192
|
+
broadcast('summary', summarize(s, store));
|
|
193
|
+
return true;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Recipes and triggers.
|
|
197
|
+
const runRecipe = async (r, { params = {}, words, cwd, agent, model, effort, mode } = {}) => {
|
|
198
|
+
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 });
|
|
200
|
+
r.runs = (r.runs || 0) + 1;
|
|
201
|
+
r.lastRunAt = Date.now();
|
|
202
|
+
store.saveConfig();
|
|
203
|
+
broadcast('config', {});
|
|
204
|
+
return s;
|
|
205
|
+
};
|
|
206
|
+
const triggers = new Triggers({ store, launch: async (body, origin) => (await launch(body, origin))[0], onChange: () => broadcast('config', {}) });
|
|
207
|
+
|
|
208
|
+
// Phone: answers and follow-ups coming back from ntfy / Telegram.
|
|
209
|
+
const remote = new Remote({
|
|
210
|
+
store,
|
|
211
|
+
onChange: () => broadcast('remote', {}),
|
|
212
|
+
act: async ({ sessionId, kind, ref, index, text }) => {
|
|
213
|
+
const s = store.get(sessionId);
|
|
214
|
+
if (!s) return 'That session is gone.';
|
|
215
|
+
if (kind === 'answer') {
|
|
216
|
+
if (!s.ask || s.ask.id !== ref) return 'That question was already answered.';
|
|
217
|
+
const answer = s.ask.options[index];
|
|
218
|
+
if (answer === undefined) return 'Unknown option.';
|
|
219
|
+
runner.answer(s, answer);
|
|
220
|
+
return `✓ Answered: ${answer}`;
|
|
221
|
+
}
|
|
222
|
+
if (kind === 'reply') {
|
|
223
|
+
const turn = s.turns.at(-1);
|
|
224
|
+
if (!turn || turn.id !== ref || s.state === 'running') return 'That was already answered.';
|
|
225
|
+
const q = turn.presentation?.blocks?.findLast((b) => b?.type === 'question');
|
|
226
|
+
const o = q?.options?.[index];
|
|
227
|
+
const msg = typeof o === 'string' ? o : o?.reply || o?.label;
|
|
228
|
+
if (!msg) return 'Unknown option.';
|
|
229
|
+
await replyTo(s, msg);
|
|
230
|
+
return `✓ Sent: ${typeof o === 'string' ? o : o.label || msg}`;
|
|
231
|
+
}
|
|
232
|
+
if (kind === 'done') {
|
|
233
|
+
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.';
|
|
235
|
+
}
|
|
236
|
+
if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
|
|
237
|
+
const r = await replyTo(s, String(text || '').trim());
|
|
238
|
+
return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Ship it: follow open PRs (checks, reviews, merges) so their cards stay current.
|
|
243
|
+
const refreshPr = async (s) => {
|
|
244
|
+
if (!s.pr || !s.worktree) return s.pr;
|
|
245
|
+
const prev = s.pr;
|
|
246
|
+
const dir = fs.existsSync(s.worktree.path) ? s.worktree.path : s.worktree.root;
|
|
247
|
+
const pr = await prView(dir, prev.number);
|
|
248
|
+
const changed = (k) => JSON.stringify(k(pr)) !== JSON.stringify(k(prev));
|
|
249
|
+
s.pr = pr;
|
|
250
|
+
// Things worth coming back for: CI finished either way, a review landed, it merged or closed.
|
|
251
|
+
const settledChecks = (x) => (x.checks.pending ? 'pending' : x.checks.fail ? 'fail' : 'pass');
|
|
252
|
+
if (changed((x) => x.state) || changed((x) => x.review) || (changed(settledChecks) && settledChecks(pr) !== 'pending')) s.unread = true;
|
|
253
|
+
store.save(s);
|
|
254
|
+
broadcast('summary', summarize(s, store));
|
|
255
|
+
return pr;
|
|
256
|
+
};
|
|
257
|
+
const prTimer = setInterval(async () => {
|
|
258
|
+
for (const s of store.all()) if (s.pr?.state === 'OPEN') await refreshPr(s).catch(() => {});
|
|
259
|
+
}, PR_POLL_MS);
|
|
260
|
+
prTimer.unref();
|
|
261
|
+
|
|
262
|
+
// Default commit message and PR text, from the session's latest presentation.
|
|
263
|
+
const shipDraft = (s) => {
|
|
264
|
+
const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
|
|
265
|
+
const blocks = turn?.presentation?.blocks || [];
|
|
266
|
+
const hero = blocks.find((b) => b?.type === 'hero');
|
|
267
|
+
const text = `${hero?.title || s.title}`.trim();
|
|
268
|
+
const conventional = /^[a-z]+(\([\w./-]+\))?!?: /.test(text);
|
|
269
|
+
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';
|
|
270
|
+
const title = conventional ? text : `${type}: ${text.charAt(0).toLowerCase()}${text.slice(1)}`;
|
|
271
|
+
const keep = blocks.filter((b) => !['hero', 'next', 'question', 'diff', 'code', 'image', 'terminal'].includes(b?.type));
|
|
272
|
+
const body = [hero?.summary, blocksToMarkdown(keep, turn)].filter(Boolean).join('\n\n').trim();
|
|
273
|
+
return { title: title.slice(0, 100), body: `${body}\n\n_Shipped from [wisp](https://github.com/M0xoo/wisp)._`.trim() };
|
|
274
|
+
};
|
|
275
|
+
|
|
99
276
|
const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
100
277
|
|
|
101
278
|
const server = http.createServer(async (req, res) => {
|
|
@@ -134,6 +311,24 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
134
311
|
}
|
|
135
312
|
|
|
136
313
|
if (root === 'health' && m === 'GET') return send(res, 200, { ok: true, name: 'wisp', version: VERSION, apiVersion: API_VERSION, pid: process.pid, url: `http://127.0.0.1:${port}` });
|
|
314
|
+
// New wisp-agents on npm? POST installs it and relaunches; running agents are stopped, so the
|
|
315
|
+
// client must pass force once it has confirmed that with the user.
|
|
316
|
+
if (root === 'update') {
|
|
317
|
+
if (m === 'GET') {
|
|
318
|
+
if (url.searchParams.has('check')) return send(res, 200, await updater.check());
|
|
319
|
+
updater.maybeCheck(); // the UI asks on focus and reconnect: a day-old check gets refreshed
|
|
320
|
+
return send(res, 200, updater.view());
|
|
321
|
+
}
|
|
322
|
+
if (m === 'POST') {
|
|
323
|
+
const body = await readJson(req);
|
|
324
|
+
if (runner.live.size && !body.force) return send(res, 409, { error: 'agents are running', running: runner.live.size });
|
|
325
|
+
await updater.install();
|
|
326
|
+
send(res, 202, updater.view());
|
|
327
|
+
telemetry.track('update', { from: updater.state.current, to: updater.state.latest, method: updater.method });
|
|
328
|
+
setTimeout(() => (shutdown(), process.exit(0)), 300);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
137
332
|
if (root === 'openapi.json' && m === 'GET') return send(res, 200, openapi({ version: VERSION, port }));
|
|
138
333
|
|
|
139
334
|
if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents());
|
|
@@ -149,6 +344,24 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
149
344
|
return send(res, 200, { id: runner.ask(s, body) });
|
|
150
345
|
}
|
|
151
346
|
if (id === 'ask' && action && m === 'GET') return send(res, 200, runner.pollAnswer(action));
|
|
347
|
+
// delegate: POST starts tasks on the lead's subagents; GET reports on them (done once all finished).
|
|
348
|
+
if (id === 'delegate' && action) {
|
|
349
|
+
const lead = store.get(action);
|
|
350
|
+
if (!lead) return send(res, 404, { error: 'no such session' });
|
|
351
|
+
if (m === 'POST') {
|
|
352
|
+
const body = await readJson(req);
|
|
353
|
+
const delegations = runner.delegate(lead, body.tasks);
|
|
354
|
+
telemetry.track('delegate', { agent: lead.agent, tasks: delegations.length, harnesses: [...new Set(delegations.filter((d) => d.agent).map((d) => d.agent))] });
|
|
355
|
+
return send(res, 200, { delegations });
|
|
356
|
+
}
|
|
357
|
+
if (m === 'GET') {
|
|
358
|
+
const ids = (url.searchParams.get('ids') || '').split(',').filter(Boolean);
|
|
359
|
+
const list = runner.delegations(lead, ids);
|
|
360
|
+
if (!list.length) return send(res, 200, { done: true, text: 'No delegated tasks to report on.' });
|
|
361
|
+
const running = ({ d, child }) => child?.turns.find((t) => t.id === d.turnId)?.status === 'running';
|
|
362
|
+
return send(res, 200, { done: !list.some(running), text: list.map(({ d, child }) => delegationReport(d, child, adapters)).join('\n\n---\n\n') });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
152
365
|
}
|
|
153
366
|
|
|
154
367
|
// Attachments: raw body streamed to disk, referenced by id from launches and replies.
|
|
@@ -187,7 +400,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
187
400
|
}
|
|
188
401
|
|
|
189
402
|
if (root === 'config') {
|
|
190
|
-
if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled } });
|
|
403
|
+
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 } });
|
|
191
404
|
if (m === 'PATCH') {
|
|
192
405
|
const body = await readJson(req);
|
|
193
406
|
if (body.defaults) Object.assign(store.config.defaults, body.defaults);
|
|
@@ -199,10 +412,133 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
199
412
|
}
|
|
200
413
|
store.saveConfig();
|
|
201
414
|
broadcast('config', {});
|
|
202
|
-
return send(res, 200, store.config);
|
|
415
|
+
return send(res, 200, { ...store.config, remote: undefined });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// What wisp learned: preferences and lessons, global and per project (scope = 'global' or a project path).
|
|
420
|
+
if (root === 'preferences') {
|
|
421
|
+
const view = () => ({
|
|
422
|
+
enabled: learning(),
|
|
423
|
+
global: prefs.data.global,
|
|
424
|
+
projects: Object.entries(prefs.data.projects).map(([p, v]) => ({ path: p, name: store.projectFor(p)?.name || path.basename(p), ...v })),
|
|
425
|
+
history: prefs.data.history.slice().reverse(),
|
|
426
|
+
running: retro.running,
|
|
427
|
+
});
|
|
428
|
+
if (m === 'GET') return send(res, 200, view());
|
|
429
|
+
if (m === 'PUT') {
|
|
430
|
+
const body = await readJson(req);
|
|
431
|
+
const scope = String(body.scope || '');
|
|
432
|
+
if (scope !== 'global' && !path.isAbsolute(scope)) return send(res, 400, { error: 'scope must be "global" or a project path' });
|
|
433
|
+
if (!KINDS.includes(body.kind)) return send(res, 400, { error: `kind must be one of ${KINDS.join(', ')}` });
|
|
434
|
+
if (!Array.isArray(body.items)) return send(res, 400, { error: 'items must be an array of strings' });
|
|
435
|
+
prefs.set(scope, body.kind, body.items);
|
|
436
|
+
broadcast('preferences', {});
|
|
437
|
+
return send(res, 200, view());
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Subagent presets: a saved team (members with harness, model, thinking and "use it for").
|
|
442
|
+
if (root === 'teams') {
|
|
443
|
+
const list = store.config.teams;
|
|
444
|
+
const saved = (status, body) => (store.saveConfig(), broadcast('config', {}), send(res, status, body));
|
|
445
|
+
if (!id && m === 'GET') return send(res, 200, list);
|
|
446
|
+
if (!id && m === 'POST') {
|
|
447
|
+
const t = normalizePreset(await readJson(req), adapters);
|
|
448
|
+
list.push(t);
|
|
449
|
+
return saved(201, t);
|
|
450
|
+
}
|
|
451
|
+
const t = list.find((x) => x.id === id);
|
|
452
|
+
if (!t) return send(res, 404, { error: 'no such preset' });
|
|
453
|
+
if (!action && m === 'GET') return send(res, 200, t);
|
|
454
|
+
if (!action && m === 'PATCH') return saved(200, Object.assign(t, normalizePreset(await readJson(req), adapters, t)));
|
|
455
|
+
if (!action && m === 'DELETE') {
|
|
456
|
+
store.config.teams = list.filter((x) => x !== t);
|
|
457
|
+
return saved(200, { ok: true });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Recipes: saved prompts with {params}. POST /recipes/:id/run { params | words, cwd, … }.
|
|
462
|
+
if (root === 'recipes') {
|
|
463
|
+
const list = store.config.recipes;
|
|
464
|
+
if (!id && m === 'GET') return send(res, 200, list.map(recipeView));
|
|
465
|
+
if (!id && m === 'POST') {
|
|
466
|
+
const r = normalizeRecipe(await readJson(req));
|
|
467
|
+
list.push(r);
|
|
468
|
+
store.saveConfig();
|
|
469
|
+
broadcast('config', {});
|
|
470
|
+
return send(res, 201, recipeView(r));
|
|
471
|
+
}
|
|
472
|
+
const r = findRecipe(list, decodeURIComponent(id || ''));
|
|
473
|
+
if (!r) return send(res, 404, { error: 'no such recipe' });
|
|
474
|
+
if (!action && m === 'GET') return send(res, 200, recipeView(r));
|
|
475
|
+
if (!action && m === 'PATCH') {
|
|
476
|
+
Object.assign(r, normalizeRecipe(await readJson(req), r));
|
|
477
|
+
store.saveConfig();
|
|
478
|
+
broadcast('config', {});
|
|
479
|
+
return send(res, 200, recipeView(r));
|
|
480
|
+
}
|
|
481
|
+
if (!action && m === 'DELETE') {
|
|
482
|
+
store.config.recipes = list.filter((x) => x !== r);
|
|
483
|
+
store.saveConfig();
|
|
484
|
+
broadcast('config', {});
|
|
485
|
+
return send(res, 200, { ok: true });
|
|
486
|
+
}
|
|
487
|
+
if (action === 'run' && m === 'POST') {
|
|
488
|
+
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));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Triggers: schedules and GitHub watchers that launch sessions on their own.
|
|
495
|
+
if (root === 'triggers') {
|
|
496
|
+
const list = store.config.triggers;
|
|
497
|
+
if (!id && m === 'GET') return send(res, 200, list.map(triggerView));
|
|
498
|
+
if (!id && m === 'POST') {
|
|
499
|
+
const t = normalizeTrigger(await readJson(req), null, { recipes: store.config.recipes });
|
|
500
|
+
list.push(t);
|
|
501
|
+
store.saveConfig();
|
|
502
|
+
broadcast('config', {});
|
|
503
|
+
return send(res, 201, triggerView(t));
|
|
504
|
+
}
|
|
505
|
+
const t = list.find((x) => x.id === id);
|
|
506
|
+
if (!t) return send(res, 404, { error: 'no such trigger' });
|
|
507
|
+
if (!action && m === 'PATCH') {
|
|
508
|
+
Object.assign(t, normalizeTrigger(await readJson(req), t, { recipes: store.config.recipes }));
|
|
509
|
+
store.saveConfig();
|
|
510
|
+
broadcast('config', {});
|
|
511
|
+
return send(res, 200, triggerView(t));
|
|
512
|
+
}
|
|
513
|
+
if (!action && m === 'DELETE') {
|
|
514
|
+
store.config.triggers = list.filter((x) => x !== t);
|
|
515
|
+
store.saveConfig();
|
|
516
|
+
broadcast('config', {});
|
|
517
|
+
return send(res, 200, { ok: true });
|
|
518
|
+
}
|
|
519
|
+
if (action === 'run' && m === 'POST') {
|
|
520
|
+
const r = await triggers.runNow(t);
|
|
521
|
+
const started = (Array.isArray(r) ? r : r ? [r] : []).map((x) => summarize(x, store));
|
|
522
|
+
return send(res, 200, { started, trigger: triggerView(t) });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Phone: ntfy / Telegram settings (secrets masked), and a test message.
|
|
527
|
+
if (root === 'remote') {
|
|
528
|
+
if (!id && m === 'GET') return send(res, 200, remote.view());
|
|
529
|
+
if (!id && m === 'PUT') return send(res, 200, remote.update(await readJson(req)));
|
|
530
|
+
if (id === 'test' && m === 'POST') {
|
|
531
|
+
const sent = await remote.send({ title: '✨ wisp', text: 'Test from wisp: questions and finished tasks will show up like this.', actions: [] });
|
|
532
|
+
if (!sent) return send(res, 400, { error: 'Turn on ntfy or link Telegram first.' });
|
|
533
|
+
return send(res, 200, remote.view());
|
|
203
534
|
}
|
|
204
535
|
}
|
|
205
536
|
|
|
537
|
+
// Spend and outcomes. ?days=7|30|90
|
|
538
|
+
if (root === 'usage' && m === 'GET') {
|
|
539
|
+
return send(res, 200, usage(store.all(), { days: Number(url.searchParams.get('days')) || 30, project: (s) => store.projectFor(s.cwd) }));
|
|
540
|
+
}
|
|
541
|
+
|
|
206
542
|
if (root === 'projects') {
|
|
207
543
|
if (!id && m === 'GET') return send(res, 200, store.config.projects);
|
|
208
544
|
if (!id && m === 'POST') {
|
|
@@ -276,7 +612,8 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
276
612
|
if (m === 'GET') {
|
|
277
613
|
const q = Object.fromEntries(url.searchParams);
|
|
278
614
|
const cwd = q.cwd && path.resolve(q.cwd.replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
279
|
-
|
|
615
|
+
// 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);
|
|
280
617
|
if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
|
|
281
618
|
if (q.agent) list = list.filter((x) => x.agent === q.agent);
|
|
282
619
|
if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
|
|
@@ -286,13 +623,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
286
623
|
return send(res, 200, list);
|
|
287
624
|
}
|
|
288
625
|
if (m === 'POST') {
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) return send(res, 400, { error: `Not a directory: ${cwd}` });
|
|
293
|
-
if (!adapters[body.agent || 'claude']) return send(res, 400, { error: 'unknown agent' });
|
|
294
|
-
const s = runner.create({ prompt: body.prompt, cwd, agent: body.agent, mode: body.mode, model: body.model, effort: /^[\w-]{1,20}$/.test(body.effort || '') ? body.effort : '', attachments: resolveAttachments(body.attachments) });
|
|
295
|
-
return send(res, 201, summarize(s, store));
|
|
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]);
|
|
296
629
|
}
|
|
297
630
|
}
|
|
298
631
|
|
|
@@ -300,21 +633,39 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
300
633
|
if (!s) return send(res, 404, { error: 'no such session' });
|
|
301
634
|
|
|
302
635
|
if (!action) {
|
|
303
|
-
|
|
636
|
+
// A lead comes with its subagents' sessions, so its cards can show their reports and diffs.
|
|
637
|
+
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);
|
|
304
638
|
if (m === 'PATCH') {
|
|
305
639
|
const body = await readJson(req);
|
|
306
640
|
if (typeof body.title === 'string' && body.title.trim()) Object.assign(s, { title: body.title.trim(), titleAuto: false });
|
|
307
641
|
if (body.state === 'done' && s.state !== 'running') s.state = 'done';
|
|
308
642
|
if (body.state === 'review' && s.state === 'done') s.state = 'review';
|
|
309
643
|
if (typeof body.unread === 'boolean') s.unread = body.unread;
|
|
644
|
+
// The team can change between turns; the next turn's lead sees the new roster.
|
|
645
|
+
if (body.subagents !== undefined && !s.parentId) {
|
|
646
|
+
s.subagents = normalizeTeam(body.subagents, adapters);
|
|
647
|
+
s.children ||= {};
|
|
648
|
+
}
|
|
310
649
|
if (body.archived === true && s.state !== 'running' && s.state !== 'waiting') Object.assign(s, { archived: true, unread: false });
|
|
311
650
|
if (body.archived === false) delete s.archived;
|
|
651
|
+
if (s.state === 'done' || s.archived) retro.schedule(s, 0); // finished with it: learn now
|
|
652
|
+
// Archived: a clean worktree goes (its branch stays, a follow-up brings it back).
|
|
653
|
+
// discardWorktree: a compare run you didn't keep; its changes go too.
|
|
654
|
+
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' });
|
|
312
655
|
store.save(s);
|
|
313
656
|
broadcast('summary', summarize(s, store));
|
|
314
657
|
return send(res, 200, summarize(s, store));
|
|
315
658
|
}
|
|
316
659
|
if (m === 'DELETE') {
|
|
317
660
|
runner.stop(s);
|
|
661
|
+
await runner.live.get(s.id)?.done;
|
|
662
|
+
cleanupCheckpoints(workdir(s), s.id);
|
|
663
|
+
for (const cid of Object.values(s.children || {})) {
|
|
664
|
+
await runner.live.get(cid)?.done;
|
|
665
|
+
if (store.get(cid)) (store.delete(cid), broadcast('removed', { id: cid }));
|
|
666
|
+
}
|
|
667
|
+
// Its worktree goes with it; the branch only if it holds nothing beyond its base.
|
|
668
|
+
if (s.worktree) await removeWorktree(s.worktree, { force: true, branch: true }).catch(() => {});
|
|
318
669
|
store.delete(id);
|
|
319
670
|
broadcast('removed', { id });
|
|
320
671
|
return send(res, 200, { ok: true });
|
|
@@ -353,7 +704,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
353
704
|
blocks: turn.presentation?.blocks || [],
|
|
354
705
|
notes: turn.presentation?.notes,
|
|
355
706
|
fallback: !!turn.presentation?.fallback,
|
|
356
|
-
|
|
707
|
+
checkpoint: turn.checkpoint ? { commit: turn.checkpoint.commit, head: turn.checkpoint.head, branch: turn.checkpoint.branch, dirty: turn.checkpoint.dirty, at: turn.checkpoint.at } : null,
|
|
708
|
+
rolledBackAt: turn.rolledBackAt || null,
|
|
709
|
+
changes: turn.changes.map((c) => ({ file: c.file, op: c.op, edits: c.edits.length, reverted: !!c.reverted })),
|
|
357
710
|
stats: turn.stats,
|
|
358
711
|
ask: latest ? s.ask || null : null,
|
|
359
712
|
markdown,
|
|
@@ -364,7 +717,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
364
717
|
const { text, attachments, queue } = await readJson(req);
|
|
365
718
|
if (!text?.trim()) return send(res, 400, { error: 'text required' });
|
|
366
719
|
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to follow up.' });
|
|
367
|
-
return send(res, 200,
|
|
720
|
+
return send(res, 200, await replyTo(s, text.trim(), resolveAttachments(attachments), queue));
|
|
368
721
|
}
|
|
369
722
|
|
|
370
723
|
if (action === 'queue' && m === 'DELETE') {
|
|
@@ -378,19 +731,78 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
378
731
|
return send(res, 200, { ok: runner.answer(s, text.trim()) });
|
|
379
732
|
}
|
|
380
733
|
|
|
734
|
+
// Revert (or re-apply) a single file's edits from a turn.
|
|
735
|
+
if (action === 'revert' && m === 'POST') {
|
|
736
|
+
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to revert files.' });
|
|
737
|
+
if (runner.live.has(s.id)) return send(res, 409, { error: 'Stop the running turn before reverting files.' });
|
|
738
|
+
const { turnId, file, restore } = await readJson(req);
|
|
739
|
+
const turn = turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1);
|
|
740
|
+
if (!turn) return send(res, 404, { error: 'no such turn' });
|
|
741
|
+
await ensureWorktree(s.worktree);
|
|
742
|
+
const result = revertFileChange(s, turn, file, { restore: !!restore });
|
|
743
|
+
turn.activity.push({ t: Date.now(), kind: 'edit', label: `${result.reverted ? 'Reverted' : 'Re-applied'} ${result.file}`, file: result.file });
|
|
744
|
+
s.updatedAt = Date.now();
|
|
745
|
+
store.save(s);
|
|
746
|
+
broadcast('summary', summarize(s, store));
|
|
747
|
+
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
748
|
+
telemetry.track('revert_file', { agent: s.agent, restore: !!restore });
|
|
749
|
+
return send(res, 200, { ok: true, turnId: turn.id, ...result });
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Roll back (or re-apply) the project working tree to the checkpoint before a turn.
|
|
753
|
+
if (action === 'rollback' && m === 'POST') {
|
|
754
|
+
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to roll back turns.' });
|
|
755
|
+
if (runner.live.has(s.id)) return send(res, 409, { error: 'Stop the running turn before rolling back.' });
|
|
756
|
+
const { turnId, restore } = await readJson(req);
|
|
757
|
+
const turn = turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1);
|
|
758
|
+
if (!turn) return send(res, 404, { error: 'no such turn' });
|
|
759
|
+
await ensureWorktree(s.worktree);
|
|
760
|
+
const result = rollbackToTurn(s, turn, { restore: !!restore });
|
|
761
|
+
turn.activity.push({ t: Date.now(), kind: 'edit', label: result.rolledBack ? 'Restored checkpoint before this turn' : 'Re-applied turn changes' });
|
|
762
|
+
s.updatedAt = Date.now();
|
|
763
|
+
store.save(s);
|
|
764
|
+
broadcast('summary', summarize(s, store));
|
|
765
|
+
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
766
|
+
telemetry.track('rollback_turn', { agent: s.agent, restore: !!restore });
|
|
767
|
+
return send(res, 200, { ok: true, turnId: turn.id, ...result });
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Run a command from an actionable presentation block (actions, tests, terminal).
|
|
771
|
+
if (action === 'exec' && m === 'POST') {
|
|
772
|
+
const { command, turnId, key } = await readJson(req);
|
|
773
|
+
const cmd = String(command || '').trim();
|
|
774
|
+
if (!cmd) return send(res, 400, { error: 'command required' });
|
|
775
|
+
const turn = (turnId && s.turns.find((t) => t.id === turnId)) || s.turns.at(-1);
|
|
776
|
+
if (s.agent === 'wisp') {
|
|
777
|
+
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 });
|
|
778
|
+
}
|
|
779
|
+
await ensureWorktree(s.worktree);
|
|
780
|
+
const resExec = await execCommand(workdir(s), cmd);
|
|
781
|
+
if (turn) {
|
|
782
|
+
turn.actionRuns ||= {};
|
|
783
|
+
turn.actionRuns[key || cmd] = { ...resExec, ranAt: Date.now() };
|
|
784
|
+
turn.activity.push({ t: Date.now(), kind: 'run', label: `Ran ${clip(cmd, 60)}`, detail: cmd });
|
|
785
|
+
store.save(s);
|
|
786
|
+
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
787
|
+
}
|
|
788
|
+
telemetry.track('exec_action', { agent: s.agent, exitCode: resExec.exitCode });
|
|
789
|
+
return send(res, 200, resExec);
|
|
790
|
+
}
|
|
791
|
+
|
|
381
792
|
// Take over: continue this exact agent session interactively in a terminal.
|
|
382
793
|
if (action === 'takeover') {
|
|
383
794
|
const adapter = adapters[s.agent];
|
|
384
795
|
if (!adapter?.resumeCommand || !s.agentSessionId) return send(res, 400, { error: 'This session cannot be resumed in a terminal.' });
|
|
385
796
|
const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
|
|
386
|
-
|
|
797
|
+
await ensureWorktree(s.worktree);
|
|
798
|
+
const command = `cd ${shq(workdir(s))} && ${argv.map(shq).join(' ')}`;
|
|
387
799
|
if (m === 'GET') return send(res, 200, { command });
|
|
388
800
|
if (m === 'POST') {
|
|
389
801
|
if (runner.live.has(s.id)) {
|
|
390
802
|
runner.stop(s);
|
|
391
803
|
await runner.live.get(s.id)?.done;
|
|
392
804
|
}
|
|
393
|
-
openInTerminal({ cwd: s
|
|
805
|
+
openInTerminal({ cwd: workdir(s), argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
|
|
394
806
|
s.takenOverAt = Date.now();
|
|
395
807
|
telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto' });
|
|
396
808
|
store.save(s);
|
|
@@ -403,18 +815,51 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
403
815
|
return send(res, 200, { ok: true });
|
|
404
816
|
}
|
|
405
817
|
|
|
818
|
+
// Ship it. GET: what would ship (branch, changes, draft message). POST { title, body, draft }:
|
|
819
|
+
// commit, push, open or update the PR.
|
|
820
|
+
if (action === 'ship') {
|
|
821
|
+
if (!s.worktree) return send(res, 400, { error: 'Ship it works on worktree sessions: launch with a worktree to get one.' });
|
|
822
|
+
if (m === 'GET') return send(res, 200, { branch: s.worktree.branch, base: s.worktree.base, remote: s.worktree.remote, ...(await worktreeStatus(s.worktree)), pr: s.pr || null, ...shipDraft(s) });
|
|
823
|
+
if (m === 'POST') {
|
|
824
|
+
if (runner.live.has(s.id)) return send(res, 409, { error: 'Still working: wait for it to finish, or stop it.' });
|
|
825
|
+
const body = await readJson(req);
|
|
826
|
+
const draft = shipDraft(s);
|
|
827
|
+
s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr });
|
|
828
|
+
telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
|
|
829
|
+
store.save(s, { now: true });
|
|
830
|
+
broadcast('summary', summarize(s, store));
|
|
831
|
+
return send(res, 200, s.pr);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (action === 'pr' && m === 'POST') {
|
|
835
|
+
if (!s.pr) return send(res, 404, { error: 'No pull request yet.' });
|
|
836
|
+
return send(res, 200, await refreshPr(s));
|
|
837
|
+
}
|
|
838
|
+
// Squash-merge the reviewed head, sync the user's base branch, clean up the worktree.
|
|
839
|
+
if (action === 'merge' && m === 'POST') {
|
|
840
|
+
if (!s.pr || !s.worktree) return send(res, 400, { error: 'No pull request to merge.' });
|
|
841
|
+
const { pr, sync } = await merge(s.worktree, s.pr.number);
|
|
842
|
+
s.pr = pr;
|
|
843
|
+
markDone(s);
|
|
844
|
+
telemetry.track('merge', { agent: s.agent });
|
|
845
|
+
store.save(s, { now: true });
|
|
846
|
+
broadcast('summary', summarize(s, store));
|
|
847
|
+
return send(res, 200, { pr, sync });
|
|
848
|
+
}
|
|
849
|
+
|
|
406
850
|
// Images the agent pointed at (screenshots etc.). Served if inside the session's project,
|
|
407
851
|
// or if the agent named that exact path in an image block (screenshots often land in /tmp).
|
|
408
852
|
if (action === 'file' && m === 'GET') {
|
|
409
|
-
|
|
853
|
+
const dir = workdir(s);
|
|
854
|
+
let p = path.resolve(dir, url.searchParams.get('path') || '');
|
|
410
855
|
const ext = path.extname(p).toLowerCase();
|
|
411
856
|
const images = s.turns.flatMap((t) => (t.presentation?.blocks || []).filter((b) => b?.type === 'image'));
|
|
412
|
-
const at = (v) => typeof v === 'string' && path.resolve(
|
|
857
|
+
const at = (v) => typeof v === 'string' && path.resolve(dir, v) === p;
|
|
413
858
|
const named = images.find((b) => at(b.path || b.src));
|
|
414
859
|
// A page rendered before keepImages() rewrote the block still asks for the original path.
|
|
415
860
|
const kept = !named && images.find((b) => at(b.source) && typeof b.path === 'string');
|
|
416
|
-
if (kept) p = path.resolve(
|
|
417
|
-
if (!(p.startsWith(
|
|
861
|
+
if (kept) p = path.resolve(dir, kept.path);
|
|
862
|
+
if (!(p.startsWith(dir + path.sep) || named || kept) || !IMAGE_EXT.has(ext) || !fs.existsSync(p)) return send(res, 404, { error: 'not found' });
|
|
418
863
|
res.writeHead(200, { 'content-type': MIME[ext] });
|
|
419
864
|
return fs.createReadStream(p).pipe(res);
|
|
420
865
|
}
|
|
@@ -440,6 +885,10 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
440
885
|
try {
|
|
441
886
|
if (JSON.parse(fs.readFileSync(serverFile, 'utf8')).pid === process.pid) fs.rmSync(serverFile, { force: true });
|
|
442
887
|
} catch {}
|
|
888
|
+
retro.stop();
|
|
889
|
+
triggers.stop();
|
|
890
|
+
remote.stop();
|
|
891
|
+
clearInterval(prTimer);
|
|
443
892
|
for (const s of store.all()) runner.stop(s);
|
|
444
893
|
store.flush();
|
|
445
894
|
telemetry.persist();
|
|
@@ -455,6 +904,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
455
904
|
server.listen(port, host, resolve);
|
|
456
905
|
});
|
|
457
906
|
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));
|
|
907
|
+
updater.start();
|
|
908
|
+
triggers.start();
|
|
909
|
+
remote.start();
|
|
458
910
|
|
|
459
911
|
if (telemetry.enabled) {
|
|
460
912
|
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.');
|
|
@@ -463,7 +915,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
463
915
|
.catch(() => {});
|
|
464
916
|
}
|
|
465
917
|
|
|
466
|
-
return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry };
|
|
918
|
+
return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry, triggers, remote };
|
|
467
919
|
}
|
|
468
920
|
|
|
469
921
|
// POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).
|