wisp-agents 0.16.0 → 0.18.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +6 -5
  3. package/bin/wisp.js +4 -2
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +5 -1
  6. package/server/adapters/claude.js +48 -5
  7. package/server/adapters/codex.js +3 -0
  8. package/server/adapters/cursor.js +2 -0
  9. package/server/adapters/grok.js +15 -0
  10. package/server/adapters/index.js +9 -1
  11. package/server/adapters/opencode.js +31 -6
  12. package/server/client.js +1 -1
  13. package/server/git.js +114 -2
  14. package/server/handoff.js +2 -1
  15. package/server/machines.js +2 -2
  16. package/server/mcp-ask.js +113 -8
  17. package/server/mentions.js +39 -0
  18. package/server/openapi.js +21 -3
  19. package/server/protocol.js +8 -1
  20. package/server/render.js +3 -1
  21. package/server/routes/internal.js +121 -1
  22. package/server/routes/meta.js +1 -1
  23. package/server/routes/sessions.js +12 -0
  24. package/server/routes/settings.js +51 -4
  25. package/server/routes/ship.js +24 -2
  26. package/server/runner.js +142 -22
  27. package/server/services/auto.js +12 -1
  28. package/server/services/launch.js +12 -8
  29. package/server/services/phone.js +1 -1
  30. package/server/services/ship.js +83 -4
  31. package/server/store.js +23 -2
  32. package/server/triggers.js +1 -1
  33. package/server/usage.js +94 -0
  34. package/server/welcome.js +2 -2
  35. package/web/css/composer.css +22 -0
  36. package/web/css/feed.css +27 -0
  37. package/web/css/launcher.css +17 -0
  38. package/web/css/layout.css +10 -2
  39. package/web/css/modals.css +8 -0
  40. package/web/css/palette.css +22 -0
  41. package/web/css/ship.css +42 -2
  42. package/web/index.html +16 -5
  43. package/web/js/app.js +14 -3
  44. package/web/js/blocks.js +46 -0
  45. package/web/js/core/state.js +2 -1
  46. package/web/js/features/actions.js +9 -4
  47. package/web/js/features/automations.js +1 -1
  48. package/web/js/features/clones.js +79 -0
  49. package/web/js/features/compare.js +2 -1
  50. package/web/js/features/composer.js +11 -7
  51. package/web/js/features/feed.js +3 -3
  52. package/web/js/features/launcher.js +150 -28
  53. package/web/js/features/live.js +3 -3
  54. package/web/js/features/mentions.js +125 -0
  55. package/web/js/features/notify.js +1 -1
  56. package/web/js/features/project-list.js +99 -0
  57. package/web/js/features/projects.js +218 -34
  58. package/web/js/features/runners.js +3 -1
  59. package/web/js/features/settings.js +16 -2
  60. package/web/js/features/setup.js +3 -3
  61. package/web/js/features/ship.js +100 -24
  62. package/web/js/features/turns.js +44 -13
  63. package/web/js/mention-text.js +59 -0
