wisp-agents 0.2.2 → 0.4.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 +45 -1
- package/README.md +41 -13
- package/bin/wisp.js +8 -3
- package/package.json +3 -1
- package/server/adapters/claude.js +14 -32
- package/server/adapters/codex.js +190 -0
- package/server/adapters/common.js +175 -0
- package/server/adapters/cursor.js +156 -0
- package/server/adapters/index.js +16 -9
- package/server/adapters/opencode.js +197 -0
- package/server/index.js +27 -12
- package/server/proc.js +127 -0
- package/server/protocol.js +13 -4
- package/server/runner.js +88 -19
- package/server/store.js +38 -1
- package/server/terminal.js +110 -41
- package/web/css/app.css +173 -17
- package/web/index.html +6 -1
- package/web/js/app.js +691 -99
- package/web/js/fuzzy.js +100 -0
- package/web/js/palette.js +110 -0
- package/web/logos/LICENSE +3 -0
- package/web/logos/claudecode-color.svg +1 -0
- package/web/logos/codex-color.svg +1 -0
- package/web/logos/cursor.svg +1 -0
- package/web/logos/opencode.svg +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,48 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Thinking level in the harness · model picker: Default / Low / Medium / High / X-High / Max under the model list, remembered per harness and shown on the launcher button. Claude Code gets `--effort`, Codex `-c model_reasoning_effort=…`, opencode `--variant` (levels read per model from `opencode models --verbose`). Cursor bakes effort into its model ids, so it has no separate selector.
|
|
13
|
+
- Take over in [cmux](https://cmux.dev): opens a new cmux workspace in the project running the resume command. Auto-detected when wisp is started from a cmux terminal.
|
|
14
|
+
|
|
15
|
+
## [0.3.0] - 2026-10-17
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Codex (`codex exec --json`), opencode (`opencode run --format json`) and Cursor CLI (`cursor-agent -p`) adapters, next to Claude Code. Pick the harness and model together in the launcher, or pass `--agent` on the CLI.
|
|
20
|
+
- One harness · model picker in the launcher: harnesses (with logos) on the left, that harness's models on the right, searchable and grouped by provider for opencode. The model is remembered per harness. opencode and Cursor model lists are read from the CLIs themselves.
|
|
21
|
+
- `ask_user` works in Codex and opencode (injected MCP server). Cursor has no flag to inject one, so Cursor agents ask with a `question` block instead.
|
|
22
|
+
- Diffs for harnesses that only report "file changed" are taken against the file as it was when the turn started (`git stash create`), or the agent's own before/after content when available.
|
|
23
|
+
- Windows: harness CLIs installed through npm (`.cmd` shims) and PowerShell-only installs now start. npm shims run as `node <script>`, so arguments reach the CLI unchanged. Stopping a run ends the whole process tree (`taskkill /T`).
|
|
24
|
+
- Take over on Windows (Windows Terminal, PowerShell, Command Prompt) and on more Linux terminals (GNOME Terminal, Konsole, Xfce Terminal, Alacritty, xterm). The settings list only shows terminals for your OS.
|
|
25
|
+
- Test suite (`npm test`) and CI on Linux, macOS, and Windows, plus `npm run test:real` to smoke-test the real CLIs.
|
|
26
|
+
- Images an agent shows are copied into `~/.wisp/shots/<session>/` when the turn finishes, so screenshots saved to `/tmp` survive reboots. Existing sessions are backfilled on startup if their files still exist. Deleting a session deletes its copies.
|
|
27
|
+
- Command palette (`⌘K` / `Ctrl+K`, or the Search button in the top bar): fuzzy search across sessions (title, project, summary, agent), project filters, global actions, and actions on the focused session.
|
|
28
|
+
- Archive tasks: right-click a task (or press `A`) to archive it. Archived tasks move to a collapsed *Archived* section and keep their state; a new question or turn brings them back.
|
|
29
|
+
- Right-click menus on tasks (open, rename, mark done, archive, delete), project chips (remove) and queued follow-ups (cancel).
|
|
30
|
+
- `⌘L` / `Ctrl+L` focuses the launcher from anywhere. `M` renames the focused item. Menus show key hints.
|
|
31
|
+
- Queued follow-ups are listed above the composer and can be cancelled from an × button, with confirmation.
|
|
32
|
+
- Task cards and requests show the harness logo, with a "harness · model" tooltip.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Image blocks pointing outside the project (e.g. `/tmp/shot.png`) no longer render broken.
|
|
37
|
+
- Replying to a finished agent no longer gets queued behind a CLI that keeps running after its result. A run now ends on exit, 3s after its result, or when a follow-up arrives.
|
|
38
|
+
- Removing a project or deleting a session no longer fails silently (body-less `DELETE`s now send a JSON content type).
|
|
39
|
+
- The notifications setting is a real on/off switch.
|
|
40
|
+
- Finished cards fold up unless you are looking at them.
|
|
41
|
+
- Shortcut labels read `⌘L` on macOS and `Ctrl+L` elsewhere, and modifier shortcuts work on non-Latin keyboard layouts.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- A follow-up sent while an agent is working now goes out right away: wisp interrupts the run and resumes the session with your message. To get the old behaviour back (wait until the run finishes), turn on the **Queue** toggle next to the send button. It shows while the agent is working, and wisp remembers the choice.
|
|
46
|
+
- The Plan / Edits / Full auto control is now one compact button with a menu. It shows a label only for a non-default mode; `Shift+Tab` in the prompt cycles modes.
|
|
47
|
+
- The top bar no longer shows harness status; Settings lists each harness with its version.
|
|
48
|
+
- Slimmer scrollbars that don't shift the layout.
|
|
49
|
+
|
|
8
50
|
## [0.2.2] - 2026-10-16
|
|
9
51
|
|
|
10
52
|
### Fixed
|
|
@@ -41,7 +83,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
41
83
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
42
84
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
43
85
|
|
|
44
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
86
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.4.0...HEAD
|
|
87
|
+
[0.4.0]: https://github.com/M0xoo/wisp/compare/v0.3.0...v0.4.0
|
|
88
|
+
[0.3.0]: https://github.com/M0xoo/wisp/compare/v0.2.2...v0.3.0
|
|
45
89
|
[0.2.2]: https://github.com/M0xoo/wisp/compare/v0.2.1...v0.2.2
|
|
46
90
|
[0.2.1]: https://github.com/M0xoo/wisp/compare/v0.2.0...v0.2.1
|
|
47
91
|
[0.2.0]: https://github.com/M0xoo/wisp/compare/v0.1.0...v0.2.0
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Launch coding agents in the background, skip the transcript, and read the presen
|
|
|
13
13
|
[](https://nodejs.org)
|
|
14
14
|
[](https://www.electronjs.org)
|
|
15
15
|
[](package.json)
|
|
16
|
-
[](#adding-a-harness)
|
|
17
17
|
|
|
18
18
|
[Quick start](#quick-start) · [Features](#features) · [CLI](#cli) · [Shortcuts](#keyboard-shortcuts) · [Architecture](#architecture) · [Adding a harness](#adding-a-harness) · [Roadmap](#roadmap)
|
|
19
19
|
|
|
@@ -23,7 +23,7 @@ Launch coding agents in the background, skip the transcript, and read the presen
|
|
|
23
23
|
|
|
24
24
|
## Why wisp?
|
|
25
25
|
|
|
26
|
-
Agent transcripts are long, and you rarely need them. wisp runs agent harnesses (Claude Code
|
|
26
|
+
Agent transcripts are long, and you rarely need them. wisp runs agent harnesses (Claude Code, Codex, opencode, and Cursor) on your projects in the background. When an agent finishes, it hands you a short, animated **presentation**: diffs, test results, commits, flows, and decisions. The session then waits in your queue.
|
|
27
27
|
|
|
28
28
|
From there you can **follow up**, **answer its questions**, **take it over** in your terminal, or **mark it done**.
|
|
29
29
|
|
|
@@ -41,15 +41,18 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
41
41
|
| 🎞️ **Presentations, not transcripts** | Agents end with structured blocks that render as an animated, skimmable report. |
|
|
42
42
|
| ❓ **Live questions** | Agents get an `ask_user` tool, so they can pause mid-run. Your answer goes straight back to the running agent. |
|
|
43
43
|
| 🧾 **Real diffs** | Edits come from the agent's own tool calls, so `diff` blocks show what actually changed. |
|
|
44
|
-
| 📥 **A queue** | Sessions sort into *Needs you*, *Working* (with a live one-line status), and *Done*. |
|
|
45
|
-
|
|
|
44
|
+
| 📥 **A queue** | Sessions sort into *Needs you*, *Working* (with a live one-line status), and *Done*. Archive what you don't need to see. |
|
|
45
|
+
| 💬 **Follow-ups that interrupt** | A follow-up sent mid-run interrupts the agent and resumes it with your message. Turn on **Queue** to wait for the run to finish instead; queued messages can be cancelled. |
|
|
46
|
+
| 🔎 **Command palette** | `⌘K` / `Ctrl+K` fuzzy-searches sessions, projects, and actions. |
|
|
47
|
+
| 🤖 **Any harness** | Claude Code, Codex, opencode, or Cursor CLI. Pick the harness, model and thinking level together in the launcher; both are remembered per harness. Each card shows the harness it ran on. |
|
|
48
|
+
| 🖥️ **Take over** | Opens your terminal with the harness's resume command (`claude --resume <id>`, `codex resume <id>`, …) in the project folder. Works with Terminal, iTerm, Ghostty, Warp, cmux, kitty, WezTerm, GNOME Terminal, Konsole, Alacritty, Windows Terminal, PowerShell, or a custom command. |
|
|
46
49
|
| 📎 **Attachments** | Paste screenshots or drop images, video, logs, or PDFs into the launcher. |
|
|
47
|
-
| 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. |
|
|
50
|
+
| 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
|
|
48
51
|
| 🪶 **No runtime dependencies** | The server uses only Node built-ins. Electron is needed only for the desktop app. |
|
|
49
52
|
|
|
50
53
|
## Quick start
|
|
51
54
|
|
|
52
|
-
> **Requirements:** Node.js ≥ 20 and [Claude Code](https://claude.com/claude-code) (`claude`)
|
|
55
|
+
> **Requirements:** macOS, Linux, or Windows; Node.js ≥ 20; and at least one harness installed and signed in: [Claude Code](https://claude.com/claude-code) (`claude`), [Codex](https://github.com/openai/codex) (`codex`), [opencode](https://opencode.ai) (`opencode`), or [Cursor CLI](https://cursor.com/cli) (`cursor-agent`). Override a binary path with `WISP_CLAUDE_BIN`, `WISP_CODEX_BIN`, `WISP_OPENCODE_BIN`, or `WISP_CURSOR_BIN`.
|
|
53
56
|
|
|
54
57
|
```sh
|
|
55
58
|
npx wisp-agents # open the wisp desktop app
|
|
@@ -98,12 +101,14 @@ wisp # open wisp (or focus the running one)
|
|
|
98
101
|
wisp "fix the flaky auth test" # launch a task in this folder (it becomes a project)
|
|
99
102
|
wisp add [path] # add a folder as a project (default: cwd)
|
|
100
103
|
wisp --mode plan "audit deps" # permission mode: plan | edits | auto
|
|
104
|
+
wisp --agent codex "add a test" # harness: claude | codex | opencode | cursor
|
|
101
105
|
```
|
|
102
106
|
|
|
103
107
|
| Option | Description |
|
|
104
108
|
|---|---|
|
|
105
109
|
| `--port`, `-p N` | Port to use (default `4477`, or `WISP_PORT`) |
|
|
106
110
|
| `--mode`, `-m` | `plan` · `edits` · `auto` |
|
|
111
|
+
| `--agent`, `-a` | `claude` · `codex` · `opencode` · `cursor` (default: the agent last picked in the launcher) |
|
|
107
112
|
| `--browser`, `-b` | Use the browser instead of the desktop app |
|
|
108
113
|
| `--no-open` | Just run the server; open nothing |
|
|
109
114
|
| `--version`, `-v` | Print the version |
|
|
@@ -114,7 +119,9 @@ wisp --mode plan "audit deps" # permission mode: plan | edits | auto
|
|
|
114
119
|
|
|
115
120
|
| Key | Action |
|
|
116
121
|
|---|---|
|
|
117
|
-
| `
|
|
122
|
+
| `⌘ K` / `Ctrl K` | Command palette: fuzzy-search sessions, projects and actions |
|
|
123
|
+
| `N` · `⌘ L` / `Ctrl L` | New task (focus the launcher; `⌘ L` works from anywhere) |
|
|
124
|
+
| `⇧ Tab` | Cycle permission mode (in the launcher) |
|
|
118
125
|
| `⌘ ↵` | Launch and keep the launcher open for the next task |
|
|
119
126
|
| `J` / `K` | Move down / up through the queue |
|
|
120
127
|
| `O` / `↵` | Open or collapse the focused session |
|
|
@@ -122,7 +129,11 @@ wisp --mode plan "audit deps" # permission mode: plan | edits | auto
|
|
|
122
129
|
| `1`–`9` | Pick an option in a pending question |
|
|
123
130
|
| `T` | Take over in your terminal |
|
|
124
131
|
| `E` | Mark done and jump to the next session that needs you |
|
|
132
|
+
| `A` | Archive / unarchive |
|
|
133
|
+
| `M` | Rename |
|
|
134
|
+
| `⌫` | Delete |
|
|
125
135
|
| `R` | Replay the presentation |
|
|
136
|
+
| `C` | Collapse all |
|
|
126
137
|
| `?` | Show all shortcuts |
|
|
127
138
|
| `Esc` | Close, blur, or collapse |
|
|
128
139
|
|
|
@@ -131,7 +142,10 @@ File paths in presentations open in your editor. VS Code, Cursor, Zed, and JetBr
|
|
|
131
142
|
## Architecture
|
|
132
143
|
|
|
133
144
|
```text
|
|
134
|
-
web/ (UI) ⇄ HTTP + SSE ⇄ server/ ── adapters/claude.js
|
|
145
|
+
web/ (UI) ⇄ HTTP + SSE ⇄ server/ ── adapters/claude.js ── claude -p --output-format stream-json
|
|
146
|
+
├─ adapters/codex.js ── codex exec --json
|
|
147
|
+
├─ adapters/opencode.js ── opencode run --format json
|
|
148
|
+
├─ adapters/cursor.js ── cursor-agent -p --output-format stream-json
|
|
135
149
|
└─ mcp-ask.js (ask_user MCP tool, injected per run)
|
|
136
150
|
```
|
|
137
151
|
|
|
@@ -149,7 +163,11 @@ wisp/
|
|
|
149
163
|
│ ├── welcome.js # the gallery session
|
|
150
164
|
│ └── adapters/
|
|
151
165
|
│ ├── index.js # adapter registry + contract
|
|
152
|
-
│
|
|
166
|
+
│ ├── common.js # shared spawn/JSONL plumbing, labels, diff capture
|
|
167
|
+
│ ├── claude.js # Claude Code adapter
|
|
168
|
+
│ ├── codex.js # Codex CLI adapter
|
|
169
|
+
│ ├── opencode.js # opencode adapter
|
|
170
|
+
│ └── cursor.js # Cursor CLI adapter
|
|
153
171
|
├── web/ # vanilla JS UI, no build step
|
|
154
172
|
│ ├── index.html
|
|
155
173
|
│ ├── css/app.css
|
|
@@ -161,7 +179,7 @@ wisp/
|
|
|
161
179
|
|
|
162
180
|
- **Protocol** (`server/protocol.js`): a system prompt tells agents to work autonomously and to end with a fenced `wisp` JSON block of presentation blocks. The parser reads that block, and falls back to plain markdown if it is missing.
|
|
163
181
|
- **Adapters** (`server/adapters/`): one file per harness. An adapter turns a run into normalized events (`session`, `tool`, `change`, `text`, `result`, …) and provides a `resumeCommand` for take over.
|
|
164
|
-
- **State** lives in `~/.wisp` (override with `WISP_HOME`): one JSON file per session, plus `config.json` and `
|
|
182
|
+
- **State** lives in `~/.wisp` (override with `WISP_HOME`): one JSON file per session, plus `config.json`, `uploads/`, and `shots/` (copies of images agents showed).
|
|
165
183
|
|
|
166
184
|
### Presentation blocks
|
|
167
185
|
|
|
@@ -173,20 +191,30 @@ wisp/
|
|
|
173
191
|
2. Implement `detect()`, `run()`, and `resumeCommand()`. The full contract is documented at the top of `server/adapters/index.js`.
|
|
174
192
|
3. Register the adapter in `server/adapters/index.js`.
|
|
175
193
|
|
|
176
|
-
If the harness has no system-prompt flag, prepend `systemPrompt` to the first prompt. If it supports MCP, pass `mcpServers` through so that `ask_user` works.
|
|
194
|
+
If the harness has no system-prompt flag, prepend `systemPrompt` to the first prompt (`withProtocol` in `common.js`). If it supports MCP, pass `mcpServers` through so that `ask_user` works and set `askTool` to the name the agent sees; set `askTool: null` if it can't, and agents will ask with a `question` block instead.
|
|
177
195
|
|
|
178
196
|
## Roadmap
|
|
179
197
|
|
|
180
198
|
- [x] Claude Code adapter
|
|
181
199
|
- [x] Live `ask_user` questions
|
|
182
200
|
- [x] Desktop app (Electron)
|
|
183
|
-
- [
|
|
184
|
-
- [
|
|
201
|
+
- [x] Codex adapter
|
|
202
|
+
- [x] opencode adapter
|
|
203
|
+
- [x] Cursor CLI adapter
|
|
185
204
|
|
|
186
205
|
## Contributing
|
|
187
206
|
|
|
188
207
|
Issues and PRs are welcome. wisp has no build step: edit `web/` or `server/`, then run `npm run dev` (or `npm run dev:web`) to see your changes.
|
|
189
208
|
|
|
209
|
+
### Tests
|
|
210
|
+
|
|
211
|
+
```sh
|
|
212
|
+
npm test # adapters, process launching, terminals, and the full ask_user loop
|
|
213
|
+
npm run test:real # smoke test against the real CLIs installed on this machine
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`npm test` needs no network or accounts. A fake CLI (`test/fixtures/fake-agent.mjs`) speaks each harness's real output format, edits files, and calls `ask_user` through the real MCP bridge. On Windows the fakes are installed as npm-style `.cmd` shims, which is how npm installs the real CLIs. CI (`.github/workflows/test.yml`) runs the suite on Linux, macOS, and Windows with Node 20 and 22, then runs `test:real` against freshly installed Claude Code, Codex, opencode, and Cursor CLIs.
|
|
217
|
+
|
|
190
218
|
## License
|
|
191
219
|
|
|
192
220
|
[MIT](LICENSE) © M0xoo
|
package/bin/wisp.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// wisp add [path] add a folder as a project (default: current folder)
|
|
7
7
|
// --port N port (default 4477, or WISP_PORT)
|
|
8
8
|
// --mode plan|edits|auto permission mode for a launch
|
|
9
|
+
// --agent ID claude, codex, opencode or cursor (default: the launcher's pick)
|
|
9
10
|
// --browser use the browser instead of the desktop app
|
|
10
11
|
// --no-open just run the server, open nothing
|
|
11
12
|
|
|
@@ -17,7 +18,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
17
18
|
import { wispAppBinary } from './mac-app.js';
|
|
18
19
|
|
|
19
20
|
const argv = process.argv.slice(2);
|
|
20
|
-
const flags = { open: true, browser: false, port: Number(process.env.WISP_PORT) || 4477, mode: null };
|
|
21
|
+
const flags = { open: true, browser: false, port: Number(process.env.WISP_PORT) || 4477, mode: null, agent: null };
|
|
21
22
|
const words = [];
|
|
22
23
|
for (let i = 0; i < argv.length; i++) {
|
|
23
24
|
const a = argv[i];
|
|
@@ -25,12 +26,13 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
25
26
|
else if (a === '--browser' || a === '-b') flags.browser = true;
|
|
26
27
|
else if (a === '--port' || a === '-p') flags.port = Number(argv[++i]);
|
|
27
28
|
else if (a === '--mode' || a === '-m') flags.mode = argv[++i];
|
|
29
|
+
else if (a === '--agent' || a === '-a') flags.agent = argv[++i];
|
|
28
30
|
else if (a === '--version' || a === '-v') {
|
|
29
31
|
const { readFileSync } = await import('node:fs');
|
|
30
32
|
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version);
|
|
31
33
|
process.exit(0);
|
|
32
34
|
} else if (a === '--help' || a === '-h') {
|
|
33
|
-
console.log('usage:\n wisp open wisp\n wisp "task" launch a task in this folder\n wisp add [path] add a project\n\noptions: --port N --mode plan|edits|auto --browser --no-open --version');
|
|
35
|
+
console.log('usage:\n wisp open wisp\n wisp "task" launch a task in this folder\n wisp add [path] add a project\n\noptions: --port N --mode plan|edits|auto --agent claude|codex|opencode|cursor --browser --no-open --version');
|
|
34
36
|
process.exit(0);
|
|
35
37
|
} else words.push(a);
|
|
36
38
|
}
|
|
@@ -80,7 +82,10 @@ if (words[0] === 'add') {
|
|
|
80
82
|
if (running || electron) process.exit(0);
|
|
81
83
|
} else if (words.length) {
|
|
82
84
|
const cfg = await api('/config');
|
|
83
|
-
const
|
|
85
|
+
const agent = flags.agent || cfg.defaults.agent;
|
|
86
|
+
// Models are remembered per agent (ids aren't portable between harnesses).
|
|
87
|
+
const model = cfg.defaults.models?.[agent] ?? (agent === cfg.defaults.agent ? cfg.defaults.model : '');
|
|
88
|
+
const s = await api('/sessions', { prompt: words.join(' '), cwd: process.cwd(), agent, mode: flags.mode || cfg.defaults.mode, model });
|
|
84
89
|
console.log(` ◌ launched: ${s.title}`);
|
|
85
90
|
openPath = `/#/s/${s.id}`;
|
|
86
91
|
if (running || electron) process.exit(0); // the running wisp will show it in the queue
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wisp-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Launch coding agents, skip the transcript, read the presentation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"app": "node bin/wisp.js",
|
|
20
20
|
"dev": "WISP_DEV=1 node bin/wisp.js",
|
|
21
21
|
"dev:web": "node --watch-path=server bin/wisp.js --no-open",
|
|
22
|
+
"test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js",
|
|
23
|
+
"test:real": "node test/real-clis.mjs",
|
|
22
24
|
"prepublishOnly": "node --check bin/wisp.js && node --check bin/mac-app.js && node -e \"import('./server/index.js')\" && node --check electron/main.js"
|
|
23
25
|
},
|
|
24
26
|
"engines": {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Claude Code adapter — drives `claude -p --output-format stream-json`.
|
|
2
2
|
|
|
3
|
-
import { spawn, execFile } from 'node:child_process';
|
|
4
3
|
import readline from 'node:readline';
|
|
5
4
|
import fs from 'node:fs';
|
|
6
5
|
import path from 'node:path';
|
|
6
|
+
import { spawn, killTree } from '../proc.js';
|
|
7
|
+
import { ASK_TIMEOUT_MS, MAX_SNAPSHOT, attachmentNote, clip, detectVersion, flatten, rel } from './common.js';
|
|
7
8
|
|
|
8
9
|
const BIN = process.env.WISP_CLAUDE_BIN || 'claude';
|
|
9
|
-
const MAX_SNAPSHOT = 256 * 1024;
|
|
10
10
|
|
|
11
11
|
const PERMISSION_MODES = {
|
|
12
12
|
plan: 'plan',
|
|
@@ -23,22 +23,19 @@ export default {
|
|
|
23
23
|
{ id: 'sonnet', label: 'Sonnet', hint: 'Fast and smart, everyday work' },
|
|
24
24
|
{ id: 'haiku', label: 'Haiku', hint: 'Fastest, for quick tasks' },
|
|
25
25
|
],
|
|
26
|
+
efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
26
27
|
|
|
27
28
|
// Interactive command to take over a session in a terminal.
|
|
28
|
-
resumeCommand({ resumeId, model }) {
|
|
29
|
-
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : [])];
|
|
29
|
+
resumeCommand({ resumeId, model, effort }) {
|
|
30
|
+
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : [])];
|
|
30
31
|
},
|
|
31
32
|
|
|
32
33
|
detect() {
|
|
33
|
-
return
|
|
34
|
-
execFile(BIN, ['--version'], { timeout: 10_000 }, (err, stdout) => {
|
|
35
|
-
resolve(err ? { available: false, error: err.message } : { available: true, version: stdout.trim() });
|
|
36
|
-
});
|
|
37
|
-
});
|
|
34
|
+
return detectVersion(BIN);
|
|
38
35
|
},
|
|
39
36
|
|
|
40
37
|
// Returns { stop(), done: Promise<void> }. Everything else is reported through onEvent.
|
|
41
|
-
run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, mcpServers = {}, onEvent }) {
|
|
38
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
|
|
42
39
|
const args = [
|
|
43
40
|
'-p',
|
|
44
41
|
'--output-format', 'stream-json',
|
|
@@ -46,6 +43,7 @@ export default {
|
|
|
46
43
|
'--permission-mode', PERMISSION_MODES[mode] || 'acceptEdits',
|
|
47
44
|
];
|
|
48
45
|
if (model) args.push('--model', model);
|
|
46
|
+
if (effort) args.push('--effort', effort);
|
|
49
47
|
if (resumeId) args.push('--resume', resumeId);
|
|
50
48
|
if (Object.keys(mcpServers).length) {
|
|
51
49
|
args.push('--mcp-config', JSON.stringify({ mcpServers }));
|
|
@@ -54,12 +52,12 @@ export default {
|
|
|
54
52
|
const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
|
|
55
53
|
if (dirs.length) args.push('--add-dir', ...dirs);
|
|
56
54
|
args.push('--verbose'); // terminates the variadic flags above
|
|
57
|
-
|
|
55
|
+
prompt += attachmentNote(attachments, 'Read images and PDFs with the Read tool');
|
|
58
56
|
|
|
59
57
|
const env = { ...process.env };
|
|
60
58
|
delete env.CLAUDECODE; // don't let a parent Claude Code session leak into the child
|
|
61
59
|
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
62
|
-
env.MCP_TOOL_TIMEOUT ??= String(
|
|
60
|
+
env.MCP_TOOL_TIMEOUT ??= String(ASK_TIMEOUT_MS);
|
|
63
61
|
|
|
64
62
|
const child = spawn(BIN, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
65
63
|
child.stdin.end(prompt);
|
|
@@ -119,6 +117,7 @@ export default {
|
|
|
119
117
|
}
|
|
120
118
|
});
|
|
121
119
|
|
|
120
|
+
let stopping = false;
|
|
122
121
|
const done = new Promise((resolve) => {
|
|
123
122
|
child.on('error', (err) => {
|
|
124
123
|
onEvent({ type: 'result', isError: true, text: `Could not start \`${BIN}\`: ${err.message}` });
|
|
@@ -130,7 +129,7 @@ export default {
|
|
|
130
129
|
onEvent({
|
|
131
130
|
type: 'result',
|
|
132
131
|
isError: true,
|
|
133
|
-
stopped: signal === 'SIGTERM' || signal === 'SIGINT',
|
|
132
|
+
stopped: stopping || signal === 'SIGTERM' || signal === 'SIGINT',
|
|
134
133
|
text: stderr.trim() || `claude exited with ${signal || `code ${code}`}`,
|
|
135
134
|
});
|
|
136
135
|
}
|
|
@@ -141,27 +140,15 @@ export default {
|
|
|
141
140
|
return {
|
|
142
141
|
done,
|
|
143
142
|
stop() {
|
|
144
|
-
|
|
143
|
+
stopping = true;
|
|
144
|
+
killTree(child);
|
|
145
145
|
},
|
|
146
146
|
};
|
|
147
147
|
},
|
|
148
148
|
};
|
|
149
149
|
|
|
150
|
-
function attachmentNote(files) {
|
|
151
|
-
const lines = files.map((f) => `- ${f.path} (${f.mime}, ${Math.max(1, Math.round(f.size / 1024))} KB)`);
|
|
152
|
-
return `\n\n<attachments>\nThe user attached these files. Read images and PDFs with the Read tool; for video/audio/other formats use the shell (e.g. ffmpeg, ffprobe) if needed.\n${lines.join('\n')}\n</attachments>`;
|
|
153
|
-
}
|
|
154
150
|
|
|
155
|
-
function rel(file, cwd) {
|
|
156
|
-
if (!file) return '';
|
|
157
|
-
const r = path.relative(cwd, file);
|
|
158
|
-
return r && !r.startsWith('..') ? r : file;
|
|
159
|
-
}
|
|
160
151
|
|
|
161
|
-
function clip(s, n = 80) {
|
|
162
|
-
s = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
163
|
-
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
164
|
-
}
|
|
165
152
|
|
|
166
153
|
// Human sentence for the live ticker + a coarse kind for the trail summary.
|
|
167
154
|
function describeTool(name, input, cwd) {
|
|
@@ -223,8 +210,3 @@ function captureChange(name, input, cwd) {
|
|
|
223
210
|
return null;
|
|
224
211
|
}
|
|
225
212
|
|
|
226
|
-
function flatten(content) {
|
|
227
|
-
if (typeof content === 'string') return content;
|
|
228
|
-
if (Array.isArray(content)) return content.map((c) => c.text || '').join('\n');
|
|
229
|
-
return '';
|
|
230
|
-
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Codex CLI adapter — drives `codex exec --json` (JSONL thread/turn/item events).
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, fileTracker, rel, spawnJsonl, withProtocol } from './common.js';
|
|
5
|
+
|
|
6
|
+
const BIN = process.env.WISP_CODEX_BIN || 'codex';
|
|
7
|
+
const IMAGE = /^image\/(png|jpe?g|gif|webp)$/;
|
|
8
|
+
|
|
9
|
+
// Codex has no "accept edits only" mode; workspace-write lets it edit the project and run
|
|
10
|
+
// sandboxed commands without asking, which is the closest match.
|
|
11
|
+
const SANDBOX = {
|
|
12
|
+
plan: 'read-only',
|
|
13
|
+
edits: 'workspace-write',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const toml = (v) =>
|
|
17
|
+
Array.isArray(v) ? `[${v.map(toml).join(',')}]` : typeof v === 'object' ? `{${Object.entries(v).map(([k, x]) => `${k}=${toml(x)}`).join(',')}}` : JSON.stringify(v);
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
id: 'codex',
|
|
21
|
+
name: 'Codex',
|
|
22
|
+
askTool: 'mcp__wisp__ask_user',
|
|
23
|
+
models: [{ id: '', label: 'Default', hint: 'Whatever Codex is set to' }],
|
|
24
|
+
efforts: ['minimal', 'low', 'medium', 'high', 'xhigh'],
|
|
25
|
+
|
|
26
|
+
resumeCommand({ resumeId, model, effort }) {
|
|
27
|
+
return [BIN, ...(resumeId ? ['resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['-c', `model_reasoning_effort=${toml(effort)}`] : [])];
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
detect() {
|
|
31
|
+
return detectVersion(BIN);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
|
|
35
|
+
const args = ['exec', '--json', '--skip-git-repo-check'];
|
|
36
|
+
// `resume` doesn't take --sandbox, so set it through config for both paths.
|
|
37
|
+
if (mode === 'auto') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
38
|
+
else args.push('-c', `sandbox_mode=${toml(SANDBOX[mode] || 'workspace-write')}`);
|
|
39
|
+
if (model) args.push('-m', model);
|
|
40
|
+
if (effort) args.push('-c', `model_reasoning_effort=${toml(effort)}`);
|
|
41
|
+
for (const [name, srv] of Object.entries(mcpServers)) {
|
|
42
|
+
const key = `mcp_servers.${name}`;
|
|
43
|
+
args.push('-c', `${key}.command=${toml(srv.command)}`, '-c', `${key}.args=${toml(srv.args || [])}`);
|
|
44
|
+
if (srv.env) args.push('-c', `${key}.env=${toml(srv.env)}`);
|
|
45
|
+
args.push('-c', `${key}.tool_timeout_sec=${ASK_TIMEOUT_MS / 1000}`);
|
|
46
|
+
}
|
|
47
|
+
const images = attachments.filter((a) => IMAGE.test(a.mime));
|
|
48
|
+
for (const img of images) args.push('-i', img.path);
|
|
49
|
+
if (!resumeId) {
|
|
50
|
+
const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
|
|
51
|
+
for (const d of dirs) args.push('--add-dir', d);
|
|
52
|
+
}
|
|
53
|
+
if (resumeId) args.push('resume', resumeId);
|
|
54
|
+
// No prompt argument: codex reads it from stdin.
|
|
55
|
+
|
|
56
|
+
let input = withProtocol(prompt, systemPrompt, resumeId);
|
|
57
|
+
input += attachmentNote(attachments.filter((a) => !images.includes(a)), 'Read text files and PDFs from disk');
|
|
58
|
+
if (images.length) input += `\n\n(${images.length} image${images.length > 1 ? 's are' : ' is'} attached to this message.)`;
|
|
59
|
+
|
|
60
|
+
const files = fileTracker(cwd);
|
|
61
|
+
let lastMessage = '';
|
|
62
|
+
let failure = null;
|
|
63
|
+
let lastError = '';
|
|
64
|
+
let usage = null;
|
|
65
|
+
let completed = false;
|
|
66
|
+
const started = Date.now();
|
|
67
|
+
|
|
68
|
+
const onItem = (item, phase) => {
|
|
69
|
+
const type = item.type || item.item_type;
|
|
70
|
+
if (phase === 'started') {
|
|
71
|
+
if (type === 'command_execution') {
|
|
72
|
+
onEvent({ type: 'tool', kind: 'run', label: `Running ${clip(unwrapShell(item.command), 60)}`, detail: item.command });
|
|
73
|
+
} else if (type === 'mcp_tool_call') {
|
|
74
|
+
onEvent({ type: 'tool', ...describeMcp(item) });
|
|
75
|
+
} else if (type === 'web_search') {
|
|
76
|
+
onEvent({ type: 'tool', kind: 'web', label: item.query ? `Searching the web: ${clip(item.query, 50)}` : 'Searching the web' });
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (phase === 'updated') {
|
|
81
|
+
if (type === 'todo_list') {
|
|
82
|
+
const next = item.items?.find((t) => !t.completed);
|
|
83
|
+
if (next) onEvent({ type: 'tool', kind: 'plan', label: clip(next.text) });
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// completed
|
|
88
|
+
switch (type) {
|
|
89
|
+
case 'agent_message':
|
|
90
|
+
case 'assistant_message':
|
|
91
|
+
if (item.text?.trim()) {
|
|
92
|
+
lastMessage = item.text;
|
|
93
|
+
onEvent({ type: 'text', text: item.text });
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
case 'reasoning':
|
|
97
|
+
onEvent({ type: 'thinking' });
|
|
98
|
+
break;
|
|
99
|
+
case 'command_execution':
|
|
100
|
+
if (item.status === 'failed' || (item.exit_code != null && item.exit_code !== 0)) {
|
|
101
|
+
onEvent({ type: 'tool_error', name: clip(unwrapShell(item.command), 40), text: clip(item.aggregated_output || `exit ${item.exit_code}`, 400) });
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
case 'file_change':
|
|
105
|
+
for (const c of item.changes || []) {
|
|
106
|
+
const f = rel(c.path, cwd);
|
|
107
|
+
onEvent({ type: 'tool', kind: 'edit', label: `${c.kind === 'add' ? 'Writing' : c.kind === 'delete' ? 'Deleting' : 'Editing'} ${f}`, file: f });
|
|
108
|
+
const change = files.change(c.path);
|
|
109
|
+
if (change) onEvent({ type: 'change', ...change });
|
|
110
|
+
}
|
|
111
|
+
if (item.status === 'failed') onEvent({ type: 'tool_error', name: 'apply_patch', text: 'The patch did not apply.' });
|
|
112
|
+
break;
|
|
113
|
+
case 'mcp_tool_call':
|
|
114
|
+
if (item.status === 'failed') onEvent({ type: 'tool_error', name: `${item.server}.${item.tool}`, text: clip(item.error?.message || 'failed', 400) });
|
|
115
|
+
break;
|
|
116
|
+
case 'todo_list': {
|
|
117
|
+
const next = item.items?.find((t) => !t.completed);
|
|
118
|
+
if (next) onEvent({ type: 'tool', kind: 'plan', label: clip(next.text) });
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return spawnJsonl({
|
|
125
|
+
bin: BIN,
|
|
126
|
+
args,
|
|
127
|
+
cwd,
|
|
128
|
+
env: process.env,
|
|
129
|
+
input,
|
|
130
|
+
onEvent,
|
|
131
|
+
onJson(msg) {
|
|
132
|
+
switch (msg.type) {
|
|
133
|
+
case 'thread.started':
|
|
134
|
+
if (msg.thread_id) onEvent({ type: 'session', id: msg.thread_id });
|
|
135
|
+
break;
|
|
136
|
+
case 'item.started':
|
|
137
|
+
case 'item.updated':
|
|
138
|
+
case 'item.completed':
|
|
139
|
+
if (msg.item) onItem(msg.item, msg.type.slice(5));
|
|
140
|
+
break;
|
|
141
|
+
case 'turn.completed':
|
|
142
|
+
completed = true;
|
|
143
|
+
usage = msg.usage || null;
|
|
144
|
+
break;
|
|
145
|
+
case 'turn.failed':
|
|
146
|
+
failure = msg.error?.message || 'The turn failed.';
|
|
147
|
+
break;
|
|
148
|
+
case 'error':
|
|
149
|
+
// "Reconnecting... n/5" is transient; keep the last one in case the turn dies.
|
|
150
|
+
if (msg.message) lastError = msg.message;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
onClose({ code, signal, stderr, stopped }) {
|
|
155
|
+
const ok = completed && !failure && code === 0;
|
|
156
|
+
onEvent({
|
|
157
|
+
type: 'result',
|
|
158
|
+
isError: !ok,
|
|
159
|
+
stopped,
|
|
160
|
+
text: ok ? lastMessage : failure || lastError || stderr || `codex exited with ${signal || `code ${code}`}`,
|
|
161
|
+
stats: {
|
|
162
|
+
durationMs: Date.now() - started,
|
|
163
|
+
inputTokens: usage?.input_tokens,
|
|
164
|
+
outputTokens: usage?.output_tokens,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// codex reports commands wrapped in a shell: `bash -lc '…'` on Unix, `powershell.exe -Command "…"`
|
|
173
|
+
// on Windows. Show the inner command.
|
|
174
|
+
export function unwrapShell(cmd) {
|
|
175
|
+
if (Array.isArray(cmd)) {
|
|
176
|
+
const i = cmd.findIndex((a) => /^(-l?c|-Command|\/c)$/i.test(a));
|
|
177
|
+
return i >= 0 && i === cmd.length - 2 ? cmd.at(-1) : cmd.join(' ');
|
|
178
|
+
}
|
|
179
|
+
const s = String(cmd ?? '');
|
|
180
|
+
const m =
|
|
181
|
+
s.match(/^(?:\S*\/)?(?:ba|z)?sh -l?c (['"])([\s\S]*)\1$/) ||
|
|
182
|
+
s.match(/^"?(?:[^"]*[\\/])?(?:powershell|pwsh)(?:\.exe)?"?(?: -NoProfile| -NoLogo)* -(?:Command|c) (['"]?)([\s\S]*)\1$/i) ||
|
|
183
|
+
s.match(/^"?(?:[^"]*[\\/])?cmd(?:\.exe)?"? \/[cC] (['"]?)([\s\S]*)\1$/);
|
|
184
|
+
return m ? m[2] : s;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function describeMcp(item) {
|
|
188
|
+
if (item.server === 'wisp' && item.tool === 'ask_user') return { kind: 'wait', label: 'Waiting for your answer' };
|
|
189
|
+
return { kind: 'tool', label: `Using ${item.server} · ${item.tool}` };
|
|
190
|
+
}
|