wisp-agents 0.16.0 → 0.17.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 (41) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +5 -5
  3. package/package.json +2 -2
  4. package/server/adapters/antigravity.js +5 -1
  5. package/server/adapters/claude.js +48 -5
  6. package/server/adapters/codex.js +3 -0
  7. package/server/adapters/cursor.js +2 -0
  8. package/server/adapters/grok.js +15 -0
  9. package/server/adapters/index.js +9 -1
  10. package/server/adapters/opencode.js +31 -6
  11. package/server/client.js +1 -1
  12. package/server/handoff.js +2 -1
  13. package/server/mcp-ask.js +113 -8
  14. package/server/mentions.js +39 -0
  15. package/server/openapi.js +3 -2
  16. package/server/protocol.js +8 -1
  17. package/server/render.js +3 -1
  18. package/server/routes/internal.js +121 -1
  19. package/server/routes/sessions.js +12 -0
  20. package/server/runner.js +136 -20
  21. package/server/services/launch.js +1 -1
  22. package/server/services/phone.js +1 -1
  23. package/server/store.js +8 -2
  24. package/server/triggers.js +1 -1
  25. package/server/usage.js +94 -0
  26. package/server/welcome.js +2 -2
  27. package/web/css/composer.css +22 -0
  28. package/web/css/feed.css +27 -0
  29. package/web/css/ship.css +2 -2
  30. package/web/index.html +1 -0
  31. package/web/js/blocks.js +46 -0
  32. package/web/js/features/actions.js +9 -4
  33. package/web/js/features/automations.js +1 -1
  34. package/web/js/features/compare.js +2 -1
  35. package/web/js/features/composer.js +11 -7
  36. package/web/js/features/launcher.js +11 -9
  37. package/web/js/features/mentions.js +125 -0
  38. package/web/js/features/notify.js +1 -1
  39. package/web/js/features/runners.js +3 -1
  40. package/web/js/features/turns.js +44 -13
  41. package/web/js/mention-text.js +59 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.17.0] - 2026-10-19
