wisp-agents 0.5.0 → 0.7.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
@@ -12,6 +12,10 @@ 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
+ import { blocksToMarkdown, sessionToMarkdown } from './render.js';
16
+ import { openapi } from './openapi.js';
17
+ import { Updater } from './update.js';
18
+ import { Preferences, Retro, KINDS } from './preferences.js';
15
19
 
16
20
  const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
17
21
 
@@ -32,6 +36,13 @@ const MIME = {
32
36
  '.pdf': 'application/pdf',
33
37
  };
34
38
  const MAX_UPLOAD = 500 * 1024 * 1024;
39
+ const MAX_WAIT_S = 290; // long-poll cap per request; clients loop (fetch drops headers after 300s)
40
+ const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
41
+ export const API_VERSION = 1;
42
+
43
+ // A session "needs you" once it stops running: finished, or blocked on ask_user. A finished run
44
+ // with follow-ups still queued is about to start the next one, so it doesn't count yet.
45
+ const settled = (s) => s.state === 'waiting' || (s.state !== 'running' && !s.queue?.length);
35
46
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
36
47
 
37
48
  export async function startServer({ port = 4477, host = '127.0.0.1', handleSignals = true } = {}) {
@@ -61,9 +72,29 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
61
72
  runner.on('activity', (a) => broadcast('activity', a));
62
73
  runner.on('turn', (t) => broadcast('turn', t));
63
74
  runner.on('finished', (f) => broadcast('finished', f));
75
+ runner.on('finished', ({ session }) => notifyWebhooks(store, session));
64
76
  setInterval(() => broadcast('ping', {}), 25_000).unref();
65
77
 
66
78
  const telemetry = new Telemetry(store);
79
+ const updater = new Updater({ port, onChange: (u) => broadcast('update', u) });
80
+
81
+ // Learned preferences: injected into every turn, refreshed by a background retro per session.
82
+ const prefs = new Preferences(store.home);
83
+ const learning = () => store.config.defaults.learn !== 'off';
84
+ const projectOf = (cwd) => store.projectFor(cwd)?.path || cwd;
85
+ runner.promptExtra = (s) => (learning() && s.agent !== 'wisp' ? prefs.prompt(projectOf(s.cwd)) : '');
86
+ const retro = new Retro({
87
+ store,
88
+ runner,
89
+ prefs,
90
+ projectOf,
91
+ enabled: learning,
92
+ onStart: () => broadcast('preferences', {}),
93
+ onDone: ({ session, ok, added = [], removed = [] }) => {
94
+ telemetry.track('retro', { agent: session.agent, ok, added: added.length, removed: removed.length });
95
+ broadcast('preferences', { added: added.length, removed: removed.length });
96
+ },
97
+ });
67
98
  const run = (s, t) => ({ agent: s.agent, model: s.model || 'default', mode: s.mode, effort: s.effort || 'default', followUp: s.turns.indexOf(t) > 0 });
68
99
  runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...run(session, turn), attachments: turn.attachments.length }));
