wisp-agents 0.20.0 → 0.22.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 +35 -1
- package/README.md +17 -8
- package/bin/wisp.js +14 -6
- package/package.json +4 -3
- package/server/adapters/claude.js +12 -7
- package/server/adapters/codex.js +13 -7
- 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 +22 -2
- 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/mcp-wisp.js +2 -1
- package/server/openapi.js +28 -2
- package/server/remote.js +4 -2
- package/server/routes/machines.js +1 -1
- package/server/routes/proxy.js +6 -0
- package/server/routes/sessions.js +47 -10
- package/server/routes/settings.js +24 -38
- package/server/routes/ship.js +19 -24
- package/server/routes/uploads.js +2 -0
- package/server/runner.js +178 -12
- package/server/services/auto.js +10 -2
- package/server/services/launch.js +67 -7
- package/server/services/move.js +130 -0
- package/server/services/phone.js +20 -1
- package/server/services/places.js +150 -0
- package/server/services/ship.js +97 -10
- package/server/services/uploads.js +23 -4
- package/server/store.js +39 -6
- package/skills/wisp/SKILL.md +3 -1
- package/web/css/blocks.css +2 -0
- package/web/css/composer.css +23 -1
- package/web/css/feed.css +52 -1
- package/web/css/launcher.css +12 -0
- package/web/css/machines.css +4 -0
- package/web/css/modals.css +19 -0
- package/web/css/palette.css +6 -0
- package/web/css/ship.css +14 -0
- package/web/css/transcript.css +115 -0
- package/web/index.html +2 -0
- package/web/js/core/state.js +5 -2
- package/web/js/features/actions.js +42 -10
- 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 +10 -10
- package/web/js/features/keys.js +2 -1
- package/web/js/features/launcher.js +121 -67
- package/web/js/features/place-menu.js +44 -0
- package/web/js/features/places.js +25 -5
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +16 -13
- package/web/js/features/settings.js +24 -3
- package/web/js/features/ship.js +66 -29
- package/web/js/features/slash.js +63 -0
- package/web/js/features/transcript.js +290 -0
- package/web/js/features/turns.js +101 -29
- package/web/js/slash-text.js +22 -0
- package/web/js/ui/kbd.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.22.0] - 2026-10-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
- **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).
|
|
15
|
+
- **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.
|
|
16
|
+
- **`/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)`.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **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.
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **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.
|
|
27
|
+
- **Chevrons inside Done cards point the right way.** Showing the Done list rotated every chevron in its cards too, so collapsed cards looked open.
|
|
28
|
+
- **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.
|
|
29
|
+
|
|
30
|
+
## [0.21.0] - 2026-10-20
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **One task across several folders.** A task can now work in more than one project at a time, for example an API and the web app that uses it. In the launcher, ⌘-click (Ctrl- or Shift-click) a second project chip, or click the **+** on its corner or next to it in the project list. One agent starts in the first folder and can read and edit the others: Claude Code and Codex get them as `--add-dir`, and every harness is told the paths. Each git repo gets its own worktree. The card reads `api + web` and shows up under either project's filter. **Ship it** opens a pull request in each repo that has changes, and **Merge all** merges them together and syncs each local base branch. CLI: `wisp "…" --also ~/code/web` (repeatable). API: `"also": [folders]` on `POST /sessions`. MCP: `also` on `wisp_launch`.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **The launcher's project row no longer throws while it isn't laid out.** Refitting the chips of a launcher that had just been painted over (or folded into the dock) read the position of a chip that wasn't there.
|
|
39
|
+
|
|
8
40
|
## [0.20.0] - 2026-10-19
|
|
9
41
|
|
|
10
42
|
### Added
|
|
@@ -381,7 +413,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
381
413
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
382
414
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
383
415
|
|
|
384
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
416
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.22.0...HEAD
|
|
417
|
+
[0.22.0]: https://github.com/M0xoo/wisp/compare/v0.21.0...v0.22.0
|
|
418
|
+
[0.21.0]: https://github.com/M0xoo/wisp/compare/v0.20.0...v0.21.0
|
|
385
419
|
[0.20.0]: https://github.com/M0xoo/wisp/compare/v0.19.0...v0.20.0
|
|
386
420
|
[0.19.0]: https://github.com/M0xoo/wisp/compare/v0.18.1...v0.19.0
|
|
387
421
|
[0.18.1]: https://github.com/M0xoo/wisp/compare/v0.18.0...v0.18.1
|
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,22 +39,24 @@ 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. |
|
|
49
50
|
| 🖥️ **Take over** | Opens your terminal with the harness's resume command (`claude --resume <id>`, `codex resume <id>`, …) in the project folder. Works with Terminal, iTerm, Ghostty, Warp, cmux, kitty, WezTerm, GNOME Terminal, Konsole, Alacritty, Windows Terminal, PowerShell, or a custom command. |
|
|
50
51
|
| 📎 **Attachments** | Paste screenshots or drop images, video, logs, or PDFs into the launcher. |
|
|
51
52
|
| 🧠 **Learns as you go** | After a session goes quiet, a background retro records your preferences and the agent's lessons (a mistake and the way that worked), globally and per project. Later runs get them in their prompt. See [Learning](#learning). |
|
|
53
|
+
| 🗂️ **One task, several folders** | ⌘-click a second project in the launcher (or its **+**) and one agent works in both, e.g. the API and the web app that uses it. Each git repo gets its own worktree, and **Ship it** opens a PR in each. |
|
|
52
54
|
| 🌿 **A worktree per task** | In a git repo each task gets its own worktree and branch from `origin/main`, so parallel agents never collide. |
|
|
53
55
|
| 🚢 **Ship it** | One click commits, pushes and opens the PR. The card follows CI and reviews; **Merge** squash-merges and syncs your local main. |
|
|
54
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). |
|
|
55
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. |
|
|
56
58
|
| ⚖️ **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.
|
|
59
|
+
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.22.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
|
|
58
60
|
| 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
|
|
59
61
|
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
|
|
60
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. |
|
|
@@ -76,14 +78,14 @@ The first run downloads Electron for the desktop app; if that isn't possible, wi
|
|
|
76
78
|
Or install it globally to get the `wisp` command everywhere. Pick one:
|
|
77
79
|
|
|
78
80
|
```sh
|
|
79
|
-
curl -fsSL https://
|
|
81
|
+
curl -fsSL https://trywisp.dev/install.sh | bash # macOS or Linux: checks Node 20+, then npm install -g wisp-agents
|
|
80
82
|
npm install -g wisp-agents # any OS with Node 20+
|
|
81
83
|
brew install m0xoo/tap/wisp-agents # Homebrew: CLI and browser mode, no Electron desktop app
|
|
82
84
|
|
|
83
85
|
wisp "fix the flaky auth test"
|
|
84
86
|
```
|
|
85
87
|
|
|
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://
|
|
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.22.0 bash`).
|
|
87
89
|
|
|
88
90
|
From source:
|
|
89
91
|
|
|
@@ -129,10 +131,11 @@ wisp ls --state running,waiting # list sessions
|
|
|
129
131
|
wisp show <id> # state + latest presentation
|
|
130
132
|
wisp wait <id> # until it finishes or asks you something
|
|
131
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
|
|
132
135
|
wisp stop <id>
|
|
133
136
|
wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
|
|
134
137
|
wisp merge <id> # squash-merge its PR, fast-forward your local main
|
|
135
|
-
wisp run release 0.
|
|
138
|
+
wisp run release 0.22.0 # run a recipe; values fill its {params} (or version=0.22.0)
|
|
136
139
|
wisp recipes # list recipes
|
|
137
140
|
wisp usage [--days 7] # spend and outcomes
|
|
138
141
|
wisp mcp # MCP server for other agents and editors
|
|
@@ -146,6 +149,7 @@ wisp skill install # install the wisp skill for Claude Code (--ag
|
|
|
146
149
|
| `--agent`, `-a` | `claude` · `codex` · `opencode` · `cursor` · `antigravity` · `grok` (default: the agent last picked in the launcher) |
|
|
147
150
|
| `--model` · `--effort` | Model id and thinking level for the launch |
|
|
148
151
|
| `--cwd`, `-C DIR` | Project folder (default: the current one) |
|
|
152
|
+
| `--also DIR` | Another folder the same task works in; repeat for more (each git repo gets its own worktree and PR) |
|
|
149
153
|
| `--wait`, `-w` | Wait for the run to finish or ask, then print the presentation |
|
|
150
154
|
| `--no-worktree` | Edit the folder in place instead of a fresh worktree (default in git repos: a worktree) |
|
|
151
155
|
| `--compare a[:model],…` | Also run the task on these harnesses, each in its own worktree |
|
|
@@ -198,6 +202,7 @@ console.log((await wisp.wait(s.id)).state, (await wisp.presentation(s.id)).markd
|
|
|
198
202
|
| `M` | Rename |
|
|
199
203
|
| `⌫` | Delete |
|
|
200
204
|
| `R` | Replay the presentation |
|
|
205
|
+
| `V` | Switch between the full transcript and the presentation |
|
|
201
206
|
| `C` | Collapse all |
|
|
202
207
|
| `U` | Back to top (also `Home`) |
|
|
203
208
|
| `?` | Show all shortcuts |
|
|
@@ -329,6 +334,8 @@ In a git repo, every launch (except Plan mode) runs in its own [git worktree](ht
|
|
|
329
334
|
|
|
330
335
|
**Compare** (the **vs** button next to the harness picker) launches the same task on a second harness. Both get their own worktree and show up as one task in the feed, with a row per run; **Compare** on the task shows them side by side, and **Keep this** archives the others and deletes their worktrees.
|
|
331
336
|
|
|
337
|
+
**Several folders.** A task can span projects: ⌘/Ctrl-click (or Shift-click) another project chip in the launcher, or use the **+** on its corner or in the project list. The agent starts in the first folder and gets the others as extra working directories (`--add-dir` for Claude Code and Codex; the rest are told the paths). Each git repo gets its own worktree on the task's branch name, the card reads `api + web` and lists under either project's filter, and **Ship it** commits, pushes and opens a PR per repo with changes, each noting the others. **Merge all** squash-merges them in order and syncs each local base branch. From the CLI or API: `wisp "…" --also ~/code/web`, `"also": ["~/code/web"]` on `POST /sessions`, or `also` on the `wisp_launch` MCP tool. wisp fixes merge conflicts only on the first repo's PR, and checkpoints and rollback cover the first folder.
|
|
338
|
+
|
|
332
339
|
Turn worktrees off in Settings → *Worktree per task*, with the launcher's **Worktree / In place** toggle, or per launch (`--no-worktree`, `"worktree": false`).
|
|
333
340
|
|
|
334
341
|
## Subagents
|
|
@@ -347,7 +354,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
|
|
|
347
354
|
Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
|
|
348
355
|
```
|
|
349
356
|
|
|
350
|
-
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.22.0` fills the fields in order, `wisp run release version=0.22.0` by name.
|
|
351
358
|
|
|
352
359
|
**Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
|
|
353
360
|
|
|
@@ -381,10 +388,12 @@ They receive titles, summaries and questions (with their context), never code or
|
|
|
381
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).
|
|
382
389
|
3. It forwards a free local port to that wisp with `ssh -L` and relays its event stream.
|
|
383
390
|
|
|
384
|
-
|
|
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.
|
|
385
392
|
|
|
386
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.
|
|
387
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
|
+
|
|
388
397
|
## Learning
|
|
389
398
|
|
|
390
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
|
|
@@ -37,12 +38,12 @@ const HELP = `usage:
|
|
|
37
38
|
wisp -- "ls the repo" a task that starts with a command name
|
|
38
39
|
|
|
39
40
|
launch options: --mode plan|edits|auto --agent claude|codex|opencode|cursor|antigravity|grok --model ID --effort LEVEL
|
|
40
|
-
--cwd DIR --wait --json --no-worktree --compare agent[:model],…
|
|
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
|
-
const flags = { open: true, browser: false, port: Number(process.env.WISP_PORT) || 4477, mode: null, agent: null, model: null, effort: null, cwd: null, json: false, wait: false, queue: undefined, state: null, limit: null, project: false, worktree: undefined, compare: null, draft: false, days: null };
|
|
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 = [];
|
|
47
48
|
let literal = false;
|
|
48
49
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -57,6 +58,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
57
58
|
else if (a === '--model') flags.model = argv[++i];
|
|
58
59
|
else if (a === '--effort') flags.effort = argv[++i];
|
|
59
60
|
else if (a === '--cwd' || a === '-C') flags.cwd = path.resolve(argv[++i].replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
61
|
+
else if (a === '--also') flags.also.push(path.resolve(argv[++i].replace(/^~(?=$|[\\/])/, os.homedir())));
|
|
60
62
|
else if (a === '--state') flags.state = argv[++i];
|
|
61
63
|
else if (a === '--limit') flags.limit = Number(argv[++i]);
|
|
62
64
|
else if (a === '--json') flags.json = true;
|
|
@@ -79,7 +81,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
79
81
|
// Newer commands are also common first words of a task ("ship the docs", "usage of X"), so they
|
|
80
82
|
// only count when the rest fits; `wisp run` falls back to a task when no recipe has that name.
|
|
81
83
|
const SESSION_ID = /^[a-f0-9]{12}$/;
|
|
82
|
-
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 };
|
|
83
85
|
const command = literal !== 'task' && COMMANDS.has(words[0]) && (FITS[words[0]]?.(words) ?? true) ? words[0] : null;
|
|
84
86
|
const cwd = flags.cwd || process.cwd();
|
|
85
87
|
const die = (msg) => {
|
|
@@ -107,6 +109,7 @@ async function scripted(cmd, args) {
|
|
|
107
109
|
const wisp = await WispClient.connect({ port: argv.some((a) => a === '--port' || a === '-p') ? flags.port : undefined });
|
|
108
110
|
const out = (v, text) => console.log(flags.json ? JSON.stringify(v, null, 2) : text);
|
|
109
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}`);
|
|
110
113
|
const show = async (id, summary) => {
|
|
111
114
|
const p = await wisp.presentation(id);
|
|
112
115
|
const s = summary || (await wisp.sessions()).find((x) => x.id === id);
|
|
@@ -114,7 +117,7 @@ async function scripted(cmd, args) {
|
|
|
114
117
|
};
|
|
115
118
|
switch (cmd) {
|
|
116
119
|
case 'launch': {
|
|
117
|
-
const s = await wisp.launch({ prompt: args.join(' '), cwd, agent: flags.agent || undefined, mode: flags.mode || undefined, model: flags.model ?? undefined, effort: flags.effort ?? undefined, worktree: flags.worktree, compare: flags.compare || undefined });
|
|
120
|
+
const s = await wisp.launch({ prompt: args.join(' '), cwd, also: flags.also, agent: flags.agent || undefined, mode: flags.mode || undefined, model: flags.model ?? undefined, effort: flags.effort ?? undefined, worktree: flags.worktree, compare: flags.compare || undefined });
|
|
118
121
|
if (s.group && !flags.wait) return out(s, s.group.map((x) => ` ◌ launched on ${x.agent}: ${x.title} (${x.id})`).join('\n'));
|
|
119
122
|
if (!flags.wait) return out(s, ` ◌ launched: ${s.title} (${s.id})`);
|
|
120
123
|
if (!flags.json) console.error(` ◌ launched: ${s.title} (${s.id}) — waiting…`);
|
|
@@ -134,9 +137,14 @@ async function scripted(cmd, args) {
|
|
|
134
137
|
case 'reply': {
|
|
135
138
|
const id = need(args[0], 'session id');
|
|
136
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
|
|
137
141
|
if (!flags.wait) return out(r, r.answered ? ' ✓ answered' : r.interrupted ? ' ✓ interrupted; resuming with your message' : r.queued ? ' ✓ queued' : ' ✓ sent');
|
|
138
142
|
return show(id, await wisp.wait(id));
|
|
139
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
|
+
}
|
|
140
148
|
case 'stop':
|
|
141
149
|
return out(await wisp.stop(need(args[0], 'session id')), ' ✓ stopped');
|
|
142
150
|
case 'ship': {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wisp-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Launch coding agents, skip the transcript, read the presentation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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
|
},
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
"type": "git",
|
|
45
46
|
"url": "git+https://github.com/M0xoo/wisp.git"
|
|
46
47
|
},
|
|
47
|
-
"homepage": "https://
|
|
48
|
+
"homepage": "https://trywisp.dev/",
|
|
48
49
|
"bugs": {
|
|
49
50
|
"url": "https://github.com/M0xoo/wisp/issues"
|
|
50
51
|
},
|
|
@@ -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' },
|
|
@@ -41,8 +42,8 @@ export default {
|
|
|
41
42
|
efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
42
43
|
|
|
43
44
|
// Interactive command to take over a session in a terminal.
|
|
44
|
-
resumeCommand({ resumeId, model, effort }) {
|
|
45
|
-
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : [])];
|
|
45
|
+
resumeCommand({ resumeId, model, effort, dirs = [] }) {
|
|
46
|
+
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : []), ...dirs.flatMap((d) => ['--add-dir', d])];
|
|
46
47
|
},
|
|
47
48
|
|
|
48
49
|
detect() {
|
|
@@ -56,7 +57,9 @@ export default {
|
|
|
56
57
|
|
|
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.
|
|
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 }) {
|
|
60
63
|
const args = [
|
|
61
64
|
'-p',
|
|
62
65
|
'--output-format', 'stream-json',
|
|
@@ -67,6 +70,7 @@ export default {
|
|
|
67
70
|
if (effort) args.push('--effort', effort);
|
|
68
71
|
if (chromeEnabled()) args.push('--chrome');
|
|
69
72
|
if (resumeId) args.push('--resume', resumeId);
|
|
73
|
+
if (resumeId && fork) args.push('--fork-session', '--no-session-persistence');
|
|
70
74
|
if (Object.keys(mcpServers).length) args.push('--mcp-config', JSON.stringify({ mcpServers }));
|
|
71
75
|
// In Edits mode, what would prompt goes to wisp's approve tool (the user decides) instead of being denied.
|
|
72
76
|
if (mcpServers.wisp && mode === 'edits') args.push('--permission-prompt-tool', this.approveTool);
|
|
@@ -74,7 +78,7 @@ export default {
|
|
|
74
78
|
if (allowed.length) args.push('--allowedTools', ...allowed);
|
|
75
79
|
// Its built-in subagents (Task, or Agent in newer versions) would bypass the user's team.
|
|
76
80
|
if (delegating) args.push('--disallowedTools', 'Task', 'Agent', 'Workflow');
|
|
77
|
-
const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
|
|
81
|
+
const dirs = [...new Set([...extra, ...attachments.map((a) => path.dirname(a.path))])];
|
|
78
82
|
if (dirs.length) args.push('--add-dir', ...dirs);
|
|
79
83
|
args.push('--verbose'); // terminates the variadic flags above
|
|
80
84
|
prompt += attachmentNote(attachments, 'Read images and PDFs with the Read tool');
|
|
@@ -107,17 +111,18 @@ export default {
|
|
|
107
111
|
onEvent({ type: 'thinking' });
|
|
108
112
|
} else if (part.type === 'tool_use') {
|
|
109
113
|
pendingTools.set(part.id, part.name);
|
|
110
|
-
onEvent({ type: 'tool', ...describeTool(part.name, part.input || {}, cwd) });
|
|
114
|
+
onEvent({ type: 'tool', id: part.id, ...describeTool(part.name, part.input || {}, cwd) });
|
|
111
115
|
const change = captureChange(part.name, part.input || {}, cwd);
|
|
112
116
|
if (change) onEvent({ type: 'change', ...change });
|
|
113
117
|
}
|
|
114
118
|
}
|
|
115
119
|
} else if (msg.type === 'user') {
|
|
116
120
|
for (const part of msg.message?.content || []) {
|
|
117
|
-
if (part.type
|
|
121
|
+
if (part.type !== 'tool_result') continue;
|
|
122
|
+
if (part.is_error) {
|
|
118
123
|
const name = pendingTools.get(part.tool_use_id);
|
|
119
124
|
onEvent({ type: 'tool_error', name, text: flatten(part.content).slice(0, 400) });
|
|
120
|
-
}
|
|
125
|
+
} else onEvent({ type: 'tool_output', id: part.tool_use_id, text: flatten(part.content) });
|
|
121
126
|
}
|
|
122
127
|
} else if (msg.type === 'system') {
|
|
123
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
|
|
|
@@ -32,11 +33,15 @@ export default {
|
|
|
32
33
|
return detectVersion(BIN);
|
|
33
34
|
},
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
// dirs: the task's other folders, writable like cwd.
|
|
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 }) {
|
|
36
39
|
const args = ['exec', '--json', '--skip-git-repo-check'];
|
|
37
40
|
// `resume` doesn't take --sandbox, so set it through config for both paths.
|
|
38
41
|
if (mode === 'auto') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
39
42
|
else args.push('-c', `sandbox_mode=${toml(SANDBOX[mode] || 'workspace-write')}`);
|
|
43
|
+
// `resume` doesn't take --add-dir either: the other folders are writable roots through config.
|
|
44
|
+
if (extra.length && mode !== 'auto') args.push('-c', `sandbox_workspace_write.writable_roots=${toml(extra)}`);
|
|
40
45
|
if (model) args.push('-m', model);
|
|
41
46
|
if (effort) args.push('-c', `model_reasoning_effort=${toml(effort)}`);
|
|
42
47
|
for (const [name, srv] of Object.entries(mcpServers)) {
|
|
@@ -48,10 +53,10 @@ export default {
|
|
|
48
53
|
const images = attachments.filter((a) => IMAGE.test(a.mime));
|
|
49
54
|
for (const img of images) args.push('-i', img.path);
|
|
50
55
|
if (!resumeId) {
|
|
51
|
-
const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
|
|
56
|
+
const dirs = [...new Set([...extra, ...attachments.map((a) => path.dirname(a.path))])];
|
|
52
57
|
for (const d of dirs) args.push('--add-dir', d);
|
|
53
58
|
}
|
|
54
|
-
if (resumeId) args.push('resume', resumeId);
|
|
59
|
+
if (resumeId) args.push(fork ? 'fork' : 'resume', resumeId);
|
|
55
60
|
// No prompt argument: codex reads it from stdin.
|
|
56
61
|
|
|
57
62
|
let input = withProtocol(prompt, systemPrompt, resumeId);
|
|
@@ -70,9 +75,9 @@ export default {
|
|
|
70
75
|
const type = item.type || item.item_type;
|
|
71
76
|
if (phase === 'started') {
|
|
72
77
|
if (type === 'command_execution') {
|
|
73
|
-
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 });
|
|
74
79
|
} else if (type === 'mcp_tool_call') {
|
|
75
|
-
onEvent({ type: 'tool', ...describeMcp(item) });
|
|
80
|
+
onEvent({ type: 'tool', id: item.id, ...describeMcp(item) });
|
|
76
81
|
} else if (type === 'web_search') {
|
|
77
82
|
onEvent({ type: 'tool', kind: 'web', label: item.query ? `Searching the web: ${clip(item.query, 50)}` : 'Searching the web' });
|
|
78
83
|
}
|
|
@@ -100,7 +105,7 @@ export default {
|
|
|
100
105
|
case 'command_execution':
|
|
101
106
|
if (item.status === 'failed' || (item.exit_code != null && item.exit_code !== 0)) {
|
|
102
107
|
onEvent({ type: 'tool_error', name: clip(unwrapShell(item.command), 40), text: clip(item.aggregated_output || `exit ${item.exit_code}`, 400) });
|
|
103
|
-
}
|
|
108
|
+
} else if (item.aggregated_output) onEvent({ type: 'tool_output', id: item.id, text: item.aggregated_output });
|
|
104
109
|
break;
|
|
105
110
|
case 'file_change':
|
|
106
111
|
for (const c of item.changes || []) {
|
|
@@ -113,6 +118,7 @@ export default {
|
|
|
113
118
|
break;
|
|
114
119
|
case 'mcp_tool_call':
|
|
115
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) });
|
|
116
122
|
break;
|
|
117
123
|
case 'todo_list': {
|
|
118
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
|
+
}
|