9
+
10
+ ### Added
11
+
12
+ - **Approve what an agent's permissions block, from wisp.** In Edits mode a coding agent used to be refused quietly when it wanted something its permissions don't cover, like `npm publish`, a write outside the project or a network call from Codex's sandbox. You only found out from a half-done report. Now:
13
+ - **Claude Code waits for you.** wisp sends Claude's permission prompts to its own `approve` tool (`--permission-prompt-tool`). The task card shows *Needs your approval*: *Allow Claude Code to run `npm publish`?*, with **Allow**, **Always allow in this task** and **Deny**. The number keys pick an option. You can also type what to do instead: that denies the call and passes your words to Claude. Claude keeps its context, and the desktop notification says *needs your approval*. *Always allow* lets the same command or file through for the rest of the task without asking again.
14
+ - **Every other harness gets an Allow & retry card.** When Codex, opencode, Grok, Cursor or Antigravity is refused, the report shows *Blocked by permissions* under its headline, listing each refused action. wisp reads these refusals from the harness itself: opencode's and Grok's stderr, Cursor's rejected tool calls, Antigravity's `PERMISSION_DENIED` steps and Claude's `permission_denials`. A new `blocked` block in the wisp protocol lets an agent report a refusal it can only see in a command's output, such as Codex's sandbox. **Allow & retry** sends a follow-up that runs those actions. For Claude and opencode only the exact actions are allowed (`--allowedTools "Bash(npm publish)"` or an opencode permission rule). Codex, Grok, Cursor and Antigravity can't allow a single action from the command line, so that one follow-up runs in Full auto, and the card says so. The task stays in Edits. **Deny** closes the card. API: `POST /api/sessions/:id/approve {"turnId","allow"}`. A live approval is answered like a question, through `POST /api/sessions/:id/answer {"text":"Allow"}`, `wisp reply` or the phone buttons.
15
+ - **Mention another task in a prompt.** Right-click a task (or open its ⋯ menu) and pick **Copy task ID**, then paste the ID into the launcher, a follow-up box or the Rerun dialog. It shows as a chip with the task's title, like pasting an issue link in Linear, and stays a chip in the sent prompt, where clicking it opens that task. Backspace removes the whole chip and Cmd+Z brings it back. Pasting several IDs at once makes a chip for each. The agent gets the title and ID, and two new read-only tools sit next to `ask_user`: `wisp_session` reads a task (its title, state, harness, folder and branch, every request made in it, and a turn's report as markdown) and `wisp_sessions` lists your other tasks (filter by `query`, `state`, `project`, `archived`). Cursor and Antigravity runs can't take injected tools, so for them wisp puts each mentioned task's latest report in the prompt instead. Prompts store a mention as `@[Title](wisp:<id>)`, so API and CLI callers can write one too. Task titles show `@Title`. The bridge routes (`GET /api/internal/sessions/:id`, `GET /api/internal/session/:id?of=<id>`) only take the per-run token.
16
+ - **Agents can answer questions about your wisp usage.** Every task now gets a read-only `wisp_stats` tool next to `ask_user`. Ask a task "which tools do my tasks use?" or "what did Codex cost me this week?" and the agent gets a table of tool calls by kind (shell, read, edit, search, web, subagents, other MCP tools, questions), by harness, by project and by day. It also gets turns, cost, tokens and success rate, taken from the same numbers as Settings → Usage. Agents can narrow it with `days` (1–365, default 30), `agent` (a harness id) and `project` (a name or id, or `current` for the task's own project). Before, an agent had no tool for this and would either say it had no access or dig through `~/.wisp/sessions` by hand. Kinds are wisp's categories, not each harness's own tool names. The bridge route (`GET /api/internal/stats/:id`) only takes the per-run token, like `ask_user`. Cursor and Antigravity runs can't take injected MCP tools, so they don't get it.
17
+
18
+ ### Changed
19
+
20
+ - **Mark done and the PR row are where you look.** An opened task now has Mark done (or Reopen) in its top bar, next to Ship it, so you can close it out without scrolling to the composer. The PR row (checks, GitHub, Merge) moved from the top of the card to right above the composer, so after reading the result it's in view without scrolling back up.
21
+ - **Full auto is the default mode.** New launches, API and CLI calls without a mode, scheduled and CI triggers, and recipes run with no permission prompts; tasks in git repos still get their own worktree and a checkpoint before every turn. If your saved launcher mode was Edits (the old default), wisp switches it to Full auto once; a Plan pick, or anything you choose afterwards, sticks. Issue triggers still start in Plan. The mode button now shows just an icon for Full auto and spells out Plan and Edits.
22
+
8
23
  ## [0.16.0] - 2026-10-19
9
24
 
10
25
  ### Added
@@ -299,7 +314,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
299
314
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
300
315
  - Older turns are folded, with a floating collapse-all button and back-to-top.
301
316
 
302
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.16.0...HEAD
317
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.17.0...HEAD
318
+ [0.17.0]: https://github.com/M0xoo/wisp/compare/v0.16.0...v0.17.0
303
319
  [0.16.0]: https://github.com/M0xoo/wisp/compare/v0.15.0...v0.16.0
304
320
  [0.15.0]: https://github.com/M0xoo/wisp/compare/v0.14.0...v0.15.0
305
321
  [0.14.0]: https://github.com/M0xoo/wisp/compare/v0.13.0...v0.14.0
package/README.md CHANGED
@@ -54,10 +54,10 @@ From there you can **follow up**, **answer its questions**, **take it over** in
54
54
  | 🤝 **Subagents** | Give the lead agent a team on other harnesses (e.g. Claude Code hands tests to Codex ×3). It delegates through a `delegate` tool; their edits land in its worktree. See [Subagents](#subagents). |
55
55
  | ⏪ **Checkpoints & rollback** | Every turn is snapshotted first. Restore to before any turn, revert a single file, or re-run a command block from the presentation. |
56
56
  | ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
57
- | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.16.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
57
+ | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.17.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
58
58
  | 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
59
59
  | 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
60
- | 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
60
+ | 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** (no permission prompts, the default) for each launch. `Shift+Tab` in the prompt cycles modes. In Edits mode, anything the permissions block comes to you: Claude Code waits for **Allow / Always / Deny**, and the other harnesses show an **Allow & retry** card. |
61
61
  | 🔌 **Integrations** | An MCP server (`wisp mcp`), an agent skill, a scriptable CLI, a JS SDK, an OpenAPI-described HTTP API, and webhooks. Hand tasks to wisp from Claude Code, Codex, opencode, Cursor, scripts or CI. |
62
62
  | 🪶 **No runtime dependencies** | The server uses only Node built-ins. Electron is needed only for the desktop app. |
63
63
 
@@ -83,7 +83,7 @@ brew install m0xoo/tap/wisp-agents # Homebrew: CLI and browser mode,
83
83
  wisp "fix the flaky auth test"
84
84
  ```
85
85
 
86
- The Homebrew formula lives in [M0xoo/homebrew-tap](https://github.com/M0xoo/homebrew-tap). `install.sh` honors `WISP_VERSION` to pin a release (`curl -fsSL https://mokh.xyz/install.sh | WISP_VERSION=0.16.0 bash`).
86
+ The Homebrew formula lives in [M0xoo/homebrew-tap](https://github.com/M0xoo/homebrew-tap). `install.sh` honors `WISP_VERSION` to pin a release (`curl -fsSL https://mokh.xyz/install.sh | WISP_VERSION=0.17.0 bash`).
87
87
 
88
88
  From source:
89
89
 
@@ -131,7 +131,7 @@ wisp reply <id> "also do X" # follow up (answers a pending question; --mod
131
131
  wisp stop <id>
132
132
  wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
133
133
  wisp merge <id> # squash-merge its PR, fast-forward your local main
134
- wisp run release 0.16.0 # run a recipe; values fill its {params} (or version=0.16.0)
134
+ wisp run release 0.17.0 # run a recipe; values fill its {params} (or version=0.17.0)
135
135
  wisp recipes # list recipes
136
136
  wisp usage [--days 7] # spend and outcomes
137
137
  wisp mcp # MCP server for other agents and editors
@@ -334,7 +334,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
334
334
  Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
335
335
  ```
336
336
 
337
- Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.16.0` fills the fields in order, `wisp run release version=0.16.0` by name.
337
+ Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.17.0` fills the fields in order, `wisp run release version=0.17.0` by name.
338
338
 
339
339
  **Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
340
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Launch coding agents, skip the transcript, read the presentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "app": "node bin/wisp.js",
21
21
  "dev": "WISP_DEV=1 node bin/wisp.js",
22
22
  "dev:web": "node --watch-path=server bin/wisp.js --no-open",
23
- "test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js test/routes.test.js test/adapters-exit.test.js test/web-pure.test.js test/install.test.js test/install-script.test.js",
23
+ "test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js test/routes.test.js test/adapters-exit.test.js test/web-pure.test.js test/install.test.js test/install-script.test.js test/mentions.test.js",
24
24
  "test:real": "node test/real-clis.mjs",
25
25
  "prepublishOnly": "node --check bin/wisp.js && node --check bin/mac-app.js && node -e \"import('./server/index.js').then(() => import('./server/mcp-wisp.js'))\" && node --check electron/main.js"
26
26
  },
@@ -105,7 +105,11 @@ export default {
105
105
  onEvent({ type: 'tool', ...describeTool(name, params, cwd) });
106
106
  }
107
107
  if (!done) return;
108
- if (info.error) onEvent({ type: 'tool_error', name, text: clip(info.error.message || info.error.type || 'failed', 400) });
108
+ if (info.error) {
109
+ onEvent({ type: 'tool_error', name, text: clip(info.error.message || info.error.type || 'failed', 400) });
110
+ // Refused by its permissions (no --dangerously-skip-permissions): the card offers a retry.
111
+ if (/PERMISSION/i.test(info.error.type || '')) onEvent({ type: 'blocked', tool: name, detail: String(params.CommandLine ?? fileOf(params) ?? params.Url ?? '') });
112
+ }
109
113
  else if (editing) {
110
114
  const change = files.change(file);
111
115
  if (change) onEvent({ type: 'change', ...change });
@@ -29,6 +29,8 @@ const PERMISSION_MODES = {
29
29
  export default {
30
30
  id: 'claude',
31
31
  name: 'Claude Code',
32
+ // Asks wisp before a tool call its permission mode won't allow on its own (Edits mode).
33
+ approveTool: 'mcp__wisp__approve',
32
34
  models: [
33
35
  { id: '', label: 'Default', hint: 'Whatever Claude Code is set to' },
34
36
  { id: 'opus', label: 'Opus', hint: 'Most capable, for hard problems' },
@@ -46,8 +48,14 @@ export default {
46
48
  return detectVersion(BIN);
47
49
  },
48
50
 
51
+ // What a permission prompt is about, for the approval card and for matching "always allow".
52
+ permission(tool, input, cwd) {
53
+ return { tool, action: actionOf(tool, input, cwd), detail: detailOf(tool, input, cwd), why: tool === 'Bash' ? input.description || '' : '' };
54
+ },
55
+
49
56
  // Returns { stop(), done: Promise<void> }. Everything else is reported through onEvent.
50
- run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, onEvent }) {
57
+ // grants: --allowedTools rules (Bash(npm publish)) for what the user allowed after a denial.
58
+ run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, grants = [], onEvent }) {
51
59
  const args = [
52
60
  '-p',
53
61
  '--output-format', 'stream-json',
@@ -58,10 +66,11 @@ export default {
58
66
  if (effort) args.push('--effort', effort);
59
67
  if (chromeEnabled()) args.push('--chrome');
60
68
  if (resumeId) args.push('--resume', resumeId);
61
- if (Object.keys(mcpServers).length) {
62
- args.push('--mcp-config', JSON.stringify({ mcpServers }));
63
- args.push('--allowedTools', ...Object.keys(mcpServers).map((n) => `mcp__${n}`));
64
- }
69
+ if (Object.keys(mcpServers).length) args.push('--mcp-config', JSON.stringify({ mcpServers }));
70
+ // In Edits mode, what would prompt goes to wisp's approve tool (the user decides) instead of being denied.
71
+ if (mcpServers.wisp && mode === 'edits') args.push('--permission-prompt-tool', this.approveTool);
72
+ const allowed = [...Object.keys(mcpServers).map((n) => `mcp__${n}`), ...grants.filter((g) => typeof g === 'string')];
73
+ if (allowed.length) args.push('--allowedTools', ...allowed);
65
74
  // Its built-in subagents (Task, or Agent in newer versions) would bypass the user's team.
66
75
  if (delegating) args.push('--disallowedTools', 'Task', 'Agent', 'Workflow');
67
76
  const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
@@ -132,6 +141,11 @@ export default {
132
141
  return;
133
142
  }
134
143
  sawResult = true;
144
+ // Refused tool calls (no approve tool here, or the user said no): the card offers a retry.
145
+ for (const d of msg.permission_denials || []) {
146
+ const input = d.tool_input || {};
147
+ onEvent({ type: 'blocked', tool: d.tool_name, detail: detailOf(d.tool_name, input, cwd), rule: ruleOf(d.tool_name, input) });
148
+ }
135
149
  onEvent({ ...ev, background: background.size }); // > 0: another turn and result follow when they finish
136
150
  }
137
151
  };
@@ -207,11 +221,40 @@ function describeTool(name, input, cwd) {
207
221
  return { kind: 'agent', label: `Delegating to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
208
222
  case 'mcp__wisp__subagent_results':
209
223
  return { kind: 'agent', label: 'Waiting on subagents' };
224
+ case 'mcp__wisp__wisp_stats':
225
+ return { kind: 'tool', label: 'Reading wisp stats' };
226
+ case 'mcp__wisp__wisp_sessions':
227
+ return { kind: 'tool', label: 'Listing wisp tasks' };
228
+ case 'mcp__wisp__wisp_session':
229
+ return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
210
230
  default:
211
231
  return { kind: 'tool', label: name.startsWith('mcp__') ? `Using ${name.split('__').slice(1).join(' · ')}` : `Using ${name}` };
212
232
  }
213
233
  }
214
234
 
235
+ // A permission prompt's subject: "run `npm publish`", "edit src/a.ts", "fetch https://…".
236
+ function actionOf(tool, input, cwd) {
237
+ const f = rel(input.file_path || input.notebook_path || input.path, cwd);
238
+ if (tool === 'Bash') return `run \`${clip(input.command, 200)}\``;
239
+ if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool) && f) return `${tool === 'Write' ? 'write' : 'edit'} ${f}`;
240
+ if (tool === 'WebFetch') return `fetch ${clip(input.url, 120)}`;
241
+ if (tool === 'WebSearch') return `search the web for “${clip(input.query, 80)}”`;
242
+ if (tool === 'ExitPlanMode') return 'leave plan mode and start making changes';
243
+ if (tool.startsWith('mcp__')) return `use ${tool.split('__').slice(1).join(' · ')}`;
244
+ return `use ${tool}`;
245
+ }
246
+
247
+ // The exact thing being allowed, so "always allow" matches only the same command or file.
248
+ function detailOf(tool, input, cwd) {
249
+ if (tool === 'Bash') return String(input.command ?? '');
250
+ const f = input.file_path || input.notebook_path || input.path;
251
+ if (f) return rel(f, cwd);
252
+ return String(input.url ?? input.query ?? input.pattern ?? '');
253
+ }
254
+
255
+ // An --allowedTools rule for a retry: the exact command for Bash, the tool for the rest.
256
+ const ruleOf = (tool, input) => (tool === 'Bash' && input.command ? `Bash(${input.command})` : tool);
257
+
215
258
  // Capture edit inputs so wisp can show real diffs without asking the agent for them.
