wisp-agents 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +28 -12
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +2 -2
  6. package/server/adapters/claude.js +31 -50
  7. package/server/adapters/codex.js +2 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +2 -2
  10. package/server/adapters/grok.js +2 -2
  11. package/server/adapters/index.js +16 -1
  12. package/server/adapters/opencode.js +47 -6
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +5 -3
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +70 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-wisp.js +17 -5
  25. package/server/openapi.js +33 -2
  26. package/server/paths.js +32 -0
  27. package/server/preferences.js +2 -6
  28. package/server/proc.js +54 -0
  29. package/server/recipes.js +2 -4
  30. package/server/remote.js +14 -14
  31. package/server/router.js +7 -3
  32. package/server/routes/auto.js +29 -0
  33. package/server/routes/automations.js +92 -0
  34. package/server/routes/internal.js +56 -0
  35. package/server/routes/machines.js +43 -0
  36. package/server/routes/meta.js +76 -0
  37. package/server/routes/proxy.js +47 -0
  38. package/server/routes/sessions.js +339 -0
  39. package/server/routes/settings.js +144 -0
  40. package/server/routes/ship.js +61 -0
  41. package/server/routes/uploads.js +19 -0
  42. package/server/runner.js +87 -36
  43. package/server/services/auto.js +67 -0
  44. package/server/services/feedback.js +53 -0
  45. package/server/services/install.js +69 -0
  46. package/server/services/launch.js +127 -0
  47. package/server/services/phone.js +65 -0
  48. package/server/services/ship.js +82 -0
  49. package/server/services/telemetry.js +34 -0
  50. package/server/services/uploads.js +59 -0
  51. package/server/services/webhooks.js +28 -0
  52. package/server/store.js +5 -3
  53. package/server/subagents.js +5 -4
  54. package/server/telemetry.js +1 -1
  55. package/server/terminal.js +2 -1
  56. package/server/triggers.js +4 -6
  57. package/server/update.js +4 -3
  58. package/server/util.js +42 -0
  59. package/server/welcome.js +1 -1
  60. package/web/css/auto.css +29 -0
  61. package/web/css/automations.css +71 -0
  62. package/web/css/base.css +83 -0
  63. package/web/css/blocks.css +461 -0
  64. package/web/css/composer.css +62 -0
  65. package/web/css/feed.css +208 -0
  66. package/web/css/launcher.css +204 -0
  67. package/web/css/layout.css +61 -0
  68. package/web/css/machines.css +125 -0
  69. package/web/css/modals.css +176 -0
  70. package/web/css/palette.css +79 -0
  71. package/web/css/responsive.css +35 -0
  72. package/web/css/ship.css +95 -0
  73. package/web/css/subagents.css +130 -0
  74. package/web/css/tokens.css +115 -0
  75. package/web/index.html +16 -1
  76. package/web/js/app.js +28 -5237
  77. package/web/js/core/api.js +25 -0
  78. package/web/js/core/bus.js +28 -0
  79. package/web/js/core/dom.js +7 -0
  80. package/web/js/core/format.js +27 -0
  81. package/web/js/core/state.js +43 -0
  82. package/web/js/features/actions.js +339 -0
  83. package/web/js/features/attachments.js +115 -0
  84. package/web/js/features/auto.js +54 -0
  85. package/web/js/features/automations.js +305 -0
  86. package/web/js/features/commands.js +123 -0
  87. package/web/js/features/compare.js +104 -0
  88. package/web/js/features/composer.js +311 -0
  89. package/web/js/features/feed.js +400 -0
  90. package/web/js/features/feedback.js +127 -0
  91. package/web/js/features/keys.js +83 -0
  92. package/web/js/features/launcher.js +559 -0
  93. package/web/js/features/live.js +91 -0
  94. package/web/js/features/machines.js +313 -0
  95. package/web/js/features/memory.js +154 -0
  96. package/web/js/features/notify.js +36 -0
  97. package/web/js/features/phone.js +124 -0
  98. package/web/js/features/places.js +38 -0
  99. package/web/js/features/projects.js +284 -0
  100. package/web/js/features/runner-picker.js +233 -0
  101. package/web/js/features/runners.js +161 -0
  102. package/web/js/features/scroll.js +75 -0
  103. package/web/js/features/settings.js +390 -0
  104. package/web/js/features/setup.js +94 -0
  105. package/web/js/features/ship.js +122 -0
  106. package/web/js/features/team.js +386 -0
  107. package/web/js/features/turns.js +401 -0
  108. package/web/js/features/update.js +48 -0
  109. package/web/js/features/usage.js +75 -0
  110. package/web/js/ui/kbd.js +46 -0
  111. package/web/js/ui/overlay.js +38 -0
  112. package/web/js/ui/popover.js +52 -0
  113. package/web/js/ui/toast.js +23 -0
  114. package/web/css/app.css +0 -1910
