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.
- package/CHANGELOG.md +17 -1
- package/README.md +5 -5
- package/package.json +2 -2
- package/server/adapters/antigravity.js +5 -1
- package/server/adapters/claude.js +48 -5
- package/server/adapters/codex.js +3 -0
- package/server/adapters/cursor.js +2 -0
- package/server/adapters/grok.js +15 -0
- package/server/adapters/index.js +9 -1
- package/server/adapters/opencode.js +31 -6
- package/server/client.js +1 -1
- package/server/handoff.js +2 -1
- package/server/mcp-ask.js +113 -8
- package/server/mentions.js +39 -0
- package/server/openapi.js +3 -2
- package/server/protocol.js +8 -1
- package/server/render.js +3 -1
- package/server/routes/internal.js +121 -1
- package/server/routes/sessions.js +12 -0
- package/server/runner.js +136 -20
- package/server/services/launch.js +1 -1
- package/server/services/phone.js +1 -1
- package/server/store.js +8 -2
- package/server/triggers.js +1 -1
- package/server/usage.js +94 -0
- package/server/welcome.js +2 -2
- package/web/css/composer.css +22 -0
- package/web/css/feed.css +27 -0
- package/web/css/ship.css +2 -2
- package/web/index.html +1 -0
- package/web/js/blocks.js +46 -0
- package/web/js/features/actions.js +9 -4
- package/web/js/features/automations.js +1 -1
- package/web/js/features/compare.js +2 -1
- package/web/js/features/composer.js +11 -7
- package/web/js/features/launcher.js +11 -9
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +1 -1
- package/web/js/features/runners.js +3 -1
- package/web/js/features/turns.js +44 -13
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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;
|
package/server/adapters/codex.js
CHANGED
|
@@ -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 });
|
package/server/adapters/grok.js
CHANGED
|
@@ -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
|
}
|
package/server/adapters/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 || '
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|