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.
@@ -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,184 @@
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
+ '/agents': { get: { summary: 'Installed harnesses with their models and thinking levels', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
28
+ '/config': {
29
+ get: { summary: 'Launcher defaults, projects, webhooks', responses: { 200: json({ type: 'object' }) } },
30
+ patch: {
31
+ summary: 'Update defaults and/or webhooks',
32
+ requestBody: body({
33
+ type: 'object',
34
+ properties: {
35
+ defaults: { type: 'object', description: 'Merged into the current defaults (agent, mode, model, models, effort, efforts, followUp, terminal, …)' },
36
+ webhooks: { type: 'array', items: { oneOf: [{ type: 'string', format: 'uri' }, ref('Webhook')] }, description: 'Replaces the webhook list' },
37
+ },
38
+ }),
39
+ responses: { 200: json({ type: 'object' }), 400: err[400] },
40
+ },
41
+ },
42
+ '/projects': {
43
+ get: { summary: 'List projects', responses: { 200: json({ type: 'array', items: ref('Project') }) } },
44
+ 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] } },
45
+ },
46
+ '/projects/{pid}': {
47
+ parameters: [{ name: 'pid', in: 'path', required: true, schema: { type: 'string' } }],
48
+ patch: { summary: 'Rename or recolor', requestBody: body({ type: 'object', properties: { name: { type: 'string' }, color: { type: 'string' } } }), responses: { 200: json(ref('Project')), 404: err[404] } },
49
+ delete: { summary: 'Remove the project (sessions are kept)', responses: { 200: ok, 404: err[404] } },
50
+ },
51
+ '/sessions': {
52
+ get: {
53
+ summary: 'List sessions, newest first',
54
+ parameters: [
55
+ { name: 'state', in: 'query', schema: { type: 'string' }, description: 'Comma-separated: running,waiting,review,done' },
56
+ { name: 'agent', in: 'query', schema: { type: 'string' } },
57
+ { name: 'project', in: 'query', schema: { type: 'string' }, description: 'Project id or name' },
58
+ { name: 'cwd', in: 'query', schema: { type: 'string' }, description: 'Sessions in this folder or below' },
59
+ { name: 'archived', in: 'query', schema: { type: 'boolean' } },
60
+ { name: 'limit', in: 'query', schema: { type: 'integer' } },
61
+ ],
62
+ responses: { 200: json({ type: 'array', items: ref('Summary') }) },
63
+ },
64
+ post: { summary: 'Launch a task', requestBody: body(ref('Launch')), responses: { 201: json(ref('Summary')), 400: err[400] } },
65
+ },
66
+ '/sessions/{id}': {
67
+ parameters: [id],
68
+ get: { summary: 'Full session record: turns, activity, captured edits, presentations', responses: { 200: json({ type: 'object' }), 404: err[404] } },
69
+ patch: {
70
+ summary: 'Rename, mark done/review, mark read, archive',
71
+ requestBody: body({ type: 'object', properties: { title: { type: 'string' }, state: { enum: ['done', 'review'] }, unread: { type: 'boolean' }, archived: { type: 'boolean' } } }),
72
+ responses: { 200: json(ref('Summary')), 404: err[404] },
73
+ },
74
+ delete: { summary: 'Stop and delete', responses: { 200: ok, 404: err[404] } },
75
+ },
76
+ '/sessions/{id}/wait': {
77
+ parameters: [id, { name: 'timeout', in: 'query', schema: { type: 'integer', default: 60, maximum: 290 }, description: 'Seconds' }],
78
+ 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] } },
79
+ },
80
+ '/sessions/{id}/presentation': {
81
+ parameters: [
82
+ id,
83
+ { name: 'turn', in: 'query', schema: { type: 'string' }, description: 'Turn id or index (negative counts from the end). Default: latest' },
84
+ { name: 'format', in: 'query', schema: { enum: ['json', 'markdown'] } },
85
+ ],
86
+ get: { summary: 'A turn’s presentation as blocks and markdown', responses: { 200: json(ref('Presentation')), 404: err[404] } },
87
+ },
88
+ '/sessions/{id}/reply': {
89
+ parameters: [id],
90
+ post: {
91
+ summary: 'Follow up (answers a pending ask_user question if there is one)',
92
+ 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' } } }),
93
+ responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
94
+ },
95
+ },
96
+ '/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 } } },
97
+ '/sessions/{id}/stop': { parameters: [id], post: { summary: 'Stop the run and drop queued follow-ups', responses: { 200: ok } } },
98
+ '/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] } } },
99
+ '/sessions/{id}/takeover': {
100
+ parameters: [id],
101
+ get: { summary: 'The shell command that resumes this agent session interactively', responses: { 200: json({ type: 'object', properties: { command: { type: 'string' } } }) } },
102
+ post: { summary: 'Stop the run and open the resume command in the user’s terminal', responses: { 200: json({ type: 'object', properties: { command: { type: 'string' } } }) } },
103
+ },
104
+ '/uploads': {
105
+ post: {
106
+ summary: 'Upload an attachment (raw body). Pass the returned id in attachments',
107
+ parameters: [{ name: 'name', in: 'query', schema: { type: 'string' } }, { name: 'type', in: 'query', schema: { type: 'string' }, description: 'MIME type' }],
108
+ requestBody: { required: true, content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } },
109
+ responses: { 201: json({ type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, size: { type: 'integer' }, mime: { type: 'string' } } }) },
110
+ },
111
+ },
112
+ '/events': {
113
+ get: {
114
+ summary: 'Server-sent events: summary, activity, turn, finished, removed, config, ping',
115
+ responses: { 200: { description: 'text/event-stream', content: { 'text/event-stream': { schema: { type: 'string' } } } } },
116
+ },
117
+ },
118
+ },
119
+ components: {
120
+ schemas: {
121
+ Error: { type: 'object', properties: { error: { type: 'string' } } },
122
+ Health: { type: 'object', properties: { ok: { type: 'boolean' }, name: { const: 'wisp' }, version: { type: 'string' }, apiVersion: { type: 'integer' }, pid: { type: 'integer' }, url: { type: 'string' } } },
123
+ 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' } } } },
124
+ Project: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, path: { type: 'string' }, color: { type: 'string' } } },
125
+ Webhook: { type: 'object', required: ['url'], properties: { url: { type: 'string', format: 'uri' }, events: { type: 'array', items: { enum: ['session.finished', 'session.waiting'] } } } },
126
+ Launch: {
127
+ type: 'object',
128
+ required: ['prompt'],
129
+ properties: {
130
+ prompt: { type: 'string' },
131
+ cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
132
+ agent: { enum: ['claude', 'codex', 'opencode', 'cursor', 'antigravity'], default: 'claude' },
133
+ mode: { enum: ['plan', 'edits', 'auto'], default: 'edits' },
134
+ model: { type: 'string' },
135
+ effort: { type: 'string', description: 'Thinking level: low, medium, high, xhigh, max (per harness)' },
136
+ attachments: { type: 'array', items: { type: 'string' }, description: 'Upload ids' },
137
+ },
138
+ },
139
+ Summary: {
140
+ type: 'object',
141
+ properties: {
142
+ id: { type: 'string' },
143
+ title: { type: 'string' },
144
+ agent: { type: 'string' },
145
+ cwd: { type: 'string' },
146
+ project: { type: 'string' },
147
+ projectId: { type: ['string', 'null'] },
148
+ mode: { type: 'string' },
149
+ model: { type: 'string' },
150
+ effort: { type: 'string' },
151
+ state: { enum: ['running', 'waiting', 'review', 'done'] },
152
+ status: { enum: ['running', 'done', 'partial', 'blocked', 'info'] },
153
+ preview: { type: 'string', description: 'The hero summary of the latest turn' },
154
+ live: { type: 'string', description: 'One-line status while running, or the pending question' },
155
+ asks: { type: 'boolean' },
156
+ question: { type: 'string' },
157
+ unread: { type: 'boolean' },
158
+ archived: { type: 'boolean' },
159
+ queued: { type: 'integer' },
160
+ turnCount: { type: 'integer' },
161
+ createdAt: { type: 'integer' },
162
+ updatedAt: { type: 'integer' },
163
+ },
164
+ },
165
+ Presentation: {
166
+ type: 'object',
167
+ properties: {
168
+ sessionId: { type: 'string' },
169
+ turnId: { type: 'string' },
170
+ turnIndex: { type: 'integer' },
171
+ state: { type: 'string' },
172
+ status: { enum: ['running', 'done', 'error', 'stopped'] },
173
+ prompt: { type: 'string' },
174
+ blocks: { type: 'array', items: { type: 'object', required: ['type'], properties: { type: { type: 'string' } } } },
175
+ markdown: { type: 'string' },
176
+ ask: { type: ['object', 'null'], properties: { id: { type: 'string' }, question: { type: 'string' }, context: { type: 'string' }, options: { type: 'array', items: { type: 'string' } } } },
177
+ changes: { type: 'array', items: { type: 'object', properties: { file: { type: 'string' }, op: { type: 'string' }, edits: { type: 'integer' } } } },
178
+ stats: { type: ['object', 'null'] },
179
+ },
180
+ },
181
+ },
182
+ },
183
+ };
184
+ }
@@ -0,0 +1,100 @@
1
+ // Presentation → plain markdown, for places that can't run the web UI: the CLI, MCP clients,
2
+ // webhooks, chat apps. Lossy by design (no animation, no syntax colours), never throws.
3
+
4
+ import { diffLines, hunks } from '../web/js/diff.js';
5
+
6
+ const STATUS = { done: '✅ Done', partial: '🟡 Partly done', blocked: '⛔ Blocked', info: 'ℹ️ Report' };
7
+ const CHANGE = { added: 'A', modified: 'M', deleted: 'D', renamed: 'R' };
8
+ const fence = (text, lang = '') => {
9
+ const s = String(text ?? '');
10
+ const ticks = '`'.repeat(Math.max(3, ...[...s.matchAll(/`+/g)].map((m) => m[0].length + 1)));
11
+ return `${ticks}${lang}\n${s.replace(/\n$/, '')}\n${ticks}`;
12
+ };
13
+ const list = (v) => (Array.isArray(v) ? v : []);
14
+ const str = (v) => (v === undefined || v === null ? '' : String(v));
15
+
16
+ function findChange(turn, file) {
17
+ if (!file || !turn?.changes) return null;
18
+ return turn.changes.find((c) => c.file === file) || turn.changes.find((c) => c.file.endsWith('/' + file) || file.endsWith('/' + c.file));
19
+ }
20
+
21
+ function unified(edits) {
22
+ return edits
23
+ .map((e) =>
24
+ hunks(diffLines(str(e.before), str(e.after)), 3)
25
+ .map((o) => (o.op === '…' ? `@@ ${o.count} unchanged line${o.count > 1 ? 's' : ''} @@` : `${o.op}${o.text}`))
26
+ .join('\n'),
27
+ )
28
+ .join('\n@@ … @@\n');
29
+ }
30
+
31
+ const R = {
32
+ hero: (b) => `## ${str(b.title) || 'Done'}\n\n**${STATUS[b.status] || STATUS.done}**${b.summary ? ` — ${b.summary}` : ''}`,
33
+ markdown: (b) => str(b.text),
34
+ steps: (b) =>
35
+ `${b.title ? `**${b.title}**\n\n` : ''}${list(b.items)
36
+ .map((s, i) => `${i + 1}. ${s.status === 'failed' ? '❌' : s.status === 'skipped' ? '⏭️' : '✔︎'} **${str(s.title)}**${s.detail ? ` — ${s.detail}` : ''}`)
37
+ .join('\n')}`,
38
+ stats: (b) => list(b.items).map((s) => `- **${str(s.label)}:** ${str(s.value)}${str(s.unit)}${s.delta ? ` (${s.delta})` : ''}`).join('\n'),
39
+ files: (b) => list(b.items).map((f) => `- \`${CHANGE[f.change] || 'M'}\` \`${str(f.path)}\`${f.note ? ` — ${f.note}` : ''}`).join('\n'),
40
+ tree: (b) => `${b.title ? `**${b.title}**\n\n` : ''}${list(b.items).map((f) => `- \`${str(f.path)}\`${f.change ? ` (${f.change})` : ''}${f.note ? ` — ${f.note}` : ''}`).join('\n')}`,
41
+ diff: (b, turn) => {
42
+ const file = str(b.file || b.path);
43
+ const edits = b.before !== undefined || b.after !== undefined ? [{ before: b.before, after: b.after }] : findChange(turn, file)?.edits;
44
+ const head = `**\`${file}\`**${b.caption ? ` — ${b.caption}` : ''}`;
45
+ return edits?.length ? `${head}\n\n${fence(unified(edits), 'diff')}` : `${head} _(no captured edits)_`;
46
+ },
47
+ code: (b) => `${b.file ? `**\`${b.file}${b.line ? `:${b.line}` : ''}\`**${b.caption ? ` — ${b.caption}` : ''}\n\n` : b.caption ? `${b.caption}\n\n` : ''}${fence(b.code, str(b.language))}`,
48
+ tests: (b) => {
49
+ const n = (k) => Number(b[k]) || 0;
50
+ const head = `**Tests** \`${str(b.command)}\` — ${n('passed')} passed, ${n('failed')} failed${n('skipped') ? `, ${n('skipped')} skipped` : ''}${b.durationMs ? ` (${(n('durationMs') / 1000).toFixed(1)}s)` : ''}`;
51
+ const fails = list(b.failures).map((f) => `- ❌ ${str(f.name)}${f.message ? `: ${f.message}` : ''}${f.file ? ` (\`${f.file}\`)` : ''}`);
52
+ return [head, ...fails].join('\n');
53
+ },
54
+ commits: (b) =>
55
+ `${b.branch ? `**Branch** \`${b.branch}\`\n\n` : ''}${list(b.items)
56
+ .map((c) => `- \`${str(c.hash).slice(0, 7)}\` ${str(c.message)}${c.additions !== undefined ? ` (+${c.additions}/−${c.deletions ?? 0})` : ''}`)
57
+ .join('\n')}`,
58
+ terminal: (b) => fence(`$ ${str(b.command)}\n${str(b.output)}${b.exitCode !== undefined && b.exitCode !== 0 ? `\n[exit ${b.exitCode}]` : ''}`, 'console'),
59
+ compare: (b) => ['before', 'after'].map((k) => `**${str(b[k]?.label) || (k === 'before' ? 'Before' : 'After')}**\n${list(b[k]?.items).map((i) => `- ${str(i)}`).join('\n')}`).join('\n\n'),
60
+ flow: (b) => {
61
+ const nodes = list(b.nodes);
62
+ const label = (id) => nodes.find((n) => n.id === id)?.label || id;
63
+ const edges = list(b.edges).length ? list(b.edges) : nodes.slice(1).map((n, i) => [nodes[i].id, n.id]);
64
+ return `${b.title ? `**${b.title}**\n\n` : ''}${edges.map(([a, z, l]) => `- ${label(a)} → ${label(z)}${l ? ` _(${l})_` : ''}`).join('\n')}`;
65
+ },
66
+ chart: (b) => `${b.title ? `**${b.title}**\n\n` : ''}${list(b.items).map((i) => `- ${str(i.label)}: ${str(i.value)}${str(b.unit)}`).join('\n')}`,
67
+ checklist: (b) => `${b.title ? `**${b.title}**\n\n` : ''}${list(b.items).map((i) => `- [${i.done ? 'x' : ' '}] ${str(i.text)}`).join('\n')}`,
68
+ callout: (b) => `> **${b.tone === 'danger' ? '⛔' : b.tone === 'warn' ? '⚠️' : b.tone === 'success' ? '✅' : 'ℹ️'} ${str(b.title)}**\n> ${str(b.text).replace(/\n/g, '\n> ')}`,
69
+ image: (b) => `🖼️ \`${str(b.path || b.src)}\`${b.caption ? ` — ${b.caption}` : ''}`,
70
+ question: (b) => `**❓ ${str(b.question)}**\n${list(b.options).map((o, i) => `${i + 1}. ${str(o.label || o)}`).join('\n')}`,
71
+ next: (b) => `**Next**\n${list(b.items).map((i) => `- ${str(i)}`).join('\n')}`,
72
+ };
73
+
74
+ // blocks: presentation blocks; turn (optional) supplies captured edits for diff blocks.
75
+ export function blocksToMarkdown(blocks = [], turn = null) {
76
+ return list(blocks)
77
+ .map((b) => {
78
+ try {
79
+ return R[b?.type] ? R[b.type](b, turn) : fence(JSON.stringify(b, null, 2), 'json');
80
+ } catch {
81
+ return '';
82
+ }
83
+ })
84
+ .filter(Boolean)
85
+ .join('\n\n');
86
+ }
87
+
88
+ // A whole session's latest state as markdown: the newest turn's presentation, plus a pending
89
+ // ask_user question if the agent is blocked on one.
90
+ export function sessionToMarkdown(session) {
91
+ const turn = session.turns?.at(-1);
92
+ const out = [];
93
+ if (turn?.status === 'running') out.push(`_Working… (${turn.activity?.findLast?.((a) => a.kind !== 'text')?.label || 'starting up'})_`);
94
+ else if (turn?.presentation) out.push(blocksToMarkdown(turn.presentation.blocks, turn));
95
+ if (session.ask) {
96
+ out.push(`**❓ The agent is waiting for your answer:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
97
+ if (session.ask.options?.length) out.push(session.ask.options.map((o, i) => `${i + 1}. ${o}`).join('\n'));
98
+ }
99
+ return out.join('\n\n');
100
+ }
package/server/runner.js CHANGED
@@ -220,6 +220,7 @@ export class Runner extends EventEmitter {
220
220
  session.unread = false;
221
221
  delete session.archived;
222
222
  this.#touch(session, { turn: turn.id });
223
+ this.emit('started', { session, turn });
223
224
 
224
225
  let released = false;
225
226
  let linger;
@@ -341,6 +342,7 @@ export class Runner extends EventEmitter {
341
342
  session.unread = !interrupted;
342
343
  this.store.save(session, { now: true });
343
344
  this.#touch(session, { turn: turn.id });
345
+ this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
344
346
  // The follow-up starts right away; don't notify for the run it replaced.
345
347
  if (!interrupted) this.emit('finished', { session: summarize(session, this.store) });
346
348
  }
@@ -0,0 +1,132 @@
1
+ // Anonymous usage analytics + error reports, sent to PostHog over its plain HTTP API (no SDK, to
2
+ // stay dependency-free). What goes out is fixed values only — agent, model, mode, status, timings,
3
+ // counts — never prompts, paths, project names or agent output. Error messages and stacks are
4
+ // scrubbed of the home directory and truncated.
5
+ //
6
+ // Off when any of: no API key, DO_NOT_TRACK=1, WISP_TELEMETRY=0, CI, or "telemetry": "off" in
7
+ // settings. See the Telemetry section of the README.
8
+
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ // PostHog project API key: write-only, safe to ship in the client.
16
+ const KEY = process.env.WISP_POSTHOG_KEY ?? 'phc_wowwZkbP8e4zYuwT67JtX6oxdPwkMWL5kKnrUmYYXhcD';
17
+ const HOST = (process.env.WISP_POSTHOG_HOST || 'https://us.i.posthog.com').replace(/\/$/, '');
18
+ const FLUSH_MS = 15_000;
19
+ const MAX_QUEUE = 50;
20
+ const TIMEOUT_MS = 3000;
21
+
22
+ const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
+ const truthy = (v) => !!v && !/^(0|false|no|off)$/i.test(v);
24
+
25
+ export function envDisabled(env = process.env) {
26
+ return !KEY || truthy(env.DO_NOT_TRACK) || /^(0|false|no|off)$/i.test(env.WISP_TELEMETRY || '') || truthy(env.CI);
27
+ }
28
+
29
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
30
+
31
+ // Paths in messages and stacks would leak usernames and project names: wisp's own files keep
32
+ // their package-relative path, anything else is cut down to its file name.
33
+ export function scrub(text = '', { home = os.homedir(), root = ROOT } = {}) {
34
+ let s = String(text).split(root).join('<wisp>');
35
+ if (home) s = s.split(home).join('~');
36
+ // Any other absolute path keeps only its file name.
37
+ return s
38
+ .replace(/(?<![\w<>~.:/\\-])(?:~|[A-Za-z]:)?[/\\](?:[^\s'"`():/\\]+[/\\])+([^\s'"`():/\\]*)/g, (m, base) => `…/${base}`)
39
+ .slice(0, 500);
40
+ }
41
+
42
+ function frames(stack = '') {
43
+ return String(stack)
44
+ .split('\n')
45
+ .slice(1, 30)
46
+ .map((l) => l.trim().match(/^at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/))
47
+ .filter(Boolean)
48
+ .map(([, fn, file, line, col]) => ({ platform: 'custom', lang: 'javascript', function: fn || '?', filename: scrub(file.replace(/^file:\/\//, '')), lineno: +line, colno: +col, in_app: file.includes(ROOT) && !file.includes('node_modules') }))
49
+ .reverse(); // PostHog wants the innermost frame last
50
+ }
51
+
52
+ export class Telemetry {
53
+ constructor(store, { shell = process.versions.electron ? 'electron' : 'browser' } = {}) {
54
+ this.store = store;
55
+ this.queue = [];
56
+ this.pendingFile = path.join(store.home, 'telemetry-pending.json');
57
+ this.common = {
58
+ $lib: 'wisp',
59
+ $process_person_profile: false, // anonymous events: no person profiles
60
+ version: VERSION,
61
+ os: process.platform,
62
+ osRelease: os.release(),
63
+ arch: process.arch,
64
+ node: process.versions.node,
65
+ electron: process.versions.electron || undefined,
66
+ shell,
67
+ };
68
+ this.timer = setInterval(() => this.flush(), FLUSH_MS);
69
+ this.timer.unref();
70
+ this.#sendPending();
71
+ }
72
+
73
+ get enabled() {
74
+ return !envDisabled() && this.store.config.defaults.telemetry !== 'off';
75
+ }
76
+
77
+ track(event, props = {}) {
78
+ if (!this.enabled) return;
79
+ if (!this.store.config.telemetryId) {
80
+ this.store.config.telemetryId = randomUUID(); // random per install, not tied to the machine or user
81
+ this.store.saveConfig();
82
+ }
83
+ this.queue.push({ event, timestamp: new Date().toISOString(), properties: { ...this.common, ...props, distinct_id: this.store.config.telemetryId } });
84
+ if (this.queue.length >= MAX_QUEUE) this.flush();
85
+ }
86
+
87
+ // PostHog error tracking format.
88
+ captureError(err, props = {}) {
89
+ const e = err instanceof Error ? err : new Error(String(err));
90
+ this.track('$exception', {
91
+ ...props,
92
+ $exception_list: [{ type: props.type || e.name || 'Error', value: scrub(e.message), mechanism: { handled: props.handled ?? true, synthetic: false }, stacktrace: { type: 'raw', frames: frames(e.stack) } }],
93
+ });
94
+ }
95
+
96
+ async flush() {
97
+ if (!this.queue.length) return;
98
+ const batch = this.queue.splice(0);
99
+ try {
100
+ await fetch(`${HOST}/batch/`, {
101
+ method: 'POST',
102
+ headers: { 'content-type': 'application/json' },
103
+ body: JSON.stringify({ api_key: KEY, batch }),
104
+ signal: AbortSignal.timeout(TIMEOUT_MS),
105
+ });
106
+ } catch {} // offline or blocked: drop it, telemetry must never get in the way
107
+ }
108
+
109
+ // Shutdown and crashes leave no time for a request: write what's queued to disk (sync) and
110
+ // send it on the next start.
111
+ persist() {
112
+ clearInterval(this.timer);
113
+ if (!this.queue.length) return;
114
+ try {
115
+ fs.writeFileSync(this.pendingFile, JSON.stringify(this.queue.splice(0)));
116
+ } catch {}
117
+ }
118
+
119
+ crashed(err) {
120
+ if (!this.enabled) return;
121
+ this.captureError(err, { handled: false, type: err?.name || 'UncaughtException' });
122
+ this.persist();
123
+ }
124
+
125
+ #sendPending() {
126
+ try {
127
+ const pending = JSON.parse(fs.readFileSync(this.pendingFile, 'utf8'));
128
+ fs.rmSync(this.pendingFile, { force: true });
129
+ if (this.enabled && Array.isArray(pending)) this.queue.push(...pending.slice(-200));
130
+ } catch {}
131
+ }
132
+ }
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: wisp
3
+ description: Delegate coding tasks to background agents (Claude Code, Codex, opencode, Cursor, Antigravity) through wisp, then read back their presentation (summary, diffs, tests). Use when the user asks to "send this to wisp", run something in the background or in parallel, fan a task out to other agents, or check on / follow up a wisp session.
4
+ ---
5
+
6
+ # wisp: hand tasks to background agents
7
+
8
+ wisp runs coding agents in the background and queues their results for the user. Each task
9
+ becomes a **session**; when the agent finishes it leaves a presentation (hero summary, diffs, test
10
+ runs, next steps) and the session waits in the user's queue. The user sees every session in the
11
+ wisp app, so anything you launch is visible to them.
12
+
13
+ Prefer the MCP tools if they are available (`wisp_launch`, `wisp_wait`, `wisp_get`, `wisp_reply`,
14
+ `wisp_list`, `wisp_stop`, `wisp_agents`). Otherwise use the `wisp` CLI below (`npx -y wisp-agents`
15
+ works in place of `wisp` if it isn't installed). Every command takes `--json`.
16
+
17
+ ## Launch
18
+
19
+ ```sh
20
+ wisp "fix the flaky auth test in test/auth.test.ts; run npm test to verify" --json
21
+ wisp "audit dependencies for known CVEs" --mode plan --agent codex --json
22
+ wisp "add pagination to /api/users" --cwd ~/code/api --wait # block until done, print the result
23
+ ```
24
+
25
+ Options: `--agent claude|codex|opencode|cursor|antigravity`, `--mode plan|edits|auto`, `--model ID`,
26
+ `--effort low|medium|high`, `--cwd DIR` (default: current folder), `--wait`, `--json`.
27
+ `--json` prints the session summary; its `id` is what every other command takes.
28
+
29
+ Write the prompt as a self-contained brief: the agent cannot see your conversation. Include the
30
+ goal, relevant files, constraints, and how to verify (which test command to run).
31
+
32
+ ## Follow the session
33
+
34
+ ```sh
35
+ wisp wait <id> # block until it finishes or asks a question; prints the presentation
36
+ wisp show <id> # current state + latest presentation as markdown
37
+ wisp ls --state running,waiting
38
+ wisp reply <id> "also cover the empty-list case" # follow-up; answers a pending question too
39
+ wisp stop <id>
40
+ ```
41
+
42
+ `state` is one of `running`, `waiting` (blocked on a question to the user), `review` (finished,
43
+ unread by the user) or `done`.
44
+
45
+ ## Rules
46
+
47
+ - **Don't answer questions meant for the user.** When a session is `waiting`, relay the question
48
+ to the user unless they already told you the answer. Only then `wisp reply`.
49
+ - **Mode is a permission level.** Default to what the user picked in wisp (omit `--mode`). Use
50
+ `--mode plan` for read-only investigations. Only use `--mode auto` if the user asked for it.
51
+ - **Parallel work goes in separate folders or worktrees.** Two agents editing the same checkout
52
+ will step on each other; launch the second in a `git worktree` if both write.
53
+ - **Report the result, not the transcript.** Summarize the hero line, files changed and test
54
+ outcome from `wisp show`; point the user at the session in wisp for the full presentation.
55
+ - `wisp wait` can take minutes. If you have other work, launch first, do it, then wait.