wisp-agents 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -1
- package/README.md +57 -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 +93 -1
- package/server/mcp-wisp.js +164 -0
- package/server/openapi.js +184 -0
- package/server/render.js +100 -0
- package/skills/wisp/SKILL.md +55 -0
- package/web/js/app.js +19 -5
- package/web/logos/antigravity-color.svg +1 -0
|
@@ -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
|
+
}
|
package/server/render.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -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.
|
package/web/js/app.js
CHANGED
|
@@ -67,6 +67,7 @@ const HARNESS_LOGO = {
|
|
|
67
67
|
codex: { file: 'codex-color' },
|
|
68
68
|
opencode: { file: 'opencode', mono: true },
|
|
69
69
|
cursor: { file: 'cursor', mono: true },
|
|
70
|
+
antigravity: { file: 'antigravity-color' },
|
|
70
71
|
};
|
|
71
72
|
function harnessLogo(agent) {
|
|
72
73
|
const l = HARNESS_LOGO[agent];
|
|
@@ -1270,13 +1271,14 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
1270
1271
|
.map((e) => `<button role="radio" aria-checked="${e === current}" class="${e === current ? 'on' : ''}" data-e="${esc(e)}" title="${e ? `${esc(effortLabel(e))} reasoning effort` : 'Whatever the harness is set to'}">${esc(e ? effortLabel(e) : 'Default')}</button>`)
|
|
1271
1272
|
.join('')}</span>`;
|
|
1272
1273
|
};
|
|
1274
|
+
const markInUse = (id) => {
|
|
1275
|
+
$$('.pk-dot', node).forEach((d) => d.remove());
|
|
1276
|
+
sideRows.find((b) => b.dataset.id === id)?.insertAdjacentHTML('beforeend', '<span class="pk-dot" title="In use"></span>');
|
|
1277
|
+
};
|
|
1273
1278
|
const chooseEffort = (e) => {
|
|
1274
1279
|
const m = modelOf(focusAgent);
|
|
1275
1280
|
if (focusAgent === agent && e === effort) return;
|
|
1276
|
-
if (focusAgent !== agent)
|
|
1277
|
-
$$('.pk-dot', node).forEach((d) => d.remove());
|
|
1278
|
-
sideRows.find((b) => b.dataset.id === focusAgent)?.insertAdjacentHTML('beforeend', '<span class="pk-dot" title="In use"></span>');
|
|
1279
|
-
}
|
|
1281
|
+
if (focusAgent !== agent) markInUse(focusAgent);
|
|
1280
1282
|
agent = focusAgent;
|
|
1281
1283
|
model = m;
|
|
1282
1284
|
effort = e;
|
|
@@ -1336,6 +1338,17 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
1336
1338
|
if (focus) sideRows.find((b) => b.dataset.id === id)?.focus();
|
|
1337
1339
|
};
|
|
1338
1340
|
|
|
1341
|
+
// Picking a harness applies it straight away with the model and level it shows as selected.
|
|
1342
|
+
const useAgent = (id) => {
|
|
1343
|
+
preview(id);
|
|
1344
|
+
if (id === agent) return;
|
|
1345
|
+
markInUse(id);
|
|
1346
|
+
agent = id;
|
|
1347
|
+
model = savedModel(id);
|
|
1348
|
+
effort = savedEffort(id, model);
|
|
1349
|
+
pick(agent, model, effort);
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1339
1352
|
const close = () => {
|
|
1340
1353
|
node.remove();
|
|
1341
1354
|
anchor.classList.remove('open');
|
|
@@ -1390,6 +1403,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
1390
1403
|
preview(focusAgent, { focus: true });
|
|
1391
1404
|
} else if ((e.key === 'ArrowRight' || e.key === 'Enter') && inSide) {
|
|
1392
1405
|
e.preventDefault();
|
|
1406
|
+
if (e.key === 'Enter') useAgent(focusAgent);
|
|
1393
1407
|
focusModels();
|
|
1394
1408
|
} else if (e.key === 'Enter' && document.activeElement === input && rows()[0]) {
|
|
1395
1409
|
e.preventDefault();
|
|
@@ -1398,7 +1412,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
1398
1412
|
input.focus(); // type-to-search from anywhere in the picker
|
|
1399
1413
|
}
|
|
1400
1414
|
};
|
|
1401
|
-
sideRows.forEach((b) => (b.onclick = () =>
|
|
1415
|
+
sideRows.forEach((b) => (b.onclick = () => useAgent(b.dataset.id)));
|
|
1402
1416
|
list.onclick = (e) => {
|
|
1403
1417
|
const b = e.target.closest('.pk-m');
|
|
1404
1418
|
if (b) choose(b.dataset.id);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Antigravity</title><mask height="23" id="lobe-icons-antigravity-0-_R_0_" maskUnits="userSpaceOnUse" width="24" x="0" y="1"><path d="M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z" fill="#fff"></path></mask><g mask="url(#lobe-icons-antigravity-0-_R_0_)"><g filter="url(#lobe-icons-antigravity-1-_R_0_)"><path d="M-1.018-3.992c-.408 3.591 2.686 6.89 6.91 7.37 4.225.48 7.98-2.043 8.387-5.633.408-3.59-2.686-6.89-6.91-7.37-4.225-.479-7.98 2.043-8.387 5.633z" fill="#FFE432"></path></g><g filter="url(#lobe-icons-antigravity-2-_R_0_)"><path d="M15.269 7.747c1.058 4.557 5.691 7.374 10.348 6.293 4.657-1.082 7.575-5.653 6.516-10.21-1.058-4.556-5.691-7.374-10.348-6.292-4.657 1.082-7.575 5.653-6.516 10.21z" fill="#FC413D"></path></g><g filter="url(#lobe-icons-antigravity-3-_R_0_)"><path d="M-12.443 10.804c1.338 4.703 7.36 7.11 13.453 5.378 6.092-1.733 9.947-6.95 8.61-11.652C8.282-.173 2.26-2.58-3.833-.848-9.925.884-13.78 6.1-12.443 10.804z" fill="#00B95C"></path></g><g filter="url(#lobe-icons-antigravity-4-_R_0_)"><path d="M-12.443 10.804c1.338 4.703 7.36 7.11 13.453 5.378 6.092-1.733 9.947-6.95 8.61-11.652C8.282-.173 2.26-2.58-3.833-.848-9.925.884-13.78 6.1-12.443 10.804z" fill="#00B95C"></path></g><g filter="url(#lobe-icons-antigravity-5-_R_0_)"><path d="M-7.608 14.703c3.352 3.424 9.126 3.208 12.896-.483 3.77-3.69 4.108-9.459.756-12.883C2.69-2.087-3.083-1.871-6.853 1.82c-3.77 3.69-4.108 9.458-.755 12.883z" fill="#00B95C"></path></g><g filter="url(#lobe-icons-antigravity-6-_R_0_)"><path d="M9.932 27.617c1.04 4.482 5.384 7.303 9.7 6.3 4.316-1.002 6.971-5.448 5.93-9.93-1.04-4.483-5.384-7.304-9.7-6.301-4.316 1.002-6.971 5.448-5.93 9.93z" fill="#3186FF"></path></g><g filter="url(#lobe-icons-antigravity-7-_R_0_)"><path d="M2.572-8.185C.392-3.329 2.778 2.472 7.9 4.771c5.122 2.3 11.042.227 13.222-4.63 2.18-4.855-.205-10.656-5.327-12.955-5.122-2.3-11.042-.227-13.222 4.63z" fill="#FBBC04"></path></g><g filter="url(#lobe-icons-antigravity-8-_R_0_)"><path d="M-3.267 38.686c-5.277-2.072 3.742-19.117 5.984-24.83 2.243-5.712 8.34-8.664 13.616-6.592 5.278 2.071 11.533 13.482 9.29 19.195-2.242 5.713-23.613 14.298-28.89 12.227z" fill="#3186FF"></path></g><g filter="url(#lobe-icons-antigravity-9-_R_0_)"><path d="M28.71 17.471c-1.413 1.649-5.1.808-8.236-1.878-3.135-2.687-4.531-6.201-3.118-7.85 1.412-1.649 5.1-.808 8.235 1.878s4.532 6.2 3.119 7.85z" fill="#749BFF"></path></g><g filter="url(#lobe-icons-antigravity-10-_R_0_)"><path d="M18.163 9.077c5.81 3.93 12.502 4.19 14.946.577 2.443-3.612-.287-9.727-6.098-13.658-5.81-3.931-12.502-4.19-14.946-.577-2.443 3.612.287 9.727 6.098 13.658z" fill="#FC413D"></path></g><g filter="url(#lobe-icons-antigravity-11-_R_0_)"><path d="M-.915 2.684c-1.44 3.473-.97 6.967 1.05 7.804 2.02.837 4.824-1.3 6.264-4.772 1.44-3.473.97-6.967-1.05-7.804-2.02-.837-4.824 1.3-6.264 4.772z" fill="#FFEE48"></path></g></g><defs><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="17.587" id="lobe-icons-antigravity-1-_R_0_" width="19.838" x="-3.288" y="-11.917"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="1.117"></feGaussianBlur></filter><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="38.565" id="lobe-icons-antigravity-2-_R_0_" width="38.9" x="4.251" y="-13.493"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="5.4"></feGaussianBlur></filter><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="36.517" id="lobe-icons-antigravity-3-_R_0_" width="40.955" x="-21.889" y="-10.592"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="4.591"></feGaussianBlur></filter><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="36.517" id="lobe-icons-antigravity-4-_R_0_" width="40.955" x="-21.889" y="-10.592"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="4.591"></feGaussianBlur></filter><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="36.595" id="lobe-icons-antigravity-5-_R_0_" width="36.632" x="-19.099" y="-10.278"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="4.591"></feGaussianBlur></filter><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="34.087" id="lobe-icons-antigravity-6-_R_0_" width="33.533" x=".981" y="8.758"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="4.363"></feGaussianBlur></filter><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="35.276" id="lobe-icons-antigravity-7-_R_0_" width="35.978" x="-6.143" y="-21.659"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="3.954"></feGaussianBlur></filter><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="46.523" id="lobe-icons-antigravity-8-_R_0_" width="45.114" x="-11.96" y="-.46"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="3.531"></feGaussianBlur></filter><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="24.054" id="lobe-icons-antigravity-9-_R_0_" width="25.094" x="10.485" y=".58"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="3.159"></feGaussianBlur></filter><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="30.007" id="lobe-icons-antigravity-10-_R_0_" width="33.508" x="5.833" y="-12.467"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="2.669"></feGaussianBlur></filter><filter color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="26.151" id="lobe-icons-antigravity-11-_R_0_" width="22.194" x="-8.355" y="-8.876"><feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"></feBlend><feGaussianBlur result="effect1_foregroundBlur_977_115" stdDeviation="3.303"></feGaussianBlur></filter></defs></svg>
|