@@ -500,7 +500,7 @@ export class Machines extends EventEmitter {
500
500
  summary(mid, s) {
501
501
  const m = this.get(mid);
502
502
  const q = (id) => qualify(id, mid);
503
- const p = this.store.projectFor(s.cwd || '', mid);
503
+ const p = s.scratch ? null : this.store.projectFor(s.cwd || '', mid);
504
504
  return {
505
505
  ...s,
506
506
  id: q(s.id),
@@ -578,7 +578,7 @@ export class Machines extends EventEmitter {
578
578
  }
579
579
 
580
580
  async addProject(mid, dir, name) {
581
- const p = await this.json(mid, 'POST', '/projects', { path: dir });
581
+ const p = await this.json(mid, 'POST', '/projects', { path: dir }, { timeout: 10 * 60_000 }); // dir may be a repo URL to clone
582
582
  const local = this.store.addProject(p.path, { name: name || p.name, machine: mid });
583
583
  return local;
584
584
  }
package/server/mcp-ask.js CHANGED
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env node
2
- // Minimal stdio MCP server exposing `ask_user`, injected into every agent wisp launches.
2
+ // Minimal stdio MCP server exposing `ask_user`, `wisp_stats`, `wisp_sessions` and `wisp_session`, 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
5
  // With a team (WISP_TEAM), it also offers `delegate` and `subagent_results`: tasks for the
6
6
  // subagents the user picked, which wisp runs as child sessions while this call waits.
7
+ // With WISP_APPROVE, it offers `approve`: the harness's permission prompt (Claude's
8
+ // --permission-prompt-tool), answered by the user in wisp.
9
+ // `wisp_stats` is read-only: tool counts, spend and success rate across the user's wisp sessions.
10
+ // `wisp_sessions` and `wisp_session` are read-only too: list the user's other tasks and read one's
11
+ // reports, e.g. a task the user mentioned in the prompt.
7
12
 
8
13
  import readline from 'node:readline';
9
14
 
10
- const { WISP_URL, WISP_TOKEN, WISP_SESSION } = process.env;
15
+ const { WISP_URL, WISP_TOKEN, WISP_SESSION, WISP_APPROVE } = process.env;
11
16
  let TEAM = [];
12
17
  try {
13
18
  TEAM = JSON.parse(process.env.WISP_TEAM || '[]');
@@ -30,6 +35,83 @@ const TOOL = {
30
35
  },
31
36
  };
32
37
 
38
+ // Claude's permission-prompt contract: { tool_name, input, tool_use_id } in, a JSON
39
+ // { behavior: 'allow', updatedInput } or { behavior: 'deny', message } back as text.
40
+ const APPROVE = {
41
+ name: 'approve',
42
+ description: 'Internal: wisp asks the user to approve a tool call your permissions do not allow on their own. Never call this yourself.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } },
46
+ required: ['tool_name'],
47
+ },
48
+ };
49
+
50
+ const STATS = {
51
+ name: 'wisp_stats',
52
+ description:
53
+ 'Read-only stats about the user\'s past wisp tasks: how many tool calls each kind of action made (run = shell, read, edit, ' +
54
+ 'search, web, agent, tool = other MCP tools, ask), broken down by harness, project and day, plus turns, cost, tokens and ' +
55
+ 'success rate. Use when the user asks about their wisp usage, which tools or harnesses their tasks use, or what tasks cost.',
56
+ inputSchema: {
57
+ type: 'object',
58
+ properties: {
59
+ days: { type: 'number', description: 'How many days back, 1–365. Default 30.' },
60
+ agent: { type: 'string', description: 'Only this harness, e.g. claude, codex, opencode.' },
61
+ project: { type: 'string', description: 'Only this project (name or id); "current" = the project of this task.' },
62
+ },
63
+ },
64
+ };
65
+
66
+ const SESSIONS = {
67
+ name: 'wisp_sessions',
68
+ description:
69
+ 'List the user\'s other wisp tasks, newest first: id, title, state (running | waiting | review | done), harness, project, ' +
70
+ 'last update and a one-line summary. Read-only. Use it to find a task the user refers to; read one with wisp_session.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {
74
+ query: { type: 'string', description: 'Only tasks whose title or summary has all these words.' },
75
+ state: { type: 'string', description: 'Comma-separated, e.g. "running,waiting".' },
76
+ project: { type: 'string', description: 'Only this project (name or id); "current" = the project of this task.' },
77
+ archived: { type: 'boolean', description: 'Include archived tasks. Default false.' },
78
+ limit: { type: 'number', description: 'Default 20, at most 100.' },
79
+ },
80
+ },
81
+ };
82
+
83
+ const SESSION = {
84
+ name: 'wisp_session',
85
+ description:
86
+ 'Read another wisp task by id: its title, state, harness, folder and branch, every request the user made in it, and a ' +
87
+ 'turn\'s report as markdown (summary, diffs, tests, next steps). Read-only. Use it when the prompt mentions a wisp task ' +
88
+ '("@Title (wisp task <id>)") or the user asks what another task did.',
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: {
92
+ id: { type: 'string', description: 'The task id.' },
93
+ turn: { type: 'number', description: 'Which turn\'s report, 1 = first. Default: the latest.' },
94
+ },
95
+ required: ['id'],
96
+ },
97
+ };
98
+
99
+ async function sessions(args = {}) {
100
+ const q = new URLSearchParams(Object.entries(args).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => [k, String(v)]));
101
+ return (await call(`/api/internal/sessions/${WISP_SESSION}?${q}`)).text;
102
+ }
103
+
104
+ async function session({ id, turn } = {}) {
105
+ if (!id) throw new Error('id is required');
106
+ const q = new URLSearchParams({ of: String(id), ...(turn !== undefined && { turn: String(turn) }) });
107
+ return (await call(`/api/internal/session/${WISP_SESSION}?${q}`)).text;
108
+ }
109
+
110
+ async function stats({ days, agent, project } = {}) {
111
+ const q = new URLSearchParams(Object.entries({ days, agent, project }).filter(([, v]) => v !== undefined && v !== ''));
112
+ return (await call(`/api/internal/stats/${WISP_SESSION}?${q}`)).text;
113
+ }
114
+
33
115
  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
