wisp-agents 0.5.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
@@ -12,6 +12,8 @@ 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';
15
17
 
16
18
  const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
17
19
 
@@ -32,6 +34,13 @@ const MIME = {
32
34
  '.pdf': 'application/pdf',
33
35
  };
34
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);
35
44
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
36
45
 
37
46
  export async function startServer({ port = 4477, host = '127.0.0.1', handleSignals = true } = {}) {
@@ -61,6 +70,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
61
70
  runner.on('activity', (a) => broadcast('activity', a));
62
71
  runner.on('turn', (t) => broadcast('turn', t));
63
72
  runner.on('finished', (f) => broadcast('finished', f));
73
+ runner.on('finished', ({ session }) => notifyWebhooks(store, session));
64
74
  setInterval(() => broadcast('ping', {}), 25_000).unref();
65
75
 
66
76
  const telemetry = new Telemetry(store);
@@ -123,6 +133,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
123
133
  return;
124
134
  }
125
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
+
126
139
  if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents());
127
140
 
128
141
  // Bridge for the ask_user MCP server running inside an agent process.
@@ -178,6 +191,12 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
178
191
  if (m === 'PATCH') {
179
192
  const body = await readJson(req);
180
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
+ }
181
200
  store.saveConfig();
182
201
  broadcast('config', {});
183
202
  return send(res, 200, store.config);
@@ -254,7 +273,18 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
254
273
 
255
274
  if (root === 'sessions') {
256
275
  if (!id) {
257
- 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
+ }
258
288
  if (m === 'POST') {
259
289
  const body = await readJson(req);
260
290
  if (!body.prompt?.trim()) return send(res, 400, { error: 'prompt required' });
@@ -291,6 +321,45 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
291
321
  }
292
322
  }
293
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
+
294
363
  if (action === 'reply' && m === 'POST') {
295
364
  const { text, attachments, queue } = await readJson(req);
296
365
  if (!text?.trim()) return send(res, 400, { error: 'text required' });
@@ -365,7 +434,12 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
365
434
  fs.createReadStream(p).pipe(res);
366
435
  }
367
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');
368
439
  const shutdown = () => {
440
+ try {
441
+ if (JSON.parse(fs.readFileSync(serverFile, 'utf8')).pid === process.pid) fs.rmSync(serverFile, { force: true });
442
+ } catch {}
369
443
  for (const s of store.all()) runner.stop(s);
370
444
  store.flush();
371
445
  telemetry.persist();
@@ -380,6 +454,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
380
454
  server.once('error', reject);
381
455
  server.listen(port, host, resolve);
382
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));
383
458
 
384
459
  if (telemetry.enabled) {
385
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.');
@@ -391,6 +466,23 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
391
466
  return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry };
392
467
  }
393
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
+ }
484
+ }
485
+
394
486
  function send(res, status, body) {
395
487
  res.writeHead(status, { 'content-type': 'application/json' });
396
488
  res.end(JSON.stringify(body));