wisp-agents 0.12.0 → 0.14.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 +27 -5
- package/bin/wisp.js +2 -2
- package/package.json +2 -2
- package/server/adapters/claude.js +50 -12
- package/server/client.js +2 -2
- package/server/index.js +173 -17
- package/server/machines.js +5 -0
- package/server/mcp-wisp.js +5 -5
- package/server/openapi.js +25 -3
- package/server/protocol.js +4 -0
- package/server/remote.js +103 -17
- package/server/router-priors.json +54 -0
- package/server/router.js +340 -0
- package/server/runner.js +66 -13
- package/server/store.js +10 -2
- package/server/subagents.js +6 -2
- package/server/telemetry.js +58 -7
- package/web/css/app.css +83 -7
- package/web/index.html +2 -0
- package/web/js/app.js +459 -54
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.14.0] - 2026-10-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Auto: wisp picks the harness and model for you, and it's now the default.** Choose **Auto** in the harness picker (existing setups are switched to it once) and set a goal in the model slot: **Balanced**, **Cheap** or **Best**. For each task, Auto estimates how likely each harness · model you have installed is to do that kind of task (bug, UI, docs, tests…) well. It starts from a wisp-bench pilot that ships with wisp, then learns from your own outcomes: merged or closed PRs, 👍 / 👎, rollbacks, follow-ups and how the run ended. It weighs that against cost and time, and now and then tries an alternative so the estimates stay honest. Hover a card's harness logo to see why Auto picked it. **Settings → Auto** sets the goal, whether new models get tried, and which harnesses Auto may use; **Open** shows what it learned. A harness or model that shows up later (a new CLI, a new version) gets a few tasks that aren't marked important before Auto relies on it, and a new version starts from its family's record. Auto works for compare runs (a different pick each), for subagents (a team member set to Auto is picked on its first task), on machines (picked here from what the machine has, and that machine's outcomes feed the same router), and from the CLI, recipes, triggers and Telegram. Everything it learns stays in `~/.wisp/router.json`. API: `agent: "auto"` on launch, `GET`/`PATCH /api/router`, `POST /api/router/reset`.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Claude Code tasks that say "I'll report once it has finished" now do.** When Claude ends its turn with background work still running (a test run, a build, a background subagent), the task stays in **Working** and shows **Waiting on background task: …**. When the work finishes, Claude's follow-up report becomes the task's answer. Before, wisp stopped Claude 3 seconds after its first reply, which also killed the background work, and the task ended on that "I'll report…" line. **Stop** and follow-ups still work while it waits, and it waits at most an hour. A reply that is already a full answer, for example one that leaves a dev server running, still ends the task right away. All harnesses are now also told to wait for their background work before finishing.
|
|
17
|
+
|
|
18
|
+
- **Follow-ups no longer end on "The agent finished without a message."** When a Claude Code run left background commands behind, the next follow-up ended right away with an empty answer, and the agent was cut off before it started on your message, so you had to send it again. wisp now waits for the answer to your follow-up.
|
|
19
|
+
|
|
20
|
+
## [0.13.0] - 2026-10-18
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Start tasks from Telegram.** In **Phone → Telegram**, switch on **Start tasks from Telegram**, then send your bot `/new fix the flaky login test`. The task runs in your default project (the last used, or the one you pick in that section) with the launcher's harness, model and mode. `/new @api add rate limiting` picks a project by name, and `/projects` lists them. The bot replies **▶ Started**, and replying to that message sends a follow-up. Its card shows **Telegram** as where it came from. It's off by default, only the linked chat can start tasks, and a `/new` sent while wisp was offline is not run later. API: `PUT /api/remote` with `telegram.allowStart` and `telegram.project`.
|
|
25
|
+
|
|
26
|
+
- **Feedback for the wisp team.** Each finished turn has a small 👍 / 👎 at the end of its trail. It stays faint until you hover the turn. Click again to take the rating back. 👎 opens one optional line for what went wrong (↵ sends, Esc skips). You can also type `/feedback <note>` in the launcher or any follow-up box, or use `⌘K` → **Send feedback…** for a short dialog. Bare `/feedback` opens the dialog too. It thanks you right away; offline, the feedback is kept and sent later. It goes to PostHog with the harness, model and status of the run, never your prompt, output or code. It's sent even with usage stats off, and `WISP_FEEDBACK=0` turns it off. API: `POST /api/feedback`.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **Switch modes in a follow-up.** Each task's follow-up box now has the same mode button as the launcher (Plan, Edits, Full auto; Shift+Tab cycles). Start in Plan, read the plan, then pick **Edits** and say "go ahead": that follow-up and the ones after it run in the new mode. Any mode can switch to any other. The pick only takes effect when you send, and the follow-up shows the new mode next to its model. A follow-up queued while the agent works keeps its own mode, so the run already going isn't affected. A task started in Plan has no worktree, so edits after switching land in the project folder. API: `mode` on `POST /api/sessions/{id}/reply`; CLI: `wisp reply <id> "…" --mode edits`; MCP: `mode` on `wisp_reply`.
|
|
31
|
+
|
|
32
|
+
- **Compare more than two harnesses.** Clicking **vs** in the launcher adds a second harness as before, and a **+** at the end of the pill now adds a third and a fourth (four side by side, the server's limit). Each one sits behind its own **vs**: hover it for **×** to drop just that harness. A new one starts on a harness that isn't in the pill yet. With three or four, the pill shows logos only (hover a logo for harness, model and thinking level), and the mode and Subagents labels fold to icons so the bar stays on one line.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Changing a Telegram setting through the API without a `token` no longer clears the bot token and unlinks the chat. After a settings change, Telegram no longer re-delivers messages wisp already handled.
|
|
37
|
+
|
|
38
|
+
- Following up on a task now keeps it on screen as it moves to **Working**, and again when it lands back in **Needs you**. On a long card, the previous answer folding away used to pull the whole card off the top of the screen, so you lost the task you had just replied to. The composer now holds still and the new exchange appears where you were typing.
|
|
39
|
+
|
|
8
40
|
## [0.12.0] - 2026-10-18
|
|
9
41
|
|
|
10
42
|
### Added
|
|
@@ -220,7 +252,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
220
252
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
221
253
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
222
254
|
|
|
223
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
255
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.14.0...HEAD
|
|
256
|
+
[0.14.0]: https://github.com/M0xoo/wisp/compare/v0.13.0...v0.14.0
|
|
257
|
+
[0.13.0]: https://github.com/M0xoo/wisp/compare/v0.12.0...v0.13.0
|
|
224
258
|
[0.12.0]: https://github.com/M0xoo/wisp/compare/v0.11.0...v0.12.0
|
|
225
259
|
[0.11.0]: https://github.com/M0xoo/wisp/compare/v0.10.0...v0.11.0
|
|
226
260
|
[0.10.0]: https://github.com/M0xoo/wisp/compare/v0.9.0...v0.10.0
|
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
44
44
|
| 📥 **A queue** | Sessions sort into *Needs you*, *Working* (with a live one-line status), and *Done*. Archive what you don't need to see. |
|
|
45
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
46
|
| 🔎 **Command palette** | `⌘K` / `Ctrl+K` fuzzy-searches sessions, projects, and actions. |
|
|
47
|
+
| ✨ **Auto** | The default harness: wisp picks the harness and model per task from what worked before, and learns from merges, 👍 / 👎 and follow-ups. See [Auto](#auto). |
|
|
47
48
|
| 🤖 **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. |
|
|
48
49
|
| 🖥️ **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. |
|
|
49
50
|
| 📎 **Attachments** | Paste screenshots or drop images, video, logs, or PDFs into the launcher. |
|
|
@@ -53,9 +54,9 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
53
54
|
| 🤝 **Subagents** | Give the lead agent a team on other harnesses (e.g. Claude Code hands tests to Codex ×3). It delegates through a `delegate` tool; their edits land in its worktree. See [Subagents](#subagents). |
|
|
54
55
|
| ⏪ **Checkpoints & rollback** | Every turn is snapshotted first. Restore to before any turn, revert a single file, or re-run a command block from the presentation. |
|
|
55
56
|
| ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
|
|
56
|
-
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.
|
|
57
|
+
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.14.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
|
|
57
58
|
| 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
|
|
58
|
-
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there
|
|
59
|
+
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
|
|
59
60
|
| 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
|
|
60
61
|
| 🔌 **Integrations** | An MCP server (`wisp mcp`), an agent skill, a scriptable CLI, a JS SDK, an OpenAPI-described HTTP API, and webhooks. Hand tasks to wisp from Claude Code, Codex, opencode, Cursor, scripts or CI. |
|
|
61
62
|
| 🪶 **No runtime dependencies** | The server uses only Node built-ins. Electron is needed only for the desktop app. |
|
|
@@ -121,11 +122,11 @@ wisp "add a test" --wait # block until it finishes, print the presentat
|
|
|
121
122
|
wisp ls --state running,waiting # list sessions
|
|
122
123
|
wisp show <id> # state + latest presentation
|
|
123
124
|
wisp wait <id> # until it finishes or asks you something
|
|
124
|
-
wisp reply <id> "also do X" # follow up (answers a pending question)
|
|
125
|
+
wisp reply <id> "also do X" # follow up (answers a pending question; --mode to switch)
|
|
125
126
|
wisp stop <id>
|
|
126
127
|
wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
|
|
127
128
|
wisp merge <id> # squash-merge its PR, fast-forward your local main
|
|
128
|
-
wisp run release 0.
|
|
129
|
+
wisp run release 0.14.0 # run a recipe; values fill its {params} (or version=0.14.0)
|
|
129
130
|
wisp recipes # list recipes
|
|
130
131
|
wisp usage [--days 7] # spend and outcomes
|
|
131
132
|
wisp mcp # MCP server for other agents and editors
|
|
@@ -279,6 +280,18 @@ If the harness has no system-prompt flag, prepend `systemPrompt` to the first pr
|
|
|
279
280
|
- [x] Antigravity CLI adapter
|
|
280
281
|
- [x] Grok Build adapter
|
|
281
282
|
|
|
283
|
+
## Auto
|
|
284
|
+
|
|
285
|
+
**Auto** is the default harness: wisp picks the harness and model for each task. Its "model" is a goal: **Balanced** (best result per dollar), **Cheap** or **Best**.
|
|
286
|
+
|
|
287
|
+
How it picks: every harness · model you have installed is an option. 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.
|
|
288
|
+
|
|
289
|
+
New harnesses and models: when a CLI or model shows up (after an install or a Re-check), it's on probation. It only gets the occasional exploring slot until it has three outcomes, and a new version (say a new Sonnet) starts from its family's record. **Settings → Auto** turns trying new models off, limits which harnesses Auto may use, and shows what it learned; **Forget** clears it.
|
|
290
|
+
|
|
291
|
+
Compare runs get different picks. A subagent set to Auto is picked on its first task and keeps that harness. A launch on a machine is picked here, from the harnesses that machine reported, and that machine's outcomes come back to the same router. All of it stays in `~/.wisp/router.json`; usage stats only add which goal, kind of task and size were used, and whether the pick was exploring.
|
|
292
|
+
|
|
293
|
+
The benchmark behind the priors is in [`bench/`](bench/README.md).
|
|
294
|
+
|
|
282
295
|
## Worktrees and shipping
|
|
283
296
|
|
|
284
297
|
In a git repo, every launch (except Plan mode) runs in its own [git worktree](https://git-scm.com/docs/git-worktree) under `~/.wisp/worktrees/`, on a branch named `wisp/<task>-<id>` made from the remote's default branch (`origin/main`, fetched first; `HEAD` when there is no remote). Uncommitted changes in your own checkout are not carried over, and dependencies aren't shared: the agent is told to install what it needs. The card shows the branch, and **Take over** opens the worktree.
|
|
@@ -305,7 +318,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
|
|
|
305
318
|
Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
|
|
306
319
|
```
|
|
307
320
|
|
|
308
|
-
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.
|
|
321
|
+
Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.14.0` fills the fields in order, `wisp run release version=0.14.0` by name.
|
|
309
322
|
|
|
310
323
|
**Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
|
|
311
324
|
|
|
@@ -327,6 +340,7 @@ Settings → *Phone* sends questions and finished tasks to your phone and takes
|
|
|
327
340
|
|
|
328
341
|
- **[ntfy](https://ntfy.sh)**: subscribe to the topic in the ntfy app. Notifications carry up to three buttons (the question's options, or *Mark done*); a tap sends a signed answer back. Use a long random topic (wisp suggests one): anyone who knows it can read the notifications, but only wisp can make answers that count.
|
|
329
342
|
- **Telegram**: create a bot with @BotFather, paste its token, then send `/link <code>` to the bot. Tap a button to answer, or reply to a message to answer in your own words or send a follow-up.
|
|
343
|
+
Switch on **Start tasks from Telegram** to launch from the chat: `/new <task>` runs in the default project (the last used, or the one you pick), `/new @project <task>` picks one by name and `/projects` lists them. Tasks use the launcher's harness, model and mode. It's off by default, and only the linked chat can start tasks.
|
|
330
344
|
|
|
331
345
|
They receive titles, summaries and questions (with their context), never code or diffs.
|
|
332
346
|
|
|
@@ -370,6 +384,14 @@ wisp sends anonymous usage stats and error reports to [PostHog](https://posthog.
|
|
|
370
384
|
|
|
371
385
|
It is also off whenever `CI` is set.
|
|
372
386
|
|
|
387
|
+
### Feedback
|
|
388
|
+
|
|
389
|
+
Rate any result with 👍 / 👎 at the end of the turn (👎 asks for an optional line on what went wrong), type `/feedback <your note>` in any prompt box, or use `⌘K` → *Send feedback*. It goes to the wisp team on PostHog as a `turn_rated` or `feedback` event.
|
|
390
|
+
|
|
391
|
+
**What is sent:** your rating and note (with your home directory replaced by `~`), the wisp version and OS. For a rated turn it also sends the run's harness, model, mode, status, duration and presentation block types. Your prompt, the agent's output and your code are **never sent**.
|
|
392
|
+
|
|
393
|
+
You send feedback on purpose, so it goes out even with usage stats off, under a one-off id. Offline, it waits in `~/.wisp/feedback-outbox.json` and is sent later. `WISP_FEEDBACK=0` turns it off and hides the thumbs.
|
|
394
|
+
|
|
373
395
|
## Contributing
|
|
374
396
|
|
|
375
397
|
Issues and PRs are welcome. wisp has no build step: edit `web/` or `server/`, then run `npm run dev` (or `npm run dev:web`) to see your changes.
|
package/bin/wisp.js
CHANGED
|
@@ -24,7 +24,7 @@ const HELP = `usage:
|
|
|
24
24
|
wisp ls list sessions (--state running,waiting --limit N)
|
|
25
25
|
wisp show <id> state + latest presentation as markdown
|
|
26
26
|
wisp wait <id> block until it finishes or asks you something
|
|
27
|
-
wisp reply <id> "text" follow up (answers a pending question) (--queue)
|
|
27
|
+
wisp reply <id> "text" follow up (answers a pending question) (--queue --mode plan|edits|auto)
|
|
28
28
|
wisp stop <id> stop a run
|
|
29
29
|
wisp ship <id> commit, push and open a PR for a worktree session (--draft)
|
|
30
30
|
wisp merge <id> squash-merge its PR and sync your local base branch
|
|
@@ -132,7 +132,7 @@ async function scripted(cmd, args) {
|
|
|
132
132
|
}
|
|
133
133
|
case 'reply': {
|
|
134
134
|
const id = need(args[0], 'session id');
|
|
135
|
-
const r = await wisp.reply(id, need(args.slice(1).join(' ').trim(), 'text'), { queue: flags.queue });
|
|
135
|
+
const r = await wisp.reply(id, need(args.slice(1).join(' ').trim(), 'text'), { queue: flags.queue, mode: flags.mode || undefined });
|
|
136
136
|
if (!flags.wait) return out(r, r.answered ? ' ✓ answered' : r.interrupted ? ' ✓ interrupted; resuming with your message' : r.queued ? ' ✓ queued' : ' ✓ sent');
|
|
137
137
|
return show(id, await wisp.wait(id));
|
|
138
138
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wisp-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Launch coding agents, skip the transcript, read the presentation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"app": "node bin/wisp.js",
|
|
21
21
|
"dev": "WISP_DEV=1 node bin/wisp.js",
|
|
22
22
|
"dev:web": "node --watch-path=server bin/wisp.js --no-open",
|
|
23
|
-
"test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js",
|
|
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",
|
|
24
24
|
"test:real": "node test/real-clis.mjs",
|
|
25
25
|
"prepublishOnly": "node --check bin/wisp.js && node --check bin/mac-app.js && node -e \"import('./server/index.js').then(() => import('./server/mcp-wisp.js'))\" && node --check electron/main.js"
|
|
26
26
|
},
|
|
@@ -65,8 +65,17 @@ export default {
|
|
|
65
65
|
child.stdin.end(prompt);
|
|
66
66
|
|
|
67
67
|
let sawResult = false;
|
|
68
|
+
let early = null; // an empty result that came before our prompt was answered (see below)
|
|
68
69
|
let stderr = '';
|
|
69
70
|
const pendingTools = new Map(); // tool_use_id -> name, to flag failed tool results
|
|
71
|
+
// Background tasks (run_in_background shells, background subagents) still running. While any
|
|
72
|
+
// are, the CLI stays up after its result and starts another turn when they finish.
|
|
73
|
+
let background = new Map(); // task_id -> description
|
|
74
|
+
const setBackground = (next) => {
|
|
75
|
+
const was = [...background.keys()].join(' ');
|
|
76
|
+
background = next;
|
|
77
|
+
if ([...next.keys()].join(' ') !== was) onEvent({ type: 'background', tasks: [...next.values()] });
|
|
78
|
+
};
|
|
70
79
|
|
|
71
80
|
child.stderr.on('data', (d) => {
|
|
72
81
|
stderr = (stderr + d).slice(-8000);
|
|
@@ -102,20 +111,30 @@ export default {
|
|
|
102
111
|
onEvent({ type: 'tool_error', name, text: flatten(part.content).slice(0, 400) });
|
|
103
112
|
}
|
|
104
113
|
}
|
|
114
|
+
} else if (msg.type === 'system') {
|
|
115
|
+
// background_tasks_changed carries the full list; task_started/task_notification are the
|
|
116
|
+
// per-task edges, kept as a fallback in case a CLI version only sends those.
|
|
117
|
+
if (msg.subtype === 'background_tasks_changed' && Array.isArray(msg.tasks)) {
|
|
118
|
+
setBackground(new Map(msg.tasks.map((t) => [t.task_id, t.description || 'Background task'])));
|
|
119
|
+
} else if (msg.subtype === 'task_started' && msg.is_backgrounded && msg.task_id) {
|
|
120
|
+
setBackground(new Map(background).set(msg.task_id, msg.description || 'Background task'));
|
|
121
|
+
} else if (msg.subtype === 'task_notification' && background.has(msg.task_id)) {
|
|
122
|
+
const next = new Map(background);
|
|
123
|
+
next.delete(msg.task_id);
|
|
124
|
+
setBackground(next);
|
|
125
|
+
}
|
|
105
126
|
} else if (msg.type === 'result') {
|
|
127
|
+
const ev = resultEvent(msg);
|
|
128
|
+
// On --resume, background tasks the last run left behind come back as a task
|
|
129
|
+
// notification queued ahead of our prompt. The CLI answers it with an empty,
|
|
130
|
+
// zero-turn result, then goes on to the prompt and reports a second result. Ending the
|
|
131
|
+
// turn on the first one would drop the real answer, so hold it in case it's all we get.
|
|
132
|
+
if (!ev.isError && !ev.text.trim() && !msg.num_turns) {
|
|
133
|
+
early = ev;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
106
136
|
sawResult = true;
|
|
107
|
-
onEvent({
|
|
108
|
-
type: 'result',
|
|
109
|
-
text: msg.result ?? '',
|
|
110
|
-
isError: !!msg.is_error || (msg.subtype && msg.subtype !== 'success'),
|
|
111
|
-
stats: {
|
|
112
|
-
costUsd: msg.total_cost_usd,
|
|
113
|
-
durationMs: msg.duration_ms,
|
|
114
|
-
turns: msg.num_turns,
|
|
115
|
-
inputTokens: msg.usage?.input_tokens,
|
|
116
|
-
outputTokens: msg.usage?.output_tokens,
|
|
117
|
-
},
|
|
118
|
-
});
|
|
137
|
+
onEvent({ ...ev, background: background.size }); // > 0: another turn and result follow when they finish
|
|
119
138
|
}
|
|
120
139
|
});
|
|
121
140
|
|
|
@@ -127,6 +146,10 @@ export default {
|
|
|
127
146
|
resolve();
|
|
128
147
|
});
|
|
129
148
|
child.on('close', (code, signal) => {
|
|
149
|
+
if (!sawResult && early && !stopping) {
|
|
150
|
+
sawResult = true;
|
|
151
|
+
onEvent(early);
|
|
152
|
+
}
|
|
130
153
|
if (!sawResult) {
|
|
131
154
|
onEvent({
|
|
132
155
|
type: 'result',
|
|
@@ -152,6 +175,21 @@ export default {
|
|
|
152
175
|
|
|
153
176
|
|
|
154
177
|
|
|
178
|
+
function resultEvent(msg) {
|
|
179
|
+
return {
|
|
180
|
+
type: 'result',
|
|
181
|
+
text: msg.result ?? '',
|
|
182
|
+
isError: !!msg.is_error || (msg.subtype && msg.subtype !== 'success'),
|
|
183
|
+
stats: {
|
|
184
|
+
costUsd: msg.total_cost_usd,
|
|
185
|
+
durationMs: msg.duration_ms,
|
|
186
|
+
turns: msg.num_turns,
|
|
187
|
+
inputTokens: msg.usage?.input_tokens,
|
|
188
|
+
outputTokens: msg.usage?.output_tokens,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
155
193
|
// Human sentence for the live ticker + a coarse kind for the trail summary.
|
|
156
194
|
function describeTool(name, input, cwd) {
|
|
157
195
|
const f = rel(input.file_path || input.notebook_path || input.path, cwd);
|
package/server/client.js
CHANGED
|
@@ -134,7 +134,7 @@ export class WispClient {
|
|
|
134
134
|
async launch({ prompt, cwd = process.cwd(), agent, mode, model, effort, attachments, worktree, compare, subagents, preset } = {}) {
|
|
135
135
|
if (!agent || !mode || model === undefined) {
|
|
136
136
|
const { defaults = {} } = await this.config();
|
|
137
|
-
agent ||= defaults.agent || '
|
|
137
|
+
agent ||= defaults.agent || 'auto';
|
|
138
138
|
mode ||= defaults.mode || 'edits';
|
|
139
139
|
model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
|
|
140
140
|
}
|
|
@@ -155,7 +155,7 @@ export class WispClient {
|
|
|
155
155
|
|
|
156
156
|
// A follow-up. If the agent is blocked on ask_user, this answers it. queue: wait for the
|
|
157
157
|
// current run instead of interrupting it (default: the user's setting).
|
|
158
|
-
reply = (id, text, { attachments, queue } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue });
|
|
158
|
+
reply = (id, text, { attachments, queue, mode } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue, mode });
|
|
159
159
|
|
|
160
160
|
// Upload a file (path, Buffer or Uint8Array) to attach to a launch or reply. Returns { id, … }.
|
|
161
161
|
async upload(file, { name, type } = {}) {
|
package/server/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { Runner, summarize, newId, workdir } from './runner.js';
|
|
|
11
11
|
import { adapters, listAgents } from './adapters/index.js';
|
|
12
12
|
import { welcomeSession } from './welcome.js';
|
|
13
13
|
import { openInTerminal, TERMINALS, shq } from './terminal.js';
|
|
14
|
-
import { Telemetry, envDisabled } from './telemetry.js';
|
|
14
|
+
import { Telemetry, envDisabled, feedbackAvailable } from './telemetry.js';
|
|
15
15
|
import { blocksToMarkdown, sessionToMarkdown } from './render.js';
|
|
16
16
|
import { openapi } from './openapi.js';
|
|
17
17
|
import { Updater } from './update.js';
|
|
@@ -25,6 +25,7 @@ import { Remote } from './remote.js';
|
|
|
25
25
|
import { Machines, splitId, qualify, sshHosts, discoverHosts } from './machines.js';
|
|
26
26
|
import { usage } from './usage.js';
|
|
27
27
|
import { normalizeTeam, normalizePreset, delegationReport } from './subagents.js';
|
|
28
|
+
import { Router, GOALS } from './router.js';
|
|
28
29
|
|
|
29
30
|
const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
|
|
30
31
|
|
|
@@ -99,6 +100,8 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
99
100
|
onChange: () => broadcast('machines', machines.view()),
|
|
100
101
|
onEvent: (event, data) => {
|
|
101
102
|
broadcast(event, data);
|
|
103
|
+
if ((event === 'finished' || event === 'summary') && data?.session) routeSignals(data.session);
|
|
104
|
+
else if (event === 'summary' && data?.id) routeSignals(data);
|
|
102
105
|
if (event === 'finished' && remote.on) notifyFromMachine(data.session).catch((err) => console.warn(`wisp: phone: ${err.message}`));
|
|
103
106
|
},
|
|
104
107
|
});
|
|
@@ -135,13 +138,55 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
135
138
|
broadcast('preferences', { added: added.length, removed: removed.length });
|
|
136
139
|
},
|
|
137
140
|
});
|
|
141
|
+
// Auto: wisp picks harness and model per task and learns from outcomes (server/router.js).
|
|
142
|
+
const router = new Router(store.home);
|
|
143
|
+
let agentsHere = [];
|
|
144
|
+
const detect = async (opts) => {
|
|
145
|
+
const agents = await listAgents(opts);
|
|
146
|
+
agentsHere = agents;
|
|
147
|
+
const added = router.sync(agents);
|
|
148
|
+
if (added.length) broadcast('router', { added });
|
|
149
|
+
return agents;
|
|
150
|
+
};
|
|
151
|
+
const autoOpts = () => {
|
|
152
|
+
const r = store.config.defaults.router || {};
|
|
153
|
+
return { explore: r.explore !== 'off', allowed: Array.isArray(r.allowed) && r.allowed.length ? r.allowed : null };
|
|
154
|
+
};
|
|
155
|
+
const goalOf = (model) => (GOALS[model] ? model : GOALS[store.config.defaults.models?.auto] ? store.config.defaults.models.auto : 'balanced');
|
|
156
|
+
// Subagents set to Auto are routed on their first task, from this computer's harnesses.
|
|
157
|
+
runner.routeAuto = (prompt) => router.choose({ prompt, agents: agentsHere, goal: goalOf(), ...autoOpts() });
|
|
158
|
+
// A routed session's outcome so far, from its summary: works the same for sessions on machines.
|
|
159
|
+
// Follow-ups after the first turn count as corrections; a merged or closed PR settles it.
|
|
160
|
+
const routeSignals = (sum) => {
|
|
161
|
+
if (!sum?.id || !router.route(sum.id) || sum.status === 'running' || sum.state === 'running') return;
|
|
162
|
+
const pr = sum.pr?.state;
|
|
163
|
+
router.signal(sum.id, {
|
|
164
|
+
hero: ['done', 'partial', 'blocked', 'info'].includes(sum.status) ? sum.status : undefined,
|
|
165
|
+
followups: Math.max(0, (sum.turnCount || 1) - 1),
|
|
166
|
+
merged: pr === 'MERGED' || undefined,
|
|
167
|
+
closed: pr === 'CLOSED' || undefined,
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
runner.on('started', ({ session }) => {
|
|
171
|
+
if (session.route && !router.route(session.id)) router.record(session.id, session.route);
|
|
172
|
+
});
|
|
173
|
+
runner.on('ended', ({ session, turn, interrupted }) => {
|
|
174
|
+
if (!session.route || interrupted) return;
|
|
175
|
+
if (session.turns.indexOf(turn) === 0) router.signal(session.id, { error: turn.status === 'error' || undefined, costUsd: turn.stats?.costUsd, ms: turn.endedAt - turn.startedAt });
|
|
176
|
+
routeSignals(summarize(session, store));
|
|
177
|
+
});
|
|
178
|
+
|
|
138
179
|
const run = (s, t) => ({ agent: s.agent, model: s.model || 'default', mode: s.mode, effort: s.effort || 'default', followUp: s.turns.indexOf(t) > 0 });
|
|
180
|
+
// Auto's choice, as fixed values only: never the prompt.
|
|
181
|
+
const routeProps = (s) =>
|
|
182
|
+
s.route ? { routed: true, routeGoal: s.route.goal, routeType: s.route.type, routeSize: s.route.size, routeExplored: !!s.route.explored, routeTrying: !!s.route.trying, routePropensity: s.route.propensity } : {};
|
|
139
183
|
runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...run(session, turn), attachments: turn.attachments.length }));
|
|
140
184
|
runner.on('ended', ({ session, turn, interrupted, errorText }) => {
|
|
141
185
|
const blocks = turn.presentation?.blocks || [];
|
|
142
186
|
const st = turn.stats || {};
|
|
143
187
|
telemetry.track('turn_ended', {
|
|
144
188
|
...run(session, turn),
|
|
189
|
+
...routeProps(session),
|
|
145
190
|
status: interrupted ? 'interrupted' : turn.status, // done | error | stopped | interrupted
|
|
146
191
|
heroStatus: blocks.find((b) => b.type === 'hero')?.status,
|
|
147
192
|
durationMs: turn.endedAt - turn.startedAt,
|
|
@@ -165,11 +210,16 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
165
210
|
const cwd = expand(body.cwd || process.cwd());
|
|
166
211
|
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
|
|
167
212
|
const d = store.config.defaults;
|
|
168
|
-
|
|
169
|
-
|
|
213
|
+
const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
|
|
214
|
+
if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
|
|
215
|
+
const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
|
|
216
|
+
// compare: more { agent, model, effort } to run the same task on, side by side. 'auto' is
|
|
217
|
+
// resolved here: the router picks a harness and model (a different one per compare run).
|
|
218
|
+
const resolved = await resolveAuto(body, agentsHere.length ? agentsHere : await detect(), { team: subagents.length > 0 });
|
|
219
|
+
const runners = [{ agent: resolved.agent, model: resolved.model, effort: resolved.effort, route: resolved.route }, ...(resolved.compare || [])].slice(0, MAX_COMPARE).map((r, i) => {
|
|
170
220
|
const agent = r.agent || (i ? d.agent : '') || 'claude';
|
|
171
221
|
if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
|
|
172
|
-
return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '' };
|
|
222
|
+
return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '', ...(r.route && { route: r.route }) };
|
|
173
223
|
});
|
|
174
224
|
const mode = MODES.has(body.mode) ? body.mode : 'edits';
|
|
175
225
|
// Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
|
|
@@ -179,9 +229,6 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
179
229
|
// groupId: one launch sent to several machines (or here and there) shows as one task.
|
|
180
230
|
const groupId = /^[a-f0-9]{12}$/.test(body.groupId || '') ? body.groupId : comparing ? newId() : null;
|
|
181
231
|
const attachments = resolveAttachments(body.attachments);
|
|
182
|
-
const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
|
|
183
|
-
if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
|
|
184
|
-
const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
|
|
185
232
|
const ids = runners.map(() => newId());
|
|
186
233
|
let trees = ids.map(() => null);
|
|
187
234
|
if (wantTree) {
|
|
@@ -204,6 +251,22 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
204
251
|
return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
|
|
205
252
|
}
|
|
206
253
|
|
|
254
|
+
// agent 'auto' (and compare entries set to it) → a harness and model picked by the router from
|
|
255
|
+
// `agents`: this computer's, or the machine's the launch goes to. Model is the goal for Auto.
|
|
256
|
+
async function resolveAuto(body, agents, { team = false } = {}) {
|
|
257
|
+
const want = [{ agent: body.agent, model: body.model, effort: body.effort }, ...(Array.isArray(body.compare) ? body.compare : [])];
|
|
258
|
+
if (!want.some((r) => r.agent === 'auto')) return body;
|
|
259
|
+
const taken = [];
|
|
260
|
+
const out = want.map((r, i) => {
|
|
261
|
+
if (r.agent !== 'auto') return r;
|
|
262
|
+
const pick = router.choose({ prompt: body.prompt, attachments: (body.attachments || []).length, agents, goal: goalOf(r.model), important: !!body.important, exclude: taken, needDelegate: i === 0 && team, ...autoOpts() });
|
|
263
|
+
if (!pick) throw bad('Auto found no installed harness to run this on.');
|
|
264
|
+
taken.push(pick.key);
|
|
265
|
+
return { agent: pick.agent, model: pick.model, effort: '', route: pick.route };
|
|
266
|
+
});
|
|
267
|
+
return { ...body, ...out[0], compare: out.slice(1) };
|
|
268
|
+
}
|
|
269
|
+
|
|
207
270
|
// Where a launch runs: body.machine, else a project folder that only exists on a machine
|
|
208
271
|
// (recipes and schedules keep just the path). null = here.
|
|
209
272
|
function machineFor(body) {
|
|
@@ -217,7 +280,15 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
217
280
|
async function launchAny(body, origin = null) {
|
|
218
281
|
const mid = machineFor(body);
|
|
219
282
|
if (!mid) return (await launch(body, origin)).map((x) => summarize(x, store));
|
|
220
|
-
|
|
283
|
+
// Auto for a machine: routed here, with the harnesses that machine has; its outcomes come
|
|
284
|
+
// back through the summaries it streams (routeSignals), so one router learns from every place.
|
|
285
|
+
const routed = await resolveAuto(body, machines.agentsOf(mid) || [], { team: !!(body.subagents?.length || body.preset) });
|
|
286
|
+
const routes = [routed.route, ...(routed.compare || []).map((c) => c.route)];
|
|
287
|
+
const sendBody = { ...routed, compare: routed.compare?.map(({ route, ...c }) => c) };
|
|
288
|
+
delete sendBody.route;
|
|
289
|
+
if (!sendBody.compare?.length && !body.compare) delete sendBody.compare;
|
|
290
|
+
const out = await machines.launch(mid, { ...sendBody, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
|
|
291
|
+
(out.group || [out]).forEach((x, i) => routes[i] && router.record(qualify(x.id, mid), routes[i]));
|
|
221
292
|
const known = store.projectFor(out.cwd, mid);
|
|
222
293
|
(known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
|
|
223
294
|
store.saveConfig();
|
|
@@ -226,9 +297,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
226
297
|
}
|
|
227
298
|
|
|
228
299
|
// A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
|
|
229
|
-
async function replyTo(s, text, attachments = [], queue) {
|
|
300
|
+
async function replyTo(s, text, attachments = [], queue, mode) {
|
|
230
301
|
await ensureWorktree(s.worktree);
|
|
231
|
-
return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue' });
|
|
302
|
+
return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue', mode });
|
|
232
303
|
}
|
|
233
304
|
|
|
234
305
|
const markDone = (s) => {
|
|
@@ -257,6 +328,15 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
257
328
|
const remote = new Remote({
|
|
258
329
|
store,
|
|
259
330
|
onChange: () => broadcast('remote', {}),
|
|
331
|
+
// /new from Telegram: the launcher's remembered harness, model and mode, like `wisp "…"`.
|
|
332
|
+
launch: async ({ project, prompt }) => {
|
|
333
|
+
const d = store.config.defaults;
|
|
334
|
+
const agent = d.agent || 'claude';
|
|
335
|
+
const model = d.models?.[agent] ?? d.model ?? '';
|
|
336
|
+
const [s] = await launchAny({ prompt, cwd: project.path, machine: project.machine || undefined, agent, model, mode: d.mode || 'edits' }, { kind: 'telegram', name: 'Telegram' });
|
|
337
|
+
broadcast('config', {});
|
|
338
|
+
return s;
|
|
339
|
+
},
|
|
260
340
|
act: async ({ sessionId, kind, ref, index, text }) => {
|
|
261
341
|
const ops = await sessionOps(sessionId).catch((err) => ({ error: err.message }));
|
|
262
342
|
if (ops?.error) return `Can’t reach it: ${ops.error}`;
|
|
@@ -345,6 +425,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
345
425
|
const pr = await prView(dir, prev.number);
|
|
346
426
|
const changed = (k) => JSON.stringify(k(pr)) !== JSON.stringify(k(prev));
|
|
347
427
|
s.pr = pr;
|
|
428
|
+
if (pr.state !== prev.state) routeSignals(summarize(s, store));
|
|
348
429
|
// Things worth coming back for: CI finished either way, a review landed, it merged or closed.
|
|
349
430
|
const settledChecks = (x) => (x.checks.pending ? 'pending' : x.checks.fail ? 'fail' : 'pass');
|
|
350
431
|
if (changed((x) => x.state) || changed((x) => x.review) || (changed(settledChecks) && settledChecks(pr) !== 'pending')) s.unread = true;
|
|
@@ -429,13 +510,39 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
429
510
|
}
|
|
430
511
|
if (root === 'openapi.json' && m === 'GET') return send(res, 200, openapi({ version: VERSION, port }));
|
|
431
512
|
|
|
432
|
-
if (root === 'agents' && m === 'GET') return send(res, 200, await
|
|
513
|
+
if (root === 'agents' && m === 'GET') return send(res, 200, await detect({ fresh: url.searchParams.has('fresh') }));
|
|
433
514
|
// Re-check: detect harnesses and models again, here and on every online machine.
|
|
434
515
|
if (root === 'agents' && id === 'recheck' && m === 'POST') {
|
|
435
|
-
const [agents] = await Promise.all([
|
|
516
|
+
const [agents] = await Promise.all([detect({ fresh: true }), machines.recheckAgents()]);
|
|
436
517
|
return send(res, 200, agents);
|
|
437
518
|
}
|
|
438
519
|
|
|
520
|
+
// Auto: what the router learned, and its settings (goal, trying new models, allowed harnesses).
|
|
521
|
+
if (root === 'router') {
|
|
522
|
+
const view = async () => ({ goals: GOALS, goal: goalOf(), settings: { explore: autoOpts().explore, allowed: autoOpts().allowed }, ...router.report(agentsHere.length ? agentsHere : await detect()) });
|
|
523
|
+
if (!id && m === 'GET') return send(res, 200, await view());
|
|
524
|
+
if (!id && m === 'PATCH') {
|
|
525
|
+
const body = await readJson(req);
|
|
526
|
+
const d = store.config.defaults;
|
|
527
|
+
if (body.goal !== undefined) {
|
|
528
|
+
if (!GOALS[body.goal]) return send(res, 400, { error: `goal must be one of ${Object.keys(GOALS).join(', ')}` });
|
|
529
|
+
d.models = { ...d.models, auto: body.goal };
|
|
530
|
+
if (d.agent === 'auto') d.model = body.goal;
|
|
531
|
+
}
|
|
532
|
+
d.router = { ...d.router };
|
|
533
|
+
if (body.explore !== undefined) d.router.explore = body.explore ? 'on' : 'off';
|
|
534
|
+
if (body.allowed !== undefined) d.router.allowed = Array.isArray(body.allowed) ? body.allowed.filter((a) => adapters[a]) : null;
|
|
535
|
+
store.saveConfig();
|
|
536
|
+
broadcast('config', {});
|
|
537
|
+
return send(res, 200, await view());
|
|
538
|
+
}
|
|
539
|
+
if (id === 'reset' && m === 'POST') {
|
|
540
|
+
router.reset();
|
|
541
|
+
broadcast('router', {});
|
|
542
|
+
return send(res, 200, await view());
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
439
546
|
// Bridge for the ask_user MCP server running inside an agent process.
|
|
440
547
|
if (root === 'internal') {
|
|
441
548
|
if (req.headers['x-wisp-token'] !== token) return send(res, 403, { error: 'bad token' });
|
|
@@ -507,7 +614,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
507
614
|
}
|
|
508
615
|
|
|
509
616
|
if (root === 'config') {
|
|
510
|
-
if (m === 'GET') return send(res, 200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled } });
|
|
617
|
+
if (m === 'GET') return send(res, 200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() });
|
|
511
618
|
if (m === 'PATCH') {
|
|
512
619
|
const body = await readJson(req);
|
|
513
620
|
if (body.defaults) Object.assign(store.config.defaults, body.defaults);
|
|
@@ -722,6 +829,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
722
829
|
|
|
723
830
|
if (root === 'machines') return machinesApi(req, res, url, id, action);
|
|
724
831
|
|
|
832
|
+
// Feedback for the wisp team: thumbs on a turn and/or a note. { rating?, text?, sessionId?, turnId? }
|
|
833
|
+
if (root === 'feedback' && m === 'POST') return send(res, 200, await sendFeedback(await readJson(req)));
|
|
834
|
+
|
|
725
835
|
if (root === 'sessions') {
|
|
726
836
|
if (id && splitId(id)) return machineSessionApi(req, res, url, splitId(id), action);
|
|
727
837
|
if (!id) {
|
|
@@ -742,9 +852,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
742
852
|
}
|
|
743
853
|
if (m === 'POST') {
|
|
744
854
|
const body = await readJson(req);
|
|
745
|
-
// A machine's wisp hands on where a recipe or
|
|
855
|
+
// A machine's wisp hands on where a recipe, trigger or Telegram launch came from.
|
|
746
856
|
const o = body.origin;
|
|
747
|
-
const origin = o &&
|
|
857
|
+
const origin = o && ['recipe', 'trigger', 'telegram'].includes(o.kind) ? { kind: o.kind, id: String(o.id || ''), name: String(o.name || '') } : null;
|
|
748
858
|
const out = await launchAny(body, origin);
|
|
749
859
|
return send(res, 201, out.length > 1 ? { ...out[0], group: out } : out[0]);
|
|
750
860
|
}
|
|
@@ -836,10 +946,11 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
836
946
|
}
|
|
837
947
|
|
|
838
948
|
if (action === 'reply' && m === 'POST') {
|
|
839
|
-
const { text, attachments, queue } = await readJson(req);
|
|
949
|
+
const { text, attachments, queue, mode } = await readJson(req);
|
|
840
950
|
if (!text?.trim()) return send(res, 400, { error: 'text required' });
|
|
841
951
|
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to follow up.' });
|
|
842
|
-
return send(res,
|
|
952
|
+
if (mode !== undefined && !MODES.has(mode)) return send(res, 400, { error: 'mode must be plan, edits or auto.' });
|
|
953
|
+
return send(res, 200, await replyTo(s, text.trim(), resolveAttachments(attachments), queue, mode));
|
|
843
954
|
}
|
|
844
955
|
|
|
845
956
|
if (action === 'queue' && m === 'DELETE') {
|
|
@@ -887,6 +998,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
887
998
|
broadcast('summary', summarize(s, store));
|
|
888
999
|
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
889
1000
|
telemetry.track('rollback_turn', { agent: s.agent, restore: !!restore });
|
|
1001
|
+
router.signal(s.id, { rolledBack: restore ? null : true });
|
|
890
1002
|
recheckShip(s);
|
|
891
1003
|
return send(res, 200, { ok: true, turnId: turn.id, ...result });
|
|
892
1004
|
}
|
|
@@ -1030,6 +1142,49 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
1030
1142
|
}
|
|
1031
1143
|
|
|
1032
1144
|
// /api/sessions/<id>@<machine>/…: passed through to that machine's wisp, ids rewritten.
|
|
1145
|
+
// A rating sticks to its turn (it shows on the card and can be taken back). What goes out is
|
|
1146
|
+
// the rating, the user's own words, and how the run was set up — never the prompt or the result.
|
|
1147
|
+
async function sendFeedback({ rating, text, sessionId, turnId, source } = {}) {
|
|
1148
|
+
if (rating !== undefined && rating !== null && rating !== 'up' && rating !== 'down') throw Object.assign(new Error('rating must be up or down'), { status: 400 });
|
|
1149
|
+
text = String(text || '').trim();
|
|
1150
|
+
if (rating === undefined && !text) throw Object.assign(new Error('Write something, or pick 👍 / 👎.'), { status: 400 });
|
|
1151
|
+
// A session on a machine: that wisp keeps the rating on its turn and sends it.
|
|
1152
|
+
const at = sessionId && splitId(sessionId);
|
|
1153
|
+
if (sessionId && turnId && rating !== undefined) router.signal(sessionId, { feedback: rating });
|
|
1154
|
+
if (at) {
|
|
1155
|
+
try {
|
|
1156
|
+
return await machines.json(at.machine, 'POST', '/feedback', { rating, text, sessionId: at.id, turnId, source });
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
if (err.status === 400) throw err; // offline, or an older wisp there: send it from here, without the run
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const s = sessionId && !at ? store.get(sessionId) : null;
|
|
1162
|
+
if (sessionId && !at && !s) throw Object.assign(new Error('no such session'), { status: 404 });
|
|
1163
|
+
const turn = s && (turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1));
|
|
1164
|
+
if (turnId && s && !turn) throw Object.assign(new Error('no such turn'), { status: 404 });
|
|
1165
|
+
// Only thumbs on a turn stick to it; a rating in the feedback dialog is about wisp as a whole.
|
|
1166
|
+
if (turn && turnId && rating !== undefined) {
|
|
1167
|
+
turn.feedback = rating ? { rating, at: Date.now() } : undefined;
|
|
1168
|
+
store.save(s);
|
|
1169
|
+
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
1170
|
+
}
|
|
1171
|
+
const blocks = turn?.presentation?.blocks || [];
|
|
1172
|
+
const context = turn && {
|
|
1173
|
+
...run(s, turn),
|
|
1174
|
+
status: turn.status,
|
|
1175
|
+
heroStatus: blocks.find((b) => b.type === 'hero')?.status,
|
|
1176
|
+
blockTypes: [...new Set(blocks.map((b) => b.type))],
|
|
1177
|
+
durationMs: turn.endedAt ? turn.endedAt - turn.startedAt : undefined,
|
|
1178
|
+
tour: s.agent === 'wisp' || undefined,
|
|
1179
|
+
};
|
|
1180
|
+
const props = { ...context, rating: rating || undefined, source: String(source || (turn ? 'turn' : 'app')).slice(0, 20), machine: at ? true : undefined };
|
|
1181
|
+
// Thumbs on a turn count as a rating; anything written, or sent from the dialog, is 'feedback'.
|
|
1182
|
+
const event = text || !turnId ? 'feedback' : 'turn_rated';
|
|
1183
|
+
if (event === 'turn_rated' && rating === null) props.cleared = true;
|
|
1184
|
+
const sent = await telemetry.feedback(event, { ...props, text: text || undefined });
|
|
1185
|
+
return { ok: true, sent, available: feedbackAvailable() };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1033
1188
|
async function machineSessionApi(req, res, url, at, action) {
|
|
1034
1189
|
const m = req.method;
|
|
1035
1190
|
const mid = at.machine;
|
|
@@ -1098,6 +1253,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
1098
1253
|
remote.start();
|
|
1099
1254
|
machines.start();
|
|
1100
1255
|
|
|
1256
|
+
detect().catch(() => {});
|
|
1101
1257
|
if (telemetry.enabled) {
|
|
1102
1258
|
if (store.config.defaults.telemetryNotice !== 'seen') console.log('wisp sends anonymous usage stats and error reports (no prompts, paths or code). Opt out: Settings, or WISP_TELEMETRY=0 / DO_NOT_TRACK=1.');
|
|
1103
1259
|
listAgents()
|