wisp-agents 0.4.0 → 0.6.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,155 @@
1
+ // Antigravity CLI adapter — drives `agy --input-format stream-json --output-format stream-json`
2
+ // (headless mode: one init event, step_update events, one result event per turn).
3
+ //
4
+ // agy has no flag to inject an MCP server (it reads ~/.gemini/config/mcp_config.json and
5
+ // .agents/mcp_config.json), so wisp's ask_user tool isn't available here; the protocol tells
6
+ // the agent to end with a question block. Same for the system prompt: it goes in front of the
7
+ // first message.
8
+
9
+ import path from 'node:path';
10
+ import { attachmentNote, clip, detectVersion, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
11
+
12
+ const BIN = process.env.WISP_ANTIGRAVITY_BIN || 'agy';
13
+
14
+ // Headless runs give up after --print-timeout (default 5m); wisp tasks routinely take longer.
15
+ const PRINT_TIMEOUT = '24h';
16
+
17
+ // `--mode` sets the execution mode; shell commands are governed by permissions, which only
18
+ // --dangerously-skip-permissions lifts in headless mode.
19
+ const MODE_ARGS = {
20
+ plan: ['--mode', 'plan'],
21
+ edits: ['--mode', 'accept-edits'],
22
+ auto: ['--dangerously-skip-permissions'],
23
+ };
24
+
25
+ const EDIT_TOOL = /write|replace|edit|delete/i;
26
+ const EFFORT_SUFFIX = /-(low|medium|high)$/;
27
+
28
+ export default {
29
+ id: 'antigravity',
30
+ name: 'Antigravity',
31
+ askTool: null,
32
+ models: [{ id: '', label: 'Default', hint: 'Whatever Antigravity is set to' }],
33
+ efforts: ['low', 'medium', 'high'],
34
+
35
+ resumeCommand({ resumeId, model, effort }) {
36
+ return [BIN, ...(resumeId ? ['--conversation', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : [])];
37
+ },
38
+
39
+ async detect() {
40
+ const found = await detectVersion(BIN);
41
+ if (!found.available) return found;
42
+ // `agy models` prints a "Fetching…" line, then "slug<TAB>Label" lines (older builds pad with
43
+ // spaces). Most slugs bake the effort in (…-high), so those get no separate selector.
44
+ const models = [];
45
+ for (const line of (await quietOutput(BIN, ['models'])).split('\n')) {
46
+ const m = line.trim().match(/^([\w.\-]+)(?:\t|\s{2,})\s*(.+)$/);
47
+ if (m) models.push({ id: m[1], label: m[2].trim(), hint: m[1], ...(EFFORT_SUFFIX.test(m[1]) ? { efforts: [] } : {}) });
48
+ }
49
+ return { ...found, models: [this.models[0], ...models] };
50
+ },
51
+
52
+ run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, onEvent }) {
53
+ // The prompt goes on stdin as a single `user` event; closing stdin ends the session once
54
+ // that turn's result is out.
55
+ const args = ['--input-format', 'stream-json', '--output-format', 'stream-json', '--print-timeout', PRINT_TIMEOUT];
56
+ args.push(...(MODE_ARGS[mode] || MODE_ARGS.edits));
57
+ if (model) args.push('--model', model);
58
+ if (effort) args.push('--effort', effort);
59
+ if (resumeId) args.push('--conversation', resumeId);
60
+ for (const d of new Set(attachments.map((a) => path.dirname(a.path)))) args.push('--add-dir', d);
61
+
62
+ const content = withProtocol(prompt, systemPrompt, resumeId) + attachmentNote(attachments);
63
+ const input = JSON.stringify({ event: 'user', message: { content } }) + '\n';
64
+ const files = fileTracker(cwd);
65
+ const text = new Map(); // step_index -> text_delta so far
66
+ const shown = new Set(); // tool steps already announced
67
+ let sawResult = false;
68
+ let session = null;
69
+ const sessionFrom = (id) => {
70
+ if (id && id !== session) onEvent({ type: 'session', id: (session = id) });
71
+ };
72
+
73
+ return spawnJsonl({
74
+ bin: BIN,
75
+ args,
76
+ cwd,
77
+ env: process.env,
78
+ input,
79
+ onEvent,
80
+ onJson(msg) {
81
+ if (msg.event === 'init') sessionFrom(msg.conversation_id);
82
+ else if (msg.event === 'step_update') {
83
+ const s = msg.step_update || {};
84
+ sessionFrom(s.conversation_id);
85
+ const done = s.state === 'DONE';
86
+ if (s.step_type === 'agent_response') {
87
+ const t = (text.get(s.step_index) || '') + (s.text_delta || '');
88
+ text.set(s.step_index, t);
89
+ if (done && t.trim()) onEvent({ type: 'text', text: t });
90
+ } else if (s.subagent_info && !shown.has(s.step_index)) {
91
+ shown.add(s.step_index);
92
+ const sub = s.subagent_info.subagents?.[0];
93
+ onEvent({ type: 'tool', kind: 'agent', label: `Delegating${sub ? ` to ${sub.role || sub.type_name}` : ''}` });
94
+ } else if (s.step_type === 'tool') {
95
+ const info = s.tool_info || {};
96
+ const name = s.tool_name || info.name;
97
+ const params = info.parameters || {};
98
+ if (!name) return;
99
+ const file = fileOf(params);
100
+ const editing = file && EDIT_TOOL.test(name);
101
+ // Announce once we know the arguments: on ACTIVE if it carries them, else on DONE.
102
+ if (!shown.has(s.step_index) && (done || Object.keys(params).length)) {
103
+ shown.add(s.step_index);
104
+ if (editing && !done) files.remember(file);
105
+ onEvent({ type: 'tool', ...describeTool(name, params, cwd) });
106
+ }
107
+ if (!done) return;
108
+ if (info.error) onEvent({ type: 'tool_error', name, text: clip(info.error.message || info.error.type || 'failed', 400) });
109
+ else if (editing) {
110
+ const change = files.change(file);
111
+ if (change) onEvent({ type: 'change', ...change });
112
+ }
113
+ }
114
+ } else if (msg.event === 'result') {
115
+ const r = msg.result || {};
116
+ sessionFrom(r.conversation_id);
117
+ sawResult = true;
118
+ onEvent({
119
+ type: 'result',
120
+ text: r.status === 'SUCCESS' ? r.response ?? '' : r.error || r.response || `Antigravity ended with status ${r.status}`,
121
+ isError: r.status !== 'SUCCESS',
122
+ stats: { durationMs: r.duration_seconds != null ? Math.round(r.duration_seconds * 1000) : undefined, inputTokens: r.usage?.input_tokens, outputTokens: r.usage?.output_tokens },
123
+ });
124
+ }
125
+ },
126
+ onClose({ code, signal, stderr, stopped }) {
127
+ if (sawResult) return;
128
+ onEvent({ type: 'result', isError: true, stopped, text: stderr || `agy exited with ${signal || `code ${code}`}` });
129
+ },
130
+ });
131
+ },
132
+ };
133
+
134
+ // Tool parameters are PascalCase (TargetFile, AbsolutePath, CommandLine, …).
135
+ const pick = (p, ...keys) => {
136
+ for (const k of keys) if (p[k] != null && p[k] !== '') return p[k];
137
+ };
138
+ const fileOf = (p) => pick(p, 'TargetFile', 'AbsolutePath', 'FilePath', 'File', 'Path', 'path', 'file_path');
139
+
140
+ function describeTool(name, p, cwd) {
141
+ const f = rel(fileOf(p), cwd);
142
+ const n = name.toLowerCase();
143
+ if (n === 'run_command') return { kind: 'run', label: `Running ${clip(p.CommandLine, 60)}`, detail: p.CommandLine };
144
+ if (EDIT_TOOL.test(n) && f) return { kind: 'edit', label: `${/delete/.test(n) ? 'Deleting' : 'Editing'} ${f}`, file: f };
145
+ if (/^view_/.test(n) && f) return { kind: 'read', label: `Reading ${f}`, file: f };
146
+ if (n === 'list_dir') return { kind: 'search', label: `Listing ${rel(p.DirectoryPath, cwd) || '.'}` };
147
+ if (n === 'find_by_name') return { kind: 'search', label: `Finding ${clip(p.Pattern, 50)}` };
148
+ if (/grep/.test(n)) return { kind: 'search', label: `Searching for “${clip(p.Query || p.Pattern, 40)}”` };
149
+ if (/code_?search|codebase/.test(n)) return { kind: 'search', label: `Searching the codebase: ${clip(p.Query || p.query, 50)}` };
150
+ if (/search_web|web_search/.test(n)) return { kind: 'web', label: `Searching the web: ${clip(p.query || p.Query, 50)}` };
151
+ if (/url|fetch/.test(n)) return { kind: 'web', label: `Reading ${clip(p.Url || p.url, 60)}` };
152
+ if (/browser/.test(n)) return { kind: 'web', label: `Browsing: ${clip(p.Task || p.TaskName || 'browser', 60)}` };
153
+ if (/task_boundary|todo/.test(n)) return { kind: 'plan', label: clip(p.TaskStatus || p.TaskName || 'Planning') };
154
+ return { kind: 'tool', label: `Using ${n.replace(/_/g, ' ')}` };
155
+ }
@@ -28,8 +28,9 @@ import claude from './claude.js';
28
28
  import codex from './codex.js';
