wisp-agents 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +29 -13
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +7 -3
  6. package/server/adapters/claude.js +79 -55
  7. package/server/adapters/codex.js +5 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +4 -2
  10. package/server/adapters/grok.js +17 -2
  11. package/server/adapters/index.js +25 -2
  12. package/server/adapters/opencode.js +78 -12
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +6 -4
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +71 -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-ask.js +113 -8
  25. package/server/mcp-wisp.js +17 -5
  26. package/server/mentions.js +39 -0
  27. package/server/openapi.js +36 -4
  28. package/server/paths.js +32 -0
  29. package/server/preferences.js +2 -6
  30. package/server/proc.js +54 -0
  31. package/server/protocol.js +8 -1
  32. package/server/recipes.js +2 -4
  33. package/server/remote.js +14 -14
  34. package/server/render.js +3 -1
  35. package/server/router.js +7 -3
  36. package/server/routes/auto.js +29 -0
  37. package/server/routes/automations.js +92 -0
  38. package/server/routes/internal.js +176 -0
  39. package/server/routes/machines.js +43 -0
  40. package/server/routes/meta.js +76 -0
  41. package/server/routes/proxy.js +47 -0
  42. package/server/routes/sessions.js +351 -0
  43. package/server/routes/settings.js +144 -0
  44. package/server/routes/ship.js +61 -0
  45. package/server/routes/uploads.js +19 -0
  46. package/server/runner.js +216 -49
  47. package/server/services/auto.js +67 -0
  48. package/server/services/feedback.js +53 -0
  49. package/server/services/install.js +69 -0
  50. package/server/services/launch.js +127 -0
  51. package/server/services/phone.js +65 -0
  52. package/server/services/ship.js +82 -0
  53. package/server/services/telemetry.js +34 -0
  54. package/server/services/uploads.js +59 -0
  55. package/server/services/webhooks.js +28 -0
  56. package/server/store.js +13 -5
  57. package/server/subagents.js +5 -4
  58. package/server/telemetry.js +1 -1
  59. package/server/terminal.js +2 -1
  60. package/server/triggers.js +5 -7
  61. package/server/update.js +4 -3
  62. package/server/usage.js +94 -0
  63. package/server/util.js +42 -0
  64. package/server/welcome.js +3 -3
  65. package/web/css/auto.css +29 -0
  66. package/web/css/automations.css +71 -0
  67. package/web/css/base.css +83 -0
  68. package/web/css/blocks.css +461 -0
  69. package/web/css/composer.css +84 -0
  70. package/web/css/feed.css +235 -0
  71. package/web/css/launcher.css +204 -0
  72. package/web/css/layout.css +61 -0
  73. package/web/css/machines.css +125 -0
  74. package/web/css/modals.css +176 -0
  75. package/web/css/palette.css +79 -0
  76. package/web/css/responsive.css +35 -0
  77. package/web/css/ship.css +95 -0
  78. package/web/css/subagents.css +130 -0
  79. package/web/css/tokens.css +115 -0
  80. package/web/index.html +17 -1
  81. package/web/js/app.js +28 -5237
  82. package/web/js/blocks.js +46 -0
  83. package/web/js/core/api.js +25 -0
  84. package/web/js/core/bus.js +28 -0
  85. package/web/js/core/dom.js +7 -0
  86. package/web/js/core/format.js +27 -0
  87. package/web/js/core/state.js +43 -0
  88. package/web/js/features/actions.js +344 -0
  89. package/web/js/features/attachments.js +115 -0
  90. package/web/js/features/auto.js +54 -0
  91. package/web/js/features/automations.js +305 -0
  92. package/web/js/features/commands.js +123 -0
  93. package/web/js/features/compare.js +105 -0
  94. package/web/js/features/composer.js +315 -0
  95. package/web/js/features/feed.js +400 -0
  96. package/web/js/features/feedback.js +127 -0
  97. package/web/js/features/keys.js +83 -0
  98. package/web/js/features/launcher.js +561 -0
  99. package/web/js/features/live.js +91 -0
  100. package/web/js/features/machines.js +313 -0
  101. package/web/js/features/memory.js +154 -0
  102. package/web/js/features/mentions.js +125 -0
  103. package/web/js/features/notify.js +36 -0
  104. package/web/js/features/phone.js +124 -0
  105. package/web/js/features/places.js +38 -0
  106. package/web/js/features/projects.js +284 -0
  107. package/web/js/features/runner-picker.js +233 -0
  108. package/web/js/features/runners.js +163 -0
  109. package/web/js/features/scroll.js +75 -0
  110. package/web/js/features/settings.js +390 -0
  111. package/web/js/features/setup.js +94 -0
  112. package/web/js/features/ship.js +122 -0
  113. package/web/js/features/team.js +386 -0
  114. package/web/js/features/turns.js +432 -0
  115. package/web/js/features/update.js +48 -0
  116. package/web/js/features/usage.js +75 -0
  117. package/web/js/mention-text.js +59 -0
  118. package/web/js/ui/kbd.js +46 -0
  119. package/web/js/ui/overlay.js +38 -0
  120. package/web/js/ui/popover.js +52 -0
  121. package/web/js/ui/toast.js +23 -0
  122. package/web/css/app.css +0 -1910
