wisp-agents 0.0.0-stage → 0.2.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.
@@ -0,0 +1,230 @@
1
+ // Claude Code adapter — drives `claude -p --output-format stream-json`.
2
+
3
+ import { spawn, execFile } from 'node:child_process';
4
+ import readline from 'node:readline';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ const BIN = process.env.WISP_CLAUDE_BIN || 'claude';
9
+ const MAX_SNAPSHOT = 256 * 1024;
10
+
11
+ const PERMISSION_MODES = {
12
+ plan: 'plan',
13
+ edits: 'acceptEdits',
14
+ auto: 'bypassPermissions',
15
+ };
16
+
17
+ export default {
18
+ id: 'claude',
19
+ name: 'Claude Code',
20
+ models: [
21
+ { id: '', label: 'Default', hint: 'Whatever Claude Code is set to' },
22
+ { id: 'opus', label: 'Opus', hint: 'Most capable, for hard problems' },
23
+ { id: 'sonnet', label: 'Sonnet', hint: 'Fast and smart, everyday work' },
24
+ { id: 'haiku', label: 'Haiku', hint: 'Fastest, for quick tasks' },
25
+ ],
26
+
27
+ // Interactive command to take over a session in a terminal.
28
+ resumeCommand({ resumeId, model }) {
29
+ return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : [])];
30
+ },
31
+
32
+ detect() {
33
+ return new Promise((resolve) => {
34
+ execFile(BIN, ['--version'], { timeout: 10_000 }, (err, stdout) => {
35
+ resolve(err ? { available: false, error: err.message } : { available: true, version: stdout.trim() });
36
+ });
37
+ });
38
+ },
39
+
40
+ // Returns { stop(), done: Promise<void> }. Everything else is reported through onEvent.
41
+ run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, mcpServers = {}, onEvent }) {
42
+ const args = [
43
+ '-p',
44
+ '--output-format', 'stream-json',
45
+ '--append-system-prompt', systemPrompt,
46
+ '--permission-mode', PERMISSION_MODES[mode] || 'acceptEdits',
47
+ ];
48
+ if (model) args.push('--model', model);
49
+ if (resumeId) args.push('--resume', resumeId);
50
+ if (Object.keys(mcpServers).length) {
51
+ args.push('--mcp-config', JSON.stringify({ mcpServers }));
52
+ args.push('--allowedTools', ...Object.keys(mcpServers).map((n) => `mcp__${n}`));
53
+ }
54
+ const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
55
+ if (dirs.length) args.push('--add-dir', ...dirs);
56
+ args.push('--verbose'); // terminates the variadic flags above
57
+ if (attachments.length) prompt += attachmentNote(attachments);
58
+
59
+ const env = { ...process.env };
60
+ delete env.CLAUDECODE; // don't let a parent Claude Code session leak into the child
61
+ delete env.CLAUDE_CODE_ENTRYPOINT;
62
+ env.MCP_TOOL_TIMEOUT ??= String(7 * 24 * 3600 * 1000); // ask_user may wait for the human a long time
63
+
64
+ const child = spawn(BIN, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
65
+ child.stdin.end(prompt);
66
+
67
+ let sawResult = false;
68
+ let stderr = '';
69
+ const pendingTools = new Map(); // tool_use_id -> name, to flag failed tool results
70
+
71
+ child.stderr.on('data', (d) => {
72
+ stderr = (stderr + d).slice(-8000);
73
+ });
74
+
75
+ readline.createInterface({ input: child.stdout }).on('line', (line) => {
76
+ if (!line.trim()) return;
77
+ let msg;
78
+ try {
79
+ msg = JSON.parse(line);
80
+ } catch {
81
+ return;
82
+ }
83
+ if (msg.session_id) onEvent({ type: 'session', id: msg.session_id });
84
+
85
+ if (msg.type === 'assistant') {
86
+ for (const part of msg.message?.content || []) {
87
+ if (part.type === 'text' && part.text.trim()) {
88
+ onEvent({ type: 'text', text: part.text });
89
+ } else if (part.type === 'thinking') {
90
+ onEvent({ type: 'thinking' });
91
+ } else if (part.type === 'tool_use') {
92
+ pendingTools.set(part.id, part.name);
93
+ onEvent({ type: 'tool', ...describeTool(part.name, part.input || {}, cwd) });
94
+ const change = captureChange(part.name, part.input || {}, cwd);
95
+ if (change) onEvent({ type: 'change', ...change });
96
+ }
97
+ }
98
+ } else if (msg.type === 'user') {
99
+ for (const part of msg.message?.content || []) {
100
+ if (part.type === 'tool_result' && part.is_error) {
101
+ const name = pendingTools.get(part.tool_use_id);
102
+ onEvent({ type: 'tool_error', name, text: flatten(part.content).slice(0, 400) });
103
+ }
104
+ }
105
+ } else if (msg.type === 'result') {
106
+ sawResult = true;
107
+ onEvent({
108
+ type: 'result',
109
+ text: msg.result ?? '',
110
+ isError: !!msg.is_error || (msg.subtype && msg.subtype !== 'success'),
111
+ stats: {
112
+ costUsd: msg.total_cost_usd,
113
+ durationMs: msg.duration_ms,
114
+ turns: msg.num_turns,
115
+ inputTokens: msg.usage?.input_tokens,
116
+ outputTokens: msg.usage?.output_tokens,
117
+ },
118
+ });
119
+ }
120
+ });
121
+
122
+ const done = new Promise((resolve) => {
123
+ child.on('error', (err) => {
124
+ onEvent({ type: 'result', isError: true, text: `Could not start \`${BIN}\`: ${err.message}` });
125
+ sawResult = true;
126
+ resolve();
127
+ });
128
+ child.on('close', (code, signal) => {
129
+ if (!sawResult) {
130
+ onEvent({
131
+ type: 'result',
132
+ isError: true,
133
+ stopped: signal === 'SIGTERM' || signal === 'SIGINT',
134
+ text: stderr.trim() || `claude exited with ${signal || `code ${code}`}`,
135
+ });
136
+ }
137
+ resolve();
138
+ });
139
+ });
140
+
141
+ return {
142
+ done,
143
+ stop() {
144
+ if (child.exitCode === null) child.kill('SIGTERM');
145
+ },
146
+ };
147
+ },
148
+ };
149
+
150
+ function attachmentNote(files) {
151
+ const lines = files.map((f) => `- ${f.path} (${f.mime}, ${Math.max(1, Math.round(f.size / 1024))} KB)`);
152
+ return `\n\n<attachments>\nThe user attached these files. Read images and PDFs with the Read tool; for video/audio/other formats use the shell (e.g. ffmpeg, ffprobe) if needed.\n${lines.join('\n')}\n</attachments>`;
153
+ }
154
+
155
+ function rel(file, cwd) {
156
+ if (!file) return '';
157
+ const r = path.relative(cwd, file);
158
+ return r && !r.startsWith('..') ? r : file;
159
+ }
160
+
161
+ function clip(s, n = 80) {
162
+ s = String(s ?? '').replace(/\s+/g, ' ').trim();
163
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
164
+ }
165
+
166
+ // Human sentence for the live ticker + a coarse kind for the trail summary.
167
+ function describeTool(name, input, cwd) {
168
+ const f = rel(input.file_path || input.notebook_path || input.path, cwd);
169
+ switch (name) {
170
+ case 'Read':
171
+ return { kind: 'read', label: `Reading ${f}`, file: f };
172
+ case 'Edit':
173
+ case 'MultiEdit':
174
+ return { kind: 'edit', label: `Editing ${f}`, file: f };
175
+ case 'Write':
176
+ return { kind: 'edit', label: `Writing ${f}`, file: f };
177
+ case 'NotebookEdit':
178
+ return { kind: 'edit', label: `Editing notebook ${f}`, file: f };
179
+ case 'Bash':
180
+ return { kind: 'run', label: input.description ? clip(input.description) : `Running ${clip(input.command, 60)}`, detail: input.command };
181
+ case 'Grep':
182
+ return { kind: 'search', label: `Searching for “${clip(input.pattern, 40)}”` };
183
+ case 'Glob':
184
+ return { kind: 'search', label: `Finding ${clip(input.pattern, 50)}` };
185
+ case 'WebSearch':
186
+ return { kind: 'web', label: `Searching the web: ${clip(input.query, 50)}` };
187
+ case 'WebFetch':
188
+ return { kind: 'web', label: `Reading ${clip(input.url, 60)}` };
189
+ case 'Task':
190
+ case 'Agent':
191
+ return { kind: 'agent', label: `Delegating: ${clip(input.description || input.prompt, 60)}` };
192
+ case 'TodoWrite': {
193
+ const active = input.todos?.find((t) => t.status === 'in_progress');
194
+ return { kind: 'plan', label: active ? clip(active.activeForm || active.content) : 'Planning' };
195
+ }
196
+ case 'mcp__wisp__ask_user':
197
+ return { kind: 'wait', label: 'Waiting for your answer' };
198
+ default:
199
+ return { kind: 'tool', label: name.startsWith('mcp__') ? `Using ${name.split('__').slice(1).join(' · ')}` : `Using ${name}` };
200
+ }
201
+ }
202
+
203
+ // Capture edit inputs so wisp can show real diffs without asking the agent for them.
204
+ function captureChange(name, input, cwd) {
205
+ const file = input.file_path;
206
+ if (!file) return null;
207
+ if (name === 'Edit') {
208
+ return { file: rel(file, cwd), op: 'edit', edits: [{ before: input.old_string ?? '', after: input.new_string ?? '' }] };
209
+ }
210
+ if (name === 'MultiEdit') {
211
+ return { file: rel(file, cwd), op: 'edit', edits: (input.edits || []).map((e) => ({ before: e.old_string ?? '', after: e.new_string ?? '' })) };
212
+ }
213
+ if (name === 'Write') {
214
+ // tool_use is streamed before the tool runs, so the file still holds its old content here.
215
+ let before = null;
216
+ try {
217
+ const st = fs.statSync(path.resolve(cwd, file));
218
+ if (st.size <= MAX_SNAPSHOT) before = fs.readFileSync(path.resolve(cwd, file), 'utf8');
219
+ } catch {}
220
+ const after = String(input.content ?? '');
221
+ return { file: rel(file, cwd), op: before === null ? 'create' : 'write', edits: [{ before: before ?? '', after: after.slice(0, MAX_SNAPSHOT) }] };
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function flatten(content) {
227
+ if (typeof content === 'string') return content;
228
+ if (Array.isArray(content)) return content.map((c) => c.text || '').join('\n');
229
+ return '';
230
+ }
@@ -0,0 +1,36 @@
1
+ // Agent registry. To add a harness (codex, opencode, …) write an adapter with the
2
+ // same shape as ./claude.js and register it here:
3
+ //
4
+ // {
5
+ // id, name, models: [{id, label}],
6
+ // detect(): Promise<{ available, version?, error? }>,
7
+ // resumeCommand({ resumeId, model }): string[] argv to continue the session interactively
8
+ // run({ prompt, cwd, resumeId, mode, model, systemPrompt, onEvent }): { stop(), done: Promise }
9
+ // }
10
+ //
11
+ // onEvent receives normalized events:
12
+ // { type: 'session', id } agent-side session id (used to resume follow-ups)
13
+ // { type: 'tool', kind, label, file?, detail? } kind: read|edit|run|search|web|agent|plan|tool
14
+ // { type: 'tool_error', name, text }
15
+ // { type: 'text', text } intermediate assistant prose
16
+ // { type: 'thinking' }
17
+ // { type: 'change', file, op, edits: [{ before, after }] }
18
+ // { type: 'result', text, isError, stopped?, stats? } exactly once, at the end
19
+ //
20
+ // mode is one of: plan (read-only), edits (auto-accept file edits), auto (no permission prompts).
21
+ // Agents without a system-prompt flag can prepend `systemPrompt` to the first prompt instead.
22
+
23
+ import claude from './claude.js';
24
+
25
+ export const adapters = { [claude.id]: claude };
26
+
27
+ const detected = new Map();
28
+
29
+ export async function listAgents() {
30
+ return Promise.all(
31
+ Object.values(adapters).map(async (a) => {
32
+ if (!detected.has(a.id)) detected.set(a.id, a.detect());
33
+ return { id: a.id, name: a.name, models: a.models, ...(await detected.get(a.id)) };
34
+ }),
35
+ );
36
+ }
@@ -0,0 +1,364 @@
1
+ // HTTP API + SSE + static web UI. No dependencies.
2
+
3
+ import http from 'node:http';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { Store } from './store.js';
10
+ import { Runner, summarize, newId } from './runner.js';
11
+ import { adapters, listAgents } from './adapters/index.js';
12
+ import { welcomeSession } from './welcome.js';
13
+ import { openInTerminal, TERMINALS, shq } from './terminal.js';
14
+
15
+ const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
16
+
17
+ const MIME = {
18
+ '.html': 'text/html; charset=utf-8',
19
+ '.js': 'text/javascript; charset=utf-8',
20
+ '.css': 'text/css; charset=utf-8',
21
+ '.svg': 'image/svg+xml',
22
+ '.png': 'image/png',
23
+ '.jpg': 'image/jpeg',
24
+ '.jpeg': 'image/jpeg',
25
+ '.gif': 'image/gif',
26
+ '.webp': 'image/webp',
27
+ '.ico': 'image/x-icon',
28
+ '.mp4': 'video/mp4',
29
+ '.webm': 'video/webm',
30
+ '.mov': 'video/quicktime',
31
+ '.pdf': 'application/pdf',
32
+ };
33
+ const MAX_UPLOAD = 500 * 1024 * 1024;
34
+ const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
35
+
36
+ export async function startServer({ port = 4477, host = '127.0.0.1', handleSignals = true } = {}) {
37
+ const store = new Store();
38
+ const token = randomBytes(16).toString('hex');
39
+ const runner = new Runner(store, { url: `http://127.0.0.1:${port}`, token });
40
+ const uploadsDir = path.join(store.home, 'uploads');
41
+ fs.mkdirSync(uploadsDir, { recursive: true });
42
+ const uploads = new Map(); // id -> { id, name, mime, size, path }
43
+ for (const f of fs.readdirSync(uploadsDir)) {
44
+ if (!f.endsWith('.json')) continue;
45
+ try {
46
+ const u = JSON.parse(fs.readFileSync(path.join(uploadsDir, f), 'utf8'));
47
+ uploads.set(u.id, u);
48
+ } catch {}
49
+ }
50
+ const resolveAttachments = (ids) => (Array.isArray(ids) ? ids.map((i) => uploads.get(i)).filter(Boolean) : []);
51
+
52
+ if (store.all().length === 0) store.put(welcomeSession());
53
+
54
+ const clients = new Set();
55
+ const broadcast = (event, data) => {
56
+ const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
57
+ for (const res of clients) res.write(frame);
58
+ };
59
+ runner.on('summary', (s) => broadcast('summary', s));
60
+ runner.on('activity', (a) => broadcast('activity', a));
61
+ runner.on('turn', (t) => broadcast('turn', t));
62
+ runner.on('finished', (f) => broadcast('finished', f));
63
+ setInterval(() => broadcast('ping', {}), 25_000).unref();
64
+
65
+ const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
66
+
67
+ const server = http.createServer(async (req, res) => {
68
+ // Agents can run with broad permissions, so only same-origin local pages may talk to us.
69
+ if (!allowedHosts.has(req.headers.host)) return send(res, 403, { error: 'forbidden host' });
70
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
71
+ const origin = req.headers.origin;
72
+ if (origin && !allowedHosts.has(origin.replace(/^https?:\/\//, ''))) return send(res, 403, { error: 'forbidden origin' });
73
+ const ct = String(req.headers['content-type'] || '');
74
+ const isUpload = req.url.startsWith('/api/uploads') && ct.startsWith('application/octet-stream');
75
+ if (!ct.startsWith('application/json') && !isUpload) return send(res, 415, { error: 'json only' });
76
+ }
77
+
78
+ const url = new URL(req.url, `http://${req.headers.host}`);
79
+ try {
80
+ if (url.pathname.startsWith('/api/')) return await api(req, res, url);
81
+ return serveStatic(res, url.pathname);
82
+ } catch (err) {
83
+ console.error(err);
84
+ send(res, err.status || 500, { error: err.message });
85
+ }
86
+ });
87
+
88
+ async function api(req, res, url) {
89
+ const parts = url.pathname.split('/').filter(Boolean).slice(1); // drop "api"
90
+ const [root, id, action] = parts;
91
+ const m = req.method;
92
+
93
+ if (root === 'events' && m === 'GET') {
94
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
95
+ res.write(`event: hello\ndata: {}\n\n`);
96
+ clients.add(res);
97
+ req.on('close', () => clients.delete(res));
98
+ return;
99
+ }
100
+
101
+ if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents());
102
+
103
+ // Bridge for the ask_user MCP server running inside an agent process.
104
+ if (root === 'internal') {
105
+ if (req.headers['x-wisp-token'] !== token) return send(res, 403, { error: 'bad token' });
106
+ if (id === 'ask' && !action && m === 'POST') {
107
+ const body = await readJson(req);
108
+ const s = store.get(body.session);
109
+ if (!s) return send(res, 404, { error: 'no such session' });
110
+ return send(res, 200, { id: runner.ask(s, body) });
111
+ }
112
+ if (id === 'ask' && action && m === 'GET') return send(res, 200, runner.pollAnswer(action));
113
+ }
114
+
115
+ // Attachments: raw body streamed to disk, referenced by id from launches and replies.
116
+ if (root === 'uploads') {
117
+ if (!id && m === 'POST') {
118
+ const name = path.basename(url.searchParams.get('name') || 'file').replace(/[^\w.\- ]+/g, '_').slice(-120) || 'file';
119
+ const uid = newId();
120
+ const file = path.join(uploadsDir, `${uid}-${name}`);
121
+ let size = 0;
122
+ await new Promise((resolve, reject) => {
123
+ const out = fs.createWriteStream(file);
124
+ req.on('data', (c) => {
125
+ size += c.length;
126
+ if (size > MAX_UPLOAD) {
127
+ req.destroy();
128
+ out.destroy();
129
+ fs.rmSync(file, { force: true });
130
+ reject(Object.assign(new Error('file too large (500 MB max)'), { status: 413 }));
131
+ }
132
+ });
133
+ req.pipe(out);
134
+ out.on('finish', resolve);
135
+ out.on('error', reject);
136
+ });
137
+ const meta = { id: uid, name, size, mime: url.searchParams.get('type') || MIME[path.extname(name).toLowerCase()] || 'application/octet-stream', path: file };
138
+ uploads.set(uid, meta);
139
+ fs.writeFileSync(path.join(uploadsDir, `${uid}.json`), JSON.stringify(meta));
140
+ return send(res, 201, meta);
141
+ }
142
+ if (id && m === 'GET') {
143
+ const u = uploads.get(id);
144
+ if (!u || !fs.existsSync(u.path)) return send(res, 404, { error: 'not found' });
145
+ res.writeHead(200, { 'content-type': u.mime, 'content-length': fs.statSync(u.path).size });
146
+ return fs.createReadStream(u.path).pipe(res);
147
+ }
148
+ }
149
+
150
+ if (root === 'config') {
151
+ if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS });
152
+ if (m === 'PATCH') {
153
+ const body = await readJson(req);
154
+ if (body.defaults) Object.assign(store.config.defaults, body.defaults);
155
+ store.saveConfig();
156
+ broadcast('config', {});
157
+ return send(res, 200, store.config);
158
+ }
159
+ }
160
+
161
+ if (root === 'projects') {
162
+ if (!id && m === 'GET') return send(res, 200, store.config.projects);
163
+ if (!id && m === 'POST') {
164
+ const body = await readJson(req);
165
+ const dir = path.resolve(String(body.path || '').replace(/^~(?=$|\/)/, os.homedir()));
166
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return send(res, 400, { error: `Not a folder: ${dir}` });
167
+ const p = store.addProject(dir, { name: body.name });
168
+ broadcast('config', {});
169
+ return send(res, 201, p);
170
+ }
171
+ // Find git repos in the usual places so adding projects is one click.
172
+ if (id === 'discover' && m === 'GET') {
173
+ const known = new Set(store.config.projects.map((p) => p.path));
174
+ const roots = ['code', 'Code', 'dev', 'Developer', 'projects', 'Projects', 'src', 'repos', 'work', 'git', 'GitHub', 'Documents', 'Downloads', 'Desktop'].map((d) => path.join(os.homedir(), d));
175
+ const found = [];
176
+ const walk = (dir, depth) => {
177
+ if (found.length >= 60) return;
178
+ let entries;
179
+ try {
180
+ entries = fs.readdirSync(dir, { withFileTypes: true });
181
+ } catch {
182
+ return;
183
+ }
184
+ if (entries.some((e) => e.name === '.git')) {
185
+ if (!known.has(dir)) found.push({ path: dir, name: path.basename(dir), mtime: fs.statSync(dir).mtimeMs });
186
+ return;
187
+ }
188
+ if (depth <= 0) return;
189
+ for (const e of entries) if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') walk(path.join(dir, e.name), depth - 1);
190
+ };
191
+ for (const r of roots) walk(r, 2);
192
+ return send(res, 200, found.sort((a, b) => b.mtime - a.mtime).slice(0, 24));
193
+ }
194
+ const p = store.config.projects.find((x) => x.id === id);
195
+ if (!p) return send(res, 404, { error: 'no such project' });
196
+ if (m === 'PATCH') {
197
+ const body = await readJson(req);
198
+ if (typeof body.name === 'string' && body.name.trim()) p.name = body.name.trim();
199
+ if (typeof body.color === 'string') p.color = body.color;
200
+ store.saveConfig();
201
+ broadcast('config', {});
202
+ return send(res, 200, p);
203
+ }
204
+ if (m === 'DELETE') {
205
+ store.config.projects = store.config.projects.filter((x) => x.id !== id);
206
+ store.saveConfig();
207
+ broadcast('config', {});
208
+ return send(res, 200, { ok: true });
209
+ }
210
+ }
211
+
212
+ // Directory suggestions for the launcher's project picker.
213
+ if (root === 'fs' && m === 'GET') {
214
+ const raw = (url.searchParams.get('q') || '').replace(/^~(?=$|\/)/, os.homedir());
215
+ const dir = raw.endsWith('/') ? raw : path.dirname(raw);
216
+ const prefix = raw.endsWith('/') ? '' : path.basename(raw).toLowerCase();
217
+ let entries = [];
218
+ try {
219
+ entries = fs
220
+ .readdirSync(dir, { withFileTypes: true })
221
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name.toLowerCase().startsWith(prefix))
222
+ .slice(0, 12)
223
+ .map((e) => path.join(dir, e.name));
224
+ } catch {}
225
+ return send(res, 200, entries);
226
+ }
227
+
228
+ if (root === 'sessions') {
229
+ if (!id) {
230
+ if (m === 'GET') return send(res, 200, store.all().map((x) => summarize(x, store)).sort((a, b) => b.updatedAt - a.updatedAt));
231
+ if (m === 'POST') {
232
+ const body = await readJson(req);
233
+ if (!body.prompt?.trim()) return send(res, 400, { error: 'prompt required' });
234
+ const cwd = path.resolve((body.cwd || process.cwd()).replace(/^~(?=$|\/)/, os.homedir()));
235
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) return send(res, 400, { error: `Not a directory: ${cwd}` });
236
+ if (!adapters[body.agent || 'claude']) return send(res, 400, { error: 'unknown agent' });
237
+ const s = runner.create({ prompt: body.prompt, cwd, agent: body.agent, mode: body.mode, model: body.model, attachments: resolveAttachments(body.attachments) });
238
+ return send(res, 201, summarize(s, store));
239
+ }
240
+ }
241
+
242
+ const s = store.get(id);
243
+ if (!s) return send(res, 404, { error: 'no such session' });
244
+
245
+ if (!action) {
246
+ if (m === 'GET') return send(res, 200, s);
247
+ if (m === 'PATCH') {
248
+ const body = await readJson(req);
249
+ if (typeof body.title === 'string' && body.title.trim()) Object.assign(s, { title: body.title.trim(), titleAuto: false });
250
+ if (body.state === 'done' && s.state !== 'running') s.state = 'done';
251
+ if (body.state === 'review' && s.state === 'done') s.state = 'review';
252
+ if (typeof body.unread === 'boolean') s.unread = body.unread;
253
+ store.save(s);
254
+ broadcast('summary', summarize(s, store));
255
+ return send(res, 200, summarize(s, store));
256
+ }
257
+ if (m === 'DELETE') {
258
+ runner.stop(s);
259
+ store.delete(id);
260
+ broadcast('removed', { id });
261
+ return send(res, 200, { ok: true });
262
+ }
263
+ }
264
+
265
+ if (action === 'reply' && m === 'POST') {
266
+ const { text, attachments } = await readJson(req);
267
+ if (!text?.trim()) return send(res, 400, { error: 'text required' });
268
+ if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to follow up.' });
269
+ return send(res, 200, runner.reply(s, text.trim(), resolveAttachments(attachments)));
270
+ }
271
+
272
+ if (action === 'answer' && m === 'POST') {
273
+ const { text } = await readJson(req);
274
+ if (!text?.trim()) return send(res, 400, { error: 'text required' });
275
+ return send(res, 200, { ok: runner.answer(s, text.trim()) });
276
+ }
277
+
278
+ // Take over: continue this exact agent session interactively in a terminal.
279
+ if (action === 'takeover') {
280
+ const adapter = adapters[s.agent];
281
+ if (!adapter?.resumeCommand || !s.agentSessionId) return send(res, 400, { error: 'This session cannot be resumed in a terminal.' });
282
+ const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model });
283
+ const command = `cd ${shq(s.cwd)} && ${argv.map(shq).join(' ')}`;
284
+ if (m === 'GET') return send(res, 200, { command });
285
+ if (m === 'POST') {
286
+ if (runner.live.has(s.id)) {
287
+ runner.stop(s);
288
+ await runner.live.get(s.id)?.done;
289
+ }
290
+ openInTerminal({ cwd: s.cwd, argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
291
+ s.takenOverAt = Date.now();
292
+ store.save(s);
293
+ return send(res, 200, { command });
294
+ }
295
+ }
296
+
297
+ if (action === 'stop' && m === 'POST') {
298
+ runner.stop(s);
299
+ return send(res, 200, { ok: true });
300
+ }
301
+
302
+ // Images the agent pointed at (screenshots etc.), confined to the session's project.
303
+ if (action === 'file' && m === 'GET') {
304
+ const p = path.resolve(s.cwd, url.searchParams.get('path') || '');
305
+ const ext = path.extname(p).toLowerCase();
306
+ if (!p.startsWith(s.cwd + path.sep) || !IMAGE_EXT.has(ext) || !fs.existsSync(p)) return send(res, 404, { error: 'not found' });
307
+ res.writeHead(200, { 'content-type': MIME[ext] });
308
+ return fs.createReadStream(p).pipe(res);
309
+ }
310
+ }
311
+
312
+ send(res, 404, { error: 'not found' });
313
+ }
314
+
315
+ function serveStatic(res, pathname) {
316
+ const p = path.normalize(path.join(WEB, pathname === '/' ? 'index.html' : pathname));
317
+ if (!p.startsWith(WEB) || !fs.existsSync(p) || fs.statSync(p).isDirectory()) {
318
+ // SPA fallback
319
+ res.writeHead(200, { 'content-type': MIME['.html'] });
320
+ return fs.createReadStream(path.join(WEB, 'index.html')).pipe(res);
321
+ }
322
+ res.writeHead(200, { 'content-type': MIME[path.extname(p)] || 'application/octet-stream', 'cache-control': 'no-cache' });
323
+ fs.createReadStream(p).pipe(res);
324
+ }
325
+
326
+ const shutdown = () => {
327
+ for (const s of store.all()) runner.stop(s);
328
+ store.flush();
329
+ };
330
+ if (handleSignals) {
331
+ const exit = () => (shutdown(), process.exit(0));
332
+ process.once('SIGINT', exit);
333
+ process.once('SIGTERM', exit);
334
+ }
335
+
336
+ await new Promise((resolve, reject) => {
337
+ server.once('error', reject);
338
+ server.listen(port, host, resolve);
339
+ });
340
+
341
+ return { url: `http://localhost:${port}`, server, store, runner, shutdown };
342
+ }
343
+
344
+ function send(res, status, body) {
345
+ res.writeHead(status, { 'content-type': 'application/json' });
346
+ res.end(JSON.stringify(body));
347
+ }
348
+
349
+ function readJson(req) {
350
+ return new Promise((resolve, reject) => {
351
+ let data = '';
352
+ req.on('data', (c) => {
353
+ data += c;
354
+ if (data.length > 2e6) reject(Object.assign(new Error('body too large'), { status: 413 }));
355
+ });
356
+ req.on('end', () => {
357
+ try {
358
+ resolve(data ? JSON.parse(data) : {});
359
+ } catch {
360
+ reject(Object.assign(new Error('invalid json'), { status: 400 }));
361
+ }
362
+ });
363
+ });
364
+ }