wisp-agents 0.7.0 → 0.9.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/server/mcp-ask.js CHANGED
@@ -2,10 +2,16 @@
2
2
  // Minimal stdio MCP server exposing `ask_user`, injected into every agent wisp launches.
3
3
  // The call is relayed to the wisp server, which surfaces it in the queue and blocks
4
4
  // (by polling) until the user answers — so the agent keeps its full context.
5
+ // With a team (WISP_TEAM), it also offers `delegate` and `subagent_results`: tasks for the
6
+ // subagents the user picked, which wisp runs as child sessions while this call waits.
5
7
 
6
8
  import readline from 'node:readline';
7
9
 
8
10
  const { WISP_URL, WISP_TOKEN, WISP_SESSION } = process.env;
11
+ let TEAM = [];
12
+ try {
13
+ TEAM = JSON.parse(process.env.WISP_TEAM || '[]');
14
+ } catch {}
9
15
 
10
16
  const TOOL = {
11
17
  name: 'ask_user',
@@ -24,6 +30,67 @@ const TOOL = {
24
30
  },
25
31
  };
26
32
 
33
+ const roster = TEAM.map((m) => `- ${m.name}${m.count > 1 ? ` ×${m.count} (parallel copies ${m.copies.join(', ')})` : ''}: ${[m.agent, m.model || 'default model', m.effort && `${m.effort} thinking`].filter(Boolean).join(' · ')}${m.use ? ` — use for: ${m.use}` : ''}`).join('\n');
34
+ const TEAM_TOOLS = [
35
+ {
36
+ name: 'delegate',
37
+ description:
38
+ 'Hand tasks to your subagents (separate coding agents the user picked, each on its own harness and model). ' +
39
+ 'Tasks in one call run in parallel in your working folder. By default the call waits until every task finishes and ' +
40
+ 'returns each report plus the files it changed; wait=false returns delegation ids at once. Delegating to a subagent ' +
41
+ 'again continues its conversation. Use this instead of any built-in subagent or task tool.\n\nThe team:\n' + roster,
42
+ inputSchema: {
43
+ type: 'object',
44
+ properties: {
45
+ tasks: {
46
+ type: 'array',
47
+ minItems: 1,
48
+ items: {
49
+ type: 'object',
50
+ properties: {
51
+ subagent: { type: 'string', enum: TEAM.flatMap((m) => (m.count > 1 ? [m.name, ...m.copies] : [m.name])), description: 'Which subagent. A name with copies (×N) goes to a free copy; name a copy (x#2) to continue it.' },
52
+ prompt: { type: 'string', description: 'The task, briefed like a colleague: goal, files, constraints, how to verify. It cannot see your conversation.' },
53
+ },
54
+ required: ['subagent', 'prompt'],
55
+ },
56
+ },
57
+ wait: { type: 'boolean', description: 'Wait for all tasks to finish (default true).' },
58
+ },
59
+ required: ['tasks'],
60
+ },
61
+ },
62
+ {
63
+ name: 'subagent_results',
64
+ description: 'Wait for delegated tasks and return their reports. Pass the ids delegate returned; with none, every task delegated in this turn.',
65
+ inputSchema: { type: 'object', properties: { ids: { type: 'array', items: { type: 'string' } }, wait: { type: 'boolean', description: 'Default true; false returns current status at once.' } } },
66
+ },
67
+ ];
68
+
69
+ async function results(ids, wait = true) {
70
+ for (;;) {
71
+ let r;
72
+ try {
73
+ r = await call(`/api/internal/delegate/${WISP_SESSION}?ids=${encodeURIComponent((ids || []).join(','))}`);
74
+ } catch (err) {
75
+ if (!wait) throw err;
76
+ await sleep(1500);
77
+ continue; // wisp restarting — keep waiting
78
+ }
79
+ if (r.done || !wait) return r.text;
80
+ await sleep(1500);
81
+ }
82
+ }
83
+
84
+ async function delegate({ tasks, wait = true }) {
85
+ const r = await call(`/api/internal/delegate/${WISP_SESSION}`, { tasks });
86
+ const ok = r.delegations.filter((d) => !d.error);
87
+ const errors = r.delegations.filter((d) => d.error).map((d) => `- ${d.subagent}: ${d.error}`).join('\n');
88
+ if (!ok.length) return `Nothing was delegated.\n${errors}`;
89
+ const head = errors ? `Some tasks were not delegated:\n${errors}\n\n` : '';
90
+ if (!wait) return `${head}Delegated: ${ok.map((d) => `${d.name} (id ${d.id})`).join(', ')}. Collect the reports with subagent_results.`;
91
+ return head + (await results(ok.map((d) => d.id)));
92
+ }
93
+
27
94
  const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