package/CHANGELOG.md CHANGED
@@ -5,6 +5,58 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.17.0] - 2026-10-19
9
+
10
+ ### Added
11
+
12
+ - **Approve what an agent's permissions block, from wisp.** In Edits mode a coding agent used to be refused quietly when it wanted something its permissions don't cover, like `npm publish`, a write outside the project or a network call from Codex's sandbox. You only found out from a half-done report. Now:
13
+ - **Claude Code waits for you.** wisp sends Claude's permission prompts to its own `approve` tool (`--permission-prompt-tool`). The task card shows *Needs your approval*: *Allow Claude Code to run `npm publish`?*, with **Allow**, **Always allow in this task** and **Deny**. The number keys pick an option. You can also type what to do instead: that denies the call and passes your words to Claude. Claude keeps its context, and the desktop notification says *needs your approval*. *Always allow* lets the same command or file through for the rest of the task without asking again.
14
+ - **Every other harness gets an Allow & retry card.** When Codex, opencode, Grok, Cursor or Antigravity is refused, the report shows *Blocked by permissions* under its headline, listing each refused action. wisp reads these refusals from the harness itself: opencode's and Grok's stderr, Cursor's rejected tool calls, Antigravity's `PERMISSION_DENIED` steps and Claude's `permission_denials`. A new `blocked` block in the wisp protocol lets an agent report a refusal it can only see in a command's output, such as Codex's sandbox. **Allow & retry** sends a follow-up that runs those actions. For Claude and opencode only the exact actions are allowed (`--allowedTools "Bash(npm publish)"` or an opencode permission rule). Codex, Grok, Cursor and Antigravity can't allow a single action from the command line, so that one follow-up runs in Full auto, and the card says so. The task stays in Edits. **Deny** closes the card. API: `POST /api/sessions/:id/approve {"turnId","allow"}`. A live approval is answered like a question, through `POST /api/sessions/:id/answer {"text":"Allow"}`, `wisp reply` or the phone buttons.
15
+ - **Mention another task in a prompt.** Right-click a task (or open its ⋯ menu) and pick **Copy task ID**, then paste the ID into the launcher, a follow-up box or the Rerun dialog. It shows as a chip with the task's title, like pasting an issue link in Linear, and stays a chip in the sent prompt, where clicking it opens that task. Backspace removes the whole chip and Cmd+Z brings it back. Pasting several IDs at once makes a chip for each. The agent gets the title and ID, and two new read-only tools sit next to `ask_user`: `wisp_session` reads a task (its title, state, harness, folder and branch, every request made in it, and a turn's report as markdown) and `wisp_sessions` lists your other tasks (filter by `query`, `state`, `project`, `archived`). Cursor and Antigravity runs can't take injected tools, so for them wisp puts each mentioned task's latest report in the prompt instead. Prompts store a mention as `@[Title](wisp:<id>)`, so API and CLI callers can write one too. Task titles show `@Title`. The bridge routes (`GET /api/internal/sessions/:id`, `GET /api/internal/session/:id?of=<id>`) only take the per-run token.
16
+ - **Agents can answer questions about your wisp usage.** Every task now gets a read-only `wisp_stats` tool next to `ask_user`. Ask a task "which tools do my tasks use?" or "what did Codex cost me this week?" and the agent gets a table of tool calls by kind (shell, read, edit, search, web, subagents, other MCP tools, questions), by harness, by project and by day. It also gets turns, cost, tokens and success rate, taken from the same numbers as Settings → Usage. Agents can narrow it with `days` (1–365, default 30), `agent` (a harness id) and `project` (a name or id, or `current` for the task's own project). Before, an agent had no tool for this and would either say it had no access or dig through `~/.wisp/sessions` by hand. Kinds are wisp's categories, not each harness's own tool names. The bridge route (`GET /api/internal/stats/:id`) only takes the per-run token, like `ask_user`. Cursor and Antigravity runs can't take injected MCP tools, so they don't get it.
17
+
18
+ ### Changed
19
+
20
+ - **Mark done and the PR row are where you look.** An opened task now has Mark done (or Reopen) in its top bar, next to Ship it, so you can close it out without scrolling to the composer. The PR row (checks, GitHub, Merge) moved from the top of the card to right above the composer, so after reading the result it's in view without scrolling back up.
21
+ - **Full auto is the default mode.** New launches, API and CLI calls without a mode, scheduled and CI triggers, and recipes run with no permission prompts; tasks in git repos still get their own worktree and a checkpoint before every turn. If your saved launcher mode was Edits (the old default), wisp switches it to Full auto once; a Plan pick, or anything you choose afterwards, sticks. Issue triggers still start in Plan. The mode button now shows just an icon for Full auto and spells out Plan and Edits.
22
+
23
+ ## [0.16.0] - 2026-10-19
24
+
25
+ ### Added
26
+
27
+ - **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`.
28
+ - **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"}`.
29
+ - 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).
30
+
31
+ - **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.
32
+ - **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`.
33
+ - **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.
34
+ - **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.
35
+
36
+ ### Changed
37
+
38
+ - **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.
39
+ - **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.
40
+
41
+ ### Fixed
42
+
43
+ - **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.
44
+ - 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`.
45
+ - 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.
46
+ - `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.
47
+ - 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`.
48
+ - Refused requests (bad input, not found) no longer print a stack trace in the terminal running wisp.
49
+ - **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.
50
+ - **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.
51
+ - 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".
52
+ - Expanding cards no longer leaves a hidden file picker behind each time, and the launcher no longer adds another listener on every redraw.
53
+ - `openapi.json` now documents subagent presets (`/teams`) and `GET /uploads/{id}`.
54
+
55
+ ### Internal
56
+
57
+ - 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.
58
+ - 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.
59
+
8
60
  ## [0.15.0] - 2026-10-18
