wisp-agents 0.13.0 → 0.15.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 CHANGED
@@ -5,6 +5,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.15.0] - 2026-10-18
9
+
10
+ ### Changed
11
+
12
+ - **Calmer turn bar.** The bar under each turn now fits on one line: the read / edited / ran / searched counts open the activity log themselves (no separate **Activity** link), and **Restore to before this turn** and **Agent notes** are icon buttons next to 👍 / 👎 that stay faint until you hover the turn. After a restore, **Re-apply** shows as text so the rolled-back state stays visible.
13
+
14
+ ### Fixed
15
+
16
+ - Rating a turn with 👍 / 👎 no longer re-renders the whole card and replays its entrance animation. Every refetch re-inserted the turn nodes, which restarts their CSS animations; unchanged turns are now left in place.
17
+
18
+ ## [0.14.0] - 2026-10-18
19
+
20
+ ### Added
21
+
22
+ - **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`.
23
+
24
+ ### Fixed
25
+
26
+ - **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.
27
+
28
+ - **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.
29
+
8
30
  ## [0.13.0] - 2026-10-18
9
31
 
10
32
  ### Added
@@ -240,7 +262,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
240
262
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
241
263
  - Older turns are folded, with a floating collapse-all button and back-to-top.
242
264
 
243
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.13.0...HEAD
265
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.15.0...HEAD
266
+ [0.15.0]: https://github.com/M0xoo/wisp/compare/v0.14.0...v0.15.0
267
+ [0.14.0]: https://github.com/M0xoo/wisp/compare/v0.13.0...v0.14.0
244
268
  [0.13.0]: https://github.com/M0xoo/wisp/compare/v0.12.0...v0.13.0
245
269
  [0.12.0]: https://github.com/M0xoo/wisp/compare/v0.11.0...v0.12.0
246
270
  [0.11.0]: https://github.com/M0xoo/wisp/compare/v0.10.0...v0.11.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,7 +54,7 @@ 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.13.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
57
+ | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.15.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
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. |
@@ -125,7 +126,7 @@ wisp reply <id> "also do X" # follow up (answers a pending question; --mod
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.13.0 # run a recipe; values fill its {params} (or version=0.13.0)
129
+ wisp run release 0.15.0 # run a recipe; values fill its {params} (or version=0.15.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.
@@ -295,7 +308,7 @@ The launcher's **Subagents** button gives the lead agent a team: up to 6 members
295
308
 
296
309
  ## Checkpoints and rollback
297
310
 
298
- Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/checkpoints/<session>/<turn>`). **Restore to before this turn** (turn bar, session menu or ⌘K) rolls back; **Re-apply turn** undoes that. `diff` and `files` blocks have a per-file **Revert / Re-apply**, and `actions`, `terminal` and `tests` blocks run their command in the project folder with one click.
311
+ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/checkpoints/<session>/<turn>`). **Restore to before this turn** (the ⏪ icon in the turn bar, session menu or ⌘K) rolls back; **Re-apply** undoes that. `diff` and `files` blocks have a per-file **Revert / Re-apply**, and `actions`, `terminal` and `tests` blocks run their command in the project folder with one click.
299
312
 
300
313
  ## Automations
301
314
 
@@ -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.13.0` fills the fields in order, `wisp run release version=0.13.0` by name.
321
+ Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.15.0` fills the fields in order, `wisp run release version=0.15.0` by name.
309
322
 
310
323
  **Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
311
324
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Launch coding agents, skip the transcript, read the presentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "app": "node bin/wisp.js",
21
21
  "dev": "WISP_DEV=1 node bin/wisp.js",
22
22
  "dev:web": "node --watch-path=server bin/wisp.js --no-open",
23
- "test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js",
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 || 'claude';
137
+ agent ||= defaults.agent || 'auto';
138
138
  mode ||= defaults.mode || 'edits';
139
139
  model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
140
140
  }
package/server/index.js CHANGED
@@ -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
- // compare: more { agent, model, effort } to run the same task on, side by side.
169
- const runners = [{ agent: body.agent, model: body.model, effort: body.effort }, ...(Array.isArray(body.compare) ? body.compare : [])].slice(0, MAX_COMPARE).map((r, i) => {
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
- const out = await machines.launch(mid, { ...body, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
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();
@@ -354,6 +425,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
354
425
  const pr = await prView(dir, prev.number);
355
426
  const changed = (k) => JSON.stringify(k(pr)) !== JSON.stringify(k(prev));
356
427
  s.pr = pr;
428
+ if (pr.state !== prev.state) routeSignals(summarize(s, store));
357
429
  // Things worth coming back for: CI finished either way, a review landed, it merged or closed.
358
430
  const settledChecks = (x) => (x.checks.pending ? 'pending' : x.checks.fail ? 'fail' : 'pass');
359
431
  if (changed((x) => x.state) || changed((x) => x.review) || (changed(settledChecks) && settledChecks(pr) !== 'pending')) s.unread = true;
@@ -438,13 +510,39 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
438
510
  }
439
511
  if (root === 'openapi.json' && m === 'GET') return send(res, 200, openapi({ version: VERSION, port }));
440
512
 
441
- if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents({ fresh: url.searchParams.has('fresh') }));
513
+ if (root === 'agents' && m === 'GET') return send(res, 200, await detect({ fresh: url.searchParams.has('fresh') }));
442
514
  // Re-check: detect harnesses and models again, here and on every online machine.
443
515
  if (root === 'agents' && id === 'recheck' && m === 'POST') {
444
- const [agents] = await Promise.all([listAgents({ fresh: true }), machines.recheckAgents()]);
516
+ const [agents] = await Promise.all([detect({ fresh: true }), machines.recheckAgents()]);
445
517
  return send(res, 200, agents);
446
518
  }
447
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
+
448
546
  // Bridge for the ask_user MCP server running inside an agent process.
449
547
  if (root === 'internal') {
450
548
  if (req.headers['x-wisp-token'] !== token) return send(res, 403, { error: 'bad token' });
@@ -900,6 +998,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
900
998
  broadcast('summary', summarize(s, store));
901
999
  broadcast('turn', { sessionId: s.id, turnId: turn.id });
902
1000
  telemetry.track('rollback_turn', { agent: s.agent, restore: !!restore });
1001
+ router.signal(s.id, { rolledBack: restore ? null : true });
903
1002
  recheckShip(s);
904
1003
  return send(res, 200, { ok: true, turnId: turn.id, ...result });
905
1004
  }
@@ -1051,6 +1150,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
1051
1150
  if (rating === undefined && !text) throw Object.assign(new Error('Write something, or pick 👍 / 👎.'), { status: 400 });
1052
1151
  // A session on a machine: that wisp keeps the rating on its turn and sends it.
1053
1152
  const at = sessionId && splitId(sessionId);
1153
+ if (sessionId && turnId && rating !== undefined) router.signal(sessionId, { feedback: rating });
1054
1154
  if (at) {
1055
1155
  try {
1056
1156
  return await machines.json(at.machine, 'POST', '/feedback', { rating, text, sessionId: at.id, turnId, source });
@@ -1153,6 +1253,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
1153
1253
  remote.start();
1154
1254
  machines.start();
1155
1255
 
1256
+ detect().catch(() => {});
1156
1257
  if (telemetry.enabled) {
1157
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.');
1158
1259
  listAgents()
@@ -266,6 +266,11 @@ export class Machines extends EventEmitter {
266
266
  return m;
267
267
  }
268
268
 
269
+ // The harnesses the machine reported when it came online (Auto routes with these), or null.
270
+ agentsOf(id) {
271
+ return this.conns.get(id)?.remote?.agents || null;
272
+ }
273
+
269
274
  isOnline(id) {
270
275
  return this.conns.get(id)?.state === 'online';
271
276
  }
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import { WispClient } from './client.js';
10
10
 
11
11
  const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
- const AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'];
12
+ const AGENTS = ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'];
13
13
  const DEFAULT_WAIT_S = 240; // stay under typical MCP client tool-call timeouts; call wisp_wait again for more
14
14
 
15
15
  const TOOLS = [
@@ -24,7 +24,7 @@ const TOOLS = [
24
24
  properties: {
25
25
  prompt: { type: 'string', description: 'The task, written as you would brief a colleague: goal, constraints, how to verify.' },
26
26
  cwd: { type: 'string', description: 'Absolute path of the project folder. Default: this MCP server’s working directory.' },
27
- agent: { type: 'string', enum: AGENTS, description: 'Harness to run. Default: the user’s last pick in wisp.' },
27
+ agent: { type: 'string', enum: AGENTS, description: 'Harness to run; auto lets wisp pick harness and model from what worked before. Default: the user’s last pick in wisp.' },
28
28
  mode: { type: 'string', enum: ['plan', 'edits', 'auto'], description: 'plan = read-only, edits = auto-accept file edits, auto = no permission prompts. Default: the user’s pick.' },
29
29
  model: { type: 'string', description: 'Model id for that harness. Default: the user’s pick.' },
30
30
  effort: { type: 'string', description: 'Thinking level, e.g. low | medium | high.' },
package/server/openapi.js CHANGED
@@ -45,6 +45,11 @@ export function openapi({ version, port }) {
45
45
  },
46
46
  },
47
47
  '/agents': { get: { summary: 'Installed harnesses with their models and thinking levels (?fresh=1 detects again)', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
48
+ '/router': {
49
+ get: { summary: 'Auto: the goal, settings, every harness · model with its learned chance of success and cost, and recent routes', responses: { 200: json({ type: 'object' }) } },
50
+ patch: { summary: 'Auto settings', requestBody: body({ type: 'object', properties: { goal: { enum: ['cheap', 'balanced', 'best'] }, explore: { type: 'boolean', description: 'Let Auto try new models on a few tasks' }, allowed: { type: 'array', items: { type: 'string' }, description: 'Harness ids Auto may pick; empty or null = all' } } }), responses: { 200: json({ type: 'object' }) } },
51
+ },
52
+ '/router/reset': { post: { summary: 'Forget what Auto learned here (the shipped starting point stays)', responses: { 200: json({ type: 'object' }) } } },
48
53
  '/agents/recheck': { post: { summary: 'Detect harnesses and models again, here and on every online machine', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
49
54
  '/feedback': {
50
55
  post: {
@@ -264,7 +269,8 @@ export function openapi({ version, port }) {
264
269
  properties: {
265
270
  prompt: { type: 'string' },
266
271
  cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
267
- agent: { enum: ['claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'claude' },
272
+ agent: { enum: ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'auto', description: 'auto: wisp picks the harness and model from what worked before; model is then the goal (cheap, balanced, best)' },
273
+ important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
268
274
  mode: { enum: ['plan', 'edits', 'auto'], default: 'edits' },
269
275
  model: { type: 'string' },
270
276
  effort: { type: 'string', description: 'Thinking level: low, medium, high, xhigh, max (per harness)' },
@@ -14,6 +14,10 @@ presentation you put in your final message. Work autonomously:
14
14
  - If the task is done but there is a choice for later, end with a "question" block instead.
15
15
  - Verify your work where it is cheap (tests, type-check, lint, build) and report results honestly,
16
16
  including failures. Never claim something passes that you did not run.
17
+ - Your final message ends the task. Don't end a turn with "I'll report once it has finished": wait for
18
+ background work (builds, test runs, background shells) to finish, then report its result. Anything
19
+ still running when you finish may be stopped; if you leave something up on purpose (a dev server),
20
+ say so.
17
21
  - The user can follow up; follow-ups resume this same session.
18
22
  - Attached files (images, video, docs) are listed in an <attachments> section of the prompt.
19
23
 
@@ -0,0 +1,54 @@
1
+ {
2
+ "version": "pilot-2026-10-18",
3
+ "source": "wisp-bench pilot: 121 runs on SWE-bench Verified, Harness-Bench and replayed wisp PRs, graded by hidden tests (first attempt). Thin: treat as a starting point.",
4
+ "arms": {
5
+ "opencode:opencode/deepseek-v4.1-flash-free": {
6
+ "a": 10,
7
+ "b": 0,
8
+ "usd": 0.0,
9
+ "min": 3.1
10
+ },
11
+ "claude:haiku": {
12
+ "a": 18,
13
+ "b": 3,
14
+ "usd": 0.017,
15
+ "min": 1.5
16
+ },
17
+ "claude:sonnet": {
18
+ "a": 18,
19
+ "b": 3,
20
+ "usd": 0.137,
21
+ "min": 0.7
22
+ },
23
+ "grok:grok-4.7": {
24
+ "a": 10,
25
+ "b": 2,
26
+ "usd": 0.984,
27
+ "min": 5.2
28
+ },
29
+ "claude:opus": {
30
+ "a": 17,
31
+ "b": 4,
32
+ "usd": 0.344,
33
+ "min": 1.3
34
+ },
35
+ "antigravity:gemini-3.8-flash-medium": {
36
+ "a": 4,
37
+ "b": 1,
38
+ "usd": 0.054,
39
+ "min": 4.4
40
+ },
41
+ "codex:": {
42
+ "a": 19,
43
+ "b": 5,
44
+ "usd": 0.129,
45
+ "min": 0.6
46
+ },
47
+ "antigravity:gemini-4-argon-medium": {
48
+ "a": 5,
49
+ "b": 2,
50
+ "usd": 0.677,
51
+ "min": 4.7
52
+ }
53
+ }
54
+ }