29
29
  import opencode from './opencode.js';
30
30
  import cursor from './cursor.js';
31
+ import antigravity from './antigravity.js';
31
32
 
32
- export const adapters = Object.fromEntries([claude, codex, opencode, cursor].map((a) => [a.id, a]));
33
+ export const adapters = Object.fromEntries([claude, codex, opencode, cursor, antigravity].map((a) => [a.id, a]));
33
34
 
34
35
  const detected = new Map();
35
36
 
@@ -37,7 +38,10 @@ export async function listAgents() {
37
38
  return Promise.all(
38
39
  Object.values(adapters).map(async (a) => {
39
40
  if (!detected.has(a.id)) detected.set(a.id, a.detect());
40
- return { id: a.id, name: a.name, models: a.models, efforts: a.efforts || [], ...(await detected.get(a.id)) };
41
+ const found = await detected.get(a.id);
42
+ // Only a hit is cached: a CLI installed while wisp is running shows up on the next call.
43
+ if (!found.available) detected.delete(a.id);
44
+ return { id: a.id, name: a.name, models: a.models, efforts: a.efforts || [], ...found };
41
45
  }),
42
46
  );
43
47
  }
@@ -0,0 +1,177 @@
1
+ // wisp client SDK: everything the web UI can do, from Node (or anything with fetch).
2
+ // Zero dependencies. Used by the `wisp` CLI and the `wisp mcp` server; importable as
3
+ // `wisp-agents/client`.
4
+ //
5
+ // import { WispClient } from 'wisp-agents/client';
6
+ // const wisp = await WispClient.connect(); // finds the running wisp, or starts one
7
+ // const s = await wisp.launch({ prompt: 'fix the flaky auth test', cwd: process.cwd() });
8
+ // const done = await wisp.wait(s.id); // until it finishes or asks you something
9
+ // console.log((await wisp.presentation(s.id)).markdown);
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ export const DEFAULT_PORT = 4477;
18
+ const BIN = fileURLToPath(new URL('../bin/wisp.js', import.meta.url));
19
+ const MAX_WAIT_CALL = 280; // seconds per long-poll; fetch gives up on headers after 300s
20
+
21
+ export const wispHome = () => process.env.WISP_HOME || path.join(os.homedir(), '.wisp');
22
+
23
+ // The running server advertises itself in ~/.wisp/server.json. Stale files (dead pid) are ignored.
24
+ export function discover(home = wispHome()) {
25
+ try {
26
+ const info = JSON.parse(fs.readFileSync(path.join(home, 'server.json'), 'utf8'));
27
+ if (info.pid) process.kill(info.pid, 0);
28
+ return info;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export class WispError extends Error {
35
+ constructor(message, status, body) {
36
+ super(message);
37
+ this.name = 'WispError';
38
+ this.status = status;
39
+ this.body = body;
40
+ }
41
+ }
42
+
43
+ export class WispClient {
44
+ // url: e.g. http://127.0.0.1:4477. Defaults to WISP_PORT, then server.json, then 4477.
45
+ constructor({ url, port } = {}) {
46
+ const p = port || Number(process.env.WISP_PORT) || discover()?.port || DEFAULT_PORT;
47
+ this.url = (url || `http://127.0.0.1:${p}`).replace(/\/$/, '');
48
+ }
49
+
50
+ // Connect to the running wisp; with start (default), launch a headless one if none answers.
51
+ static async connect({ url, port, start = true, timeoutMs = 15_000 } = {}) {
52
+ const client = new WispClient({ url, port });
53
+ if (await client.ping()) return client;
54
+ if (!start) throw new WispError(`wisp is not running at ${client.url}`, 0);
55
+ const child = spawn(process.execPath, [BIN, '--no-open', '--port', new URL(client.url).port || String(DEFAULT_PORT)], {
56
+ detached: true,
57
+ stdio: 'ignore',
58
+ windowsHide: true,
59
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
60
+ });
61
+ child.unref();
62
+ const end = Date.now() + timeoutMs;
63
+ while (Date.now() < end) {
64
+ if (await client.ping()) return client;
65
+ await new Promise((r) => setTimeout(r, 150));
66
+ }
67
+ throw new WispError(`wisp did not start at ${client.url}`, 0);
68
+ }
69
+
70
+ async request(method, p, body, { raw = false, signal } = {}) {
71
+ const isBuf = body instanceof Uint8Array || body instanceof ArrayBuffer;
72
+ const res = await fetch(`${this.url}/api${p}`, {
73
+ method,
74
+ signal,
75
+ headers: body === undefined ? {} : { 'content-type': isBuf ? 'application/octet-stream' : 'application/json' },
76
+ body: body === undefined ? undefined : isBuf ? body : JSON.stringify(body),
77
+ });
78
+ if (raw && res.ok) return res;
79
+ const text = await res.text();
80
+ let json;
81
+ try {
82
+ json = text ? JSON.parse(text) : {};
83
+ } catch {
84
+ json = { text };
85
+ }
86
+ if (!res.ok) throw new WispError(json.error || res.statusText, res.status, json);
87
+ return json;
88
+ }
89
+
90
+ async ping() {
91
+ try {
92
+ return (await this.health()).ok === true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ health = () => this.request('GET', '/health');
99
+ agents = () => this.request('GET', '/agents');
100
+ config = () => this.request('GET', '/config');
101
+ updateConfig = (patch) => this.request('PATCH', '/config', patch);
102
+
103
+ projects = () => this.request('GET', '/projects');
104
+ addProject = (dir, { name } = {}) => this.request('POST', '/projects', { path: path.resolve(dir), name });
105
+ removeProject = (id) => this.request('DELETE', `/projects/${id}`);
106
+
107
+ // filter: { state, cwd, project, archived, limit }
108
+ sessions(filter = {}) {
109
+ const q = new URLSearchParams(Object.entries(filter).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => [k, String(v)]));
110
+ return this.request('GET', `/sessions${q.size ? `?${q}` : ''}`);
111
+ }
112
+ session = (id) => this.request('GET', `/sessions/${id}`);
113
+ presentation = (id, { turn } = {}) => this.request('GET', `/sessions/${id}/presentation${turn !== undefined ? `?turn=${encodeURIComponent(turn)}` : ''}`);
114
+ update = (id, patch) => this.request('PATCH', `/sessions/${id}`, patch); // { title, state, unread, archived }
115
+ remove = (id) => this.request('DELETE', `/sessions/${id}`);
116
+ stop = (id) => this.request('POST', `/sessions/${id}/stop`, {});
117
+ answer = (id, text) => this.request('POST', `/sessions/${id}/answer`, { text });
118
+ takeover = (id, { open = false } = {}) => this.request(open ? 'POST' : 'GET', `/sessions/${id}/takeover`, open ? {} : undefined);
119
+
120
+ // { prompt, cwd, agent, mode, model, effort, attachments: [uploadId] }. Missing agent/mode/model
121
+ // fall back to the launcher's remembered defaults, like `wisp "…"` does; effort only if given.
122
+ async launch({ prompt, cwd = process.cwd(), agent, mode, model, effort, attachments } = {}) {
123
+ if (!agent || !mode || model === undefined) {
124
+ const { defaults = {} } = await this.config();
125
+ agent ||= defaults.agent || 'claude';
126
+ mode ||= defaults.mode || 'edits';
127
+ model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
128
+ }
129
+ return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), agent, mode, model, effort, attachments });
130
+ }
131
+
132
+ // A follow-up. If the agent is blocked on ask_user, this answers it. queue: wait for the
133
+ // current run instead of interrupting it (default: the user's setting).
134
+ reply = (id, text, { attachments, queue } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue });
135
+
136
+ // Upload a file (path, Buffer or Uint8Array) to attach to a launch or reply. Returns { id, … }.
137
+ async upload(file, { name, type } = {}) {
138
+ const data = typeof file === 'string' ? fs.readFileSync(file) : file;
139
+ name ||= typeof file === 'string' ? path.basename(file) : 'file';
140
+ const q = new URLSearchParams({ name, ...(type ? { type } : {}) });
141
+ return this.request('POST', `/uploads?${q}`, data);
142
+ }
143
+
144
+ // Resolve once the session needs you: finished (review/done) or waiting on a question.
145
+ // Returns the session summary plus { timedOut } when timeoutMs runs out first.
146
+ async wait(id, { timeoutMs = 30 * 60_000, signal } = {}) {
147
+ const end = Date.now() + timeoutMs;
148
+ for (;;) {
149
+ const left = Math.max(0, Math.ceil((end - Date.now()) / 1000));
150
+ const s = await this.request('GET', `/sessions/${id}/wait?timeout=${Math.min(left, MAX_WAIT_CALL)}`, undefined, { signal });
151
+ if (!s.timedOut || left <= MAX_WAIT_CALL) return s;
152
+ }
153
+ }
154
+
155
+ // Live server events (the same stream the UI uses), as an async iterator of { event, data }.
156
+ // Events: summary, activity, turn, finished, removed, config, ping.
157
+ async *events({ signal } = {}) {
158
+ const res = await this.request('GET', '/events', undefined, { raw: true, signal });
159
+ const decoder = new TextDecoder();
160
+ let buf = '';
161
+ for await (const chunk of res.body) {
162
+ buf += decoder.decode(chunk, { stream: true });
163
+ let at;
164
+ while ((at = buf.indexOf('\n\n')) !== -1) {
165
+ const frame = buf.slice(0, at);
166
+ buf = buf.slice(at + 2);
167
+ const event = /^event: (.*)$/m.exec(frame)?.[1] || 'message';
168
+ const data = frame.split('\n').filter((l) => l.startsWith('data: ')).map((l) => l.slice(6)).join('\n');
169
+ try {
170
+ yield { event, data: data ? JSON.parse(data) : null };
171
+ } catch {
172
+ yield { event, data };
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
package/server/index.js CHANGED
@@ -11,6 +11,9 @@ import { Runner, summarize, newId } 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
+ import { Telemetry, envDisabled } from './telemetry.js';
15
+ import { blocksToMarkdown, sessionToMarkdown } from './render.js';
16
+ import { openapi } from './openapi.js';
14
17
 
15
18
  const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
16
19
 
@@ -31,6 +34,13 @@ const MIME = {
31
34
  '.pdf': 'application/pdf',
32
35
  };
33
36
  const MAX_UPLOAD = 500 * 1024 * 1024;
37
+ const MAX_WAIT_S = 290; // long-poll cap per request; clients loop (fetch drops headers after 300s)
38
+ const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
39
+ export const API_VERSION = 1;
40
+
41
+ // A session "needs you" once it stops running: finished, or blocked on ask_user. A finished run
42
+ // with follow-ups still queued is about to start the next one, so it doesn't count yet.
43
+ const settled = (s) => s.state === 'waiting' || (s.state !== 'running' && !s.queue?.length);
34
44
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
35
45
 
36
46
  export async function startServer({ port = 4477, host = '127.0.0.1', handleSignals = true } = {}) {
@@ -60,8 +70,32 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
60
70
  runner.on('activity', (a) => broadcast('activity', a));
61
71
  runner.on('turn', (t) => broadcast('turn', t));
62
72
  runner.on('finished', (f) => broadcast('finished', f));
73
+ runner.on('finished', ({ session }) => notifyWebhooks(store, session));
63
74
  setInterval(() => broadcast('ping', {}), 25_000).unref();
64
75
 
76
+ const telemetry = new Telemetry(store);
77
+ 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
+ runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...run(session, turn), attachments: turn.attachments.length }));
79
+ runner.on('ended', ({ session, turn, interrupted, errorText }) => {
80
+ const blocks = turn.presentation?.blocks || [];
81
+ const st = turn.stats || {};
82
+ telemetry.track('turn_ended', {
83
+ ...run(session, turn),
84
+ status: interrupted ? 'interrupted' : turn.status, // done | error | stopped | interrupted
85
+ heroStatus: blocks.find((b) => b.type === 'hero')?.status,
86
+ durationMs: turn.endedAt - turn.startedAt,
87
+ blockCount: blocks.length,
88
+ blockTypes: [...new Set(blocks.map((b) => b.type))],
89
+ presentationFallback: !!turn.presentation?.fallback, // agent ignored or broke the wisp protocol
90
+ changedFiles: turn.changes.length,
91
+ inputTokens: st.inputTokens,
92
+ outputTokens: st.outputTokens,
93
+ costUsd: st.costUsd,
94
+ });
95
+ if (errorText) telemetry.captureError(new Error(errorText), { type: 'AgentError', ...run(session, turn) });
96
+ });
97
+ if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
98
+
65
99
  const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
66
100
 
67
101
  const server = http.createServer(async (req, res) => {
@@ -81,6 +115,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
81
115
  return serveStatic(res, url.pathname);
82
116
  } catch (err) {
83
117
  console.error(err);
118
+ if (!err.status || err.status >= 500) telemetry.captureError(err, { route: `${req.method} ${url.pathname.replace(/\/[a-f0-9]{8,}(?=\/|$)/g, '/:id')}` });
84
119
  send(res, err.status || 500, { error: err.message });
85
120
  }
86
121
  });
@@ -98,6 +133,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
98
133
  return;
99
134
  }