9
61
 
10
62
  ### Changed
@@ -262,7 +314,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
262
314
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
263
315
  - Older turns are folded, with a floating collapse-all button and back-to-top.
264
316
 
265
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.15.0...HEAD
317
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.17.0...HEAD
318
+ [0.17.0]: https://github.com/M0xoo/wisp/compare/v0.16.0...v0.17.0
319
+ [0.16.0]: https://github.com/M0xoo/wisp/compare/v0.15.0...v0.16.0
266
320
  [0.15.0]: https://github.com/M0xoo/wisp/compare/v0.14.0...v0.15.0
267
321
  [0.14.0]: https://github.com/M0xoo/wisp/compare/v0.13.0...v0.14.0
268
322
  [0.13.0]: https://github.com/M0xoo/wisp/compare/v0.12.0...v0.13.0
package/README.md CHANGED
@@ -54,10 +54,10 @@ From there you can **follow up**, **answer its questions**, **take it over** in
54
54
  | 🤝 **Subagents** | Give the lead agent a team on other harnesses (e.g. Claude Code hands tests to Codex ×3). It delegates through a `delegate` tool; their edits land in its worktree. See [Subagents](#subagents). |
55
55
  | ⏪ **Checkpoints & rollback** | Every turn is snapshotted first. Restore to before any turn, revert a single file, or re-run a command block from the presentation. |
56
56
  | ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
57
- | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.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.17.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
58
58
  | 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
59
59
  | 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
60
- | 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
60
+ | 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** (no permission prompts, the default) for each launch. `Shift+Tab` in the prompt cycles modes. In Edits mode, anything the permissions block comes to you: Claude Code waits for **Allow / Always / Deny**, and the other harnesses show an **Allow & retry** card. |
61
61
  | 🔌 **Integrations** | An MCP server (`wisp mcp`), an agent skill, a scriptable CLI, a JS SDK, an OpenAPI-described HTTP API, and webhooks. Hand tasks to wisp from Claude Code, Codex, opencode, Cursor, scripts or CI. |
62
62
  | 🪶 **No runtime dependencies** | The server uses only Node built-ins. Electron is needed only for the desktop app. |
63
63
 
@@ -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.17.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.17.0 # run a recipe; values fill its {params} (or version=0.17.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.17.0` fills the fields in order, `wisp run release version=0.17.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.17.0",
4
4
  "description": "Launch coding agents, skip the transcript, read the presentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "app": "node bin/wisp.js",
21
21
  "dev": "WISP_DEV=1 node bin/wisp.js",
22
22
  "dev:web": "node --watch-path=server bin/wisp.js --no-open",
23
- "test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js",
23
+ "test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js test/routes.test.js test/adapters-exit.test.js test/web-pure.test.js test/install.test.js test/install-script.test.js test/mentions.test.js",
24
24
  "test:real": "node test/real-clis.mjs",
25
25
  "prepublishOnly": "node --check bin/wisp.js && node --check bin/mac-app.js && node -e \"import('./server/index.js').then(() => import('./server/mcp-wisp.js'))\" && node --check electron/main.js"
26
26
  },
