wisp-agents 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -1
- package/README.md +15 -10
- package/bin/wisp.js +10 -3
- package/package.json +15 -4
- package/server/adapters/claude.js +8 -4
- package/server/adapters/codex.js +9 -6
- package/server/adapters/cursor.js +4 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/index.js +7 -2
- package/server/adapters/opencode.js +5 -2
- package/server/btw.js +76 -0
- package/server/client.js +19 -0
- package/server/git.js +80 -4
- package/server/handoff.js +13 -7
- package/server/index.js +4 -0
- package/server/machines.js +15 -4
- package/server/openapi.js +27 -2
- package/server/remote.js +4 -2
- package/server/router.js +35 -20
- package/server/routes/machines.js +1 -1
- package/server/routes/proxy.js +6 -0
- package/server/routes/sessions.js +32 -2
- package/server/routes/settings.js +24 -38
- package/server/routes/uploads.js +2 -0
- package/server/runner.js +154 -8
- package/server/services/launch.js +32 -4
- package/server/services/move.js +130 -0
- package/server/services/phone.js +20 -1
- package/server/services/places.js +150 -0
- package/server/services/uploads.js +23 -4
- package/server/store.js +39 -6
- package/skills/wisp/SKILL.md +1 -0
- package/web/css/blocks.css +2 -0
- package/web/css/composer.css +23 -1
- package/web/css/feed.css +49 -1
- package/web/css/machines.css +4 -0
- package/web/css/modals.css +19 -0
- package/web/css/ship.css +2 -0
- package/web/css/transcript.css +115 -0
- package/web/index.html +2 -0
- package/web/js/core/state.js +3 -2
- package/web/js/features/actions.js +42 -10
- package/web/js/features/auto.js +1 -1
- package/web/js/features/btw.js +145 -0
- package/web/js/features/clones.js +7 -6
- package/web/js/features/commands.js +3 -1
- package/web/js/features/composer.js +162 -18
- package/web/js/features/feed.js +1 -1
- package/web/js/features/keys.js +2 -1
- package/web/js/features/launcher.js +104 -94
- package/web/js/features/place-menu.js +44 -0
- package/web/js/features/places.js +15 -5
- package/web/js/features/projects.js +14 -12
- package/web/js/features/runners.js +2 -2
- package/web/js/features/settings.js +24 -3
- package/web/js/features/slash.js +63 -0
- package/web/js/features/transcript.js +290 -0
- package/web/js/features/turns.js +99 -28
- package/web/js/slash-text.js +22 -0
- package/web/js/ui/kbd.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.23.0] - 2026-10-20
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Auto's Balanced goal runs frontier models.** When you have a frontier model installed and allowed (Opus, Codex's default, Gemini Argon, Grok 4.x), Balanced now picks it, at the harness's default or medium thinking, instead of Sonnet or Haiku. Best stays on frontier models at high or x-high, so the thinking level is what sets the two apart. Balanced steps down to an everyday model (Sonnet, never Haiku) only while every frontier model is at its limit, so the task still runs, or when you have none; small models' wins no longer pull Balanced to them after 20 outcomes. Cheap is unchanged. For models with the level in their name (Antigravity's `gemini-4-argon-low` / `-medium` / `-high`), the goal now picks the variant: Cheap low, Balanced medium, Best high, and the other variants count as the same model.
|
|
13
|
+
- **Guides on the website for what people search for.** Six new pages under [trywisp.dev/guides](https://trywisp.dev/guides/): [run Claude Code and Codex in parallel](https://trywisp.dev/guides/run-claude-code-and-codex-in-parallel.html), [run multiple Claude Code agents in parallel](https://trywisp.dev/guides/run-multiple-claude-code-agents-in-parallel.html), [run coding agents on multiple machines](https://trywisp.dev/guides/run-coding-agents-on-multiple-machines.html), [multi-agent coding](https://trywisp.dev/guides/multi-agent-coding.html), [Claude Code vs Codex on your own repo](https://trywisp.dev/guides/claude-code-vs-codex.html) and [run coding agents in the background](https://trywisp.dev/guides/run-coding-agents-in-the-background.html). Each answers its question in the first paragraph, shows the manual way with `git worktree` and `ssh`, then how wisp does it, and ends with FAQs (with `FAQPage` structured data). `scripts/guides.mjs` holds them; `scripts/site.mjs` renders them into the sitemap. The landing page's title, description and structured data now say *in parallel* and *on your other machines*, its FAQ answers both questions, and Guides is in the nav and footer of every page. The npm description and keywords match.
|
|
14
|
+
|
|
15
|
+
## [0.22.0] - 2026-10-20
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Full transcript, when you want it.** Turn on Settings → General → **Full transcript** and every card shows its run as a timeline: the agent's messages in full, every step it took (commands, reads, edits, searches, questions), and at the end its presentation, rendered as blocks as usual. Click a step to see the whole command and what it printed, the diff that one edit made, or the full error. Long runs of steps fold their middle, and **Jump to the result** skips to the end. While a task runs, the timeline grows live above a one-line status bar with Stop. Any card can switch either way with its **Transcript** button or `V` (also in ⌘K), and keeps that choice. Off by default. Agent messages are now kept whole (they used to be cut at 600 characters), and so is the output of each command, search, web or MCP call, up to 2,000 characters (its start and end), from Claude Code, Codex, opencode, Cursor and Grok. Turns from before this change show their steps without output.
|
|
20
|
+
- **Every project runs on every machine.** Projects are no longer tied to one computer. Pick any project, pick a machine under **Run on**, and launch: if the machine has no checkout of it yet, wisp finds one there (a project or a git repo in the usual folders with the same origin) or clones the repo's origin into the machine's clone folder, then remembers it. The launcher shows one row of projects for all the places you picked, the card says "Cloning … on devbox" while it does that, and the Projects dialog lists every project with the places it's checked out on. A folder you add on a machine that's the same repo as a project you already have joins that project instead of becoming a second one. Repos without a git remote can't be cloned this way; wisp says so.
|
|
21
|
+
- **Move a task to another computer with a follow-up.** The composer has a new **Runs on** button (shown once you have a machine). Pick another computer and your next follow-up moves the task there: its branch, uncommitted and untracked changes travel as a git bundle and land in a worktree on the same branch, and the earlier turns come along as history, so the harness there is told what happened before it continues. The old session is archived with a "Continues on …" link; the new card shows "From …" and tags each earlier turn with where it ran. A running task has to stop or finish first. API: `"machine"` on `POST /sessions/:id/reply` (`""` for this computer).
|
|
22
|
+
- **Rerun on another computer.** The rerun dialog has a **Run on** picker; the rerun uses that computer's checkout of the project, cloned there first if needed.
|
|
23
|
+
- **`/btw` side questions.** Type `/btw did you update the docs too?` in a card's follow-up box to ask while the agent works, without interrupting it. A separate read-only run answers it, and the answer shows on the card under the turn you asked during. Claude Code, Codex and opencode answer on a copy (fork) of the agent's conversation, so they know what the run did and why; the task's own conversation never sees the question. Cursor, Antigravity and Grok, and CLIs too old to fork, get wisp's record of the task instead: requests, reports, actions so far and files changed. It also works when the task isn't running, and it adds no turn. Dismiss an answer with its ×; dismissing one that is still running stops it. Typing `/` in the box opens a small command menu (`/btw`, `/feedback`), and a working task shows a quiet **Tip: `/btw` asks without interrupting** next to Show all activity. Click it to start one in the box. It goes away after your first `/btw`, or when you hide it with its ×. CLI: `wisp btw <id> "question"` prints the answer, and `wisp reply <id> "/btw …"` works too, as does a `/btw` reply from Telegram. API: `POST /sessions/:id/btw`, `DELETE /sessions/:id/btw?item=<id>`. SDK: `wisp.btw(id, text)`.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **No folder is picked by default when wisp has no folders.** With no projects added, the launcher now starts on **No folder**, so a prompt runs right away in an empty scratch directory instead of opening the folder picker. Adding a folder picks it, as before.
|
|
28
|
+
|
|
29
|
+
- **The website is now [trywisp.dev](https://trywisp.dev/), with more pages for search engines to find.** Canonical, Open Graph, structured-data, `robots.txt`, `llms.txt`, the install script and the npm and Homebrew `homepage` use the new domain. The site gained a page per harness ([Claude Code](https://trywisp.dev/agents/claude-code.html), Codex, Cursor CLI, opencode, Antigravity CLI, Grok Build) that shows how wisp runs that CLI: the flags behind each permission mode, how it asks you questions, `/btw` and the take-over command. An [agents overview](https://trywisp.dev/agents/) compares them, and the integrations guide and changelog are now [pages on the site](https://trywisp.dev/changelog.html) instead of GitHub links. `scripts/site.mjs` (`npm run site`) builds these pages, a `404.html` and a `sitemap.xml` with each page's last-commit date. The Pages workflow runs it before each deploy, redeploys when `CHANGELOG.md` or an adapter changes, and pings IndexNow (Bing and friends) afterwards. The harness logos on the landing page link to their pages.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **The Auto-fix PR conflicts switch works on its own.** In Settings → General it only responded after you had clicked Worktree per task in the same visit.
|
|
34
|
+
- **Chevrons inside Done cards point the right way.** Showing the Done list rotated every chevron in its cards too, so collapsed cards looked open.
|
|
35
|
+
- **The rerun dialog's Rerun button no longer slides off the edge.** With the **Run on** picker added, the bar under the prompt (place, mode, harness) was wider than the dialog and pushed **Rerun** out of view. The dialog is a little wider, and when space runs short the harness name folds first (its logo stays), then the model; on narrow windows the place and mode buttons show only their icons.
|
|
36
|
+
|
|
8
37
|
## [0.21.0] - 2026-10-20
|
|
9
38
|
|
|
10
39
|
### Added
|
|
@@ -391,7 +420,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
391
420
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
392
421
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
393
422
|
|
|
394
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
423
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.23.0...HEAD
|
|
424
|
+
[0.23.0]: https://github.com/M0xoo/wisp/compare/v0.22.0...v0.23.0
|
|
425
|
+
[0.22.0]: https://github.com/M0xoo/wisp/compare/v0.21.0...v0.22.0
|
|
395
426
|
[0.21.0]: https://github.com/M0xoo/wisp/compare/v0.20.0...v0.21.0
|
|
396
427
|
[0.20.0]: https://github.com/M0xoo/wisp/compare/v0.19.0...v0.20.0
|
|
397
428
|
[0.19.0]: https://github.com/M0xoo/wisp/compare/v0.18.1...v0.19.0
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Launch coding agents in the background, skip the transcript, and read the presen
|
|
|
15
15
|
[](package.json)
|
|
16
16
|
[](#adding-a-harness)
|
|
17
17
|
|
|
18
|
-
[Quick start](#quick-start) · [Features](#features) · [CLI](#cli) · [Integrations](#integrations) · [Shortcuts](#keyboard-shortcuts) · [Architecture](#architecture) · [Adding a harness](#adding-a-harness) · [Roadmap](#roadmap)
|
|
18
|
+
[Website](https://trywisp.dev/) · [Quick start](#quick-start) · [Features](#features) · [CLI](#cli) · [Integrations](#integrations) · [Shortcuts](#keyboard-shortcuts) · [Architecture](#architecture) · [Adding a harness](#adding-a-harness) · [Roadmap](#roadmap)
|
|
19
19
|
|
|
20
20
|
</div>
|
|
21
21
|
|
|
@@ -39,10 +39,11 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
39
39
|
|---|---|
|
|
40
40
|
| 🚀 **Fire and forget** | Launch a task from the UI or from any folder with `wisp "…"`. Each folder becomes a project. |
|
|
41
41
|
| 🎞️ **Presentations, not transcripts** | Agents end with structured blocks that render as an animated, skimmable report. |
|
|
42
|
+
| 📜 **The full transcript, if you want it** | Off by default. Settings → General → *Full transcript*, or a card's **Transcript** button (`V`), shows the agent's messages and every step on a timeline. Click a step to see its output or diff. The presentation still renders as blocks at the end. |
|
|
42
43
|
| ❓ **Live questions** | Agents get an `ask_user` tool, so they can pause mid-run. Your answer goes straight back to the running agent. |
|
|
43
44
|
| 🧾 **Real diffs** | Edits come from the agent's own tool calls, so `diff` blocks show what actually changed. |
|
|
44
45
|
| 📥 **A queue** | Sessions sort into *Needs you*, *Working* (with a live one-line status), and *Done*. Archive what you don't need to see. |
|
|
45
|
-
| 💬 **Follow-ups that interrupt** | A follow-up sent mid-run interrupts the agent and resumes it with your message. Turn on **Queue** to wait for the run to finish instead; queued messages can be cancelled. |
|
|
46
|
+
| 💬 **Follow-ups that interrupt** | A follow-up sent mid-run interrupts the agent and resumes it with your message. Turn on **Queue** to wait for the run to finish instead; queued messages can be cancelled. To ask without interrupting, start with `/btw` (`/btw did you update the docs too?`): a separate read-only run answers on a copy of the conversation, and the agent keeps working. |
|
|
46
47
|
| 🔎 **Command palette** | `⌘K` / `Ctrl+K` fuzzy-searches sessions, projects, and actions. |
|
|
47
48
|
| ✨ **Auto** | The default harness: wisp picks the harness, model and thinking level per task from what worked before, and learns from merges, 👍 / 👎 and follow-ups. See [Auto](#auto). |
|
|
48
49
|
| 🤖 **Any harness** | Claude Code, Codex, opencode, Cursor CLI, Antigravity CLI, or Grok Build. Pick the harness, model and thinking level together in the launcher; both are remembered per harness. Each card shows the harness it ran on. |
|
|
@@ -55,7 +56,7 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
55
56
|
| 🤝 **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). |
|
|
56
57
|
| ⏪ **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. |
|
|
57
58
|
| ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
|
|
58
|
-
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.
|
|
59
|
+
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.23.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
|
|
59
60
|
| 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
|
|
60
61
|
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
|
|
61
62
|
| 🔐 **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. |
|
|
@@ -77,14 +78,14 @@ The first run downloads Electron for the desktop app; if that isn't possible, wi
|
|
|
77
78
|
Or install it globally to get the `wisp` command everywhere. Pick one:
|
|
78
79
|
|
|
79
80
|
```sh
|
|
80
|
-
curl -fsSL https://
|
|
81
|
+
curl -fsSL https://trywisp.dev/install.sh | bash # macOS or Linux: checks Node 20+, then npm install -g wisp-agents
|
|
81
82
|
npm install -g wisp-agents # any OS with Node 20+
|
|
82
83
|
brew install m0xoo/tap/wisp-agents # Homebrew: CLI and browser mode, no Electron desktop app
|
|
83
84
|
|
|
84
85
|
wisp "fix the flaky auth test"
|
|
85
86
|
```
|
|
86
87
|
|
|
87
|
-
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://
|
|
88
|
+
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://trywisp.dev/install.sh | WISP_VERSION=0.23.0 bash`).
|
|
88
89
|
|
|
89
90
|
From source:
|
|
90
91
|
|
|
@@ -130,10 +131,11 @@ wisp ls --state running,waiting # list sessions
|
|
|
130
131
|
wisp show <id> # state + latest presentation
|
|
131
132
|
wisp wait <id> # until it finishes or asks you something
|
|
132
133
|
wisp reply <id> "also do X" # follow up (answers a pending question; --mode, --agent, --model to switch)
|
|
134
|
+
wisp btw <id> "did you do X?" # ask on the side without interrupting the run; prints the answer
|
|
133
135
|
wisp stop <id>
|
|
134
136
|
wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
|
|
135
137
|
wisp merge <id> # squash-merge its PR, fast-forward your local main
|
|
136
|
-
wisp run release 0.
|
|
138
|
+
wisp run release 0.23.0 # run a recipe; values fill its {params} (or version=0.23.0)
|
|
137
139
|
wisp recipes # list recipes
|
|
138
140
|
wisp usage [--days 7] # spend and outcomes
|
|
139
141
|
wisp mcp # MCP server for other agents and editors
|
|
@@ -200,6 +202,7 @@ console.log((await wisp.wait(s.id)).state, (await wisp.presentation(s.id)).markd
|
|
|
200
202
|
| `M` | Rename |
|
|
201
203
|
| `⌫` | Delete |
|
|
202
204
|
| `R` | Replay the presentation |
|
|
205
|
+
| `V` | Switch between the full transcript and the presentation |
|
|
203
206
|
| `C` | Collapse all |
|
|
204
207
|
| `U` | Back to top (also `Home`) |
|
|
205
208
|
| `?` | Show all shortcuts |
|
|
@@ -301,7 +304,7 @@ If the harness has no system-prompt flag, prepend `systemPrompt` to the first pr
|
|
|
301
304
|
|
|
302
305
|
## Auto
|
|
303
306
|
|
|
304
|
-
**Auto** is the default harness: wisp picks the harness, model and thinking level for each task. Its "model" is a goal: **Balanced** (
|
|
307
|
+
**Auto** is the default harness: wisp picks the harness, model and thinking level for each task. Its "model" is a goal: **Balanced** (the default), **Cheap** or **Best**. Balanced and Best both run frontier models (Opus, not Sonnet or Haiku) when you have one installed and allowed; what sets them apart is how hard the agent thinks: Balanced runs at the harness's default or medium, Best at high or x-high. Cheap runs at low (or minimal) on whichever model does the job for least. A harness or model without levels runs at its default; for models with the level in their name (Antigravity's `gemini-4-argon-high`) the goal picks the variant the same way.
|
|
305
308
|
|
|
306
309
|
How it picks: every harness · model · thinking level you have installed is an option (`claude:opus:high`). For the kind of task (bug, feature, UI, tests, refactor, docs, release, question) Auto keeps an estimate of how often each option does it well. The estimate starts from a small wisp-bench pilot that ships with wisp (`server/router-priors.json`), then counts your own outcomes: merged PR (best), closed PR, 👍 / 👎, rollback, then how the run ended, less a bit for each follow-up you needed. It draws from each estimate (Thompson sampling), so options with little data now and then get a turn, and takes the best value for the goal after expected cost and time. A launch with `important: true` (API, SDK) gets the best-known option, with no trying. Options that keep failing a kind of task are left out of it. Thinking harder starts out a little more likely to work and costs more, and then your outcomes decide. A model's record at one level counts half at its other levels.
|
|
307
310
|
|
|
@@ -316,7 +319,7 @@ After the run, Auto checks the work before it lands in your queue:
|
|
|
316
319
|
3. **Repair**: new failures go back to the same agent (1 round by default, up to 2).
|
|
317
320
|
4. **Hand over**: still failing, or the run errored, so the task runs again on a stronger tier (small → everyday → frontier) in its own worktree, next to the first run.
|
|
318
321
|
|
|
319
|
-
A harness that says it's at its limit (session or usage limit, rate limit, no credit) isn't failing the task. Auto rests it until the limit resets, doesn't count the run against it, and moves the task to the best pick that can run now. Best always runs frontier models only
|
|
322
|
+
A harness that says it's at its limit (session or usage limit, rate limit, no credit) isn't failing the task. Auto rests it until the limit resets, doesn't count the run against it, and moves the task to the best pick that can run now. Best always runs frontier models only; it falls back to the strongest you have only when no frontier model is installed. Balanced runs on the strongest tier that can run now, whatever the record of the weaker models: frontier, or everyday (Sonnet, never Haiku) while every frontier model is at its limit or when you have none. The shipped estimates come from a small pilot, so they count for at most 4 tasks each.
|
|
320
323
|
5. **Best of two** (goal Best, off by default): two picks work side by side; tests decide, then a cheap model reads both diffs, and the better run is marked **Pick**.
|
|
321
324
|
|
|
322
325
|
Each step's outcome feeds the router: a test pass counts more than the agent's "done", needing a repair counts less, and losing a best-of-2 a little less again. Your own follow-up stops whatever Auto was about to do next. A launch on a machine is checked on that machine, where the files are. Settings → Auto turns each step on or off and sets the test command per project.
|
|
@@ -351,7 +354,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
|
|
|
351
354
|
Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
|
|
352
355
|
```
|
|
353
356
|
|
|
354
|
-
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.
|
|
357
|
+
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.23.0` fills the fields in order, `wisp run release version=0.23.0` by name.
|
|
355
358
|
|
|
356
359
|
**Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
|
|
357
360
|
|
|
@@ -385,10 +388,12 @@ They receive titles, summaries and questions (with their context), never code or
|
|
|
385
388
|
2. On the machine it finds Node.js 20+ and starts `npx -y wisp-agents@<this version> --no-open` detached, or reuses the wisp already answering on its port (4477 by default).
|
|
386
389
|
3. It forwards a free local port to that wisp with `ssh -L` and relays its event stream.
|
|
387
390
|
|
|
388
|
-
|
|
391
|
+
Every project runs on every machine. In the launcher, **Run on** (next to the harness) picks where the prompt goes: this computer, any machine, or several at once. A machine without a checkout of the project gets one first: wisp reuses a clone of the same repo it finds there, or clones the project's origin (repos without a remote can't be cloned; add their folder on the machine in Projects instead). Enter launches on every place in parallel, and the runs land as one task with a row per machine. Each run executes on its machine, with worktrees, diffs, rollback, `ask_user`, subagents, compare and Ship it all working on that machine. Its sessions join your feed with a badge, answer from your phone like any other, open in VS Code, Cursor or Windsurf through Remote - SSH, and **Take over** opens `ssh -t <host>` resuming the agent in your terminal.
|
|
389
392
|
|
|
390
393
|
Nothing new listens on the network: wisp on the machine stays on its own `127.0.0.1`, and it keeps running (agents too) when this computer sleeps or disconnects; wisp reconnects on its own. **Stop wisp there** (the machine's ⋯ menu) stops it. When the machine runs an older wisp than this computer, **Update to X** next to its version restarts it there as this version. The machine needs Node.js 20+ and the harness CLIs, signed in. Sessions there are `<id>@<machine>` in the API, CLI, SDK and MCP; recipes and schedules work with machine projects, while CI and issue triggers, usage and memory stay per machine.
|
|
391
394
|
|
|
395
|
+
A task can change computers mid-way: the **Runs on** button next to the composer moves your next follow-up (and the task) to another computer. The task's branch and uncommitted work travel as a git bundle into a worktree there, on the same branch, and its earlier turns come along so the harness picks up where the other one stopped. The old session is archived and links to the new one. **Rerun** has the same picker.
|
|
396
|
+
|
|
392
397
|
## Learning
|
|
393
398
|
|
|
394
399
|
After a session has been quiet for 90 seconds (or right away when you mark it done or archive it), wisp runs a short **retro**: the same harness, read-only and without tools (Claude Code runs it on Haiku), reads a digest of the turns it hasn't seen yet. The digest holds your requests and follow-ups, questions and answers, failed tool calls and the commands run around them, and how each run ended. The retro then updates four small lists:
|
package/bin/wisp.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// wisp open the wisp desktop app (or focus the running one)
|
|
5
5
|
// wisp "fix the tests" launch a wisp in the current folder
|
|
6
6
|
// wisp add [path] add a folder as a project (default: current folder)
|
|
7
|
-
// wisp ls | show | wait | reply | stop script sessions (see --help)
|
|
7
|
+
// wisp ls | show | wait | reply | btw | stop script sessions (see --help)
|
|
8
8
|
// wisp run <recipe> [values…] run a saved recipe
|
|
9
9
|
// wisp mcp stdio MCP server, so other agents and editors can use wisp
|
|
10
10
|
// wisp skill [install] the agent skill that teaches other agents to use wisp
|
|
@@ -26,6 +26,7 @@ const HELP = `usage:
|
|
|
26
26
|
wisp wait <id> block until it finishes or asks you something
|
|
27
27
|
wisp reply <id> "text" follow up (answers a pending question) (--queue --mode plan|edits|auto
|
|
28
28
|
--agent ID --model ID --effort LEVEL: switch harness / model from here on)
|
|
29
|
+
wisp btw <id> "question" ask on the side without interrupting the run; prints the answer
|
|
29
30
|
wisp stop <id> stop a run
|
|
30
31
|
wisp ship <id> commit, push and open a PR for a worktree session (--draft)
|
|
31
32
|
wisp merge <id> squash-merge its PR and sync your local base branch
|
|
@@ -40,7 +41,7 @@ launch options: --mode plan|edits|auto --agent claude|codex|opencode|cursor|ant
|
|
|
40
41
|
--cwd DIR --also DIR (repeatable) --wait --json --no-worktree --compare agent[:model],…
|
|
41
42
|
options: --port N --browser --no-open --json --version`;
|
|
42
43
|
|
|
43
|
-
const COMMANDS = new Set(['add', 'ls', 'list', 'show', 'wait', 'reply', 'stop', 'mcp', 'skill', 'ship', 'merge', 'run', 'recipes', 'usage']);
|
|
44
|
+
const COMMANDS = new Set(['add', 'ls', 'list', 'show', 'wait', 'reply', 'btw', 'stop', 'mcp', 'skill', 'ship', 'merge', 'run', 'recipes', 'usage']);
|
|
44
45
|
const argv = process.argv.slice(2);
|
|
45
46
|
const flags = { open: true, browser: false, port: Number(process.env.WISP_PORT) || 4477, mode: null, agent: null, model: null, effort: null, cwd: null, also: [], json: false, wait: false, queue: undefined, state: null, limit: null, project: false, worktree: undefined, compare: null, draft: false, days: null };
|
|
46
47
|
const words = [];
|
|
@@ -80,7 +81,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
80
81
|
// Newer commands are also common first words of a task ("ship the docs", "usage of X"), so they
|
|
81
82
|
// only count when the rest fits; `wisp run` falls back to a task when no recipe has that name.
|
|
82
83
|
const SESSION_ID = /^[a-f0-9]{12}$/;
|
|
83
|
-
const FITS = { ship: (w) => SESSION_ID.test(w[1] || ''), merge: (w) => SESSION_ID.test(w[1] || ''), usage: (w) => w.length === 1, recipes: (w) => w.length === 1, run: (w) => w.length > 1 };
|
|
84
|
+
const FITS = { ship: (w) => SESSION_ID.test(w[1] || ''), merge: (w) => SESSION_ID.test(w[1] || ''), btw: (w) => SESSION_ID.test(w[1] || ''), usage: (w) => w.length === 1, recipes: (w) => w.length === 1, run: (w) => w.length > 1 };
|
|
84
85
|
const command = literal !== 'task' && COMMANDS.has(words[0]) && (FITS[words[0]]?.(words) ?? true) ? words[0] : null;
|
|
85
86
|
const cwd = flags.cwd || process.cwd();
|
|
86
87
|
const die = (msg) => {
|
|
@@ -108,6 +109,7 @@ async function scripted(cmd, args) {
|
|
|
108
109
|
const wisp = await WispClient.connect({ port: argv.some((a) => a === '--port' || a === '-p') ? flags.port : undefined });
|
|
109
110
|
const out = (v, text) => console.log(flags.json ? JSON.stringify(v, null, 2) : text);
|
|
110
111
|
const need = (v, what) => v || die(`${what} required — see wisp --help`);
|
|
112
|
+
const btw = (b) => out(b, b.status === 'done' ? b.answer : b.status === 'running' ? ' … still answering; it will show on the card' : ` ! ${b.error}`);
|
|
111
113
|
const show = async (id, summary) => {
|
|
112
114
|
const p = await wisp.presentation(id);
|
|
113
115
|
const s = summary || (await wisp.sessions()).find((x) => x.id === id);
|
|
@@ -135,9 +137,14 @@ async function scripted(cmd, args) {
|
|
|
135
137
|
case 'reply': {
|
|
136
138
|
const id = need(args[0], 'session id');
|
|
137
139
|
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 });
|
|
140
|
+
if (r.btw) return btw(await wisp.btwAnswer(id, r.btw.id)); // "/btw …": a side question
|
|
138
141
|
if (!flags.wait) return out(r, r.answered ? ' ✓ answered' : r.interrupted ? ' ✓ interrupted; resuming with your message' : r.queued ? ' ✓ queued' : ' ✓ sent');
|
|
139
142
|
return show(id, await wisp.wait(id));
|
|
140
143
|
}
|
|
144
|
+
case 'btw': {
|
|
145
|
+
const id = need(args[0], 'session id');
|
|
146
|
+
return btw(await wisp.btw(id, need(args.slice(1).join(' ').trim(), 'question')));
|
|
147
|
+
}
|
|
141
148
|
case 'stop':
|
|
142
149
|
return out(await wisp.stop(need(args[0], 'session id')), ' ✓ stopped');
|
|
143
150
|
case 'ship': {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wisp-agents",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.23.0",
|
|
4
|
+
"description": "Run Claude Code, Codex, Cursor and other coding agents in parallel, in the background, on any of your machines. Skip the transcript, read the report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"wisp": "bin/wisp.js",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"app": "node bin/wisp.js",
|
|
21
21
|
"dev": "WISP_DEV=1 node bin/wisp.js",
|
|
22
22
|
"dev:web": "node --watch-path=server bin/wisp.js --no-open",
|
|
23
|
-
"test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js test/routes.test.js test/adapters-exit.test.js test/web-pure.test.js test/install.test.js test/install-script.test.js test/mentions.test.js test/clone.test.js test/pipeline.test.js test/commands.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 test/clone.test.js test/pipeline.test.js test/commands.test.js test/btw.test.js test/site.test.js",
|
|
24
|
+
"site": "node scripts/site.mjs",
|
|
24
25
|
"test:real": "node test/real-clis.mjs",
|
|
25
26
|
"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
27
|
},
|
|
@@ -30,7 +31,17 @@
|
|
|
30
31
|
"keywords": [
|
|
31
32
|
"claude",
|
|
32
33
|
"claude-code",
|
|
34
|
+
"codex",
|
|
35
|
+
"openai-codex",
|
|
36
|
+
"cursor",
|
|
37
|
+
"opencode",
|
|
33
38
|
"coding-agent",
|
|
39
|
+
"ai-agents",
|
|
40
|
+
"multi-agent",
|
|
41
|
+
"parallel",
|
|
42
|
+
"git-worktree",
|
|
43
|
+
"ssh",
|
|
44
|
+
"orchestration",
|
|
34
45
|
"ai",
|
|
35
46
|
"agents",
|
|
36
47
|
"cli",
|
|
@@ -44,7 +55,7 @@
|
|
|
44
55
|
"type": "git",
|
|
45
56
|
"url": "git+https://github.com/M0xoo/wisp.git"
|
|
46
57
|
},
|
|
47
|
-
"homepage": "https://
|
|
58
|
+
"homepage": "https://trywisp.dev/",
|
|
48
59
|
"bugs": {
|
|
49
60
|
"url": "https://github.com/M0xoo/wisp/issues"
|
|
50
61
|
},
|
|
@@ -32,6 +32,7 @@ export default {
|
|
|
32
32
|
name: 'Claude Code',
|
|
33
33
|
// Asks wisp before a tool call its permission mode won't allow on its own (Edits mode).
|
|
34
34
|
approveTool: 'mcp__wisp__approve',
|
|
35
|
+
fork: true, // run({ resumeId, fork }) answers on a copy of the conversation (/btw)
|
|
35
36
|
models: [
|
|
36
37
|
{ id: '', label: 'Default', hint: 'Whatever Claude Code is set to' },
|
|
37
38
|
{ id: 'opus', label: 'Opus', hint: 'Most capable, for hard problems' },
|
|
@@ -57,7 +58,8 @@ export default {
|
|
|
57
58
|
// Returns { stop(), done: Promise<void> }. Everything else is reported through onEvent.
|
|
58
59
|
// grants: --allowedTools rules (Bash(npm publish)) for what the user allowed after a denial.
|
|
59
60
|
// dirs: the task's other folders, which it may read and edit like cwd.
|
|
60
|
-
|
|
61
|
+
// fork: resume a throwaway copy of resumeId's conversation, leaving the original as it is.
|
|
62
|
+
run({ prompt, attachments = [], cwd, dirs: extra = [], resumeId, fork = false, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, grants = [], onEvent }) {
|
|
61
63
|
const args = [
|
|
62
64
|
'-p',
|
|
63
65
|
'--output-format', 'stream-json',
|
|
@@ -68,6 +70,7 @@ export default {
|
|
|
68
70
|
if (effort) args.push('--effort', effort);
|
|
69
71
|
if (chromeEnabled()) args.push('--chrome');
|
|
70
72
|
if (resumeId) args.push('--resume', resumeId);
|
|
73
|
+
if (resumeId && fork) args.push('--fork-session', '--no-session-persistence');
|
|
71
74
|
if (Object.keys(mcpServers).length) args.push('--mcp-config', JSON.stringify({ mcpServers }));
|
|
72
75
|
// In Edits mode, what would prompt goes to wisp's approve tool (the user decides) instead of being denied.
|
|
73
76
|
if (mcpServers.wisp && mode === 'edits') args.push('--permission-prompt-tool', this.approveTool);
|
|
@@ -108,17 +111,18 @@ export default {
|
|
|
108
111
|
onEvent({ type: 'thinking' });
|
|
109
112
|
} else if (part.type === 'tool_use') {
|
|
110
113
|
pendingTools.set(part.id, part.name);
|
|
111
|
-
onEvent({ type: 'tool', ...describeTool(part.name, part.input || {}, cwd) });
|
|
114
|
+
onEvent({ type: 'tool', id: part.id, ...describeTool(part.name, part.input || {}, cwd) });
|
|
112
115
|
const change = captureChange(part.name, part.input || {}, cwd);
|
|
113
116
|
if (change) onEvent({ type: 'change', ...change });
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
} else if (msg.type === 'user') {
|
|
117
120
|
for (const part of msg.message?.content || []) {
|
|
118
|
-
if (part.type
|
|
121
|
+
if (part.type !== 'tool_result') continue;
|
|
122
|
+
if (part.is_error) {
|
|
119
123
|
const name = pendingTools.get(part.tool_use_id);
|
|
120
124
|
onEvent({ type: 'tool_error', name, text: flatten(part.content).slice(0, 400) });
|
|
121
|
-
}
|
|
125
|
+
} else onEvent({ type: 'tool_output', id: part.tool_use_id, text: flatten(part.content) });
|
|
122
126
|
}
|
|
123
127
|
} else if (msg.type === 'system') {
|
|
124
128
|
// background_tasks_changed carries the full list; task_started/task_notification are the
|
package/server/adapters/codex.js
CHANGED
|
@@ -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, exitText, fileTracker, rel, spawnJsonl, withProtocol } from './common.js';
|
|
4
|
+
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, flatten, rel, spawnJsonl, withProtocol } from './common.js';
|
|
5
5
|
import { describeCommand } from './commands.js';
|
|
6
6
|
|
|
7
7
|
const BIN = process.env.WISP_CODEX_BIN || 'codex';
|
|
@@ -21,6 +21,7 @@ export default {
|
|
|
21
21
|
id: 'codex',
|
|
22
22
|
name: 'Codex',
|
|
23
23
|
askTool: 'mcp__wisp__ask_user',
|
|
24
|
+
fork: true, // run({ resumeId, fork }) answers on a copy of the conversation (/btw)
|
|
24
25
|
models: [{ id: '', label: 'Default', hint: 'Whatever Codex is set to' }],
|
|
25
26
|
efforts: ['minimal', 'low', 'medium', 'high', 'xhigh'],
|
|
26
27
|
|
|
@@ -33,7 +34,8 @@ export default {
|
|
|
33
34
|
},
|
|
34
35
|
|
|
35
36
|
// dirs: the task's other folders, writable like cwd.
|
|
36
|
-
|
|
37
|
+
// fork: `exec fork` a copy of resumeId's conversation instead of resuming it (/btw).
|
|
38
|
+
run({ prompt, attachments = [], cwd, dirs: extra = [], resumeId, fork = false, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
|
|
37
39
|
const args = ['exec', '--json', '--skip-git-repo-check'];
|
|
38
40
|
// `resume` doesn't take --sandbox, so set it through config for both paths.
|
|
39
41
|
if (mode === 'auto') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
@@ -54,7 +56,7 @@ export default {
|
|
|
54
56
|
const dirs = [...new Set([...extra, ...attachments.map((a) => path.dirname(a.path))])];
|
|
55
57
|
for (const d of dirs) args.push('--add-dir', d);
|
|
56
58
|
}
|
|
57
|
-
if (resumeId) args.push('resume', resumeId);
|
|
59
|
+
if (resumeId) args.push(fork ? 'fork' : 'resume', resumeId);
|
|
58
60
|
// No prompt argument: codex reads it from stdin.
|
|
59
61
|
|
|
60
62
|
let input = withProtocol(prompt, systemPrompt, resumeId);
|
|
@@ -73,9 +75,9 @@ export default {
|
|
|
73
75
|
const type = item.type || item.item_type;
|
|
74
76
|
if (phase === 'started') {
|
|
75
77
|
if (type === 'command_execution') {
|
|
76
|
-
onEvent({ type: 'tool', kind: 'run', label: describeCommand(unwrapShell(item.command), cwd), detail: item.command });
|
|
78
|
+
onEvent({ type: 'tool', id: item.id, kind: 'run', label: describeCommand(unwrapShell(item.command), cwd), detail: item.command });
|
|
77
79
|
} else if (type === 'mcp_tool_call') {
|
|
78
|
-
onEvent({ type: 'tool', ...describeMcp(item) });
|
|
80
|
+
onEvent({ type: 'tool', id: item.id, ...describeMcp(item) });
|
|
79
81
|
} else if (type === 'web_search') {
|
|
80
82
|
onEvent({ type: 'tool', kind: 'web', label: item.query ? `Searching the web: ${clip(item.query, 50)}` : 'Searching the web' });
|
|
81
83
|
}
|
|
@@ -103,7 +105,7 @@ export default {
|
|
|
103
105
|
case 'command_execution':
|
|
104
106
|
if (item.status === 'failed' || (item.exit_code != null && item.exit_code !== 0)) {
|
|
105
107
|
onEvent({ type: 'tool_error', name: clip(unwrapShell(item.command), 40), text: clip(item.aggregated_output || `exit ${item.exit_code}`, 400) });
|
|
106
|
-
}
|
|
108
|
+
} else if (item.aggregated_output) onEvent({ type: 'tool_output', id: item.id, text: item.aggregated_output });
|
|
107
109
|
break;
|
|
108
110
|
case 'file_change':
|
|
109
111
|
for (const c of item.changes || []) {
|
|
@@ -116,6 +118,7 @@ export default {
|
|
|
116
118
|
break;
|
|
117
119
|
case 'mcp_tool_call':
|
|
118
120
|
if (item.status === 'failed') onEvent({ type: 'tool_error', name: `${item.server}.${item.tool}`, text: clip(item.error?.message || 'failed', 400) });
|
|
121
|
+
else if (item.result?.content) onEvent({ type: 'tool_output', id: item.id, text: flatten(item.result.content) });
|
|
119
122
|
break;
|
|
120
123
|
case 'todo_list': {
|
|
121
124
|
const next = item.items?.find((t) => !t.completed);
|
|
@@ -67,7 +67,7 @@ export default {
|
|
|
67
67
|
const args = body.args || {};
|
|
68
68
|
if (msg.subtype === 'started') {
|
|
69
69
|
if ((name === 'editToolCall' || name === 'deleteToolCall') && args.path) files.remember(args.path);
|
|
70
|
-
onEvent({ type: 'tool', ...describeTool(name, args, cwd) });
|
|
70
|
+
onEvent({ type: 'tool', id: msg.call_id, ...describeTool(name, args, cwd) });
|
|
71
71
|
} else if (msg.subtype === 'completed') {
|
|
72
72
|
const [outcome, value = {}] = resultOf(body.result);
|
|
73
73
|
if (outcome && outcome !== 'success') {
|
|
@@ -77,6 +77,9 @@ export default {
|
|
|
77
77
|
} else if (name === 'editToolCall' || name === 'deleteToolCall') {
|
|
78
78
|
const change = editChange(args.path || value.path, value, cwd, files);
|
|
79
79
|
if (change) onEvent({ type: 'change', ...change });
|
|
80
|
+
} else {
|
|
81
|
+
const output = [value.stdout, value.stderr].filter((x) => typeof x === 'string' && x).join('\n') || (typeof value.content === 'string' ? value.content : '');
|
|
82
|
+
if (output) onEvent({ type: 'tool_output', id: msg.call_id, text: output });
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
break;
|
package/server/adapters/grok.js
CHANGED
|
@@ -108,7 +108,7 @@ export default {
|
|
|
108
108
|
if (part.name === 'write' && input.file_path) files.remember(input.file_path);
|
|
109
109
|
// Streamed once answered, so "waiting" would land after "You answered"; the trail has both.
|
|
110
110
|
if (part.name === 'use_tool' && input.tool_name === 'wisp__ask_user') continue;
|
|
111
|
-
onEvent({ type: 'tool', ...describeTool(part.name, input, cwd) });
|
|
111
|
+
onEvent({ type: 'tool', id: part.id, ...describeTool(part.name, input, cwd) });
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
break;
|
|
@@ -122,6 +122,7 @@ export default {
|
|
|
122
122
|
else {
|
|
123
123
|
const change = captureChange(call.name, call.input, cwd, files);
|
|
124
124
|
if (change) onEvent({ type: 'change', ...change });
|
|
125
|
+
else onEvent({ type: 'tool_output', id: part.tool_use_id, text: flatten(part.content) });
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
break;
|
package/server/adapters/index.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// null = can't inject MCP, ask via a question block
|
|
10
10
|
// approveTool?: string its permission prompts go to wisp's `approve` MCP
|
|
11
11
|
// tool (live approval); omit if it can't route them
|
|
12
|
+
// fork?: true run({ resumeId, fork: true }) works on a copy of
|
|
13
|
+
// that conversation, leaving it as it is (/btw)
|
|
12
14
|
// permission?(tool, input, cwd): { tool, action, detail, why? } what a live approval is about
|
|
13
15
|
// detect(): Promise<{ available, version?, error?, models? }> models overrides the static list
|
|
14
16
|
// resumeCommand({ resumeId, model, effort }): string[] argv to continue the session interactively
|
|
@@ -19,9 +21,12 @@
|
|
|
19
21
|
//
|
|
20
22
|
// onEvent receives normalized events:
|
|
21
23
|
// { type: 'session', id } agent-side session id (used to resume follow-ups)
|
|
22
|
-
// { type: 'tool', kind, label, file?, detail? } kind: read|edit|run|search|web|agent|plan|tool
|
|
24
|
+
// { type: 'tool', kind, label, file?, detail?, id?, output? } kind: read|edit|run|search|web|agent|plan|tool
|
|
25
|
+
// id: the harness's call id, for a later tool_output;
|
|
26
|
+
// output: what it returned, if it reports calls once run
|
|
27
|
+
// { type: 'tool_output', id, text } what a finished call returned (the transcript shows it)
|
|
23
28
|
// { type: 'tool_error', name, text }
|
|
24
|
-
// { type: 'text', text }
|
|
29
|
+
// { type: 'text', text } assistant prose, the final answer included
|
|
25
30
|
// { type: 'thinking' }
|
|
26
31
|
// { type: 'change', file, op, edits: [{ before, after }] }
|
|
27
32
|
// { type: 'blocked', tool, detail, rule? } a call its permissions refused; rule: what allows
|
|
@@ -27,6 +27,7 @@ export default {
|
|
|
27
27
|
id: 'opencode',
|
|
28
28
|
name: 'opencode',
|
|
29
29
|
askTool: 'wisp_ask_user',
|
|
30
|
+
fork: true, // run({ resumeId, fork }) answers on a copy of the session (/btw)
|
|
30
31
|
models: [{ id: '', label: 'Default', hint: 'Whatever opencode is set to' }],
|
|
31
32
|
|
|
32
33
|
resumeCommand({ resumeId, model }) {
|
|
@@ -67,7 +68,8 @@ export default {
|
|
|
67
68
|
},
|
|
68
69
|
|
|
69
70
|
// grants: { permission, patterns } the user allowed after `run` rejected them, for this turn.
|
|
70
|
-
|
|
71
|
+
// fork: continue a copy of resumeId's session instead of the session itself (/btw).
|
|
72
|
+
run({ prompt, attachments = [], cwd, resumeId, fork = false, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, grants = [], onEvent }) {
|
|
71
73
|
// The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
|
|
72
74
|
// Errors to stderr: the only place a rate limit shows up (see RATE_LIMITED).
|
|
73
75
|
const args = ['run', '--format', 'json', '--print-logs', '--log-level', 'ERROR'];
|
|
@@ -76,6 +78,7 @@ export default {
|
|
|
76
78
|
if (model) args.push('--model', model);
|
|
77
79
|
if (model && effort) args.push('--variant', effort);
|
|
78
80
|
if (resumeId) args.push('--session', resumeId);
|
|
81
|
+
if (resumeId && fork) args.push('--fork');
|
|
79
82
|
for (const a of attachments) args.push('--file', a.path);
|
|
80
83
|
|
|
81
84
|
// Inject the ask_user server through inline config, merged over the user's own.
|
|
@@ -152,7 +155,7 @@ export default {
|
|
|
152
155
|
// Emitted once the tool has finished, with its input and output.
|
|
153
156
|
const state = part.state || {};
|
|
154
157
|
const input = state.input || {};
|
|
155
|
-
onEvent({ type: 'tool', ...describeTool(part.tool, input, cwd) });
|
|
158
|
+
onEvent({ type: 'tool', ...describeTool(part.tool, input, cwd), ...(state.status !== 'error' && typeof state.output === 'string' && { output: state.output }) });
|
|
156
159
|
if (state.status === 'error') onEvent({ type: 'tool_error', name: part.tool, text: clip(state.error || state.output, 400) });
|
|
157
160
|
else {
|
|
158
161
|
for (const change of captureChanges(part.tool, input, state.metadata || {}, cwd, files)) onEvent({ type: 'change', ...change });
|
package/server/btw.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// /btw: a side question about a task ("/btw did you also update the docs?"), answered while its
|
|
2
|
+
// run carries on. The answer comes from a read-only run of its own: a fork of the agent's
|
|
3
|
+
// conversation where the harness can fork one, so it knows everything the run did and why; else a
|
|
4
|
+
// fresh run that gets wisp's record of the task. Nothing goes back into the task's conversation.
|
|
5
|
+
|
|
6
|
+
import { turnText } from './handoff.js';
|
|
7
|
+
import { textMentions } from './mentions.js';
|
|
8
|
+
import { blocksToMarkdown } from './render.js';
|
|
9
|
+
import { parsePresentation } from './protocol.js';
|
|
10
|
+
import { clip, truncate } from './util.js';
|
|
11
|
+
|
|
12
|
+
export const BTW_CMD = /^\/btw(?:\s+|$)/i;
|
|
13
|
+
export const MAX_BTW = 3; // side questions running at once, per task
|
|
14
|
+
export const BTW_TIMEOUT_MS = 5 * 60_000;
|
|
15
|
+
const DIGEST_TURNS = 4; // earlier turns written out for a run that can't fork
|
|
16
|
+
const LIVE_ACTS = 40; // the running turn's latest actions it gets to see
|
|
17
|
+
|
|
18
|
+
// The question in "/btw did you …", or null when the text isn't a /btw.
|
|
19
|
+
export function btwQuestion(text) {
|
|
20
|
+
const t = String(text || '').trim();
|
|
21
|
+
return BTW_CMD.test(t) ? t.replace(BTW_CMD, '').trim() : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Marks the run as a side question (the system prompt, where the harness takes one).
|
|
25
|
+
export const BTW_SYSTEM = '# wisp btw\n\nYou answer a side question about a coding task. You only read; you change nothing.';
|
|
26
|
+
|
|
27
|
+
const rules = (forked) => `# wisp btw
|
|
28
|
+
|
|
29
|
+
The user has a quick side question (/btw) about this task. ${forked ? 'This is a copy of the conversation so far: the original keeps working on the task in parallel, and it' : 'Another agent run is working on this task, and it'} does the work. This reply only answers the user.
|
|
30
|
+
|
|
31
|
+
- Answer just the question, briefly: a few sentences or a short list, in plain markdown.
|
|
32
|
+
- Change nothing: no edits, no file writes, no commits, no commands with side effects. Reading files, \`git diff\` and \`git status\` is fine when you need to check.
|
|
33
|
+
- No wisp presentation block and no wisp tools (ask_user, delegate): this isn't a task result.
|
|
34
|
+
- If you can't tell from what's here, say so instead of guessing.`;
|
|
35
|
+
|
|
36
|
+
// What the task has been doing, for a run that can't fork the conversation: the earlier turns'
|
|
37
|
+
// requests and reports, and the latest turn's actions so far.
|
|
38
|
+
export function btwDigest(session, name = (t) => t.agent || session.agent) {
|
|
39
|
+
const turns = session.turns || [];
|
|
40
|
+
const parts = [];
|
|
41
|
+
const from = Math.max(0, turns.length - DIGEST_TURNS);
|
|
42
|
+
if (from) parts.push(`_${from} earlier turn${from > 1 ? 's' : ''} left out._`);
|
|
43
|
+
turns.slice(from).forEach((t, i) => {
|
|
44
|
+
const n = from + i + 1;
|
|
45
|
+
if (t.status !== 'running') return parts.push(turnText(t, n, name));
|
|
46
|
+
const acts = (t.activity || []).filter((a) => a.kind !== 'text').slice(-LIVE_ACTS);
|
|
47
|
+
const said = (t.activity || []).findLast((a) => a.kind === 'text')?.label;
|
|
48
|
+
const files = (t.changes || []).map((c) => `\`${c.file}\``);
|
|
49
|
+
parts.push(
|
|
50
|
+
[
|
|
51
|
+
`### Turn ${n} · ${name(t)} · still running (started ${Math.max(1, Math.round((Date.now() - t.startedAt) / 60_000))} min ago)`,
|
|
52
|
+
`**User:** ${textMentions(t.prompt)}`,
|
|
53
|
+
acts.length && `**What it has done so far (oldest first):**\n${acts.map((a) => `- ${clip(a.label, 160)}`).join('\n')}`,
|
|
54
|
+
files.length && `**Files it edited so far:** ${files.join(', ')}`,
|
|
55
|
+
said && `**Its latest message:** ${truncate(said, 1200)}`,
|
|
56
|
+
]
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join('\n\n'),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
return parts.join('\n\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// forked: the run resumes a copy of the conversation, so it already knows the task.
|
|
65
|
+
export function btwPrompt(session, question, { forked, name } = {}) {
|
|
66
|
+
const head = rules(forked);
|
|
67
|
+
if (forked) return `${head}\n\n## Side question\n\n${question}`;
|
|
68
|
+
return `${head}\n\n## The task so far\n\n${btwDigest(session, name)}\n\n---\n\n## Side question\n\n${question}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The reply as markdown. An agent that ended with a wisp block anyway gets it rendered.
|
|
72
|
+
export function btwAnswer(text) {
|
|
73
|
+
const p = parsePresentation(String(text || ''));
|
|
74
|
+
const md = p.fallback ? String(text || '').trim() : [p.notes, blocksToMarkdown(p.blocks)].filter(Boolean).join('\n\n');
|
|
75
|
+
return md || '_No answer._';
|
|
76
|
+
}
|
package/server/client.js
CHANGED
|
@@ -160,6 +160,25 @@ export class WispClient {
|
|
|
160
160
|
// run it, and the follow-ups after it, on another harness, model or thinking level.
|
|
161
161
|
reply = (id, text, { attachments, queue, mode, agent, model, effort } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue, mode, agent, model, effort });
|
|
162
162
|
|
|
163
|
+
// A side question (/btw): a read-only run of its own answers it while the task's run carries on.
|
|
164
|
+
// Resolves with { id, question, status: done | error, answer | error } once it's answered
|
|
165
|
+
// (status stays running if timeoutMs runs out first), or right away with wait: false.
|
|
166
|
+
async btw(id, text, { attachments, wait = true, timeoutMs = 6 * 60_000 } = {}) {
|
|
167
|
+
const q = await this.request('POST', `/sessions/${id}/btw`, { text, attachments });
|
|
168
|
+
return wait ? this.btwAnswer(id, q.id, { timeoutMs }) : q;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async btwAnswer(id, btwId, { timeoutMs = 6 * 60_000 } = {}) {
|
|
172
|
+
const end = Date.now() + timeoutMs;
|
|
173
|
+
for (;;) {
|
|
174
|
+
const s = await this.request('GET', `/sessions/${id}`);
|
|
175
|
+
const b = s.turns.flatMap((t) => t.btw || []).find((x) => x.id === btwId);
|
|
176
|
+
if (!b) return { id: btwId, status: 'error', error: 'It was dismissed.' };
|
|
177
|
+
if (b.status !== 'running' || Date.now() > end) return b;
|
|
178
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
163
182
|
// Upload a file (path, Buffer or Uint8Array) to attach to a launch or reply. Returns { id, … }.
|
|
164
183
|
async upload(file, { name, type } = {}) {
|
|
165
184
|
const data = typeof file === 'string' ? fs.readFileSync(file) : file;
|