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.
- package/CHANGELOG.md +29 -1
- package/README.md +72 -9
- package/bin/wisp.js +182 -69
- package/package.json +15 -4
- package/server/adapters/antigravity.js +155 -0
- package/server/adapters/index.js +6 -2
- package/server/client.js +177 -0
- package/server/index.js +130 -3
- package/server/mcp-wisp.js +164 -0
- package/server/openapi.js +184 -0
- package/server/render.js +100 -0
- package/server/runner.js +2 -0
- package/server/telemetry.js +132 -0
- package/skills/wisp/SKILL.md +55 -0
- package/web/js/app.js +43 -5
- package/web/logos/antigravity-color.svg +1 -0
|
@@ -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
|
+
}
|
package/server/adapters/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/server/client.js
ADDED
|
@@ -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')
|
|
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
|
-
|
|
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) {
|