@@ -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
 
@@ -105,7 +105,11 @@ export default {
105
105
  onEvent({ type: 'tool', ...describeTool(name, params, cwd) });
106
106
  }
107
107
  if (!done) return;
108
- if (info.error) onEvent({ type: 'tool_error', name, text: clip(info.error.message || info.error.type || 'failed', 400) });
108
+ if (info.error) {
109
+ onEvent({ type: 'tool_error', name, text: clip(info.error.message || info.error.type || 'failed', 400) });
110
+ // Refused by its permissions (no --dangerously-skip-permissions): the card offers a retry.
111
+ if (/PERMISSION/i.test(info.error.type || '')) onEvent({ type: 'blocked', tool: name, detail: String(params.CommandLine ?? fileOf(params) ?? params.Url ?? '') });
112
+ }
109
113
  else if (editing) {
110
114
  const change = files.change(file);
111
115
  if (change) onEvent({ type: 'change', ...change });
@@ -125,7 +129,7 @@ export default {
125
129
  },
126
130
  onClose({ code, signal, stderr, stopped }) {
127
131
  if (sawResult) return;
128
- onEvent({ type: 'result', isError: true, stopped, text: stderr || `agy exited with ${signal || `code ${code}`}` });
132
+ onEvent({ type: 'result', isError: true, stopped, text: stderr || exitText('agy', code, signal) });
129
133
  },
130
134
  });
131
135
  },
