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.
- package/CHANGELOG.md +36 -1
- package/README.md +71 -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 +157 -1
- package/server/mcp-wisp.js +164 -0
- package/server/openapi.js +204 -0
- package/server/preferences.js +296 -0
- package/server/render.js +100 -0
- package/server/runner.js +2 -1
- package/server/update.js +136 -0
- package/skills/wisp/SKILL.md +55 -0
- package/web/css/app.css +83 -1
- package/web/index.html +5 -0
- package/web/js/app.js +369 -23
- 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
|
@@ -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')
|
|
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));
|