116
  const TEAM_TOOLS = [
35
117
  {
@@ -104,8 +186,8 @@ async function call(path, body) {
104
186
  return res.json();
105
187
  }
106
188
 
107
- async function askUser(args) {
108
- const { id } = await call(`/api/internal/ask`, { session: WISP_SESSION, ...args });
189
+ // Poll a question until it's answered ({ answer, … }) or cancelled (null).
190
+ async function waitFor(id) {
109
191
  for (;;) {
110
192
  await sleep(1500);
111
193
  let r;
@@ -114,11 +196,24 @@ async function askUser(args) {
114
196
  } catch {
115
197
  continue; // wisp restarting — keep waiting
116
198
  }
117
- if (r.answer !== undefined) return r.answer;
199
+ if (r.answer !== undefined) return r;
118
200
  if (r.cancelled) return null;
119
201
  }
120
202
  }
121
203
 
204
+ async function askUser(args) {
205
+ const { id } = await call(`/api/internal/ask`, { session: WISP_SESSION, ...args });
206
+ return (await waitFor(id))?.answer ?? null;
207
+ }
208
+
209
+ async function approve({ tool_name: tool, input = {} }) {
210
+ let r = await call(`/api/internal/approve`, { session: WISP_SESSION, tool, input });
211
+ if (r.id) r = await waitFor(r.id);
212
+ if (r?.decision === 'allow') return { behavior: 'allow', updatedInput: input };
213
+ if (!r) return { behavior: 'deny', message: 'The user did not answer (the request was cancelled). Do not retry it; carry on without it and say what was skipped.' };
214
+ return { behavior: 'deny', message: r.message ? `The user denied this and said: ${r.message}` : 'The user denied this. Do not retry it; carry on without it or ask how to proceed.' };
215
+ }
216
+
122
217
  readline.createInterface({ input: process.stdin }).on('line', async (line) => {
123
218
  let msg;
124
219
  try {
@@ -133,11 +228,19 @@ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
133
228
  if (method === 'initialize') {
134
229
  send({ id, result: { protocolVersion: params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'wisp', version: '0.1.0' } } });
135
230
  } else if (method === 'tools/list') {
136
- send({ id, result: { tools: TEAM.length ? [TOOL, ...TEAM_TOOLS] : [TOOL] } });
231
+ send({ id, result: { tools: [TOOL, STATS, SESSIONS, SESSION, ...(WISP_APPROVE ? [APPROVE] : []), ...(TEAM.length ? TEAM_TOOLS : [])] } });
137
232
  } else if (method === 'tools/call' && params?.name === 'ask_user') {
138
233
  const answer = await askUser(params.arguments || {});
139
234
  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}`;
140
235
  send({ id, result: { content: [{ type: 'text', text }] } });
236
+ } else if (method === 'tools/call' && WISP_APPROVE && params?.name === 'approve') {
237
+ send({ id, result: { content: [{ type: 'text', text: JSON.stringify(await approve(params.arguments || {})) }] } });
238
+ } else if (method === 'tools/call' && params?.name === 'wisp_stats') {
239
+ send({ id, result: { content: [{ type: 'text', text: await stats(params.arguments || {}) }] } });
240
+ } else if (method === 'tools/call' && params?.name === 'wisp_sessions') {
241
+ send({ id, result: { content: [{ type: 'text', text: await sessions(params.arguments || {}) }] } });
242
+ } else if (method === 'tools/call' && params?.name === 'wisp_session') {
243
+ send({ id, result: { content: [{ type: 'text', text: await session(params.arguments || {}) }] } });
141
244
  } else if (method === 'tools/call' && TEAM.length && params?.name === 'delegate') {
142
245
  send({ id, result: { content: [{ type: 'text', text: await delegate(params.arguments || {}) }] } });
143
246
  } else if (method === 'tools/call' && TEAM.length && params?.name === 'subagent_results') {
@@ -149,7 +252,9 @@ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
149
252
  send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
150
253
  }
151
254
  } catch (err) {
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}` }] } });
255
+ const what = params?.name === 'ask_user' || params?.name === 'approve' ? 'wisp could not reach the user' : 'wisp';
256
+ // A permission prompt that errors would leave the harness guessing: deny, and say why.
257
+ if (params?.name === 'approve') send({ id, result: { content: [{ type: 'text', text: JSON.stringify({ behavior: 'deny', message: `${what}: ${err.message}` }) }] } });
258
+ else send({ id, result: { isError: true, content: [{ type: 'text', text: `${what}: ${err.message}` }] } });
154
259
  }
155
260
  });