216
259
  function captureChange(name, input, cwd) {
217
260
  const file = input.file_path;
@@ -187,5 +187,8 @@ export function unwrapShell(cmd) {
187
187
  function describeMcp(item) {
188
188
  if (item.server === 'wisp' && item.tool === 'ask_user') return { kind: 'wait', label: 'Waiting for your answer' };
189
189
  if (item.server === 'wisp' && (item.tool === 'delegate' || item.tool === 'subagent_results')) return { kind: 'agent', label: 'Waiting on subagents' };
190
+ if (item.server === 'wisp' && item.tool === 'wisp_stats') return { kind: 'tool', label: 'Reading wisp stats' };
191
+ if (item.server === 'wisp' && item.tool === 'wisp_sessions') return { kind: 'tool', label: 'Listing wisp tasks' };
192
+ if (item.server === 'wisp' && item.tool === 'wisp_session') return { kind: 'tool', label: 'Reading a wisp task' };
190
193
  return { kind: 'tool', label: `Using ${item.server} · ${item.tool}` };
191
194
  }
@@ -71,6 +71,8 @@ export default {
71
71
  const [outcome, value = {}] = resultOf(body.result);
72
72
  if (outcome && outcome !== 'success') {
73
73
  onEvent({ type: 'tool_error', name: name.replace(/ToolCall$/, ''), text: clip(value.error || value.message || value.reason || value.stderr || outcome, 400) });
74
+ // Refused by its permissions (no --force): the card offers to retry it.
75
+ if (/reject|denied|permission/i.test(outcome)) onEvent({ type: 'blocked', tool: name.replace(/ToolCall$/, ''), detail: String(args.command ?? args.path ?? args.url ?? '') });
74
76
  } else if (name === 'editToolCall' || name === 'deleteToolCall') {
75
77
  const change = editChange(args.path || value.path, value, cwd, files);
76
78
  if (change) onEvent({ type: 'change', ...change });
@@ -19,6 +19,9 @@ const PERMISSION_MODES = {
19
19
  auto: 'bypassPermissions',
20
20
  };
21
21
  const EFFORT_ORDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
22
+ // A headless run refuses what would need approval and names it on stderr, once per call:
23
+ // "permission required: Edit calc.py was not run, as a headless run cannot ask for approval".
24
+ const REFUSED = /permission required: (\S+) ?(.*?) was not run\b/g;
22
25
 
23
26
  export default {
24
27
  id: 'grok',
@@ -75,6 +78,7 @@ export default {
75
78
  let session = null;
76
79
  let sawResult = false;
77
80
  let error = null;
81
+ let carry = ''; // a stderr line still being written
78
82
 
79
83
  const handle = spawnJsonl({
80
84
  bin: BIN,
@@ -82,6 +86,11 @@ export default {
82
86
  cwd,
83
87
  env: { ...process.env, GROK_DISABLE_AUTOUPDATER: '1' },
84
88
  onEvent,
89
+ onStderr(chunk) {
90
+ const lines = (carry + chunk).split('\n');
91
+ carry = lines.pop();
92
+ for (const [, tool, detail] of lines.join('\n').matchAll(REFUSED)) onEvent({ type: 'blocked', tool, detail: detail.replace(/ \(workflow .*\)$/, '') });
93
+ },
85
94
  onJson(msg) {
86
95
  if (msg.session_id && msg.session_id !== session) onEvent({ type: 'session', id: (session = msg.session_id) });
87
96
  // Subagent traffic carries its parent's tool id; only the main conversation is shown.
@@ -232,6 +241,12 @@ function describeMcp(tool, input) {
232
241
  return { kind: 'agent', label: `Delegating to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
233
242
  case 'wisp__subagent_results':
234
243
  return { kind: 'agent', label: 'Waiting on subagents' };
244
+ case 'wisp__wisp_stats':
245
+ return { kind: 'tool', label: 'Reading wisp stats' };
246
+ case 'wisp__wisp_sessions':
247
+ return { kind: 'tool', label: 'Listing wisp tasks' };
248
+ case 'wisp__wisp_session':
249
+ return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
235
250
  default:
236
251
  return { kind: 'tool', label: `Using ${tool.split('__').join(' · ') || 'an integration'}` };
237
252
  }
@@ -7,9 +7,14 @@
7
7
  // efforts list wins); omit if the CLI has none
8
8
  // askTool: string | null | undefined how the agent sees wisp's ask_user MCP tool;
9
9
  // null = can't inject MCP, ask via a question block
10
+ // approveTool?: string its permission prompts go to wisp's `approve` MCP
11
+ // tool (live approval); omit if it can't route them
12
+ // permission?(tool, input, cwd): { tool, action, detail, why? } what a live approval is about
10
13
  // detect(): Promise<{ available, version?, error?, models? }> models overrides the static list
11
14
  // resumeCommand({ resumeId, model, effort }): string[] argv to continue the session interactively
12
- // run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, onEvent }): { stop(), done: Promise }
15
+ // run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, grants, onEvent }): { stop(), done: Promise }
16
+ // grants: `rule`s from its own blocked events the
17
+ // user allowed, for this turn only
13
18
  // }
14
19
  //
15
20
  // onEvent receives normalized events:
@@ -19,6 +24,8 @@
19
24
  // { type: 'text', text } intermediate assistant prose
20
25
  // { type: 'thinking' }
21
26
  // { type: 'change', file, op, edits: [{ before, after }] }
27
+ // { type: 'blocked', tool, detail, rule? } a call its permissions refused; rule: what allows
28
+ // exactly that on a retry (none: retry in Full auto)
22
29
  // { type: 'result', text, isError, stopped?, stats? } exactly once, at the end
23
30
  //
24
31
  // mode is one of: plan (read-only), edits (auto-accept file edits), auto (no permission prompts).
@@ -38,6 +45,7 @@ function checked(a) {
38
45
  typeof a.name !== 'string' && 'name',
39
46
  !Array.isArray(a.models) && 'models',
40
47
  a.askTool !== null && a.askTool !== undefined && typeof a.askTool !== 'string' && 'askTool',
48
+ a.approveTool !== undefined && (typeof a.approveTool !== 'string' || typeof a.permission !== 'function') && 'approveTool + permission()',
41
49
  typeof a.detect !== 'function' && 'detect()',
42
50
  typeof a.run !== 'function' && 'run()',
43
51
  a.resumeCommand !== undefined && typeof a.resumeCommand !== 'function' && 'resumeCommand()',
@@ -17,6 +17,10 @@ let BIN = process.env.WISP_OPENCODE_BIN || 'opencode';
17
17
  // through, then stop and tell the user what to do.
18
18
  const rateLimitGrace = () => Number(process.env.WISP_RATE_LIMIT_GRACE_MS) || 60_000;
19
19
  const RATE_LIMITED = /Rate limit exceeded|Too Many Requests|status(?:Code)?[=: ]+429\b/i;
20
+ // `run` answers every permission prompt itself and says so on stderr (cli/cmd/run.ts):
21
+ // "! permission requested: bash (npm publish); auto-rejecting".
22
+ const REJECTED = /permission requested: ([\w.-]+) \((.*?)\); auto-rejecting/g;
23
+ const ANSI = /\x1b\[[0-9;]*m/g;
20
24
 
21
25
  export default {
22
26
  id: 'opencode',
@@ -61,7 +65,8 @@ export default {
61
65
  return { ...found, models: [this.models[0], ...models] };
62
66
  },
63
67
 
64
- run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, onEvent }) {
68
+ // grants: { permission, patterns } the user allowed after `run` rejected them, for this turn.
69
+ run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, grants = [], onEvent }) {
65
70
  // The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
66
71
  // Errors to stderr: the only place a rate limit shows up (see RATE_LIMITED).
67
72
  const args = ['run', '--format', 'json', '--print-logs', '--log-level', 'ERROR'];
@@ -78,14 +83,20 @@ export default {
78
83
  mcp[name] = { type: 'local', command: [srv.command, ...(srv.args || [])], environment: srv.env || {}, enabled: true, timeout: ASK_TIMEOUT_MS };
79
84
  }
80
85
  const env = { ...process.env };
81
- if (Object.keys(mcp).length) {
86
+ const allow = grants.filter((g) => g && typeof g.permission === 'string' && Array.isArray(g.patterns));
87
+ if (Object.keys(mcp).length || allow.length) {
82
88
  const inline = env.OPENCODE_CONFIG_CONTENT ? safeJson(env.OPENCODE_CONFIG_CONTENT) : {};
89
+ const permission = typeof inline.permission === 'object' ? { ...inline.permission } : inline.permission ? { '*': inline.permission } : {};
90
+ // Its built-in `task` subagents would bypass the user's team.
91
+ if (delegating) permission.task = 'deny';
92
+ for (const g of allow) {
93
+ const was = permission[g.permission];
94
+ permission[g.permission] = { ...(typeof was === 'object' ? was : was && { '*': was }), ...Object.fromEntries(g.patterns.map((p) => [String(p), 'allow'])) };
95
+ }
83
96
  env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
84
97
  ...inline,
85
- mcp: { ...inline.mcp, ...mcp },
86
- experimental: { ...inline.experimental, mcp_timeout: ASK_TIMEOUT_MS },
87
- // Its built-in `task` subagents would bypass the user's team.
88
- ...(delegating && { permission: { ...(typeof inline.permission === 'object' ? inline.permission : inline.permission && { '*': inline.permission }), task: 'deny' } }),
98
+ ...(Object.keys(mcp).length && { mcp: { ...inline.mcp, ...mcp }, experimental: { ...inline.experimental, mcp_timeout: ASK_TIMEOUT_MS } }),
99
+ ...(Object.keys(permission).length && { permission }),
89
100
  });
90
101
  }
91
102
 
@@ -101,6 +112,7 @@ export default {
101
112
 
102
113
  let limitTimer = null;
103
114
  let limited = false;
115
+ let carry = ''; // a stderr line still being written
104
116
  const proc = spawnJsonl({
105
117
  bin: BIN,
106
118
  args,
@@ -109,6 +121,13 @@ export default {
109
121
  input,
110
122
  onEvent,
111
123
  onStderr(chunk) {
124
+ // Whole lines only: a chunk can end mid-line.
125
+ const lines = (carry + chunk).split('\n');
126
+ carry = lines.pop();
127
+ for (const [, permission, list] of lines.join('\n').replace(ANSI, '').matchAll(REJECTED)) {
128
+ const patterns = list.split(', ').filter(Boolean);
129
+ onEvent({ type: 'blocked', tool: permission, detail: patterns.join(', '), rule: { permission, patterns } });
130
+ }
112
131
  if (limitTimer || limited || !RATE_LIMITED.test(chunk)) return;
113
132
  limitTimer = setTimeout(() => ((limited = true), proc.stop()), rateLimitGrace());
114
133
  },
@@ -216,6 +235,12 @@ function describeTool(name, input, cwd) {
216
235
  return { kind: 'agent', label: `Delegated to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
217
236
  case 'wisp_subagent_results':
218
237
  return { kind: 'agent', label: 'Collected subagent reports' };
238
+ case 'wisp_wisp_stats':
239
+ return { kind: 'tool', label: 'Reading wisp stats' };
240
+ case 'wisp_wisp_sessions':
241
+ return { kind: 'tool', label: 'Listing wisp tasks' };
242
+ case 'wisp_wisp_session':
243
+ return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
219
244
  default:
220
245
  return { kind: 'tool', label: `Using ${name.replace(/_/g, ' ')}` };
221
246
  }
package/server/client.js CHANGED
@@ -136,7 +136,7 @@ export class WispClient {
136
136
  if (!agent || !mode || model === undefined) {
137
137
  const { defaults = {} } = await this.config();
138
138
  agent ||= defaults.agent || 'auto';
139
- mode ||= defaults.mode || 'edits';
139
+ mode ||= defaults.mode || 'auto';
140
140
  model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
141
141
  }
142
142
  return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), agent, mode, model, effort, attachments, worktree, compare, subagents, preset });
package/server/handoff.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // those turns out (requests, reports, files touched) and goes ahead of the follow-up's prompt.
4
4
  // A harness that ran earlier in the task resumes its own session and only hears what it missed.
5
5
 
6
+ import { textMentions } from './mentions.js';
6
7
  import { blocksToMarkdown } from './render.js';
7
8
  import { clip, truncate } from './util.js';
8
9
 
@@ -27,7 +28,7 @@ function turnText(t, n, name) {
27
28
  return truncate(
28
29
  [
29
30
  `### Turn ${n} · ${name(t)}${t.model ? ` (${t.model})` : ''}${t.status === 'stopped' ? ' · stopped' : t.status === 'error' ? ' · failed' : ''}${t.rolledBackAt ? ' · rolled back, its edits were undone' : ''}`,
30
- `**User:** ${t.prompt}`,
31
+ `**User:** ${textMentions(t.prompt)}`,
31
32
  report && `**Report:**\n\n${report}`,
32
33
  files.length && `**Files it edited:** ${files.join(', ')}`,
33
34
  ]
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
+ }