28
95
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
96
 
@@ -66,17 +133,23 @@ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
66
133
  if (method === 'initialize') {
67
134
  send({ id, result: { protocolVersion: params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'wisp', version: '0.1.0' } } });
68
135
  } else if (method === 'tools/list') {
69
- send({ id, result: { tools: [TOOL] } });
136
+ send({ id, result: { tools: TEAM.length ? [TOOL, ...TEAM_TOOLS] : [TOOL] } });
70
137
  } else if (method === 'tools/call' && params?.name === 'ask_user') {
71
138
  const answer = await askUser(params.arguments || {});
72
139
  const text = answer === null ? 'The user did not answer (the question was cancelled). Use your best judgment and note the assumption.' : `The user answered: ${answer}`;
73
140
  send({ id, result: { content: [{ type: 'text', text }] } });
141
+ } else if (method === 'tools/call' && TEAM.length && params?.name === 'delegate') {
142
+ send({ id, result: { content: [{ type: 'text', text: await delegate(params.arguments || {}) }] } });
143
+ } else if (method === 'tools/call' && TEAM.length && params?.name === 'subagent_results') {
144
+ const a = params.arguments || {};
145
+ send({ id, result: { content: [{ type: 'text', text: await results(a.ids, a.wait !== false) }] } });
74
146
  } else if (method === 'ping') {
75
147
  send({ id, result: {} });
76
148
  } else {
77
149
  send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
78
150
  }
79
151
  } catch (err) {
80
- send({ id, result: { isError: true, content: [{ type: 'text', text: `wisp could not reach the user: ${err.message}` }] } });
152
+ const what = params?.name === 'ask_user' ? 'wisp could not reach the user' : 'wisp';
153
+ send({ id, result: { isError: true, content: [{ type: 'text', text: `${what}: ${err.message}` }] } });
81
154
  }
82
155
  });
@@ -1,5 +1,5 @@
1
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.
2
+ // opencode, Cursor, Antigravity, Grok, Claude Desktop, Zed, …) hand tasks to wisp and read the results back.
3
3
  // It talks to the running wisp over HTTP (starting a headless one if needed) via the client SDK.
4
4
  //
5
5
  // claude mcp add wisp -- npx -y wisp-agents mcp
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import { WispClient } from './client.js';
10
10
 
11
11
  const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
- const AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'antigravity'];
12
+ const AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'];
13
13
  const DEFAULT_WAIT_S = 240; // stay under typical MCP client tool-call timeouts; call wisp_wait again for more
14
14
 
15
15
  const TOOLS = [
@@ -28,6 +28,12 @@ const TOOLS = [
28
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
29
  model: { type: 'string', description: 'Model id for that harness. Default: the user’s pick.' },
30
30
  effort: { type: 'string', description: 'Thinking level, e.g. low | medium | high.' },
31
+ subagents: {
32
+ type: 'array',
33
+ description: 'A team the agent can delegate to, each on its own harness: [{ agent, model?, effort?, name?, use?, count? }]. use = when to pick it ("research", "image work"); count = parallel copies (1–5).',
34
+ items: { type: 'object', properties: { agent: { type: 'string', enum: AGENTS }, model: { type: 'string' }, effort: { type: 'string' }, name: { type: 'string' }, use: { type: 'string' }, count: { type: 'integer', minimum: 1, maximum: 5 } }, required: ['agent'] },
35
+ },
36
+ preset: { type: 'string', description: 'Id of a saved subagent preset, instead of subagents.' },
31
37
  wait: { type: 'boolean', description: 'Block until the agent finishes or asks (up to timeout_seconds).' },
32
38
  timeout_seconds: { type: 'number', description: `Max wait when wait=true. Default ${DEFAULT_WAIT_S}.` },
33
39
  },
@@ -98,7 +104,7 @@ async function callTool(name, a = {}) {
98
104
  switch (name) {
99
105
  case 'wisp_launch': {
100
106
  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 });
107
+ 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, subagents: a.subagents, preset: a.preset });
102
108
  if (a.wait) return waitFor(s.id);