@@ -0,0 +1,39 @@
1
+ // Task mentions: a prompt can point at another wisp task. The web app stores one as
2
+ // `@[Title](wisp:<id>)` (pasting a copied task id in a prompt box makes one); the agent gets
3
+ // `@Title (wisp task <id>)` plus a note on how to read it: the wisp_session tool, or the task's
4
+ // report inline for runs that can't take wisp's MCP tools.
5
+
6
+ import { sessionToMarkdown } from './render.js';
7
+ import { truncate } from './util.js';
8
+
9
+ export const MENTION = /@\[([^\]\n]{1,200})\]\(wisp:([a-z0-9]{6,40})\)/g;
10
+ const INLINE_MAX = 8_000; // characters of a mentioned task's report, when it goes in the prompt
11
+
12
+ export const mentionsIn = (text = '') => [...new Map([...String(text).matchAll(MENTION)].map((m) => [m[2], { id: m[2], title: m[1] }])).values()];
13
+
14
+ // What a person reads: titles, no ids (session titles, notifications).
15
+ export const plainMentions = (text = '') => String(text).replace(MENTION, '@$1');
16
+
17
+ // What an agent reads in place of each mention.
18
+ export const textMentions = (text = '') => String(text).replace(MENTION, '@$1 (wisp task $2)');
19
+
20
+ // The prompt for an agent run. tool: how this harness names the wisp_session tool, or null when
21
+ // it has none (then each mentioned task's latest report goes in the prompt).
22
+ export function agentPrompt(prompt, store, { tool = 'wisp_session', self } = {}) {
23
+ const found = mentionsIn(prompt).filter((m) => m.id !== self);
24
+ if (!found.length) return textMentions(prompt);
25
+ const line = (m) => {
26
+ const s = store.get(m.id);
27
+ if (!s) return `- \`${m.id}\` (${m.title}): not on this computer; it may have been deleted or run on another machine.`;
28
+ return `- \`${s.id}\` **${s.title}**: ${s.state}, ${s.agent}, ${s.turns.length} turn${s.turns.length === 1 ? '' : 's'}, in \`${s.worktree?.path || s.cwd}\``;
29
+ };
30
+ const note = tool
31
+ ? `The user mentions other wisp tasks. Read one with \`wisp_session\` (${tool}) before relying on it; \`wisp_sessions\` lists the rest.\n${found.map(line).join('\n')}`
32
+ : `The user mentions other wisp tasks. Their latest reports:\n\n${found
33
+ .map((m) => {
34
+ const s = store.get(m.id);
35
+ return s ? `### ${s.title} (\`${s.id}\`)\n\n${truncate(sessionToMarkdown(s), INLINE_MAX)}` : line(m);
36
+ })
37
+ .join('\n\n')}`;
38
+ return `${textMentions(prompt)}\n\n<wisp-tasks>\n${note}\n</wisp-tasks>`;
39
+ }
package/server/openapi.js CHANGED
@@ -90,7 +90,19 @@ export function openapi({ version, port }) {
90
90
  },
91
91
  '/projects': {
92
92
  get: { summary: 'List projects', responses: { 200: json({ type: 'array', items: ref('Project') }) } },
93
- 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] } },
93
+ post: {
94
+ summary: 'Add a folder as a project, or clone a repo URL and add the clone',
95
+ requestBody: body({
96
+ type: 'object',
97
+ properties: {
98
+ path: { type: 'string', description: 'A folder, or a repo URL (https://github.com/o/r, git@host:o/r.git, o/r)' },
99
+ repo: { type: 'string', description: 'A repo URL to clone into defaults.cloneDir (default ~/code), then add' },
100
+ name: { type: 'string' },
101
+ machine: { type: 'string', description: 'Add (or clone) it on this machine' },
102
+ },
103
+ }),
104
+ responses: { 201: json(ref('Project')), 400: err[400] },
105
+ },
94
106
  },
95
107
  '/projects/{pid}': {
96
108
  parameters: [{ name: 'pid', in: 'path', required: true, schema: { type: 'string' } }],
@@ -132,6 +144,7 @@ export function openapi({ version, port }) {
132
144
  },
133
145
  },
134
146
  '/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] } } },
147
+ '/sessions/{id}/fix-conflicts': { parameters: [id], post: { summary: 'Retry the automatic merge-conflict fix after it gave up: a follow-up merges the base in, then wisp pushes to the PR', responses: { 200: json({ type: 'object' }), 400: err[400] } } },
135
148
  '/sessions/{id}/merge': {
136
149
  parameters: [id],
137
150
  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] } },
@@ -226,7 +239,8 @@ export function openapi({ version, port }) {
226
239
  responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
227
240
  },
228
241
  },
229
- '/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 } } },
242
+ '/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question (or approval: Allow, Always allow in this task, Deny, or what to do instead)', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
243
+ '/sessions/{id}/approve': { parameters: [id], post: { summary: 'Allow and retry (or deny) what a finished turn’s permissions blocked', requestBody: body({ type: 'object', properties: { turnId: { type: 'string' }, allow: { type: 'boolean', description: 'Default true' } } }), responses: { 200: ok } } },
230
244
  '/sessions/{id}/revert': {
231
245
  parameters: [id],
232
246
  post: {
@@ -290,9 +304,10 @@ export function openapi({ version, port }) {
290
304
  properties: {
291
305
  prompt: { type: 'string' },
292
306
  cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
307
+ scratch: { type: 'boolean', description: 'No project folder: run in an empty scratch directory (no worktree). Overrides cwd' },
293
308
  agent: { enum: ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'auto', description: 'auto: wisp picks the harness and model from what worked before; model is then the goal (cheap, balanced, best)' },
294
309
  important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
295
- mode: { enum: ['plan', 'edits', 'auto'], default: 'edits' },
310
+ mode: { enum: ['plan', 'edits', 'auto'], default: 'auto' },
296
311
  model: { type: 'string' },
297
312
  effort: { type: 'string', description: 'Thinking level: low, medium, high, xhigh, max (per harness)' },
298
313
  attachments: { type: 'array', items: { type: 'string' }, description: 'Upload ids' },
@@ -311,6 +326,7 @@ export function openapi({ version, port }) {
311
326
  draft: { type: 'boolean' },
312
327
  review: { type: 'string', description: 'APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | ""' },
313
328
  checks: { type: 'object', properties: { pass: { type: 'integer' }, fail: { type: 'integer' }, pending: { type: 'integer' } } },
329
+ conflict: { type: 'boolean', description: 'GitHub cannot merge it into its base: merge conflicts' },
314
330
  head: { type: 'string' },
315
331
  base: { type: 'string' },
316
332
  },
@@ -392,6 +408,8 @@ export function openapi({ version, port }) {
392
408
  updatedAt: { type: 'integer' },
393
409
  branch: { type: 'string', description: 'Worktree branch, if it has one' },
394
410
  pr: { type: ['object', 'null'], description: 'Its pull request, once shipped' },
411
+ shipping: { type: ['object', 'null'], description: "{ step: start | commit | push | pr, update } while Ship it runs" },
412
+ conflictFix: { type: ['object', 'null'], description: "{ step: fixing | pushing | done | failed, why } while wisp fixes the PR's merge conflicts on its own (defaults.fixConflicts: 'off' turns it off)" },
395
413
  groupId: { type: ['string', 'null'], description: 'Shared by sessions launched together with compare' },
396
414
  origin: { type: ['object', 'null'], description: '{ kind: recipe | trigger, id, name } when not launched by hand' },
397
415
  },
@@ -69,6 +69,9 @@ diff, the test run, the command output — over prose. Use file paths relative t
69
69
  A screenshot or generated image on disk.
70
70
  - {"type":"question","question":"…","options":[{"label":"Short choice","reply":"full message sent back to you"}]}
71
71
  When you need a decision. The user's click resumes you with the reply.
72
+ - {"type":"blocked","items":[{"tool":"shell","detail":"npm publish","why":"needs network access"}]}
73
+ Actions your permissions or sandbox refused (a denied tool call, a command that failed for lack of
74
+ network or write access). wisp shows them with an Allow & retry button. Not for ordinary failures.
72
75
  - {"type":"actions","title":"…","items":[{"label":"Run tests","command":"npm test"},{"label":"Follow up","reply":"…"}]}
73
76
  One-click shell commands (executed in the project root) or follow-up actions the user may want to trigger.
74
77
  - {"type":"next","items":["Suggested follow-up the user might send", "…"]}
@@ -89,7 +92,11 @@ Strings must be valid JSON (escape newlines as \\n and quotes as \\"). Example:
89
92
 
90
93
  const ASK_TOOL = (tool) => `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