69
100
  runner.on('ended', ({ session, turn, interrupted, errorText }) => {
@@ -123,6 +154,27 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
123
154
  return;
124
155
  }
125
156
 
157
+ 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}` });
158
+ // New wisp-agents on npm? POST installs it and relaunches; running agents are stopped, so the
159
+ // client must pass force once it has confirmed that with the user.
160
+ if (root === 'update') {
161
+ if (m === 'GET') {
162
+ if (url.searchParams.has('check')) return send(res, 200, await updater.check());
163
+ updater.maybeCheck(); // the UI asks on focus and reconnect: a day-old check gets refreshed
164
+ return send(res, 200, updater.view());
165
+ }
166
+ if (m === 'POST') {
167
+ const body = await readJson(req);
168
+ if (runner.live.size && !body.force) return send(res, 409, { error: 'agents are running', running: runner.live.size });
169
+ await updater.install();
170
+ send(res, 202, updater.view());
171
+ telemetry.track('update', { from: updater.state.current, to: updater.state.latest, method: updater.method });
172
+ setTimeout(() => (shutdown(), process.exit(0)), 300);
173
+ return;
174
+ }
175
+ }
176
+ if (root === 'openapi.json' && m === 'GET') return send(res, 200, openapi({ version: VERSION, port }));
177
+
126
178
  if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents());
127
179
 
128
180
  // Bridge for the ask_user MCP server running inside an agent process.
@@ -178,12 +230,40 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
178
230
  if (m === 'PATCH') {
179
231
  const body = await readJson(req);
180
232
  if (body.defaults) Object.assign(store.config.defaults, body.defaults);
233
+ if (body.webhooks !== undefined) {
234
+ const hooks = Array.isArray(body.webhooks) ? body.webhooks : [];
235
+ const bad = hooks.find((h) => !/^https?:\/\/\S+$/.test(String(h?.url ?? h)));
236
+ if (bad !== undefined) return send(res, 400, { error: `Not an http(s) URL: ${bad?.url ?? bad}` });
237
+ store.config.webhooks = hooks.map((h) => (typeof h === 'string' ? { url: h } : { url: h.url, events: Array.isArray(h.events) ? h.events : undefined }));
238
+ }
181
239
  store.saveConfig();
182
240
  broadcast('config', {});
183
241
  return send(res, 200, store.config);
184
242
  }
185
243
  }
186
244
 
245
+ // What wisp learned: preferences and lessons, global and per project (scope = 'global' or a project path).
246
+ if (root === 'preferences') {
247
+ const view = () => ({
248
+ enabled: learning(),
249
+ global: prefs.data.global,
250
+ projects: Object.entries(prefs.data.projects).map(([p, v]) => ({ path: p, name: store.projectFor(p)?.name || path.basename(p), ...v })),
251
+ history: prefs.data.history.slice().reverse(),
252
+ running: retro.running,
253
+ });
254
+ if (m === 'GET') return send(res, 200, view());
255
+ if (m === 'PUT') {
256
+ const body = await readJson(req);
257
+ const scope = String(body.scope || '');
258
+ if (scope !== 'global' && !path.isAbsolute(scope)) return send(res, 400, { error: 'scope must be "global" or a project path' });
259
+ if (!KINDS.includes(body.kind)) return send(res, 400, { error: `kind must be one of ${KINDS.join(', ')}` });
260
+ if (!Array.isArray(body.items)) return send(res, 400, { error: 'items must be an array of strings' });
261
+ prefs.set(scope, body.kind, body.items);
262
+ broadcast('preferences', {});
263
+ return send(res, 200, view());
264
+ }
265
+ }
266
+
187
267
  if (root === 'projects') {
188
268
  if (!id && m === 'GET') return send(res, 200, store.config.projects);
189
269
  if (!id && m === 'POST') {
@@ -254,7 +334,18 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
254
334
 
255
335
  if (root === 'sessions') {
256
336
  if (!id) {
257
- if (m === 'GET') return send(res, 200, store.all().map((x) => summarize(x, store)).sort((a, b) => b.updatedAt - a.updatedAt));
337
+ if (m === 'GET') {
338
+ const q = Object.fromEntries(url.searchParams);
339
+ const cwd = q.cwd && path.resolve(q.cwd.replace(/^~(?=$|[\\/])/, os.homedir()));
340
+ let list = store.all().map((x) => summarize(x, store)).sort((a, b) => b.updatedAt - a.updatedAt);
341
+ if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
342
+ if (q.agent) list = list.filter((x) => x.agent === q.agent);
343
+ if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
344
+ if (cwd) list = list.filter((x) => x.cwd === cwd || x.cwd?.startsWith(cwd + path.sep));
345
+ if (q.archived !== undefined) list = list.filter((x) => x.archived === (q.archived === 'true' || q.archived === '1'));
346
+ if (Number(q.limit) > 0) list = list.slice(0, Number(q.limit));
347
+ return send(res, 200, list);
348
+ }
258
349
  if (m === 'POST') {
259
350
  const body = await readJson(req);
260
351
  if (!body.prompt?.trim()) return send(res, 400, { error: 'prompt required' });
@@ -279,6 +370,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
279
370
  if (typeof body.unread === 'boolean') s.unread = body.unread;
280
371
  if (body.archived === true && s.state !== 'running' && s.state !== 'waiting') Object.assign(s, { archived: true, unread: false });
281
372
  if (body.archived === false) delete s.archived;
373
+ if (s.state === 'done' || s.archived) retro.schedule(s, 0); // finished with it: learn now
282
374
  store.save(s);
283
375
  broadcast('summary', summarize(s, store));
284
376
  return send(res, 200, summarize(s, store));
@@ -291,6 +383,45 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
291
383
  }
292
384
  }
293
385
 
386
+ // Long-poll until the session needs the user (finished, or asking). ?timeout=seconds.
387
+ if (action === 'wait' && m === 'GET') {
388
+ const timeout = Math.min(Math.max(Number(url.searchParams.get('timeout')) || 60, 0), MAX_WAIT_S) * 1000;
389
+ const end = Date.now() + timeout;
390
+ let gone = false;
391
+ req.on('close', () => (gone = true));
392
+ while (!gone && store.get(id) && !settled(s) && Date.now() < end) await new Promise((r) => setTimeout(r, 200));
393
+ if (!store.get(id)) return send(res, 404, { error: 'session was deleted' });
394
+ return send(res, 200, { ...summarize(s, store), timedOut: !settled(s) });
395
+ }
396
+
397
+ // The latest (or ?turn=<id|index>) presentation, as blocks and as markdown.
398
+ if (action === 'presentation' && m === 'GET') {
399
+ const q = url.searchParams.get('turn');
400
+ const turn = q === null ? s.turns.at(-1) : s.turns.find((t) => t.id === q) || s.turns.at(Number(q));
401
+ if (!turn) return send(res, 404, { error: 'no such turn' });
402
+ const latest = turn === s.turns.at(-1);
403
+ const markdown = latest ? sessionToMarkdown(s) : blocksToMarkdown(turn.presentation?.blocks, turn);
404
+ if (url.searchParams.get('format') === 'markdown') {
405
+ res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' });
406
+ return res.end(markdown);
407
+ }
408
+ return send(res, 200, {
409
+ sessionId: s.id,
410
+ turnId: turn.id,
411
+ turnIndex: s.turns.indexOf(turn),
412
+ state: s.state,
413
+ status: turn.status,
414
+ prompt: turn.prompt,
415
+ blocks: turn.presentation?.blocks || [],
416
+ notes: turn.presentation?.notes,
417
+ fallback: !!turn.presentation?.fallback,
418
+ changes: turn.changes.map((c) => ({ file: c.file, op: c.op, edits: c.edits.length })),
419
+ stats: turn.stats,
420
+ ask: latest ? s.ask || null : null,
421
+ markdown,
422
+ });
423
+ }
424
+
294
425
  if (action === 'reply' && m === 'POST') {
295
426
  const { text, attachments, queue } = await readJson(req);
296
427
  if (!text?.trim()) return send(res, 400, { error: 'text required' });
@@ -365,7 +496,13 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
365
496
  fs.createReadStream(p).pipe(res);
366
497
  }
367
498
 
499
+ // Advertise this server so integrations (CLI, MCP, editors) can find it without knowing the port.
500
+ const serverFile = path.join(store.home, 'server.json');
368
501
  const shutdown = () => {
502
+ try {
503
+ if (JSON.parse(fs.readFileSync(serverFile, 'utf8')).pid === process.pid) fs.rmSync(serverFile, { force: true });
504
+ } catch {}
505
+ retro.stop();
369
506
  for (const s of store.all()) runner.stop(s);
370
507
  store.flush();
371
508
  telemetry.persist();
@@ -380,6 +517,8 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
380
517
  server.once('error', reject);
381
518
  server.listen(port, host, resolve);
382
519
  });
520
+ fs.writeFileSync(serverFile, JSON.stringify({ url: `http://127.0.0.1:${port}`, port, pid: process.pid, version: VERSION, apiVersion: API_VERSION, startedAt: Date.now() }, null, 2));
521
+ updater.start();
383
522
 
384
523
  if (telemetry.enabled) {
385
524
  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.');
@@ -391,6 +530,23 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
391
530
  return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry };
392
531
  }
393
532
 
533
+ // POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).
534
+ // event: session.waiting (blocked on ask_user) | session.finished. Fire and forget.
535
+ function notifyWebhooks(store, summary) {
536
+ const hooks = store.config.webhooks || [];
537
+ if (!hooks.length) return;
538
+ const s = store.get(summary.id);
539
+ if (!s) return;
540
+ const event = s.state === 'waiting' ? 'session.waiting' : 'session.finished';
541
+ const body = JSON.stringify({ event, session: summary, markdown: sessionToMarkdown(s), at: Date.now() });
542
+ for (const h of hooks) {
543
+ if (h.events?.length && !h.events.includes(event)) continue;
544
+ fetch(h.url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `wisp/${VERSION}` }, body, signal: AbortSignal.timeout(10_000) }).catch((err) =>
545
+ console.warn(`wisp: webhook ${h.url} failed: ${err.message}`),
546
+ );
547
+ }
548
+ }
549
+
394
550
  function send(res, status, body) {
395
551
  res.writeHead(status, { 'content-type': 'application/json' });
396
552
  res.end(JSON.stringify(body));