103
109
  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
110
  }
package/server/openapi.js CHANGED
@@ -16,7 +16,7 @@ export function openapi({ version, port }) {
16
16
  title: 'wisp',
17
17
  version,
18
18
  description:
19
- 'Local API of wisp, a launcher and queue for coding agents (Claude Code, Codex, opencode, Cursor, Antigravity). ' +
19
+ 'Local API of wisp, a launcher and queue for coding agents (Claude Code, Codex, opencode, Cursor, Antigravity, Grok). ' +
20
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
21
  'application/json (uploads: application/octet-stream) and, if they send an Origin, a local one. ' +
22
22
  'The running server advertises { url, port, pid, version } in ~/.wisp/server.json.',
@@ -81,17 +81,90 @@ export function openapi({ version, port }) {
81
81
  ],
82
82
  responses: { 200: json({ type: 'array', items: ref('Summary') }) },
83
83
  },
84
- post: { summary: 'Launch a task', requestBody: body(ref('Launch')), responses: { 201: json(ref('Summary')), 400: err[400] } },
84
+ post: { summary: 'Launch a task. With `compare`, the response is the first session plus `group`: every session started', requestBody: body(ref('Launch')), responses: { 201: json(ref('Summary')), 400: err[400] } },
85
85
  },
86
86
  '/sessions/{id}': {
87
87
  parameters: [id],
88
88
  get: { summary: 'Full session record: turns, activity, captured edits, presentations', responses: { 200: json({ type: 'object' }), 404: err[404] } },
89
89
  patch: {
90
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' } } }),
91
+ requestBody: body({ type: 'object', properties: { title: { type: 'string' }, state: { enum: ['done', 'review'] }, unread: { type: 'boolean' }, archived: { type: 'boolean' }, discardWorktree: { type: 'boolean', description: 'Remove the worktree and its uncommitted changes (a compare run you did not keep)' } } }),
92
92
  responses: { 200: json(ref('Summary')), 404: err[404] },
93
93
  },
94
- delete: { summary: 'Stop and delete', responses: { 200: ok, 404: err[404] } },
94
+ delete: { summary: 'Stop and delete. Its worktree is removed; its branch too unless it has commits', responses: { 200: ok, 404: err[404] } },
95
+ },
96
+ '/sessions/{id}/ship': {
97
+ parameters: [id],
98
+ get: { summary: 'What would ship: { branch, base, remote, dirty, ahead, pr, title, body } (drafted from the latest presentation)', responses: { 200: json({ type: 'object' }), 400: err[400] } },
99
+ post: {
100
+ summary: 'Commit uncommitted changes in the worktree, push its branch, open (or update) the pull request with gh',
101
+ requestBody: body({ type: 'object', properties: { title: { type: 'string', description: 'Commit message and PR title' }, body: { type: 'string' }, draft: { type: 'boolean' } } }),
102
+ responses: { 200: json(ref('PullRequest')), 400: err[400], 409: json(ref('Error'), 'Still running') },
103
+ },
104
+ },
105
+ '/sessions/{id}/pr': { parameters: [id], post: { summary: 'Refresh the PR status (checks, review, state) now; it is also polled every minute', responses: { 200: json(ref('PullRequest')), 404: err[404] } } },
106
+ '/sessions/{id}/merge': {
107
+ parameters: [id],
108
+ post: { summary: 'Mark the PR ready if draft, squash-merge it with --match-head-commit, fast-forward the local base branch, remove the worktree', responses: { 200: json({ type: 'object', properties: { pr: ref('PullRequest'), sync: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' } } } } }), 400: err[400] } },
109
+ },
110
+ '/recipes': {
111
+ get: { summary: 'Saved recipes, with the {params} found in each prompt', responses: { 200: json({ type: 'array', items: ref('Recipe') }) } },
112
+ post: { summary: 'Save a recipe', requestBody: body(ref('Recipe')), responses: { 201: json(ref('Recipe')), 400: err[400] } },
113
+ },
114
+ '/recipes/{key}': {
115
+ parameters: [{ name: 'key', in: 'path', required: true, schema: { type: 'string' }, description: 'Recipe id or name' }],
116
+ get: { summary: 'One recipe', responses: { 200: json(ref('Recipe')), 404: err[404] } },
117
+ patch: { summary: 'Edit a recipe', requestBody: body(ref('Recipe')), responses: { 200: json(ref('Recipe')), 400: err[400] } },
118
+ delete: { summary: 'Delete a recipe', responses: { 200: ok } },
119
+ },
120
+ '/recipes/{key}/run': {
121
+ parameters: [{ name: 'key', in: 'path', required: true, schema: { type: 'string' } }],
122
+ post: {
123
+ summary: 'Run a recipe: fill its {params} and launch',
124
+ requestBody: body({ type: 'object', properties: { params: { type: 'object', additionalProperties: { type: 'string' } }, words: { type: 'array', items: { type: 'string' }, description: 'Positional values, in the order the params appear' }, cwd: { type: 'string', description: 'Used when the recipe has no project of its own' }, agent: { type: 'string' }, model: { type: 'string' }, effort: { type: 'string' }, mode: { enum: ['plan', 'edits', 'auto'] } } }),
125
+ responses: { 201: json(ref('Summary')), 400: err[400], 404: err[404] },
126
+ },
127
+ },
128
+ '/triggers': {
129
+ get: { summary: 'Schedules and GitHub watchers, with nextAt and the last run', responses: { 200: json({ type: 'array', items: ref('Trigger') }) } },
130
+ post: { summary: 'Add a trigger', requestBody: body(ref('Trigger')), responses: { 201: json(ref('Trigger')), 400: err[400] } },
131
+ },
132
+ '/triggers/{tid}': {
133
+ parameters: [{ name: 'tid', in: 'path', required: true, schema: { type: 'string' } }],
134
+ patch: { summary: 'Edit, enable or disable a trigger', requestBody: body(ref('Trigger')), responses: { 200: json(ref('Trigger')), 400: err[400] } },
135
+ delete: { summary: 'Delete a trigger', responses: { 200: ok } },
136
+ },
137
+ '/triggers/{tid}/run': { parameters: [{ name: 'tid', in: 'path', required: true, schema: { type: 'string' } }], post: { summary: 'Fire a schedule now, or poll a watcher now (firing for anything new)', responses: { 200: json({ type: 'object', properties: { started: { type: 'array', items: ref('Summary') }, trigger: ref('Trigger') } }) } } },
138
+ '/remote': {
139
+ get: { summary: 'Phone channels (ntfy, Telegram) with secrets masked, and their status', responses: { 200: json({ type: 'object' }) } },
140
+ put: {
141
+ summary: 'Configure phone channels. A masked token (••••1234) means unchanged',
142
+ requestBody: body({ type: 'object', properties: { events: { type: 'array', items: { enum: ['waiting', 'finished'] } }, ntfy: { type: 'object', properties: { enabled: { type: 'boolean' }, server: { type: 'string' }, topic: { type: 'string' }, token: { type: 'string' } } }, telegram: { type: 'object', properties: { enabled: { type: 'boolean' }, token: { type: 'string' }, unlink: { type: 'boolean' } } } } }),
143
+ responses: { 200: json({ type: 'object' }), 400: err[400] },
144
+ },
145
+ },
146
+ '/machines': {
147
+ get: { summary: 'Machines (other computers over SSH) with connection state, steps, hints and their wisp version and harnesses', responses: { 200: json({ type: 'array', items: { type: 'object' } }) } },
148
+ post: {
149
+ summary: 'Add a machine and connect (unless connect: false). wisp starts itself there over SSH and tunnels back',
150
+ requestBody: body({ type: 'object', required: ['target'], properties: { target: { type: 'string', description: 'user@host, a host, or a Host from ~/.ssh/config' }, name: { type: 'string' }, port: { type: 'integer', default: 4477, description: 'wisp port on the machine' }, command: { type: 'string', description: 'Start command; default npx -y wisp-agents@<this version>' }, autoConnect: { type: 'boolean', default: true }, connect: { type: 'boolean', default: true } } }),
151
+ responses: { 201: json({ type: 'object' }), 400: err[400] },
152
+ },
153
+ },
154
+ '/machines/ssh-hosts': { get: { summary: 'Hosts from ~/.ssh/config, for suggestions', responses: { 200: json({ type: 'array', items: { type: 'object' } }) } } },
155
+ '/machines/{mid}': { parameters: [{ name: 'mid', in: 'path', required: true, schema: { type: 'string' } }], patch: { summary: 'Edit a machine (host, port or command changes reconnect)', responses: { 200: json({ type: 'object' }) } }, delete: { summary: 'Remove a machine and its projects here (nothing on the machine is touched)', responses: { 200: json({ type: 'object' }) } } },
156
+ '/machines/{mid}/connect': { parameters: [{ name: 'mid', in: 'path', required: true, schema: { type: 'string' } }], post: { summary: 'Connect: SSH in, start wisp there if needed, open the tunnel', responses: { 200: json({ type: 'object' }) } } },
157
+ '/machines/{mid}/disconnect': { parameters: [{ name: 'mid', in: 'path', required: true, schema: { type: 'string' } }], post: { summary: 'Close the tunnel; wisp and its agents keep running there', responses: { 200: json({ type: 'object' }) } } },
158
+ '/machines/{mid}/stop': { parameters: [{ name: 'mid', in: 'path', required: true, schema: { type: 'string' } }], post: { summary: 'Stop wisp on the machine (its agents too); restart: true starts this version in its place', requestBody: body({ type: 'object', properties: { restart: { type: 'boolean' } } }), responses: { 200: json({ type: 'object' }) } } },
159
+ '/machines/{mid}/fs': { parameters: [{ name: 'mid', in: 'path', required: true, schema: { type: 'string' } }, { name: 'q', in: 'query', schema: { type: 'string' } }], get: { summary: 'Folder suggestions on the machine', responses: { 200: json({ type: 'array', items: { type: 'string' } }) } } },
160
+ '/machines/{mid}/discover': { parameters: [{ name: 'mid', in: 'path', required: true, schema: { type: 'string' } }], get: { summary: 'Git repos found on the machine that are not projects yet', responses: { 200: json({ type: 'array', items: { type: 'object' } }) } } },
161
+ '/remote/test': { post: { summary: 'Send a test message to the enabled channels', responses: { 200: json({ type: 'object' }), 400: err[400] } } },
162
+ '/usage': {
163
+ get: {
164
+ summary: 'Spend and outcomes: totals, per day, per project, per harness · model',
165
+ parameters: [{ name: 'days', in: 'query', schema: { type: 'integer', default: 30, maximum: 365 } }],
166
+ responses: { 200: json({ type: 'object' }) },
167
+ },
95
168
  },
96
169
  '/sessions/{id}/wait': {
97
170
  parameters: [id, { name: 'timeout', in: 'query', schema: { type: 'integer', default: 60, maximum: 290 }, description: 'Seconds' }],
@@ -114,6 +187,30 @@ export function openapi({ version, port }) {
114
187
  },
115
188
  },
116
189
  '/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 } } },
190
+ '/sessions/{id}/revert': {
191
+ parameters: [id],
192
+ post: {
193
+ summary: 'Revert (or re-apply) a single file’s changes from a turn',
194
+ requestBody: body({ type: 'object', required: ['file'], properties: { file: { type: 'string' }, turnId: { type: 'string' }, restore: { type: 'boolean' } } }),
195
+ responses: { 200: json({ type: 'object', properties: { ok: { type: 'boolean' }, turnId: { type: 'string' }, file: { type: 'string' }, reverted: { type: 'boolean' } } }), 400: err[400], 404: err[404] },
196
+ },
197
+ },
198
+ '/sessions/{id}/rollback': {
199
+ parameters: [id],
200
+ post: {
201
+ summary: 'Restore the project working tree to the checkpoint before a turn (or re-apply)',
202
+ requestBody: body({ type: 'object', properties: { turnId: { type: 'string' }, restore: { type: 'boolean' } } }),
203
+ responses: { 200: json({ type: 'object', properties: { ok: { type: 'boolean' }, turnId: { type: 'string' }, rolledBack: { type: 'boolean' }, files: { type: 'array', items: { type: 'string' } } } }), 400: err[400], 404: err[404] },
204
+ },
205
+ },
206
+ '/sessions/{id}/exec': {
207
+ parameters: [id],
208
+ post: {
209
+ summary: 'Run a shell command from an actionable presentation block in the session’s project folder',
210
+ requestBody: body({ type: 'object', required: ['command'], properties: { command: { type: 'string' }, turnId: { type: 'string' }, key: { type: 'string' } } }),
211
+ responses: { 200: json({ type: 'object', properties: { command: { type: 'string' }, output: { type: 'string' }, exitCode: { type: 'integer' }, durationMs: { type: 'integer' } } }), 400: err[400] },
212
+ },
213
+ },
117
214
  '/sessions/{id}/stop': { parameters: [id], post: { summary: 'Stop the run and drop queued follow-ups', responses: { 200: ok } } },
118
215
  '/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
216
  '/sessions/{id}/takeover': {
@@ -149,11 +246,69 @@ export function openapi({ version, port }) {
149
246
  properties: {
150
247
  prompt: { type: 'string' },
151
248
  cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
152
- agent: { enum: ['claude', 'codex', 'opencode', 'cursor', 'antigravity'], default: 'claude' },
249
+ agent: { enum: ['claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'claude' },
153
250
  mode: { enum: ['plan', 'edits', 'auto'], default: 'edits' },
154
251
  model: { type: 'string' },
155
252
  effort: { type: 'string', description: 'Thinking level: low, medium, high, xhigh, max (per harness)' },
156
253
  attachments: { type: 'array', items: { type: 'string' }, description: 'Upload ids' },
254
+ worktree: { type: 'boolean', description: 'Run in a fresh git worktree + branch. Default: on in git repos (Settings), off in plan mode' },
255
+ subagents: { type: 'array', maxItems: 6, items: { type: 'object', required: ['agent'], properties: { name: { type: 'string' }, agent: { type: 'string' }, model: { type: 'string' }, effort: { type: 'string' }, use: { type: 'string', description: 'When the lead should pick this one' }, count: { type: 'integer', minimum: 1, maximum: 5, description: 'Identical copies that take tasks in parallel (name#1, name#2, …); 12 in all' } } }, description: 'A team the lead can delegate to through the injected `delegate` tool. Each runs as a child session in the lead’s folder' },
256
+ preset: { type: 'string', description: 'Id of a saved team (`/teams`), used when `subagents` is absent' },
257
+ compare: { type: 'array', maxItems: 3, items: { type: 'object', properties: { agent: { type: 'string' }, model: { type: 'string' }, effort: { type: 'string' } } }, description: 'Also run the same task on these, each in its own worktree' },
258
+ },
259
+ },
260
+ PullRequest: {
261
+ type: 'object',
262
+ properties: {
263
+ number: { type: 'integer' },
264
+ url: { type: 'string' },
265
+ state: { enum: ['OPEN', 'CLOSED', 'MERGED'] },
266
+ draft: { type: 'boolean' },
267
+ review: { type: 'string', description: 'APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | ""' },
268
+ checks: { type: 'object', properties: { pass: { type: 'integer' }, fail: { type: 'integer' }, pending: { type: 'integer' } } },
269
+ head: { type: 'string' },
270
+ base: { type: 'string' },
271
+ },
272
+ },
273
+ Recipe: {
274
+ type: 'object',
275
+ required: ['name', 'prompt'],
276
+ properties: {
277
+ id: { type: 'string' },
278
+ name: { type: 'string' },
279
+ prompt: { type: 'string', description: '{name} placeholders become parameters' },
280
+ params: { type: 'array', items: { type: 'string' }, readOnly: true },
281
+ cwd: { type: 'string', description: 'Project path, or empty for any project' },
282
+ agent: { type: 'string' },
283
+ model: { type: 'string' },
284
+ effort: { type: 'string' },
285
+ mode: { enum: ['', 'plan', 'edits', 'auto'] },
286
+ },
287
+ },
288
+ Trigger: {
289
+ type: 'object',
290
+ required: ['kind', 'cwd'],
291
+ properties: {
292
+ id: { type: 'string' },
293
+ name: { type: 'string' },
294
+ kind: { enum: ['schedule', 'ci', 'issues'] },
295
+ enabled: { type: 'boolean' },
296
+ cwd: { type: 'string' },
297
+ prompt: { type: 'string', description: 'Template; ci gets {id} {name} {branch} {sha} {url}, issues get {number} {title} {body} {url} {author}' },
298
+ recipeId: { type: 'string', description: 'Run this recipe instead of prompt' },
299
+ params: { type: 'object' },
300
+ cron: { type: 'string', description: 'schedule: 5-field cron, local time' },
301
+ branch: { type: 'string', description: 'ci: only failures on this branch' },
302
+ label: { type: 'string', description: 'issues: the label to watch' },
303
+ every: { type: 'integer', description: 'ci/issues: minutes between polls' },
304
+ agent: { type: 'string' },
305
+ model: { type: 'string' },
306
+ effort: { type: 'string' },
307
+ mode: { enum: ['plan', 'edits', 'auto'] },
308
+ nextAt: { type: ['integer', 'null'], readOnly: true },
309
+ lastRunAt: { type: ['integer', 'null'], readOnly: true },
310
+ lastSessionId: { type: ['string', 'null'], readOnly: true },
311
+ lastError: { type: 'string', readOnly: true },
157
312
  },
158
313
  },
159
314
  Summary: {
@@ -180,6 +335,10 @@ export function openapi({ version, port }) {
180
335
  turnCount: { type: 'integer' },
181
336
  createdAt: { type: 'integer' },
182
337
  updatedAt: { type: 'integer' },
338
+ branch: { type: 'string', description: 'Worktree branch, if it has one' },
339
+ pr: { type: ['object', 'null'], description: 'Its pull request, once shipped' },
340
+ groupId: { type: ['string', 'null'], description: 'Shared by sessions launched together with compare' },
341
+ origin: { type: ['object', 'null'], description: '{ kind: recipe | trigger, id, name } when not launched by hand' },
183
342
  },
184
343
  },
185
344
  Presentation: {
@@ -42,6 +42,13 @@ diff, the test run, the command output — over prose. Use file paths relative t
42
42
  A test/lint/type-check run you actually executed. Prefer this over "terminal" for test runs.
43
43
  - {"type":"commits","items":[{"hash":"a1b2c3d","message":"feat(auth): add rate limiter","files":3,"additions":120,"deletions":8}],"branch":"feat/limit"}
44
44
  Commits you created, if any.
45
+ - {"type":"graph","title":"Refresh sessions in auth","ref":"#128","state":"open|draft|merged|closed","into":"main","merge":"conflict|ready|merged|blocked|pending",
46
+ "commits":[{"hash":"d29f50","message":"Add refresh tests","branch":"feat/refresh","author":"You","when":"now"},{"hash":"5be27d","message":"Rotate keys","branch":"main","author":"Lena","when":"6m","conflict":true}],
47
+ "conflicts":[{"file":"src/auth.ts","line":42,"commit":"5be27d","detail":"Both sides rewrote getSession()","ours":"refresh(token)","theirs":"rotate(key)","resolution":"Rotate first, then refresh"}],
48
+ "checks":[{"label":"Tests","value":"42 passed","status":"pass|fail|pending"}],"action":{"label":"Resolve conflicts","reply":"Merge main in and resolve the conflicts"}}
49
+ Branch graph, newest commit first; each commit's "branch" gives its lane ("into" is the base lane).
50
+ Use it to explain merge conflicts, a rebase, or where branches diverged. Mark the base commits that
51
+ conflict with "conflict":true; optional "parents":[hash,…] for merge commits.
45
52
  - {"type":"tree","title":"…","items":[{"path":"src/auth/limiter.ts","change":"added","note":"…"}]}
46
53
  A file tree; use for new modules/scaffolding where structure matters.
47
54
  - {"type":"code","language":"ts","code":"…","caption":"…","file":"src/a.ts","line":10,"highlight":[3,4]}
@@ -58,6 +65,8 @@ diff, the test run, the command output — over prose. Use file paths relative t
58
65
  A screenshot or generated image on disk.
59
66
  - {"type":"question","question":"…","options":[{"label":"Short choice","reply":"full message sent back to you"}]}
60
67
  When you need a decision. The user's click resumes you with the reply.
68
+ - {"type":"actions","title":"…","items":[{"label":"Run tests","command":"npm test"},{"label":"Follow up","reply":"…"}]}
69
+ One-click shell commands (executed in the project root) or follow-up actions the user may want to trigger.
61
70
  - {"type":"next","items":["Suggested follow-up the user might send", "…"]}
62
71
  Usually last. 2–4 concrete follow-ups.
63
72
 
@@ -82,7 +91,8 @@ const ASK_BLOCK = `- If a decision would change the rest of the work (ambiguous
82
91
  The user's answer comes back as a follow-up in this same session.`;
83
92
 
84
93
  // askTool: how the harness names wisp's injected ask_user tool, or null if it can't take one.
85
- export const systemPrompt = ({ askTool = 'mcp__wisp__ask_user' } = {}) => PROTOCOL.replace('{{ASK}}', askTool ? ASK_TOOL(askTool) : ASK_BLOCK);
94
+ // ask: replaces the ask guidance outright (subagents, who have nobody to ask).
95
+ export const systemPrompt = ({ askTool = 'mcp__wisp__ask_user', ask } = {}) => PROTOCOL.replace('{{ASK}}', ask || (askTool ? ASK_TOOL(askTool) : ASK_BLOCK));
86
96
  export const SYSTEM_PROMPT = systemPrompt();
87
97
 
88
98
  // Only the opener is matched by regex. The closing fence can't be: the JSON itself often
@@ -0,0 +1,64 @@
1
+ // Recipes: saved prompts for tasks you send again and again. `{name}` in the prompt is a
2
+ // parameter, filled in when the recipe runs (the run dialog, `wisp run release 0.8.0`, a trigger).
3
+
4
+ import path from 'node:path';
5
+ import { newId } from './runner.js';
6
+
7
+ const PARAM = /\{([a-zA-Z][\w-]{0,30})\}/g;
8
+ const MODES = new Set(['plan', 'edits', 'auto']);
9
+
10
+ export const params = (prompt) => [...new Set([...String(prompt || '').matchAll(PARAM)].map((m) => m[1]))];
11
+
12
+ // strict: every parameter needs a value (a person ran it). Otherwise unknown ones become ''
13
+ // (a trigger filling in an empty issue body). Values are inserted once, never re-expanded.
14
+ export function fill(prompt, values = {}, { strict = true } = {}) {
15
+ const has = (k) => values[k] !== undefined && values[k] !== null && String(values[k]).trim() !== '';
16
+ const missing = params(prompt).filter((k) => !has(k));
17
+ if (strict && missing.length) throw Object.assign(new Error(`Missing ${missing.map((k) => `{${k}}`).join(', ')}`), { status: 400 });
18
+ return String(prompt).replace(PARAM, (_, k) => (has(k) ? String(values[k]) : ''));
19
+ }
20
+
21
+ // Fill positional CLI words into the parameters in the order they appear.
22
+ export function positional(prompt, words = []) {
23
+ const out = {};
24
+ params(prompt).forEach((k, i) => words[i] !== undefined && (out[k] = words[i]));
25
+ return out;
26
+ }
27
+
28
+ const bad = (msg) => Object.assign(new Error(msg), { status: 400 });
29
+
30
+ export function normalizeRecipe(body, prev = null) {
31
+ const name = String(body.name ?? prev?.name ?? '').trim().slice(0, 60);
32
+ const prompt = String(body.prompt ?? prev?.prompt ?? '').trim();
33
+ if (!name) throw bad('A recipe needs a name.');
34
+ if (!prompt) throw bad('A recipe needs a prompt.');
35
+ const cwd = body.cwd !== undefined ? String(body.cwd || '') : prev?.cwd || '';
36
+ if (cwd && !path.isAbsolute(cwd)) throw bad('cwd must be an absolute project path (or empty for any project).');
37
+ const mode = body.mode ?? prev?.mode ?? '';
38
+ if (mode && !MODES.has(mode)) throw bad('mode must be plan, edits or auto.');
39
+ const pick = (k) => String(body[k] ?? prev?.[k] ?? '');
40
+ return {
41
+ id: prev?.id || newId(),
42
+ name,
43
+ prompt,
44
+ cwd, // '' = whichever project it's run in
45
+ agent: pick('agent'),
46
+ model: pick('model'),
47
+ effort: /^[\w-]{0,20}$/.test(pick('effort')) ? pick('effort') : '',
48
+ mode,
49
+ createdAt: prev?.createdAt || Date.now(),
50
+ updatedAt: Date.now(),
51
+ runs: prev?.runs || 0,
52
+ lastRunAt: prev?.lastRunAt || null,
53
+ };
54
+ }
55
+
56
+ // By id, else by name (case- and punctuation-insensitive), so `wisp run "Release"` and
57
+ // `wisp run release` both work.
58
+ export function findRecipe(recipes, key) {
59
+ const k = String(key || '');
60
+ const loose = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
61
+ return recipes.find((r) => r.id === k) || recipes.find((r) => loose(r.name) === loose(k)) || null;
62
+ }
63
+
64
+ export const recipeView = (r) => ({ ...r, params: params(r.prompt) });