91
94
  two very different approaches), call the \`ask_user\` tool (${tool}). It pauses you until
92
- the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options.`;
95
+ the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options.
96
+ - If the user asks about their past wisp tasks (which tools or harnesses they use, cost, success rate),
97
+ call \`wisp_stats\` (${tool.replace(/ask_user$/, 'wisp_stats')}) instead of digging through files.
98
+ - If the prompt mentions another wisp task ("@Title (wisp task <id>)") or the user asks what another task
99
+ did, read it with \`wisp_session\` (${tool.replace(/ask_user$/, 'wisp_session')}); \`wisp_sessions\` lists them.`;
93
100
  const ASK_BLOCK = `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
94
101
  two very different approaches), stop before acting on it and end with a "question" block (2–4 options).
95
102
  The user's answer comes back as a follow-up in this same session.`;
package/server/render.js CHANGED
@@ -80,6 +80,8 @@ const R = {
80
80
  callout: (b) => `> **${b.tone === 'danger' ? '⛔' : b.tone === 'warn' ? '⚠️' : b.tone === 'success' ? '✅' : 'ℹ️'} ${str(b.title)}**\n> ${str(b.text).replace(/\n/g, '\n> ')}`,
81
81
  image: (b) => `🖼️ \`${str(b.path || b.src)}\`${b.caption ? ` — ${b.caption}` : ''}`,
82
82
  question: (b) => `**❓ ${str(b.question)}**\n${list(b.options).map((o, i) => `${i + 1}. ${str(o.label || o)}`).join('\n')}`,
83
+ blocked: (b, turn) =>
84
+ `**🔒 Blocked by permissions**${turn?.approval ? ` _(${turn.approval})_` : ''}\n${list(b.items).map((i) => `- ${str(i.tool)}${i.detail ? `: \`${str(i.detail)}\`` : ''}${i.why ? ` — ${i.why}` : ''}`).join('\n')}`,
83
85
  actions: (b) =>
84
86
  `${b.title ? `**${b.title}**\n` : '**Actions**\n'}${list(b.items)
85
87
  .map((a) => (typeof a === 'string' ? `- ${a}` : `- **${str(a.label || a.command)}**${a.command ? ` — \`$ ${str(a.command)}\`` : a.reply ? ` — _${str(a.reply)}_` : ''}${a.detail ? ` (${str(a.detail)})` : ''}`))
@@ -109,7 +111,7 @@ export function sessionToMarkdown(session) {
109
111
  if (turn?.status === 'running') out.push(`_Working… (${turn.activity?.findLast?.((a) => a.kind !== 'text')?.label || 'starting up'})_`);
110
112
  else if (turn?.presentation) out.push(blocksToMarkdown(turn.presentation.blocks, turn));
111
113
  if (session.ask) {
112
- out.push(`**❓ The agent is waiting for your answer:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
114
+ out.push(`**${session.ask.permission ? '🔒 The agent needs your approval' : '❓ The agent is waiting for your answer'}:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
113
115
  if (session.ask.options?.length) out.push(session.ask.options.map((o, i) => `${i + 1}. ${o}`).join('\n'));
114
116
  }
115
117
  // Worktree sessions: say where the changes are, since they aren't in the caller's checkout.
@@ -1,8 +1,14 @@
1
- // Bridge for the MCP servers running inside an agent process (ask_user, delegate). Every call
1
+ // Bridge for the MCP servers running inside an agent process (ask_user, approve, delegate, wisp_stats,
2
+ // wisp_sessions, wisp_session). Every call
2
3
  // carries the per-run token wisp handed the agent; nothing else may use these.
3
4
 
4
5
  import { adapters } from '../adapters/index.js';
6
+ import { plainMentions } from '../mentions.js';
7
+ import { blocksToMarkdown, sessionToMarkdown } from '../render.js';
8
+ import { summarize } from '../runner.js';
5
9
  import { delegationReport } from '../subagents.js';
10
+ import { toolStats, toolStatsText } from '../usage.js';
11
+ import { clip } from '../util.js';
6
12
 
7
13
  export function register(r, app) {
8
14
  const { store, runner, telemetry } = app;
@@ -25,6 +31,22 @@ export function register(r, app) {
25
31
  );
26
32
  r.route('GET', '/internal/ask/:id', guarded((c) => c.send(200, runner.pollAnswer(c.params.id))), opts);
27
33
 
34
+ // A live approval (the harness's permission prompt): { decision } at once, or an { id } to poll
35
+ // like ask_user, whose answer carries the decision.
36
+ r.route(
37
+ 'POST',
38
+ '/internal/approve',
39
+ guarded(async (c) => {
40
+ const body = await c.body();
41
+ const s = store.get(body.session);
42
+ if (!s) return c.send(404, { error: 'no such session' });
43
+ const out = runner.requestApproval(s, body);
44
+ if (out.id) telemetry.track('approval', { agent: s.agent });
45
+ c.send(200, out);
46
+ }),
47
+ opts,
48
+ );
49
+
28
50
  // delegate: POST starts tasks on the lead's subagents; GET reports on them (done once all finished).
29
51
  r.route(
30
52
  'POST',
@@ -53,4 +75,102 @@ export function register(r, app) {
53
75
  }),
54
76
  opts,
55
77
  );
78
+
79
+ // wisp_stats: read-only tool counts, spend and success across sessions. project=current is the
80
+ // asking session's project.
81
+ r.route(
82
+ 'GET',
83
+ '/internal/stats/:id',
84
+ guarded((c) => {
85
+ const s = lead(c);
86
+ if (!s) return c.send(404, { error: 'no such session' });
87
+ const projectFor = (x) => store.projectFor(x.cwd);
88
+ let project = c.query.get('project') || '';
89
+ if (project === 'current') project = projectFor(s)?.id || String(s.cwd || '').split(/[\\/]/).pop();
90
+ const stats = toolStats(store.all(), { days: Number(c.query.get('days')) || 30, project: projectFor, filter: { agent: c.query.get('agent') || '', project } });
91
+ telemetry.track('wisp_stats', { agent: s.agent });
92
+ c.send(200, { stats, text: toolStatsText(stats) });
93
+ }),
94
+ opts,
95
+ );
96
+ // wisp_sessions: the user's other tasks (not the asker, its subagents or the tour), newest first.
97
+ r.route(
98
+ 'GET',
99
+ '/internal/sessions/:id',
100
+ guarded((c) => {
101
+ const s = lead(c);
102
+ if (!s) return c.send(404, { error: 'no such session' });
103
+ const q = Object.fromEntries(c.query);
104
+ let project = q.project || '';
105
+ if (project === 'current') project = store.projectFor(s.cwd)?.id || String(s.cwd || '').split(/[\\/]/).pop();
106
+ const states = q.state ? q.state.split(',').map((x) => x.trim()) : null;
107
+ const words = String(q.query || '').toLowerCase().split(/\s+/).filter(Boolean);
108
+ const all = store
109
+ .all()
110
+ .filter((x) => x.id !== s.id && !x.parentId && x.agent !== 'wisp' && x.id !== s.parentId)
111
+ .map((x) => summarize(x, store))
112
+ .filter((x) => (q.archived === 'true' || !x.archived) && (!states || states.includes(x.state)))
113
+ .filter((x) => !project || x.projectId === project || x.project === project)
114
+ .filter((x) => words.every((w) => `${x.title} ${x.preview}`.toLowerCase().includes(w)))
115
+ .sort((a, b) => b.updatedAt - a.updatedAt);
116
+ const list = all.slice(0, Math.min(Math.max(Number(q.limit) || 20, 1), 100));
117
+ telemetry.track('wisp_sessions', { agent: s.agent });
118
+ const text = list.length
119
+ ? `${all.length} task${all.length === 1 ? '' : 's'}${all.length > list.length ? `, the newest ${list.length}` : ''}. Read one with wisp_session.\n\n${list.map(sessionLine).join('\n')}`
120
+ : 'No tasks match.';
121
+ c.send(200, { text });
122
+ }),
123
+ opts,
124
+ );
125
+
126
+ // wisp_session: one task's requests and a turn's report (?of=<id>&turn=<1-based>).
127
+ r.route(
128
+ 'GET',
129
+ '/internal/session/:id',
130
+ guarded((c) => {
131
+ const s = lead(c);
132
+ if (!s) return c.send(404, { error: 'no such session' });
133
+ const id = String(c.query.get('of') || '').trim().replace(/^wisp:/, '');
134
+ const x = store.get(id);
135
+ telemetry.track('wisp_session', { agent: s.agent });
136
+ if (!x) return c.send(200, { text: `No wisp task \`${id}\` on this computer. It may have been deleted, or it ran on another machine. List tasks with wisp_sessions.` });
137
+ c.send(200, { text: sessionText(x, store, c.query.get('turn')) });
138
+ }),
139
+ opts,
140
+ );
141
+ }
142
+
143
+ const age = (t) => {
144
+ const m = Math.round((Date.now() - t) / 60_000);
145
+ return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : m < 48 * 60 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
146
+ };
147
+
148
+ const sessionLine = (x) =>
149
+ `- \`${x.id}\` **${x.title}** — ${x.archived ? 'archived, ' : ''}${x.state}, ${x.agent}${x.model ? ` (${x.model})` : ''}, ${x.project}, ${age(x.updatedAt)}${x.state === 'running' && x.live ? `: ${x.live}` : x.preview ? `: ${clip(x.preview, 160)}` : ''}`;
150
+
151
+ export function sessionText(x, store, turnArg) {
152
+ const sum = summarize(x, store);
153
+ const n = Number(turnArg);
154
+ const turn = turnArg === null || turnArg === undefined || turnArg === '' || !n ? x.turns.at(-1) : x.turns[n - 1];
155
+ const head = [
156
+ `# ${x.title}`,
157
+ `Task \`${x.id}\` · ${sum.archived ? 'archived · ' : ''}${x.state} · ${x.agent}${x.model ? ` (${x.model})` : ''} · project ${sum.project} · updated ${age(x.updatedAt)}`,
158
+ `Folder: \`${x.worktree?.path || x.cwd}\`${x.worktree ? ` (worktree, branch \`${x.worktree.branch}\`)` : ''}${x.pr ? ` · PR #${x.pr.number} (${String(x.pr.state).toLowerCase()}): ${x.pr.url}` : ''}`,
159
+ ];
160
+ const asks = x.turns.map((t, i) => `${i + 1}. ${clip(plainMentions(t.prompt).replace(/\s+/g, ' '), 300)}${t.status === 'running' ? ' _(running)_' : t.status === 'error' ? ' _(failed)_' : t.status === 'stopped' ? ' _(stopped)_' : ''}`);
161
+ if (!turn) return [...head, '', `Turn ${turnArg} doesn't exist; this task has ${x.turns.length}.`, '', '## Requests', ...asks].join('\n');
162
+ const i = x.turns.indexOf(turn);
163
+ const report = turn === x.turns.at(-1) ? sessionToMarkdown(x) : blocksToMarkdown(turn.presentation?.blocks, turn);
164
+ const files = (turn.changes || []).filter((ch) => !ch.reverted).map((ch) => `\`${ch.file}\``);
165
+ return [
166
+ ...head,
167
+ '',
168
+ `## Requests (${x.turns.length})`,
169
+ ...asks,
170
+ '',
171
+ `## Report for turn ${i + 1}${x.turns.length > 1 ? ` of ${x.turns.length} (pass turn to read another)` : ''}`,
172
+ '',
173
+ report || '_No report yet._',
174
+ ...(files.length ? ['', `Files edited in this turn: ${files.join(', ')}`] : []),
175
+ ].join('\n');
56
176
  }
@@ -34,7 +34,7 @@ export function register(r, app) {
34
34
 
35
35
  r.route('GET', '/openapi.json', (c) => c.send(200, openapi({ version: VERSION, port })), { doc: false });
36
36
 
37
- r.route('GET', '/agents', async (c) => c.send(200, await app.auto.detect({ fresh: c.query.has('fresh') })));
37
+ r.route('GET', '/agents', async (c) => c.send(200, await (c.query.has('fresh') ? app.auto.detect({ fresh: true }) : app.auto.listed())));
38
38
  // Re-check: detect harnesses and models again, here and on every online machine.
39
39
  r.route('POST', '/agents/recheck', async (c) => {
40
40
  const [agents] = await Promise.all([app.auto.detect({ fresh: true }), machines.recheckAgents()]);
@@ -206,6 +206,18 @@ export function register(r, app) {
206
206
  }),
207
207
  );
208
208
 
209
+ // The card under a turn whose permissions blocked something: allow retries it, deny closes it.
210
+ r.route(
211
+ 'POST',
212
+ '/sessions/:id/approve',
213
+ one(async (c, s) => {
214
+ const { turnId, allow } = await c.body();
215
+ notTour(s, 'approve');
216
+ await ensureWorktree(s.worktree);
217
+ c.send(200, runner.approveBlocked(s, { turnId, allow: allow !== false }));
218
+ }),
219
+ );
220
+
209
221
  // Revert (or re-apply) a single file's edits from a turn, or roll back (or re-apply) the
210
222
  // project's working tree to the checkpoint before a turn. Both note it on the turn and tell
211
223
  // the cards; Ship it may have something new (or nothing left) to ship.
@@ -9,16 +9,27 @@ import { normalizePreset } from '../subagents.js';
9
9
  import { envDisabled, feedbackAvailable } from '../telemetry.js';
10
10
  import { TERMINALS } from '../terminal.js';
11
11
  import { normalizeWebhooks } from '../services/webhooks.js';
12
+ import { cloneRepo, gh, git, parseRepo } from '../git.js';
12
13
  import { expand } from '../util.js';
13
14
 
14
15
  // Where people keep their repos, for one-click "add project".
15
16
  const REPO_ROOTS = ['code', 'Code', 'dev', 'Developer', 'projects', 'Projects', 'src', 'repos', 'work', 'git', 'GitHub', 'Documents', 'Downloads', 'Desktop'];
16
17
 
18
+ // Where a pasted repo URL is cloned: defaults.cloneDir, else the first usual repo folder that
19
+ // exists (not Documents/Downloads/Desktop), else ~/code.
20
+ export function cloneRoot(config) {
21
+ if (config.defaults?.cloneDir) return expand(config.defaults.cloneDir);
22
+ const dir = REPO_ROOTS.slice(0, -3)
23
+ .map((r) => path.join(os.homedir(), r))
24
+ .find((d) => fs.existsSync(d) && fs.statSync(d).isDirectory());
25
+ return dir || path.join(os.homedir(), 'code');
26
+ }
27
+
17
28
  export function register(r, app) {
18
29
  const { store, telemetry, prefs, retro, remote, machines } = app;
19
30
 
20
31
  r.route('GET', '/config', (c) =>
21
- c.send(200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
32
+ c.send(200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), cloneDir: cloneRoot(store.config), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
22
33
  );
23
34
  r.route('PATCH', '/config', async (c) => {
24
35
  const body = await c.body();
@@ -91,18 +102,54 @@ export function register(r, app) {
91
102
  r.route('POST', '/projects', async (c) => {
92
103
  const body = await c.body();
93
104
  if (body.machine) {
94
- const p = await machines.addProject(String(body.machine), String(body.path || '').trim(), body.name);
105
+ const p = await machines.addProject(String(body.machine), String(body.repo || body.path || '').trim(), body.name);
95
106
  app.broadcast('config', {});
96
107
  return c.send(201, p);
97
108
  }
98
- const dir = expand(body.path || '');
109
+ // A repo URL (in `repo`, or pasted into `path`): clone it, then add the clone.
110
+ const typed = String(body.repo || body.path || '').trim();
111
+ const local = !body.repo && (/^[~./\\]|^[a-z]:/i.test(typed) || fs.existsSync(expand(typed)));
112
+ const repo = !local && parseRepo(typed, { shorthand: true });
113
+ if (body.repo && !repo) return c.send(400, { error: `Not a repo URL: ${typed}` });
114
+ let dir = expand(typed);
115
+ let cloned = null;
116
+ if (repo) {
117
+ const out = await cloneRepo(repo, cloneRoot(store.config));
118
+ dir = out.dir;
119
+ cloned = { url: repo.url, existed: out.existed };
120
+ }
99
121
  if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return c.send(400, { error: `Not a folder: ${dir}` });
100
122
  const p = store.addProject(dir, { name: body.name });
101
123
  app.broadcast('config', {});
102
- c.send(201, p);
124
+ c.send(201, cloned ? { ...p, cloned } : p);
103
125
  });
104
126
  // Find git repos in the usual places so adding projects is one click.
105
127
  r.route('GET', '/projects/discover', (c) => c.send(200, discoverRepos(new Set(store.config.projects.map((p) => p.path)))), { doc: false });
128
+ // The user's GitHub repos (gh's login), newest first, minus ones already added: one click clones.
129
+ // Empty when gh isn't installed or signed in.
130
+ r.route(
131
+ 'GET',
132
+ '/projects/github',
133
+ async (c) => {
134
+ const list = await gh(os.homedir(), ['repo', 'list', '--limit', '40', '--json', 'nameWithOwner,description,isPrivate,isFork,pushedAt,url'], { timeout: 20_000 })
135
+ .then((out) => JSON.parse(out || '[]'))
136
+ .catch(() => null);
137
+ if (!list) return c.send(200, []);
138
+ const origins = await Promise.all(
139
+ store.config.projects.filter((p) => !p.machine).map((p) => git(p.path, ['remote', 'get-url', 'origin'], { timeout: 5000 }).catch(() => '')),
140
+ );
141
+ const have = new Set(origins.map((o) => parseRepo(o)?.gh?.toLowerCase()).filter(Boolean));
142
+ c.send(
143
+ 200,
144
+ list
145
+ .filter((x) => !have.has(x.nameWithOwner.toLowerCase()))
146
+ .sort((a, b) => String(b.pushedAt).localeCompare(String(a.pushedAt)))
147
+ .slice(0, 24)
148
+ .map((x) => ({ repo: x.nameWithOwner, url: x.url, description: x.description || '', private: !!x.isPrivate, fork: !!x.isFork, pushedAt: x.pushedAt })),
149
+ );
150
+ },
151
+ { doc: false },
152
+ );
106
153
  const project = (c) => store.config.projects.find((x) => x.id === c.params.id);
107
154
  r.route('PATCH', '/projects/:id', async (c) => {
108
155
  const p = project(c);