wisp-agents 0.2.2 → 0.3.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 CHANGED
@@ -5,6 +5,41 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.3.0] - 2026-10-17
9
+
10
+ ### Added
11
+
12
+ - 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.
13
+ - 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.
14
+ - `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.
15
+ - 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.
16
+ - 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`).
17
+ - 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.
18
+ - Test suite (`npm test`) and CI on Linux, macOS, and Windows, plus `npm run test:real` to smoke-test the real CLIs.
19
+ - 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.
20
+ - 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.
21
+ - 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.
22
+ - Right-click menus on tasks (open, rename, mark done, archive, delete), project chips (remove) and queued follow-ups (cancel).
23
+ - `⌘L` / `Ctrl+L` focuses the launcher from anywhere. `M` renames the focused item. Menus show key hints.
24
+ - Queued follow-ups are listed above the composer and can be cancelled from an × button, with confirmation.
25
+ - Task cards and requests show the harness logo, with a "harness · model" tooltip.
26
+
27
+ ### Fixed
28
+
29
+ - Image blocks pointing outside the project (e.g. `/tmp/shot.png`) no longer render broken.
30
+ - 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.
31
+ - Removing a project or deleting a session no longer fails silently (body-less `DELETE`s now send a JSON content type).
32
+ - The notifications setting is a real on/off switch.
33
+ - Finished cards fold up unless you are looking at them.
34
+ - Shortcut labels read `⌘L` on macOS and `Ctrl+L` elsewhere, and modifier shortcuts work on non-Latin keyboard layouts.
35
+
36
+ ### Changed
37
+
38
+ - 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.
39
+ - 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.
40
+ - The top bar no longer shows harness status; Settings lists each harness with its version.
41
+ - Slimmer scrollbars that don't shift the layout.
42
+
8
43
  ## [0.2.2] - 2026-10-16
9
44
 
10
45
  ### Fixed
@@ -41,7 +76,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
41
76
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
42
77
  - Older turns are folded, with a floating collapse-all button and back-to-top.
43
78
 
44
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.2.2...HEAD
79
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.3.0...HEAD
80
+ [0.3.0]: https://github.com/M0xoo/wisp/compare/v0.2.2...v0.3.0
45
81
  [0.2.2]: https://github.com/M0xoo/wisp/compare/v0.2.1...v0.2.2
46
82
  [0.2.1]: https://github.com/M0xoo/wisp/compare/v0.2.0...v0.2.1
