wisp-agents 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ // `wisp mcp`: a stdio MCP server that lets any MCP-capable agent or editor (Claude Code, Codex,
2
+ // opencode, Cursor, Antigravity, Claude Desktop, Zed, …) hand tasks to wisp and read the results back.
3
+ // It talks to the running wisp over HTTP (starting a headless one if needed) via the client SDK.
4
+ //
5
+ // claude mcp add wisp -- npx -y wisp-agents mcp
6
+
7
+ import readline from 'node:readline';
8
+ import fs from 'node:fs';
9
+ import { WispClient } from './client.js';
10
+
11
+ const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
+ const AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'antigravity'];
13
+ const DEFAULT_WAIT_S = 240; // stay under typical MCP client tool-call timeouts; call wisp_wait again for more
14
+
15
+ const TOOLS = [
16
+ {
17
+ name: 'wisp_launch',
18
+ description:
19
+ 'Hand a coding task to a background agent in wisp. The agent works autonomously in the given folder and ends with a ' +
20
+ 'presentation (summary, diffs, test results). Returns the session id at once; pass wait=true to block until it finishes ' +
21
+ 'or asks a question. Use for independent, well-scoped tasks that can run in parallel with your own work.',
22
+ inputSchema: {
23
+ type: 'object',
24
+ properties: {
25
+ prompt: { type: 'string', description: 'The task, written as you would brief a colleague: goal, constraints, how to verify.' },
26
+ cwd: { type: 'string', description: 'Absolute path of the project folder. Default: this MCP server’s working directory.' },
27
+ agent: { type: 'string', enum: AGENTS, description: 'Harness to run. Default: the user’s last pick in wisp.' },
28
+ mode: { type: 'string', enum: ['plan', 'edits', 'auto'], description: 'plan = read-only, edits = auto-accept file edits, auto = no permission prompts. Default: the user’s pick.' },
29
+ model: { type: 'string', description: 'Model id for that harness. Default: the user’s pick.' },
30
+ effort: { type: 'string', description: 'Thinking level, e.g. low | medium | high.' },
31
+ wait: { type: 'boolean', description: 'Block until the agent finishes or asks (up to timeout_seconds).' },
32
+ timeout_seconds: { type: 'number', description: `Max wait when wait=true. Default ${DEFAULT_WAIT_S}.` },
33
+ },
34
+ required: ['prompt'],
35
+ },
36
+ },
37
+ {
38
+ name: 'wisp_list',
39
+ description: 'List wisp sessions, newest first, with state (running | waiting | review | done) and a one-line summary.',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ state: { type: 'string', description: 'Comma-separated filter, e.g. "running,waiting".' },
44
+ cwd: { type: 'string', description: 'Only sessions in this folder (or below).' },
45
+ limit: { type: 'number', description: 'Default 20.' },
46
+ },
47
+ },
48
+ },
49
+ {
50
+ name: 'wisp_get',
51
+ description: 'Read a session: its state and the latest presentation as markdown (diffs, tests, next steps), plus any question the agent is waiting on.',
52
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
53
+ },
54
+ {
55
+ name: 'wisp_wait',
56
+ description: 'Block until a session finishes or asks a question, then return it like wisp_get. Returns early with timed_out if it is still running.',
57
+ inputSchema: { type: 'object', properties: { id: { type: 'string' }, timeout_seconds: { type: 'number', description: `Default ${DEFAULT_WAIT_S}.` } }, required: ['id'] },
58
+ },
59
+ {
60
+ name: 'wisp_reply',
61
+ description:
62
+ 'Send a follow-up to a session; it resumes the same agent conversation. If the agent is waiting on a question, this answers it. ' +
63
+ 'A follow-up sent while it runs interrupts it (queue=true waits for the run to finish instead).',
64
+ inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' }, queue: { type: 'boolean' } }, required: ['id', 'text'] },
65
+ },
66
+ {
67
+ name: 'wisp_stop',
68
+ description: 'Stop a running session.',
69
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
70
+ },
71
+ {
72
+ name: 'wisp_agents',
73
+ description: 'Which harnesses are installed, with their models and thinking levels.',
74
+ inputSchema: { type: 'object', properties: {} },
75
+ },
76
+ ];
77
+
78
+ let client;
79
+ const wisp = async () => (client ||= await WispClient.connect());
80
+
81
+ const line = (s) => `- \`${s.id}\` **${s.title}** — ${s.state}${s.state === 'running' ? ` (${s.live})` : s.preview ? `: ${s.preview}` : ''}${s.question ? ` — asks: ${s.question}` : ''} [${s.agent}, ${s.project}]`;
82
+
83
+ async function describe(id, extra = '') {
84
+ const c = await wisp();
85
+ const [s, p] = await Promise.all([c.session(id), c.presentation(id)]);
86
+ const head = `Session \`${id}\` — **${s?.title}** — state: **${s?.state}**${extra}`;
87
+ const hint =
88
+ s?.state === 'waiting' ? '\n\n_Answer with wisp_reply._' : s?.state === 'running' ? '\n\n_Still running. Call wisp_wait to block until it finishes._' : '';
89
+ return `${head}\n\n${p.markdown}${hint}`;
90
+ }
91
+
92
+ async function callTool(name, a = {}) {
93
+ const c = await wisp();
94
+ const waitFor = async (id) => {
95
+ const s = await c.wait(id, { timeoutMs: (Number(a.timeout_seconds) || DEFAULT_WAIT_S) * 1000 });
96
+ return describe(id, s.timedOut ? ' (timed out waiting; still running)' : '');
97
+ };
98
+ switch (name) {
99
+ case 'wisp_launch': {
100
+ if (!a.prompt?.trim()) throw new Error('prompt is required');
101
+ const s = await c.launch({ prompt: a.prompt, cwd: a.cwd || process.cwd(), agent: a.agent, mode: a.mode, model: a.model, effort: a.effort });
102
+ if (a.wait) return waitFor(s.id);
103
+ return `Launched session \`${s.id}\` (“${s.title}”) on ${s.agent} in ${s.cwd}, mode ${s.mode}. Call wisp_wait or wisp_get with this id for the result.`;
104
+ }
105
+ case 'wisp_list': {
106
+ const list = await c.sessions({ state: a.state, cwd: a.cwd, limit: Number(a.limit) || 20 });
107
+ return list.length ? list.map(line).join('\n') : 'No sessions.';
108
+ }
109
+ case 'wisp_get':
110
+ return describe(a.id);
111
+ case 'wisp_wait':
112
+ return waitFor(a.id);
113
+ case 'wisp_reply': {
114
+ if (!a.text?.trim()) throw new Error('text is required');
115
+ const r = await c.reply(a.id, a.text.trim(), { queue: a.queue });
116
+ return r.answered ? 'Answered the agent’s question; it is continuing.' : r.interrupted ? 'Interrupted the run; it resumes with your message.' : r.queued ? 'Queued; it goes out when the current run finishes.' : 'Sent; the agent is working on it.';
117
+ }
118
+ case 'wisp_stop':
119
+ await c.stop(a.id);
120
+ return `Stopped \`${a.id}\`.`;
121
+ case 'wisp_agents':
122
+ return (await c.agents())
123
+ .map((x) => `- **${x.id}** (${x.name}) — ${x.available ? `installed${x.version ? ` ${x.version}` : ''}; models: ${(x.models || []).map((m) => m.id).filter(Boolean).slice(0, 12).join(', ') || 'default'}` : 'not installed'}`)
124
+ .join('\n');
125
+ default:
126
+ throw Object.assign(new Error(`Unknown tool: ${name}`), { code: -32602 });
127
+ }
128
+ }
129
+
130
+ export function serveMcp({ input = process.stdin, output = process.stdout } = {}) {
131
+ const send = (msg) => output.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
132
+ readline.createInterface({ input }).on('line', async (raw) => {
133
+ let msg;
134
+ try {
135
+ msg = JSON.parse(raw);
136
+ } catch {
137
+ return;
138
+ }
139
+ const { id, method, params } = msg;
140
+ if (id === undefined) return; // notification
141
+ if (method === 'initialize')
142
+ return send({
143
+ id,
144
+ result: {
145
+ protocolVersion: params?.protocolVersion || '2025-06-18',
146
+ capabilities: { tools: {} },
147
+ serverInfo: { name: 'wisp', version: VERSION },
148
+ instructions: 'wisp runs coding agents in the background. Launch with wisp_launch, then wisp_wait / wisp_get for the result; wisp_reply to follow up or answer a question.',
149
+ },
150
+ });
151
+ if (method === 'tools/list') return send({ id, result: { tools: TOOLS } });
152
+ if (method === 'ping') return send({ id, result: {} });
153
+ if (method === 'tools/call') {
154
+ try {
155
+ const text = await callTool(params?.name, params?.arguments || {});
156
+ return send({ id, result: { content: [{ type: 'text', text }] } });
157
+ } catch (err) {
158
+ if (err.code === -32602) return send({ id, error: { code: err.code, message: err.message } });
159
+ return send({ id, result: { isError: true, content: [{ type: 'text', text: `wisp: ${err.message}` }] } });
160
+ }
161
+ }
162
+ send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
163
+ });
164
+ }
@@ -0,0 +1,204 @@
1
+ // OpenAPI 3.1 description of the public HTTP API, served at GET /api/openapi.json.
2
+ // Feed it to any OpenAPI client generator, Postman, or an agent's tool loader.
3
+ // Routes under /api/internal are wisp's own plumbing and deliberately left out.
4
+
5
+ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
6
+ const json = (schema, description = 'OK') => ({ description, content: { 'application/json': { schema } } });
7
+ const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Session id' };
8
+ const body = (schema) => ({ required: true, content: { 'application/json': { schema } } });
9
+ const err = { 400: json(ref('Error'), 'Bad request'), 404: json(ref('Error'), 'Not found') };
10
+ const ok = json({ type: 'object', properties: { ok: { type: 'boolean' } } });
11
+
12
+ export function openapi({ version, port }) {
13
+ return {
14
+ openapi: '3.1.0',
15
+ info: {
16
+ title: 'wisp',
17
+ version,
18
+ description:
19
+ 'Local API of wisp, a launcher and queue for coding agents (Claude Code, Codex, opencode, Cursor, Antigravity). ' +
20
+ 'Binds to 127.0.0.1 only. Requests must carry a Host of 127.0.0.1/localhost:<port>; non-GET requests must be ' +
21
+ 'application/json (uploads: application/octet-stream) and, if they send an Origin, a local one. ' +
22
+ 'The running server advertises { url, port, pid, version } in ~/.wisp/server.json.',
23
+ },
24
+ servers: [{ url: `http://127.0.0.1:${port}/api` }],
25
+ paths: {
26
+ '/health': { get: { summary: 'Liveness, version and pid', responses: { 200: json(ref('Health')) } } },
27
+ '/update': {
28
+ get: { summary: 'Current and latest published version, and how this copy updates (global | npx | git). ?check forces a registry check', responses: { 200: json({ type: 'object' }) } },
29
+ post: { summary: 'Install the latest version and relaunch. 409 while agents run unless { force: true }', responses: { 202: json({ type: 'object' }), 409: json({ type: 'object' }) } },
30
+ },
31
+ '/preferences': {
32
+ get: { summary: 'What wisp learned: { enabled, global: { preferences, lessons }, projects: [{ path, name, preferences, lessons }], history }', responses: { 200: json({ type: 'object' }) } },
33
+ put: {
34
+ summary: 'Replace one learned list',
35
+ requestBody: body({
36
+ type: 'object',
37
+ required: ['scope', 'kind', 'items'],
38
+ properties: {
39
+ scope: { type: 'string', description: '"global" or an absolute project path' },
40
+ kind: { type: 'string', enum: ['preferences', 'lessons'] },
41
+ items: { type: 'array', items: { type: 'string' }, description: 'Deduped and capped at 10 items of 180 characters' },
42
+ },
43
+ }),
44
+ responses: { 200: json({ type: 'object' }), 400: err[400] },
45
+ },
46
+ },
47
+ '/agents': { get: { summary: 'Installed harnesses with their models and thinking levels', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
48
+ '/config': {
49
+ get: { summary: 'Launcher defaults, projects, webhooks', responses: { 200: json({ type: 'object' }) } },
50
+ patch: {
51
+ summary: 'Update defaults and/or webhooks',
52
+ requestBody: body({
53
+ type: 'object',
54
+ properties: {
55
+ defaults: { type: 'object', description: 'Merged into the current defaults (agent, mode, model, models, effort, efforts, followUp, terminal, …)' },
56
+ webhooks: { type: 'array', items: { oneOf: [{ type: 'string', format: 'uri' }, ref('Webhook')] }, description: 'Replaces the webhook list' },
57
+ },
58
+ }),
59
+ responses: { 200: json({ type: 'object' }), 400: err[400] },
60
+ },
61
+ },
62
+ '/projects': {
63
+ get: { summary: 'List projects', responses: { 200: json({ type: 'array', items: ref('Project') }) } },
64
+ post: { summary: 'Add a folder as a project', requestBody: body({ type: 'object', required: ['path'], properties: { path: { type: 'string' }, name: { type: 'string' } } }), responses: { 201: json(ref('Project')), 400: err[400] } },
65
+ },
66
+ '/projects/{pid}': {
67
+ parameters: [{ name: 'pid', in: 'path', required: true, schema: { type: 'string' } }],
68
+ patch: { summary: 'Rename or recolor', requestBody: body({ type: 'object', properties: { name: { type: 'string' }, color: { type: 'string' } } }), responses: { 200: json(ref('Project')), 404: err[404] } },
69
+ delete: { summary: 'Remove the project (sessions are kept)', responses: { 200: ok, 404: err[404] } },
70
+ },
71
+ '/sessions': {
72
+ get: {
73
+ summary: 'List sessions, newest first',
74
+ parameters: [
75
+ { name: 'state', in: 'query', schema: { type: 'string' }, description: 'Comma-separated: running,waiting,review,done' },
76
+ { name: 'agent', in: 'query', schema: { type: 'string' } },
77
+ { name: 'project', in: 'query', schema: { type: 'string' }, description: 'Project id or name' },
78
+ { name: 'cwd', in: 'query', schema: { type: 'string' }, description: 'Sessions in this folder or below' },
79
+ { name: 'archived', in: 'query', schema: { type: 'boolean' } },
80
+ { name: 'limit', in: 'query', schema: { type: 'integer' } },
81
+ ],
82
+ responses: { 200: json({ type: 'array', items: ref('Summary') }) },
83
+ },
84
+ post: { summary: 'Launch a task', requestBody: body(ref('Launch')), responses: { 201: json(ref('Summary')), 400: err[400] } },
85
+ },
86
+ '/sessions/{id}': {
87
+ parameters: [id],
88
+ get: { summary: 'Full session record: turns, activity, captured edits, presentations', responses: { 200: json({ type: 'object' }), 404: err[404] } },
89
+ patch: {
90
+ summary: 'Rename, mark done/review, mark read, archive',
91
+ requestBody: body({ type: 'object', properties: { title: { type: 'string' }, state: { enum: ['done', 'review'] }, unread: { type: 'boolean' }, archived: { type: 'boolean' } } }),
92
+ responses: { 200: json(ref('Summary')), 404: err[404] },
93
+ },
94
+ delete: { summary: 'Stop and delete', responses: { 200: ok, 404: err[404] } },
95
+ },
96
+ '/sessions/{id}/wait': {
97
+ parameters: [id, { name: 'timeout', in: 'query', schema: { type: 'integer', default: 60, maximum: 290 }, description: 'Seconds' }],
98
+ get: { summary: 'Long-poll until the session needs you (finished or asking)', responses: { 200: json({ allOf: [ref('Summary'), { type: 'object', properties: { timedOut: { type: 'boolean' } } }] }), 404: err[404] } },
99
+ },
100
+ '/sessions/{id}/presentation': {
101
+ parameters: [
102
+ id,
103
+ { name: 'turn', in: 'query', schema: { type: 'string' }, description: 'Turn id or index (negative counts from the end). Default: latest' },
104
+ { name: 'format', in: 'query', schema: { enum: ['json', 'markdown'] } },
105
+ ],
106
+ get: { summary: 'A turn’s presentation as blocks and markdown', responses: { 200: json(ref('Presentation')), 404: err[404] } },
107
+ },
108
+ '/sessions/{id}/reply': {
109
+ parameters: [id],
110
+ post: {
111
+ summary: 'Follow up (answers a pending ask_user question if there is one)',
112
+ requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' } } }),
113
+ responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
114
+ },
115
+ },
116
+ '/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
117
+ '/sessions/{id}/stop': { parameters: [id], post: { summary: 'Stop the run and drop queued follow-ups', responses: { 200: ok } } },
118
+ '/sessions/{id}/queue': { parameters: [id, { name: 'item', in: 'query', required: true, schema: { type: 'string' } }], delete: { summary: 'Cancel a queued follow-up', responses: { 200: ok, 404: err[404] } } },
119
+ '/sessions/{id}/takeover': {
120
+ parameters: [id],
121
+ get: { summary: 'The shell command that resumes this agent session interactively', responses: { 200: json({ type: 'object', properties: { command: { type: 'string' } } }) } },
122
+ post: { summary: 'Stop the run and open the resume command in the user’s terminal', responses: { 200: json({ type: 'object', properties: { command: { type: 'string' } } }) } },
123
+ },
124
+ '/uploads': {
125
+ post: {
126
+ summary: 'Upload an attachment (raw body). Pass the returned id in attachments',
127
+ parameters: [{ name: 'name', in: 'query', schema: { type: 'string' } }, { name: 'type', in: 'query', schema: { type: 'string' }, description: 'MIME type' }],
128
+ requestBody: { required: true, content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } },
129
+ responses: { 201: json({ type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, size: { type: 'integer' }, mime: { type: 'string' } } }) },
130
+ },
131
+ },
132
+ '/events': {
133
+ get: {
134
+ summary: 'Server-sent events: summary, activity, turn, finished, removed, config, preferences, ping',
135
+ responses: { 200: { description: 'text/event-stream', content: { 'text/event-stream': { schema: { type: 'string' } } } } },
136
+ },
137
+ },
138
+ },
139
+ components: {
140
+ schemas: {
141
+ Error: { type: 'object', properties: { error: { type: 'string' } } },
142
+ Health: { type: 'object', properties: { ok: { type: 'boolean' }, name: { const: 'wisp' }, version: { type: 'string' }, apiVersion: { type: 'integer' }, pid: { type: 'integer' }, url: { type: 'string' } } },
143
+ Agent: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, available: { type: 'boolean' }, version: { type: 'string' }, models: { type: 'array', items: { type: 'object' } }, efforts: { type: 'array', items: { type: 'string' } } } },
144
+ Project: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, path: { type: 'string' }, color: { type: 'string' } } },
145
+ Webhook: { type: 'object', required: ['url'], properties: { url: { type: 'string', format: 'uri' }, events: { type: 'array', items: { enum: ['session.finished', 'session.waiting'] } } } },
146
+ Launch: {
147
+ type: 'object',
148
+ required: ['prompt'],
149
+ properties: {
150
+ prompt: { type: 'string' },
151
+ cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
152
+ agent: { enum: ['claude', 'codex', 'opencode', 'cursor', 'antigravity'], default: 'claude' },
153
+ mode: { enum: ['plan', 'edits', 'auto'], default: 'edits' },
154
+ model: { type: 'string' },
155
+ effort: { type: 'string', description: 'Thinking level: low, medium, high, xhigh, max (per harness)' },
156
+ attachments: { type: 'array', items: { type: 'string' }, description: 'Upload ids' },
157
+ },
158
+ },
159
+ Summary: {
160
+ type: 'object',
161
+ properties: {
162
+ id: { type: 'string' },
163
+ title: { type: 'string' },
164
+ agent: { type: 'string' },
165
+ cwd: { type: 'string' },
166
+ project: { type: 'string' },
167
+ projectId: { type: ['string', 'null'] },
168
+ mode: { type: 'string' },
169
+ model: { type: 'string' },
170
+ effort: { type: 'string' },
171
+ state: { enum: ['running', 'waiting', 'review', 'done'] },
172
+ status: { enum: ['running', 'done', 'partial', 'blocked', 'info'] },
173
+ preview: { type: 'string', description: 'The hero summary of the latest turn' },
174
+ live: { type: 'string', description: 'One-line status while running, or the pending question' },
175
+ asks: { type: 'boolean' },
176
+ question: { type: 'string' },
177
+ unread: { type: 'boolean' },
178
+ archived: { type: 'boolean' },
179
+ queued: { type: 'integer' },
180
+ turnCount: { type: 'integer' },
181
+ createdAt: { type: 'integer' },
182
+ updatedAt: { type: 'integer' },
183
+ },
184
+ },
185
+ Presentation: {
186
+ type: 'object',
187
+ properties: {
188
+ sessionId: { type: 'string' },
189
+ turnId: { type: 'string' },
190
+ turnIndex: { type: 'integer' },
191
+ state: { type: 'string' },
192
+ status: { enum: ['running', 'done', 'error', 'stopped'] },
193
+ prompt: { type: 'string' },
194
+ blocks: { type: 'array', items: { type: 'object', required: ['type'], properties: { type: { type: 'string' } } } },
195
+ markdown: { type: 'string' },
196
+ ask: { type: ['object', 'null'], properties: { id: { type: 'string' }, question: { type: 'string' }, context: { type: 'string' }, options: { type: 'array', items: { type: 'string' } } } },
197
+ changes: { type: 'array', items: { type: 'object', properties: { file: { type: 'string' }, op: { type: 'string' }, edits: { type: 'integer' } } } },
198
+ stats: { type: ['object', 'null'] },
199
+ },
200
+ },
201
+ },
202
+ },
203
+ };
204
+ }