@@ -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',
@@ -17,6 +29,8 @@ const PERMISSION_MODES = {
17
29
  export default {
18
30
  id: 'claude',
19
31
  name: 'Claude Code',
32
+ // Asks wisp before a tool call its permission mode won't allow on its own (Edits mode).
33
+ approveTool: 'mcp__wisp__approve',
20
34
  models: [
21
35
  { id: '', label: 'Default', hint: 'Whatever Claude Code is set to' },
22
36
  { id: 'opus', label: 'Opus', hint: 'Most capable, for hard problems' },
@@ -34,8 +48,14 @@ export default {
34
48
  return detectVersion(BIN);
35
49
  },
36
50
 
51
+ // What a permission prompt is about, for the approval card and for matching "always allow".
52
+ permission(tool, input, cwd) {
53
+ return { tool, action: actionOf(tool, input, cwd), detail: detailOf(tool, input, cwd), why: tool === 'Bash' ? input.description || '' : '' };
54
+ },
55
+
37
56
  // Returns { stop(), done: Promise<void> }. Everything else is reported through onEvent.
38
- run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, onEvent }) {
57
+ // grants: --allowedTools rules (Bash(npm publish)) for what the user allowed after a denial.
58
+ run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, grants = [], onEvent }) {
39
59
  const args = [
40
60
  '-p',
41
61
  '--output-format', 'stream-json',
@@ -44,11 +64,13 @@ export default {
44
64
  ];
45
65
  if (model) args.push('--model', model);
46
66
  if (effort) args.push('--effort', effort);
67
+ if (chromeEnabled()) args.push('--chrome');
47
68
  if (resumeId) args.push('--resume', resumeId);
48
- if (Object.keys(mcpServers).length) {
49
- args.push('--mcp-config', JSON.stringify({ mcpServers }));
50
- args.push('--allowedTools', ...Object.keys(mcpServers).map((n) => `mcp__${n}`));
51
- }
69
+ if (Object.keys(mcpServers).length) args.push('--mcp-config', JSON.stringify({ mcpServers }));
70
+ // In Edits mode, what would prompt goes to wisp's approve tool (the user decides) instead of being denied.
71
+ if (mcpServers.wisp && mode === 'edits') args.push('--permission-prompt-tool', this.approveTool);
72
+ const allowed = [...Object.keys(mcpServers).map((n) => `mcp__${n}`), ...grants.filter((g) => typeof g === 'string')];
73
+ if (allowed.length) args.push('--allowedTools', ...allowed);
52
74
  // Its built-in subagents (Task, or Agent in newer versions) would bypass the user's team.
53
75
  if (delegating) args.push('--disallowedTools', 'Task', 'Agent', 'Workflow');
54
76
  const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
@@ -61,12 +83,8 @@ export default {
61
83
  delete env.CLAUDE_CODE_ENTRYPOINT;
62
84
  env.MCP_TOOL_TIMEOUT ??= String(ASK_TIMEOUT_MS);
63
85
 
64
- const child = spawn(BIN, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
65
- child.stdin.end(prompt);
66
-
67
86
  let sawResult = false;
68
87
  let early = null; // an empty result that came before our prompt was answered (see below)
69
- let stderr = '';
70
88
  const pendingTools = new Map(); // tool_use_id -> name, to flag failed tool results
71
89
  // Background tasks (run_in_background shells, background subagents) still running. While any
72
90
  // are, the CLI stays up after its result and starts another turn when they finish.
@@ -77,18 +95,7 @@ export default {
77
95
  if ([...next.keys()].join(' ') !== was) onEvent({ type: 'background', tasks: [...next.values()] });
78
96
  };
79
97
 
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
- }
98
+ const onJson = (msg) => {
92
99
  if (msg.session_id) onEvent({ type: 'session', id: msg.session_id });
93
100
 
94
101
  if (msg.type === 'assistant') {
@@ -134,41 +141,29 @@ export default {
134
141
  return;
135
142
  }
136
143
  sawResult = true;
144
+ // Refused tool calls (no approve tool here, or the user said no): the card offers a retry.
145
+ for (const d of msg.permission_denials || []) {
146
+ const input = d.tool_input || {};
147
+ onEvent({ type: 'blocked', tool: d.tool_name, detail: detailOf(d.tool_name, input, cwd), rule: ruleOf(d.tool_name, input) });
148
+ }
137
149
  onEvent({ ...ev, background: background.size }); // > 0: another turn and result follow when they finish
138
150
  }
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
- });
151
+ };
164
152
 
165
- return {
166
- done,
167
- stop() {
168
- stopping = true;
169
- killTree(child);
153
+ return spawnJsonl({
154
+ bin: BIN,
155
+ args,
156
+ cwd,
157
+ env,
158
+ input: prompt,
159
+ onEvent,
160
+ onJson,
161
+ onClose: ({ code, signal, stderr, stopped, stopRequested }) => {
162
+ if (sawResult) return;
163
+ if (early && !stopRequested) return onEvent(early);
164
+ onEvent({ type: 'result', isError: true, stopped, text: stderr || exitText('claude', code, signal) });
170
165
  },
171
- };
166
+ });
172
167
  },
173
168
  };
174
169
 