package/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.16.0] - 2026-10-19
9
+
10
+ ### Added
11
+
12
+ - **Switch harness or model in a follow-up.** The follow-up box has a harness button, a small logo next to the mode button. It opens the same harness · model · thinking picker as the launcher. Pick another model and the follow-up resumes the same conversation on it. Pick another harness, say Codex on a task Claude Code started, and Codex starts its own conversation in the same folder. wisp hands it the turns it hasn't seen: each request, the report it got back and the files that were edited. Long histories keep the newest turns, up to about 24k characters. Each harness keeps its own conversation, so switching back to Claude Code resumes its session and only tells it what happened while it was away. Each turn's badge shows the harness that ran it. A queued follow-up keeps its own pick, so the run already going is not affected. On a session that Auto routed, switching counts as a miss for Auto's pick. API: `POST /api/sessions/:id/reply {"text","agent","model","effort"}`; CLI: `wisp reply <id> "…" --agent codex --model …`; MCP: `wisp_reply` takes `agent`, `model` and `effort`.
13
+ - **No harness yet? Install opencode in one click.** When wisp finds no coding-agent CLI on this computer, the launcher shows one line, *No coding agent here yet*, with an **Install opencode** button. opencode's free models need no account, so a new user can go from `npx wisp-agents` to a running task in a few seconds. wisp installs it with npm into its own folder (`~/.wisp/tools`), so it needs no sudo and leaves your shell profile alone, and finds it there. Pressing Enter with no harness shakes that line and keeps your prompt; before, the launch failed with *Auto found no installed harness*. Settings → Agents has the same **Install** button on the opencode row. API: `POST /api/agents/install {"id":"opencode"}`.
14
+ - wisp also finds opencode in `~/.opencode/bin`, where its official install script puts it, when that folder isn't on PATH (common for the desktop app).
15
+
16
+ - **wisp Cloud preview page** (`cloud.html`, linked from the site's nav and footer, and from a "Let it run while you sleep" section on the home page, between *Also in the box* and the install steps). It introduces a hosted wisp: managed remote machines, persistent queues that keep running while your laptop sleeps, and team dashboards. The local CLI stays free. You can try each part on the page: close the laptop and watch the cloud runs keep going, add machines, drag through one night of the queue, and filter a team dashboard by person. Signups for the waitlist go to PostHog.
17
+ - **One-line install: `curl -fsSL https://mokh.xyz/install.sh | bash`.** The script (`docs/install.sh`, served by the site) checks for macOS or Linux, Node.js 20+ and npm, runs `npm install -g wisp-agents` (set `WISP_VERSION` to pin a release), and confirms that `wisp` is on your PATH. It prints a PATH fix when the npm bin folder isn't in it, and a link to npm's permissions guide when the global install is refused. The site and the README show it next to `npx wisp-agents`.
18
+ - **Homebrew:** `brew install m0xoo/tap/wisp-agents`, from the new [M0xoo/homebrew-tap](https://github.com/M0xoo/homebrew-tap) repo (formula source in `packaging/homebrew/wisp-agents.rb`). It installs the CLI and browser mode; the formula removes the optional Electron package, which would otherwise download its ~300 MB binary into the Homebrew keg on first run.
19
+ - **Install tabs on the site.** The hero command and step 2 of the install section both have tabs (npx, npm, curl, brew), like opencode's install block. Picking a tab in one switches the other, the copy button copies the command shown, and the note under it changes with the tab. Arrow keys, Home and End move between tabs. Type a task into the hero launcher and every tab hands back a command that installs wisp and starts that task (`brew install m0xoo/tap/wisp-agents && wisp "…"`). A command too long for the bar stays on one line and ends in …, and a long typed task no longer makes the page scroll sideways on phones.
20
+
21
+ ### Changed
22
+
23
+ - **The website moved to [mokh.xyz](https://mokh.xyz/).** Canonical, Open Graph and structured-data URLs, `sitemap.xml`, `robots.txt`, `llms.txt` and the npm `homepage` now use the new domain. The old `m0xoo.github.io/wisp/` address redirects there.
24
+ - **New website.** The site opens on a working copy of the launcher: it types three tasks and sends them off, and their rows move from Working to Asking (answer it right there) to Done. Type your own task and press ↵ to get the `npx wisp-agents "…"` command that runs it for real. Further down it shows the real app instead of animated mock-ups. A scroll-driven "morning with wisp" walks through five real screens with a ticking clock: three tasks launched, a mid-run question with its desktop notification, a report, Ship it, and the merged PR. A Transcript / wisp report switch shows the same run both ways. Also new: a Compare section, a take-over table with each harness's resume command, and a terminal that replays real `wisp` CLI output. Screenshots follow your light or dark theme, and on phones the story becomes a simple timeline.
25
+
26
+ ### Fixed
27
+
28
+ - **Claude in Chrome now works in Claude Code tasks.** Claude Code only turns the Chrome extension on by itself in interactive sessions; wisp runs it with `-p`, so its `mcp__claude-in-chrome__*` tools were missing. wisp now passes `--chrome` when you have Claude in Chrome enabled by default (`/chrome` in Claude Code, `claudeInChromeDefaultEnabled` in `~/.claude.json`). Set `WISP_CLAUDE_CHROME=1` or `0` to force it on or off.
29
+ - An opencode task on a rate-limited model (often a free one) no longer sits at "Starting up" forever. opencode retries without printing anything, so wisp now reads its error log. If the rate limit hasn't cleared after 60s, wisp stops the task and says what to do: try again later, pick another model, or run `opencode auth login`.
30
+ - wisp no longer crashes when the `claude` CLI quits before reading its prompt (a bad flag, an auth error, a crash on start). The task now ends with Claude's error message. Before, the unread prompt raised an unhandled `EPIPE` that took the whole server down.
31
+ - `config.json` (projects, recipes, triggers, machines, settings) is now written atomically. Before, a crash or power loss mid-write could leave a cut-off file, which loaded as the defaults and was then saved over. Auto's learned state and the learned preferences are written the same way.
32
+ - Idle API connections stay open longer than clients keep them, so a request sent on a connection the server was just closing no longer fails with `ECONNRESET`.
33
+ - Refused requests (bad input, not found) no longer print a stack trace in the terminal running wisp.
34
+ - **Run** / **Re-run** on a terminal, tests or actions block now shows the new output. Before, the command ran but the block kept its old output and an error toast said it failed.
35
+ - **Escape** closes the menu or popover that is open, wherever focus is, and nothing else. Before, it could collapse the card behind a context menu and leave the menu open.
36
+ - Settings switches, project edits, and deleting a recipe, trigger or subagent preset now show the server's error when it refuses the change, and leave things as they were. Before, nothing happened on screen; deleting a preset even said "Deleted".
37
+ - Expanding cards no longer leaves a hidden file picker behind each time, and the launcher no longer adds another listener on every redraw.
38
+ - `openapi.json` now documents subagent presets (`/teams`) and `GET /uploads/{id}`.
39
+
40
+ ### Internal
41
+
42
+ - The server is split by area: `server/index.js` only wires things together. Routes live in `server/routes/` behind a small route table (`server/http/`), and shared logic lives in `server/services/` (launch, Ship it, Auto, uploads, phone, feedback). Helpers that were copied across files now come from `server/util.js`, and git always runs through one helper that honours `WISP_GIT_BIN`. Adapters are checked against their contract at startup, and `claude.js` uses the same spawn plumbing as the other harnesses. `test/routes.test.js` keeps the route table and `openapi.json` in sync. The HTTP API, CLI, SDK exports and the `~/.wisp` files are unchanged.
43
+ - The web UI's 5,300-line `app.js` is now about 40 modules under `web/js/` (`core/`, `ui/`, `features/`), and `app.css` is 15 stylesheets that, concatenated in link order, are byte-for-byte the old file. A small event bus replaces the hooks that live updates used to reach open dialogs through, and one popover helper replaces about ten copies of open, outside-click and close code. No build step was added.
44
+
8
45
  ## [0.15.0] - 2026-10-18
9
46
 
10
47
  ### Changed
@@ -262,7 +299,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
262
299
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
263
300
  - Older turns are folded, with a floating collapse-all button and back-to-top.
264
301
 
265
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.15.0...HEAD
302
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.16.0...HEAD
303
+ [0.16.0]: https://github.com/M0xoo/wisp/compare/v0.15.0...v0.16.0
266
304
  [0.15.0]: https://github.com/M0xoo/wisp/compare/v0.14.0...v0.15.0
267
305
  [0.14.0]: https://github.com/M0xoo/wisp/compare/v0.13.0...v0.14.0
268
306
  [0.13.0]: https://github.com/M0xoo/wisp/compare/v0.12.0...v0.13.0
package/README.md CHANGED
@@ -54,7 +54,7 @@ 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.15.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
57
+ | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.16.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
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. |
@@ -73,13 +73,18 @@ npx wisp-agents --browser # or use it in the browser → http://l
73
73
 
74
74
  The first run downloads Electron for the desktop app; if that isn't possible, wisp opens in the browser instead.
75
75
 
76
- Or install it globally to get the `wisp` command everywhere:
76
+ Or install it globally to get the `wisp` command everywhere. Pick one:
77
77
 
78
78
  ```sh
79
- npm install -g wisp-agents
79
+ curl -fsSL https://mokh.xyz/install.sh | bash # macOS or Linux: checks Node 20+, then npm install -g wisp-agents
80
+ npm install -g wisp-agents # any OS with Node 20+
81
+ brew install m0xoo/tap/wisp-agents # Homebrew: CLI and browser mode, no Electron desktop app
82
+
80
83
  wisp "fix the flaky auth test"
81
84
  ```
82
85
 
86
+ The Homebrew formula lives in [M0xoo/homebrew-tap](https://github.com/M0xoo/homebrew-tap). `install.sh` honors `WISP_VERSION` to pin a release (`curl -fsSL https://mokh.xyz/install.sh | WISP_VERSION=0.16.0 bash`).
87
+
83
88
  From source:
84
89
 
85
90
  ```sh
@@ -122,11 +127,11 @@ wisp "add a test" --wait # block until it finishes, print the presentat
122
127
  wisp ls --state running,waiting # list sessions
123
128
  wisp show <id> # state + latest presentation
124
129
  wisp wait <id> # until it finishes or asks you something
125
- wisp reply <id> "also do X" # follow up (answers a pending question; --mode to switch)
130
+ wisp reply <id> "also do X" # follow up (answers a pending question; --mode, --agent, --model to switch)
126
131
  wisp stop <id>
127
132
  wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
128
133
  wisp merge <id> # squash-merge its PR, fast-forward your local main
129
- wisp run release 0.15.0 # run a recipe; values fill its {params} (or version=0.15.0)
134
+ wisp run release 0.16.0 # run a recipe; values fill its {params} (or version=0.16.0)
130
135
  wisp recipes # list recipes
131
136
  wisp usage [--days 7] # spend and outcomes
132
137
  wisp mcp # MCP server for other agents and editors
@@ -216,13 +221,18 @@ wisp/
216
221
  ├── bin/
217
222
  │ └── wisp.js # CLI: starts the server or talks to the running one
218
223
  ├── server/
219
- │ ├── index.js # HTTP API, SSE stream, static UI, uploads
220
- │ ├── openapi.js # OpenAPI document for the public API
224
+ │ ├── index.js # startServer: wires the pieces below together
225
+ │ ├── http/ # route table, JSON responses + SSE, static UI
226
+ │ ├── routes/ # one file per API area: sessions, ship, settings, automations, machines, …
227
+ │ ├── services/ # shared by routes and background jobs: launch, ship, auto, uploads, phone, …
228
+ │ ├── util.js # small shared helpers (errors, ids, quoting, atomic JSON writes)
229
+ │ ├── openapi.js # OpenAPI document for the public API (test/routes.test.js keeps it in sync)
221
230
  │ ├── client.js # JS SDK (wisp-agents/client), used by the CLI and MCP server
222
231
  │ ├── mcp-wisp.js # `wisp mcp`: wisp as tools for other agents
223
232
  │ ├── render.js # presentation → markdown
224
233
  │ ├── runner.js # session lifecycle, follow-ups, event normalization
225
- │ ├── checkpoint.js # per-turn git checkpoints, file revert, rollback, action exec
234
+ │ ├── checkpoint.js # per-turn git checkpoints, file revert, rollback
235
+ │ ├── proc.js # cross-platform spawn, and running a block's command
226
236
  │ ├── protocol.js # system prompt + ```wisp block parser (markdown fallback)
227
237
  │ ├── mcp-ask.js # ask_user MCP server
228
238
  │ ├── store.js # JSON-file persistence in ~/.wisp
@@ -231,6 +241,7 @@ wisp/
231
241
  │ ├── triggers.js # cron schedules and GitHub watchers
232
242
  │ ├── remote.js # phone: ntfy and Telegram, out and back
233
243
  │ ├── machines.js # other computers: SSH, a tunnel to wisp there, relayed sessions
244
+ │ ├── machines/ # known hosts (ssh config, Tailscale) and the bootstrap script
234
245
  │ ├── usage.js # spend and outcomes
235
246
  │ ├── terminal.js # "take over" in your terminal
236
247
  │ ├── welcome.js # the gallery session
@@ -244,10 +255,15 @@ wisp/
244
255
  │ ├── antigravity.js # Antigravity CLI adapter
245
256
  │ └── grok.js # Grok Build adapter
246
257
  ├── skills/wisp/SKILL.md # agent skill: how other agents use wisp
247
- ├── web/ # vanilla JS UI, no build step
258
+ ├── web/ # vanilla JS UI, native ES modules, no build step
248
259
  │ ├── index.html
249
- │ ├── css/app.css
250
- │ └── js/{app,blocks,diff,md}.js
260
+ │ ├── css/ # one stylesheet per area (tokens, base, feed, blocks, modals, …), linked in order
261
+ │ └── js/
262
+ │ ├── app.js # boot, top bar, routing
263
+ │ ├── core/ # state, api, event bus, DOM helpers
264
+ │ ├── ui/ # toasts, dialogs, popovers, keyboard hints
265
+ │ ├── features/ # feed, turns, composer, launcher, settings, ship, machines, …
266
+ │ └── {blocks,md,diff,fuzzy,palette}.js # presentation blocks and their helpers
251
267
  └── electron/ # desktop shell
252
268
  ├── main.js
253
269
  └── preload.cjs
@@ -318,7 +334,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
318
334
  Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
319
335
  ```
320
336
 
321
- 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.15.0` fills the fields in order, `wisp run release version=0.15.0` by name.
337
+ Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.16.0` fills the fields in order, `wisp run release version=0.16.0` by name.
322
338
 
323
339
  **Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
324
340
 
package/bin/wisp.js CHANGED
@@ -24,7 +24,8 @@ const HELP = `usage:
24
24
  wisp ls list sessions (--state running,waiting --limit N)
25
25
  wisp show <id> state + latest presentation as markdown
26
26
  wisp wait <id> block until it finishes or asks you something
27
- wisp reply <id> "text" follow up (answers a pending question) (--queue --mode plan|edits|auto)
27
+ wisp reply <id> "text" follow up (answers a pending question) (--queue --mode plan|edits|auto
28
+ --agent ID --model ID --effort LEVEL: switch harness / model from here on)
28
29
  wisp stop <id> stop a run
29
30
  wisp ship <id> commit, push and open a PR for a worktree session (--draft)
30
31
  wisp merge <id> squash-merge its PR and sync your local base branch
@@ -132,7 +133,7 @@ async function scripted(cmd, args) {
132
133
  }
133
134
  case 'reply': {
134
135
  const id = need(args[0], 'session id');
135
- const r = await wisp.reply(id, need(args.slice(1).join(' ').trim(), 'text'), { queue: flags.queue, mode: flags.mode || undefined });
136
+ const r = await wisp.reply(id, need(args.slice(1).join(' ').trim(), 'text'), { queue: flags.queue, mode: flags.mode || undefined, agent: flags.agent || undefined, model: flags.model ?? undefined, effort: flags.effort ?? undefined });
136
137
  if (!flags.wait) return out(r, r.answered ? ' ✓ answered' : r.interrupted ? ' ✓ interrupted; resuming with your message' : r.queued ? ' ✓ queued' : ' ✓ sent');
137
138
  return show(id, await wisp.wait(id));
138
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",
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",
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
  },
@@ -44,7 +44,7 @@
44
44
  "type": "git",
45
45
  "url": "git+https://github.com/M0xoo/wisp.git"
46
46
  },
47
- "homepage": "https://github.com/M0xoo/wisp#readme",
47
+ "homepage": "https://mokh.xyz/",
48
48
  "bugs": {
49
49
  "url": "https://github.com/M0xoo/wisp/issues"
50
50
  },
@@ -7,7 +7,7 @@
7
7
  // first message.
8
8
 
9
9
  import path from 'node:path';
10
- import { attachmentNote, clip, detectVersion, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
10
+ import { attachmentNote, clip, detectVersion, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
11
11
 
12
12
  const BIN = process.env.WISP_ANTIGRAVITY_BIN || 'agy';
13
13
 
@@ -125,7 +125,7 @@ export default {
125
125
  },
126
126
  onClose({ code, signal, stderr, stopped }) {
127
127
  if (sawResult) return;
128
- onEvent({ type: 'result', isError: true, stopped, text: stderr || `agy exited with ${signal || `code ${code}`}` });
128
+ onEvent({ type: 'result', isError: true, stopped, text: stderr || exitText('agy', code, signal) });
129
129
  },
130
130
  });
131
131
  },
@@ -1,13 +1,25 @@
1
1
  // Claude Code adapter — drives `claude -p --output-format stream-json`.
2
2
 
3
- import readline from 'node:readline';
4
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
5
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';
6
+ import { ASK_TIMEOUT_MS, MAX_SNAPSHOT, attachmentNote, clip, detectVersion, exitText, flatten, rel, spawnJsonl } from './common.js';
8
7
 
9
8
  const BIN = process.env.WISP_CLAUDE_BIN || 'claude';
10
9
 
10
+ // Claude in Chrome turns itself on only in interactive sessions; `-p` needs --chrome. Follow the
11
+ // user's own "enabled by default" choice (/chrome in Claude Code) so a wisp run gets the same tools.
12
+ // WISP_CLAUDE_CHROME=1/0 overrides it.
13
+ export function chromeEnabled(env = process.env) {
14
+ if (env.WISP_CLAUDE_CHROME) return env.WISP_CLAUDE_CHROME !== '0';
15
+ const file = env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, '.claude.json') : path.join(os.homedir(), '.claude.json');
16
+ try {
17
+ return JSON.parse(fs.readFileSync(file, 'utf8')).claudeInChromeDefaultEnabled === true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
11
23
  const PERMISSION_MODES = {
12
24
  plan: 'plan',
13
25
  edits: 'acceptEdits',
@@ -44,6 +56,7 @@ export default {
44
56
  ];
45
57
  if (model) args.push('--model', model);
46
58
  if (effort) args.push('--effort', effort);
59
+ if (chromeEnabled()) args.push('--chrome');
47
60
  if (resumeId) args.push('--resume', resumeId);
48
61
  if (Object.keys(mcpServers).length) {
49
62
  args.push('--mcp-config', JSON.stringify({ mcpServers }));
@@ -61,12 +74,8 @@ export default {
61
74
  delete env.CLAUDE_CODE_ENTRYPOINT;
62
75
  env.MCP_TOOL_TIMEOUT ??= String(ASK_TIMEOUT_MS);
63
76
 
64
- const child = spawn(BIN, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
65
- child.stdin.end(prompt);
66
-
67
77
  let sawResult = false;
68
78
  let early = null; // an empty result that came before our prompt was answered (see below)
69
- let stderr = '';
70
79
  const pendingTools = new Map(); // tool_use_id -> name, to flag failed tool results
71
80
  // Background tasks (run_in_background shells, background subagents) still running. While any
72
81
  // are, the CLI stays up after its result and starts another turn when they finish.
@@ -77,18 +86,7 @@ export default {
77
86
  if ([...next.keys()].join(' ') !== was) onEvent({ type: 'background', tasks: [...next.values()] });
78
87
  };
79
88
 
80
- child.stderr.on('data', (d) => {
81
- stderr = (stderr + d).slice(-8000);
82
- });
83
-
84
- readline.createInterface({ input: child.stdout }).on('line', (line) => {
85
- if (!line.trim()) return;
86
- let msg;
87
- try {
88
- msg = JSON.parse(line);
89
- } catch {
90
- return;
91
- }
89
+ const onJson = (msg) => {
92
90
  if (msg.session_id) onEvent({ type: 'session', id: msg.session_id });
93
91
 
94
92
  if (msg.type === 'assistant') {
@@ -136,39 +134,22 @@ export default {
136
134
  sawResult = true;
137
135
  onEvent({ ...ev, background: background.size }); // > 0: another turn and result follow when they finish
138
136
  }
139
- });
140
-
141
- let stopping = false;
142
- const done = new Promise((resolve) => {
143
- child.on('error', (err) => {
144
- onEvent({ type: 'result', isError: true, text: `Could not start \`${BIN}\`: ${err.message}` });
145
- sawResult = true;
146
- resolve();
147
- });
148
- child.on('close', (code, signal) => {
149
- if (!sawResult && early && !stopping) {
150
- sawResult = true;
151
- onEvent(early);
152
- }
153
- if (!sawResult) {
154
- onEvent({
155
- type: 'result',
156
- isError: true,
157
- stopped: stopping || signal === 'SIGTERM' || signal === 'SIGINT',
158
- text: stderr.trim() || `claude exited with ${signal || `code ${code}`}`,
159
- });
160
- }
161
- resolve();
162
- });
163
- });
137
+ };
164
138
 
165
- return {
166
- done,
167
- stop() {
168
- stopping = true;
169
- killTree(child);
139
+ return spawnJsonl({
140
+ bin: BIN,
141
+ args,
142
+ cwd,
143
+ env,
144
+ input: prompt,
145
+ onEvent,
146
+ onJson,
147
+ onClose: ({ code, signal, stderr, stopped, stopRequested }) => {
148
+ if (sawResult) return;
149
+ if (early && !stopRequested) return onEvent(early);
150
+ onEvent({ type: 'result', isError: true, stopped, text: stderr || exitText('claude', code, signal) });
170
151
  },
171
- };
152
+ });
172
153
  },
173
154
  };
174
155
 
@@ -1,7 +1,7 @@
1
1
  // Codex CLI adapter — drives `codex exec --json` (JSONL thread/turn/item events).
2
2
 
3
3
  import path from 'node:path';
4
- import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, fileTracker, rel, spawnJsonl, withProtocol } from './common.js';
4
+ import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, rel, spawnJsonl, withProtocol } from './common.js';
5
5
 
6
6
  const BIN = process.env.WISP_CODEX_BIN || 'codex';
7
7
  const IMAGE = /^image\/(png|jpe?g|gif|webp)$/;
@@ -157,7 +157,7 @@ export default {
157
157
  type: 'result',
158
158
  isError: !ok,
159
159
  stopped,
160
- text: ok ? lastMessage : failure || lastError || stderr || `codex exited with ${signal || `code ${code}`}`,
160
+ text: ok ? lastMessage : failure || lastError || stderr || exitText('codex', code, signal),
161
161
  stats: {
162
162
  durationMs: Date.now() - started,
163
163
  inputTokens: usage?.input_tokens,
@@ -1,12 +1,13 @@
1
1
  // Helpers shared by the adapters: process plumbing, labels, and diff capture.
2
2
 
3
- import { execFileSync } 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';
7
6
  import { spawn, execFile, killTree } from '../proc.js';
7
+ import { gitSync } from '../git.js';
8
+ import { MAX_SNAPSHOT, rel } from '../paths.js';
9
+ import { clip } from '../util.js';
8
10
 
9
- export const MAX_SNAPSHOT = 256 * 1024;
10
11
  export const ASK_TIMEOUT_MS = 7 * 24 * 3600 * 1000; // ask_user may wait for the human a long time
11
12
 
12
13
  export function detectVersion(bin, args = ['--version']) {
@@ -25,8 +26,10 @@ export function quietOutput(bin, args, timeout = 15_000) {
25
26
  }
26
27
 
27
28
  // Spawn a CLI that prints one JSON object per line. onJson gets each parsed line;
28
- // onClose({ code, signal, stderr }) must emit the final `result` if the stream didn't.
29
- export function spawnJsonl({ bin, args, cwd, env, input, onJson, onClose, onEvent }) {
29
+ // onClose({ code, signal, stderr, stopped, stopRequested }) must emit the final `result` if the
30
+ // stream didn't. stopped: killed by stop() or a signal; stopRequested: by stop() only.
31
+ // onStderr (optional) sees stderr as it comes, for CLIs that only report trouble there.
32
+ export function spawnJsonl({ bin, args, cwd, env, input, onJson, onClose, onEvent, onStderr }) {
30
33
  // Some CLIs (opencode) take their working directory from $PWD rather than the real cwd.
31
34
  const child = spawn(bin, args, { cwd, env: { ...env, PWD: cwd }, stdio: ['pipe', 'pipe', 'pipe'] });
32
35
  child.stdin.on('error', () => {}); // the CLI may exit before reading stdin
@@ -35,6 +38,7 @@ export function spawnJsonl({ bin, args, cwd, env, input, onJson, onClose, onEven
35
38
  let stderr = '';
36
39
  child.stderr.on('data', (d) => {
37
40
  stderr = (stderr + d).slice(-8000);
41
+ onStderr?.(String(d));
38
42
  });
39
43
  readline.createInterface({ input: child.stdout }).on('line', (line) => {
40
44
  if (!line.trim()) return;
@@ -56,7 +60,7 @@ export function spawnJsonl({ bin, args, cwd, env, input, onJson, onClose, onEven
56
60
  resolve();
57
61
  });
58
62
  child.on('close', (code, signal) => {
59
- if (!failed) onClose({ code, signal, stderr: stderr.trim(), stopped: stopping || signal === 'SIGTERM' || signal === 'SIGINT' });
63
+ if (!failed) onClose({ code, signal, stderr: stderr.trim(), stopped: stopping || signal === 'SIGTERM' || signal === 'SIGINT', stopRequested: stopping });
60
64
  resolve();
61
65
  });
62
66
  });
@@ -70,6 +74,9 @@ export function spawnJsonl({ bin, args, cwd, env, input, onJson, onClose, onEven
70
74
  };
71
75
  }
72
76
 
77
+ // The result text for a CLI that ended without saying why.
78
+ export const exitText = (bin, code, signal) => `${bin} exited with ${signal || `code ${code}`}`;
79
+
73
80
  export function attachmentNote(files, how = 'Read images and PDFs with your file-reading tool') {
74
81
  if (!files.length) return '';
75
82
  const lines = files.map((f) => `- ${f.path} (${f.mime}, ${Math.max(1, Math.round(f.size / 1024))} KB)`);
@@ -82,34 +89,7 @@ export function withProtocol(prompt, systemPrompt, resumeId) {
82
89
  return resumeId ? prompt : `${systemPrompt.trim()}\n\n---\n\n# Task\n\n${prompt}`;
83
90
  }
84
91
 
85
- const realDirs = new Map();
86
- function realDir(dir) {
87
- if (!realDirs.has(dir)) {
88
- try {
89
- realDirs.set(dir, fs.realpathSync(dir));
90
- } catch {
91
- realDirs.set(dir, dir);
92
- }
93
- }
94
- return realDirs.get(dir);
95
- }
96
-
97
- // Path relative to cwd, or the path as given if it lies outside. Harnesses may report paths
98
- // through the resolved cwd (/private/tmp vs /tmp on macOS), so both spellings count.
99
- export function rel(file, cwd) {
100
- if (!file) return '';
101
- const abs = path.resolve(cwd, file);
102
- for (const base of [cwd, realDir(cwd)]) {
103
- const r = path.relative(base, abs);
104
- if (r && !r.startsWith('..') && !path.isAbsolute(r)) return r;
105
- }
106
- return file;
107
- }
108
-
109
- export function clip(s, n = 80) {
110
- s = String(s ?? '').replace(/\s+/g, ' ').trim();
111
- return s.length > n ? `${s.slice(0, n - 1)}…` : s;
112
- }
92
+ export { clip, MAX_SNAPSHOT, rel };
113
93
 
114
94
  export function flatten(content) {
115
95
  if (typeof content === 'string') return content;
@@ -136,7 +116,7 @@ export function fileTracker(cwd) {
136
116
  const known = new Map(); // rel path -> content after our last change() (null = missing)
137
117
  const announced = new Map(); // rel path -> content when the harness announced an edit
138
118
  let base;
139
- const git = (args, opts = {}) => execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, ...opts });
119
+ const git = (args, opts) => gitSync(cwd, args, opts);
140
120
  try {
141
121
  // Pinned before the agent starts writing.
142
122
  base = git(['stash', 'create']).toString().trim() || git(['rev-parse', '--verify', 'HEAD']).toString().trim();
@@ -3,7 +3,7 @@
3
3
  // cursor-agent has no flag to inject an MCP server (it only reads .cursor/mcp.json), so wisp's
4
4
  // ask_user tool isn't available here; the protocol tells the agent to end with a question block.
5
5
 
6
- import { attachmentNote, clip, detectVersion, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
6
+ import { attachmentNote, clip, detectVersion, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
7
7
 
8
8
  const BIN = process.env.WISP_CURSOR_BIN || 'cursor-agent';
9
9
 
@@ -91,7 +91,7 @@ export default {
91
91
  },
92
92
  onClose({ code, signal, stderr, stopped }) {
93
93
  if (sawResult) return;
94
- onEvent({ type: 'result', isError: true, stopped, text: stderr || `cursor-agent exited with ${signal || `code ${code}`}` });
94
+ onEvent({ type: 'result', isError: true, stopped, text: stderr || exitText('cursor-agent', code, signal) });
95
95
  },
96
96
  });
97
97
  },
@@ -9,7 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
- import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, fileTracker, flatten, quietOutput, rel, spawnJsonl } from './common.js';
12
+ import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, flatten, quietOutput, rel, spawnJsonl } from './common.js';
13
13
 
14
14
  const BIN = process.env.WISP_GROK_BIN || 'grok';
15
15
 
@@ -137,7 +137,7 @@ export default {
137
137
  },
138
138
  onClose({ code, signal, stderr, stopped }) {
139
139
  if (sawResult) return;
140
- onEvent({ type: 'result', isError: true, stopped, text: error || stderr || `grok exited with ${signal || `code ${code}`}` });
140
+ onEvent({ type: 'result', isError: true, stopped, text: error || stderr || exitText('grok', code, signal) });
141
141
  },
142
142
  });
143
143
  handle.done.finally(() => fs.rmSync(tmp, { recursive: true, force: true }));
@@ -31,7 +31,22 @@ import cursor from './cursor.js';
31
31
  import antigravity from './antigravity.js';
32
32
  import grok from './grok.js';
33
33
 
34
- export const adapters = Object.fromEntries([claude, codex, opencode, cursor, antigravity, grok].map((a) => [a.id, a]));
34
+ // The shape above, checked when wisp starts: a missing piece fails loudly here, not mid-run.
35
+ function checked(a) {
36
+ const missing = [
37
+ typeof a.id !== 'string' && 'id',
38
+ typeof a.name !== 'string' && 'name',
39
+ !Array.isArray(a.models) && 'models',
40
+ a.askTool !== null && a.askTool !== undefined && typeof a.askTool !== 'string' && 'askTool',
41
+ typeof a.detect !== 'function' && 'detect()',
42
+ typeof a.run !== 'function' && 'run()',
43
+ a.resumeCommand !== undefined && typeof a.resumeCommand !== 'function' && 'resumeCommand()',
44
+ ].filter(Boolean);
45
+ if (missing.length) throw new Error(`adapter ${a.id || '?'}: missing or wrong ${missing.join(', ')}`);
46
+ return a;
47
+ }
48
+
49
+ export const adapters = Object.fromEntries([claude, codex, opencode, cursor, antigravity, grok].map((a) => [a.id, checked(a)]));
35
50
 
36
51
  const detected = new Map();
37
52
 
@@ -1,8 +1,22 @@
1
1
  // opencode adapter — drives `opencode run --format json` (one JSON event per line).
2
2
 
3
- import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { toolsDir } from '../util.js';
7
+ import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
4
8
 
5
- const BIN = process.env.WISP_OPENCODE_BIN || 'opencode';
9
+ const exe = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
10
+ // Where opencode lives when it isn't on PATH: the copy wisp installs (POST /agents/install),
11
+ // and the official install script's folder (it edits your shell profile, which an app started
12
+ // from the Dock or a service never reads).
13
+ export const fallbackBins = () => [path.join(toolsDir(), 'node_modules', '.bin', exe), path.join(os.homedir(), '.opencode', 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode')];
14
+ let BIN = process.env.WISP_OPENCODE_BIN || 'opencode';
15
+ // Rate-limited (the free models, mostly), `opencode run` keeps retrying and prints nothing, so
16
+ // the task would sit at "Starting up" for good. Its error log says so: give it this long to get
17
+ // through, then stop and tell the user what to do.
18
+ const rateLimitGrace = () => Number(process.env.WISP_RATE_LIMIT_GRACE_MS) || 60_000;
19
+ const RATE_LIMITED = /Rate limit exceeded|Too Many Requests|status(?:Code)?[=: ]+429\b/i;
6
20
 
7
21
  export default {
8
22
  id: 'opencode',
@@ -16,7 +30,16 @@ export default {
16
30
  },
17
31
 
18
32
  async detect() {
19
- const found = await detectVersion(BIN);
33
+ let found = await detectVersion(BIN);
34
+ if (!found.available && !process.env.WISP_OPENCODE_BIN) {
35
+ for (const bin of fallbackBins().filter((b) => fs.existsSync(b))) {
36
+ const there = await detectVersion(bin);
37
+ if (!there.available) continue;
38
+ BIN = bin;
39
+ found = there;
40
+ break;
41
+ }
42
+ }
20
43
  if (!found.available) return found;
21
44
  // `opencode models --verbose` prints each provider/model id followed by its JSON metadata;
22
45
  // the metadata's `variants` are the reasoning efforts `run --variant` accepts for that model.
@@ -40,7 +63,8 @@ export default {
40
63
 
41
64
  run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, onEvent }) {
42
65
  // The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
43
- const args = ['run', '--format', 'json'];
66
+ // Errors to stderr: the only place a rate limit shows up (see RATE_LIMITED).
67
+ const args = ['run', '--format', 'json', '--print-logs', '--log-level', 'ERROR'];
44
68
  if (mode === 'plan') args.push('--agent', 'plan');
45
69
  if (mode === 'auto') args.push('--auto');
46
70
  if (model) args.push('--model', model);
@@ -75,14 +99,22 @@ export default {
75
99
  const tokens = { input: 0, output: 0 };
76
100
  let cost = 0;
77
101
 
78
- return spawnJsonl({
102
+ let limitTimer = null;
103
+ let limited = false;
104
+ const proc = spawnJsonl({
79
105
  bin: BIN,
80
106
  args,
81
107
  cwd,
82
108
  env,
83
109
  input,
84
110
  onEvent,
111
+ onStderr(chunk) {
112
+ if (limitTimer || limited || !RATE_LIMITED.test(chunk)) return;
113
+ limitTimer = setTimeout(() => ((limited = true), proc.stop()), rateLimitGrace());
114
+ },
85
115
  onJson(msg) {
116
+ // Anything coming through means a retry got an answer.
117
+ if (limitTimer && msg.type !== 'error') (clearTimeout(limitTimer), (limitTimer = null));
86
118
  if (msg.sessionID && msg.sessionID !== session) onEvent({ type: 'session', id: (session = msg.sessionID) });
87
119
  const part = msg.part || {};
88
120
  switch (msg.type) {
@@ -118,19 +150,28 @@ export default {
118
150
  }
119
151
  },
120
152
  onClose({ code, signal, stderr, stopped }) {
153
+ clearTimeout(limitTimer);
154
+ if (limited) {
155
+ const which = model ? `\`${model}\`` : 'opencode’s default model';
156
+ return onEvent({ type: 'result', isError: true, text: `${which} is rate-limited right now: opencode kept retrying for ${Math.round(rateLimitGrace() / 1000)}s without an answer. Try again in a few minutes, pick another model, or connect your own provider with \`opencode auth login\`.`, stats: { durationMs: Date.now() - started } });
157
+ }
121
158
  const ok = code === 0 && !error;
122
159
  onEvent({
123
160
  type: 'result',
124
161
  isError: !ok,
125
162
  stopped,
126
- text: ok ? text.join('\n') : error || stderr || `opencode exited with ${signal || `code ${code}`}`,
163
+ text: ok ? text.join('\n') : error || lastLoggedError(stderr) || exitText('opencode', code, signal),
127
164
  stats: { durationMs: Date.now() - started, costUsd: cost || undefined, inputTokens: tokens.input, outputTokens: tokens.output },
128
165
  });
129
166
  },
130
167
  });
168
+ return proc;
131
169
  },
132
170
  };
133
171
 
172
+ // stderr with --print-logs: the last logged error's message, else stderr as it is.
173
+ const lastLoggedError = (stderr) => [...stderr.matchAll(/error\.error="((?:[^"\\]|\\.)*)"/g)].at(-1)?.[1] || stderr;
174
+
134
175
  function safeJson(s) {
135
176
  try {
136
177
  return JSON.parse(s) || {};