100
135
 
136
+ 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}` });
137
+ if (root === 'openapi.json' && m === 'GET') return send(res, 200, openapi({ version: VERSION, port }));
138
+
101
139
  if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents());
102
140
 
103
141
  // Bridge for the ask_user MCP server running inside an agent process.
@@ -107,6 +145,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
107
145
  const body = await readJson(req);
108
146
  const s = store.get(body.session);
109
147
  if (!s) return send(res, 404, { error: 'no such session' });
148
+ telemetry.track('ask_user', { agent: s.agent });
110
149
  return send(res, 200, { id: runner.ask(s, body) });
111
150
  }
112
151
  if (id === 'ask' && action && m === 'GET') return send(res, 200, runner.pollAnswer(action));
@@ -148,10 +187,16 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
148
187
  }
149
188
 
150
189
  if (root === 'config') {
151
- if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS });
190
+ if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled } });
152
191
  if (m === 'PATCH') {
153
192
  const body = await readJson(req);
154
193
  if (body.defaults) Object.assign(store.config.defaults, body.defaults);
194
+ if (body.webhooks !== undefined) {
195
+ const hooks = Array.isArray(body.webhooks) ? body.webhooks : [];
196
+ const bad = hooks.find((h) => !/^https?:\/\/\S+$/.test(String(h?.url ?? h)));
197
+ if (bad !== undefined) return send(res, 400, { error: `Not an http(s) URL: ${bad?.url ?? bad}` });
198
+ store.config.webhooks = hooks.map((h) => (typeof h === 'string' ? { url: h } : { url: h.url, events: Array.isArray(h.events) ? h.events : undefined }));
199
+ }
155
200
  store.saveConfig();
156
201
  broadcast('config', {});
157
202
  return send(res, 200, store.config);
@@ -228,7 +273,18 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
228
273
 
229
274
  if (root === 'sessions') {
230
275
  if (!id) {
231
- if (m === 'GET') return send(res, 200, store.all().map((x) => summarize(x, store)).sort((a, b) => b.updatedAt - a.updatedAt));
276
+ if (m === 'GET') {
277
+ const q = Object.fromEntries(url.searchParams);
278
+ const cwd = q.cwd && path.resolve(q.cwd.replace(/^~(?=$|[\\/])/, os.homedir()));
279
+ let list = store.all().map((x) => summarize(x, store)).sort((a, b) => b.updatedAt - a.updatedAt);
280
+ if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
281
+ if (q.agent) list = list.filter((x) => x.agent === q.agent);
282
+ if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
283
+ if (cwd) list = list.filter((x) => x.cwd === cwd || x.cwd?.startsWith(cwd + path.sep));
284
+ if (q.archived !== undefined) list = list.filter((x) => x.archived === (q.archived === 'true' || q.archived === '1'));
285
+ if (Number(q.limit) > 0) list = list.slice(0, Number(q.limit));
286
+ return send(res, 200, list);
287
+ }
232
288
  if (m === 'POST') {
233
289
  const body = await readJson(req);
234
290
  if (!body.prompt?.trim()) return send(res, 400, { error: 'prompt required' });
@@ -265,6 +321,45 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
265
321
  }
266
322
  }
267
323
 
324
+ // Long-poll until the session needs the user (finished, or asking). ?timeout=seconds.
325
+ if (action === 'wait' && m === 'GET') {
326
+ const timeout = Math.min(Math.max(Number(url.searchParams.get('timeout')) || 60, 0), MAX_WAIT_S) * 1000;
327
+ const end = Date.now() + timeout;
328
+ let gone = false;
329
+ req.on('close', () => (gone = true));
330
+ while (!gone && store.get(id) && !settled(s) && Date.now() < end) await new Promise((r) => setTimeout(r, 200));
331
+ if (!store.get(id)) return send(res, 404, { error: 'session was deleted' });
332
+ return send(res, 200, { ...summarize(s, store), timedOut: !settled(s) });
333
+ }
334
+
335
+ // The latest (or ?turn=<id|index>) presentation, as blocks and as markdown.
336
+ if (action === 'presentation' && m === 'GET') {
337
+ const q = url.searchParams.get('turn');
338
+ const turn = q === null ? s.turns.at(-1) : s.turns.find((t) => t.id === q) || s.turns.at(Number(q));
339
+ if (!turn) return send(res, 404, { error: 'no such turn' });
340
+ const latest = turn === s.turns.at(-1);
341
+ const markdown = latest ? sessionToMarkdown(s) : blocksToMarkdown(turn.presentation?.blocks, turn);
342
+ if (url.searchParams.get('format') === 'markdown') {
343
+ res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' });
344
+ return res.end(markdown);
345
+ }
346
+ return send(res, 200, {
347
+ sessionId: s.id,
348
+ turnId: turn.id,
349
+ turnIndex: s.turns.indexOf(turn),
350
+ state: s.state,
351
+ status: turn.status,
352
+ prompt: turn.prompt,
353
+ blocks: turn.presentation?.blocks || [],
354
+ notes: turn.presentation?.notes,
355
+ fallback: !!turn.presentation?.fallback,
356
+ changes: turn.changes.map((c) => ({ file: c.file, op: c.op, edits: c.edits.length })),
357
+ stats: turn.stats,
358
+ ask: latest ? s.ask || null : null,
359
+ markdown,
360
+ });
361
+ }
362
+
268
363
  if (action === 'reply' && m === 'POST') {
269
364
  const { text, attachments, queue } = await readJson(req);
270
365
  if (!text?.trim()) return send(res, 400, { error: 'text required' });
@@ -297,6 +392,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
297
392
  }
298
393
  openInTerminal({ cwd: s.cwd, argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
299
394
  s.takenOverAt = Date.now();
395
+ telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto' });
300
396
  store.save(s);
301
397
  return send(res, 200, { command });
302
398
  }
@@ -338,9 +434,15 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
338
434
  fs.createReadStream(p).pipe(res);
339
435
  }
340
436
 
437
+ // Advertise this server so integrations (CLI, MCP, editors) can find it without knowing the port.
438
+ const serverFile = path.join(store.home, 'server.json');
341
439
  const shutdown = () => {
440
+ try {
441
+ if (JSON.parse(fs.readFileSync(serverFile, 'utf8')).pid === process.pid) fs.rmSync(serverFile, { force: true });
442
+ } catch {}
342
443
  for (const s of store.all()) runner.stop(s);
343
444
  store.flush();
445
+ telemetry.persist();
344
446
  };
345
447
  if (handleSignals) {
346
448
  const exit = () => (shutdown(), process.exit(0));
@@ -352,8 +454,33 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
352
454
  server.once('error', reject);
353
455
  server.listen(port, host, resolve);
354
456
  });
457
+ 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));
355
458
 
356
- return { url: `http://localhost:${port}`, server, store, runner, shutdown };
459
+ if (telemetry.enabled) {
460
+ 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.');
461
+ listAgents()
462
+ .then((agents) => telemetry.track('app_started', { agentsInstalled: agents.filter((a) => a.available).map((a) => a.id), ...Object.fromEntries(agents.filter((a) => a.available).map((a) => [`${a.id}Version`, a.version])) }))
463
+ .catch(() => {});
464
+ }
465
+
466
+ return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry };
467
+ }
468
+
469
+ // POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).
470
+ // event: session.waiting (blocked on ask_user) | session.finished. Fire and forget.
471
+ function notifyWebhooks(store, summary) {
472
+ const hooks = store.config.webhooks || [];
473
+ if (!hooks.length) return;
474
+ const s = store.get(summary.id);
475
+ if (!s) return;
476
+ const event = s.state === 'waiting' ? 'session.waiting' : 'session.finished';
477
+ const body = JSON.stringify({ event, session: summary, markdown: sessionToMarkdown(s), at: Date.now() });
478
+ for (const h of hooks) {
479
+ if (h.events?.length && !h.events.includes(event)) continue;
480
+ fetch(h.url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `wisp/${VERSION}` }, body, signal: AbortSignal.timeout(10_000) }).catch((err) =>
481
+ console.warn(`wisp: webhook ${h.url} failed: ${err.message}`),
482
+ );
483
+ }
357
484
  }
358
485
 
359
486
  function send(res, status, body) {