@@ -226,11 +221,40 @@ function describeTool(name, input, cwd) {
226
221
  return { kind: 'agent', label: `Delegating to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
227
222
  case 'mcp__wisp__subagent_results':
228
223
  return { kind: 'agent', label: 'Waiting on subagents' };
224
+ case 'mcp__wisp__wisp_stats':
225
+ return { kind: 'tool', label: 'Reading wisp stats' };
226
+ case 'mcp__wisp__wisp_sessions':
227
+ return { kind: 'tool', label: 'Listing wisp tasks' };
228
+ case 'mcp__wisp__wisp_session':
229
+ return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
229
230
  default:
230
231
  return { kind: 'tool', label: name.startsWith('mcp__') ? `Using ${name.split('__').slice(1).join(' · ')}` : `Using ${name}` };
231
232
  }
232
233
  }
233
234
 
235
+ // A permission prompt's subject: "run `npm publish`", "edit src/a.ts", "fetch https://…".
236
+ function actionOf(tool, input, cwd) {
237
+ const f = rel(input.file_path || input.notebook_path || input.path, cwd);
238
+ if (tool === 'Bash') return `run \`${clip(input.command, 200)}\``;
239
+ if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool) && f) return `${tool === 'Write' ? 'write' : 'edit'} ${f}`;
240
+ if (tool === 'WebFetch') return `fetch ${clip(input.url, 120)}`;
241
+ if (tool === 'WebSearch') return `search the web for “${clip(input.query, 80)}”`;
242
+ if (tool === 'ExitPlanMode') return 'leave plan mode and start making changes';
243
+ if (tool.startsWith('mcp__')) return `use ${tool.split('__').slice(1).join(' · ')}`;
244
+ return `use ${tool}`;
245
+ }
246
+
247
+ // The exact thing being allowed, so "always allow" matches only the same command or file.
248
+ function detailOf(tool, input, cwd) {
249
+ if (tool === 'Bash') return String(input.command ?? '');
250
+ const f = input.file_path || input.notebook_path || input.path;
251
+ if (f) return rel(f, cwd);
252
+ return String(input.url ?? input.query ?? input.pattern ?? '');
253
+ }
254
+
255
+ // An --allowedTools rule for a retry: the exact command for Bash, the tool for the rest.
256
+ const ruleOf = (tool, input) => (tool === 'Bash' && input.command ? `Bash(${input.command})` : tool);
257
+
234
258
  // Capture edit inputs so wisp can show real diffs without asking the agent for them.
235
259
  function captureChange(name, input, cwd) {
236
260
  const file = input.file_path;
@@ -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,
@@ -187,5 +187,8 @@ export function unwrapShell(cmd) {
187
187
  function describeMcp(item) {
188
188
  if (item.server === 'wisp' && item.tool === 'ask_user') return { kind: 'wait', label: 'Waiting for your answer' };
189
189
  if (item.server === 'wisp' && (item.tool === 'delegate' || item.tool === 'subagent_results')) return { kind: 'agent', label: 'Waiting on subagents' };
190
+ if (item.server === 'wisp' && item.tool === 'wisp_stats') return { kind: 'tool', label: 'Reading wisp stats' };
191
+ if (item.server === 'wisp' && item.tool === 'wisp_sessions') return { kind: 'tool', label: 'Listing wisp tasks' };
192
+ if (item.server === 'wisp' && item.tool === 'wisp_session') return { kind: 'tool', label: 'Reading a wisp task' };
190
193
  return { kind: 'tool', label: `Using ${item.server} · ${item.tool}` };
191
194
  }
@@ -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
 
@@ -71,6 +71,8 @@ export default {
71
71
  const [outcome, value = {}] = resultOf(body.result);
72
72
  if (outcome && outcome !== 'success') {
73
73
  onEvent({ type: 'tool_error', name: name.replace(/ToolCall$/, ''), text: clip(value.error || value.message || value.reason || value.stderr || outcome, 400) });
74
+ // Refused by its permissions (no --force): the card offers to retry it.
75
+ if (/reject|denied|permission/i.test(outcome)) onEvent({ type: 'blocked', tool: name.replace(/ToolCall$/, ''), detail: String(args.command ?? args.path ?? args.url ?? '') });
74
76
  } else if (name === 'editToolCall' || name === 'deleteToolCall') {
75
77
  const change = editChange(args.path || value.path, value, cwd, files);
76
78
  if (change) onEvent({ type: 'change', ...change });
@@ -91,7 +93,7 @@ export default {
91
93
  },
92
94
  onClose({ code, signal, stderr, stopped }) {
93
95
  if (sawResult) return;
94
- onEvent({ type: 'result', isError: true, stopped, text: stderr || `cursor-agent exited with ${signal || `code ${code}`}` });
96
+ onEvent({ type: 'result', isError: true, stopped, text: stderr || exitText('cursor-agent', code, signal) });
95
97
  },
96
98
  });
97
99
  },