47
83
  [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
  [![Node](https://img.shields.io/badge/node-%3E%3D20-339933?logo=node.js&logoColor=white)](https://nodejs.org)
14
14
  [![Electron](https://img.shields.io/badge/desktop-Electron-47848f?logo=electron&logoColor=white)](https://www.electronjs.org)
15
15
  [![Zero runtime deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)](package.json)
16
- [![Claude Code](https://img.shields.io/badge/harness-Claude%20Code-d97757)](https://claude.com/claude-code)
16
+ [![Harnesses](https://img.shields.io/badge/harness-Claude%20Code%20·%20Codex%20·%20opencode%20·%20Cursor-d97757)](#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 today, with Codex and opencode planned) 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.
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
- | 🖥️ **Take over** | Opens your terminal with `claude --resume <id>` in the project folder. Works with Terminal, iTerm, Ghostty, Warp, kitty, WezTerm, or a custom command. |
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 and model together in the launcher; the model is 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, 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`) installed and signed in.
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
- | `N` | New task (focus the launcher) |
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 ── claude -p --output-format stream-json
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
- │ └── claude.js # Claude Code adapter
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 `uploads/`.
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
- - [ ] Codex adapter
184
- - [ ] opencode adapter
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 s = await api('/sessions', { prompt: words.join(' '), cwd: process.cwd(), agent: cfg.defaults.agent, mode: flags.mode || cfg.defaults.mode, model: cfg.defaults.model });
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.2.2",
3
+ "version": "0.3.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',
@@ -30,11 +30,7 @@ export default {
30
30
  },
31
31
 
32
32
  detect() {
33
- return new Promise((resolve) => {
34
- execFile(BIN, ['--version'], { timeout: 10_000 }, (err, stdout) => {
35
- resolve(err ? { available: false, error: err.message } : { available: true, version: stdout.trim() });
36
- });
37
- });
33
+ return detectVersion(BIN);
38
34
  },
39
35
 
40
36
  // Returns { stop(), done: Promise<void> }. Everything else is reported through onEvent.
@@ -54,12 +50,12 @@ export default {
54
50
  const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
55
51
  if (dirs.length) args.push('--add-dir', ...dirs);
56
52
  args.push('--verbose'); // terminates the variadic flags above
57
- if (attachments.length) prompt += attachmentNote(attachments);
53
+ prompt += attachmentNote(attachments, 'Read images and PDFs with the Read tool');
58
54
 
59
55
  const env = { ...process.env };
60
56
  delete env.CLAUDECODE; // don't let a parent Claude Code session leak into the child
61
57
  delete env.CLAUDE_CODE_ENTRYPOINT;
62
- env.MCP_TOOL_TIMEOUT ??= String(7 * 24 * 3600 * 1000); // ask_user may wait for the human a long time
58
+ env.MCP_TOOL_TIMEOUT ??= String(ASK_TIMEOUT_MS);
63
59
 
64
60
  const child = spawn(BIN, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
65
61
  child.stdin.end(prompt);
@@ -119,6 +115,7 @@ export default {
119
115
  }
120
116
  });
121
117
 
118
+ let stopping = false;
122
119
  const done = new Promise((resolve) => {
123
120
  child.on('error', (err) => {
124
121
  onEvent({ type: 'result', isError: true, text: `Could not start \`${BIN}\`: ${err.message}` });
@@ -130,7 +127,7 @@ export default {
130
127
  onEvent({
131
128
  type: 'result',
132
129
  isError: true,
133
- stopped: signal === 'SIGTERM' || signal === 'SIGINT',
130
+ stopped: stopping || signal === 'SIGTERM' || signal === 'SIGINT',
134
131
  text: stderr.trim() || `claude exited with ${signal || `code ${code}`}`,
135
132
  });
136
133
  }
@@ -141,27 +138,15 @@ export default {
141
138
  return {
142
139
  done,
143
140
  stop() {
144
- if (child.exitCode === null) child.kill('SIGTERM');
141
+ stopping = true;
142
+ killTree(child);
145
143
  },
146
144
  };
147
145
  },
148
146
  };
149
147
 
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
148
 
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
149
 
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
150
 
166
151
  // Human sentence for the live ticker + a coarse kind for the trail summary.
167
152
  function describeTool(name, input, cwd) {
@@ -223,8 +208,3 @@ function captureChange(name, input, cwd) {
223
208
  return null;
224
209
  }
225
210
 
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,188 @@
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
+
25
+ resumeCommand({ resumeId, model }) {
26
+ return [BIN, ...(resumeId ? ['resume', resumeId] : []), ...(model ? ['--model', model] : [])];
27
+ },
28
+
29
+ detect() {
30
+ return detectVersion(BIN);
31
+ },
32
+
33
+ run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, mcpServers = {}, onEvent }) {
34
+ const args = ['exec', '--json', '--skip-git-repo-check'];
35
+ // `resume` doesn't take --sandbox, so set it through config for both paths.
36
+ if (mode === 'auto') args.push('--dangerously-bypass-approvals-and-sandbox');
37
+ else args.push('-c', `sandbox_mode=${toml(SANDBOX[mode] || 'workspace-write')}`);
38
+ if (model) args.push('-m', model);
39
+ for (const [name, srv] of Object.entries(mcpServers)) {
40
+ const key = `mcp_servers.${name}`;
41
+ args.push('-c', `${key}.command=${toml(srv.command)}`, '-c', `${key}.args=${toml(srv.args || [])}`);
42
+ if (srv.env) args.push('-c', `${key}.env=${toml(srv.env)}`);
43
+ args.push('-c', `${key}.tool_timeout_sec=${ASK_TIMEOUT_MS / 1000}`);
44
+ }
45
+ const images = attachments.filter((a) => IMAGE.test(a.mime));
46
+ for (const img of images) args.push('-i', img.path);
47
+ if (!resumeId) {
48
+ const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
49
+ for (const d of dirs) args.push('--add-dir', d);
50
+ }
51
+ if (resumeId) args.push('resume', resumeId);
52
+ // No prompt argument: codex reads it from stdin.
53
+
54
+ let input = withProtocol(prompt, systemPrompt, resumeId);
55
+ input += attachmentNote(attachments.filter((a) => !images.includes(a)), 'Read text files and PDFs from disk');
56
+ if (images.length) input += `\n\n(${images.length} image${images.length > 1 ? 's are' : ' is'} attached to this message.)`;
57
+
58
+ const files = fileTracker(cwd);
59
+ let lastMessage = '';
60
+ let failure = null;
61
+ let lastError = '';
62
+ let usage = null;
63
+ let completed = false;
64
+ const started = Date.now();
65
+
66
+ const onItem = (item, phase) => {
67
+ const type = item.type || item.item_type;
68
+ if (phase === 'started') {
69
+ if (type === 'command_execution') {
70
+ onEvent({ type: 'tool', kind: 'run', label: `Running ${clip(unwrapShell(item.command), 60)}`, detail: item.command });
71
+ } else if (type === 'mcp_tool_call') {
72
+ onEvent({ type: 'tool', ...describeMcp(item) });
73
+ } else if (type === 'web_search') {
74
+ onEvent({ type: 'tool', kind: 'web', label: item.query ? `Searching the web: ${clip(item.query, 50)}` : 'Searching the web' });
75
+ }
76
+ return;
77
+ }
78
+ if (phase === 'updated') {
79
+ if (type === 'todo_list') {
80
+ const next = item.items?.find((t) => !t.completed);
81
+ if (next) onEvent({ type: 'tool', kind: 'plan', label: clip(next.text) });
82
+ }
83
+ return;
84
+ }
85
+ // completed
86
+ switch (type) {
87
+ case 'agent_message':
88
+ case 'assistant_message':
89
+ if (item.text?.trim()) {
90
+ lastMessage = item.text;
91
+ onEvent({ type: 'text', text: item.text });
92
+ }
93
+ break;
94
+ case 'reasoning':
95
+ onEvent({ type: 'thinking' });
96
+ break;
97
+ case 'command_execution':
98
+ if (item.status === 'failed' || (item.exit_code != null && item.exit_code !== 0)) {
99
+ onEvent({ type: 'tool_error', name: clip(unwrapShell(item.command), 40), text: clip(item.aggregated_output || `exit ${item.exit_code}`, 400) });
100
+ }
101
+ break;
102
+ case 'file_change':
103
+ for (const c of item.changes || []) {
104
+ const f = rel(c.path, cwd);
105
+ onEvent({ type: 'tool', kind: 'edit', label: `${c.kind === 'add' ? 'Writing' : c.kind === 'delete' ? 'Deleting' : 'Editing'} ${f}`, file: f });
106
+ const change = files.change(c.path);
107
+ if (change) onEvent({ type: 'change', ...change });
108
+ }
109
+ if (item.status === 'failed') onEvent({ type: 'tool_error', name: 'apply_patch', text: 'The patch did not apply.' });
110
+ break;
111
+ case 'mcp_tool_call':
112
+ if (item.status === 'failed') onEvent({ type: 'tool_error', name: `${item.server}.${item.tool}`, text: clip(item.error?.message || 'failed', 400) });
113
+ break;
114
+ case 'todo_list': {
115
+ const next = item.items?.find((t) => !t.completed);
116
+ if (next) onEvent({ type: 'tool', kind: 'plan', label: clip(next.text) });
117
+ break;
118
+ }
119
+ }
120
+ };
121
+
122
+ return spawnJsonl({
123
+ bin: BIN,
124
+ args,
125
+ cwd,
126
+ env: process.env,
127
+ input,
128
+ onEvent,
129
+ onJson(msg) {
130
+ switch (msg.type) {
131
+ case 'thread.started':
132
+ if (msg.thread_id) onEvent({ type: 'session', id: msg.thread_id });
133
+ break;
134
+ case 'item.started':
135
+ case 'item.updated':
136
+ case 'item.completed':
137
+ if (msg.item) onItem(msg.item, msg.type.slice(5));
138
+ break;
139
+ case 'turn.completed':
140
+ completed = true;
141
+ usage = msg.usage || null;
142
+ break;
143
+ case 'turn.failed':
144
+ failure = msg.error?.message || 'The turn failed.';
145
+ break;
146
+ case 'error':
147
+ // "Reconnecting... n/5" is transient; keep the last one in case the turn dies.
148
+ if (msg.message) lastError = msg.message;
149
+ break;
150
+ }
151
+ },
152
+ onClose({ code, signal, stderr, stopped }) {
153
+ const ok = completed && !failure && code === 0;
154
+ onEvent({
155
+ type: 'result',
156
+ isError: !ok,
157
+ stopped,
158
+ text: ok ? lastMessage : failure || lastError || stderr || `codex exited with ${signal || `code ${code}`}`,
159
+ stats: {
160
+ durationMs: Date.now() - started,
161
+ inputTokens: usage?.input_tokens,
162
+ outputTokens: usage?.output_tokens,
163
+ },
164
+ });
165
+ },
166
+ });
167
+ },
168
+ };
169
+
170
+ // codex reports commands wrapped in a shell: `bash -lc '…'` on Unix, `powershell.exe -Command "…"`
171
+ // on Windows. Show the inner command.
172
+ export function unwrapShell(cmd) {
173
+ if (Array.isArray(cmd)) {
174
+ const i = cmd.findIndex((a) => /^(-l?c|-Command|\/c)$/i.test(a));
175
+ return i >= 0 && i === cmd.length - 2 ? cmd.at(-1) : cmd.join(' ');
176
+ }
177
+ const s = String(cmd ?? '');
178
+ const m =
179
+ s.match(/^(?:\S*\/)?(?:ba|z)?sh -l?c (['"])([\s\S]*)\1$/) ||
180
+ s.match(/^"?(?:[^"]*[\\/])?(?:powershell|pwsh)(?:\.exe)?"?(?: -NoProfile| -NoLogo)* -(?:Command|c) (['"]?)([\s\S]*)\1$/i) ||
181
+ s.match(/^"?(?:[^"]*[\\/])?cmd(?:\.exe)?"? \/[cC] (['"]?)([\s\S]*)\1$/);
182
+ return m ? m[2] : s;
183
+ }
184
+
185
+ function describeMcp(item) {
186
+ if (item.server === 'wisp' && item.tool === 'ask_user') return { kind: 'wait', label: 'Waiting for your answer' };
187
+ return { kind: 'tool', label: `Using ${item.server} · ${item.tool}` };
188
+ }