wisp-agents 0.17.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +20 -7
  3. package/bin/wisp.js +4 -2
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +2 -1
  6. package/server/adapters/claude.js +2 -1
  7. package/server/adapters/codex.js +2 -1
  8. package/server/adapters/commands.js +808 -0
  9. package/server/adapters/cursor.js +2 -1
  10. package/server/adapters/grok.js +2 -1
  11. package/server/adapters/opencode.js +2 -1
  12. package/server/check.js +205 -0
  13. package/server/git.js +114 -2
  14. package/server/index.js +2 -1
  15. package/server/judge.js +78 -0
  16. package/server/machines.js +2 -2
  17. package/server/openapi.js +19 -2
  18. package/server/pipeline.js +217 -0
  19. package/server/quick.js +76 -0
  20. package/server/router.js +201 -28
  21. package/server/routes/auto.js +8 -1
  22. package/server/routes/meta.js +1 -1
  23. package/server/routes/settings.js +53 -4
  24. package/server/routes/ship.js +29 -2
  25. package/server/runner.js +65 -15
  26. package/server/scout.js +71 -0
  27. package/server/services/auto.js +115 -15
  28. package/server/services/launch.js +22 -13
  29. package/server/services/ship.js +107 -6
  30. package/server/store.js +15 -0
  31. package/web/css/auto.css +22 -0
  32. package/web/css/launcher.css +17 -0
  33. package/web/css/layout.css +10 -2
  34. package/web/css/modals.css +8 -0
  35. package/web/css/palette.css +22 -0
  36. package/web/css/ship.css +44 -0
  37. package/web/index.html +16 -5
  38. package/web/js/app.js +14 -3
  39. package/web/js/blocks.js +1 -1
  40. package/web/js/core/state.js +2 -1
  41. package/web/js/features/actions.js +2 -4
  42. package/web/js/features/auto.js +38 -7
  43. package/web/js/features/clones.js +79 -0
  44. package/web/js/features/composer.js +2 -4
  45. package/web/js/features/feed.js +34 -4
  46. package/web/js/features/launcher.js +139 -19
  47. package/web/js/features/live.js +5 -10
  48. package/web/js/features/project-list.js +99 -0
  49. package/web/js/features/projects.js +220 -39
  50. package/web/js/features/runners.js +7 -6
  51. package/web/js/features/settings.js +47 -7
  52. package/web/js/features/setup.js +3 -3
  53. package/web/js/features/ship.js +128 -25
  54. package/web/js/features/turns.js +4 -3
  55. package/web/js/ui/popover.js +10 -0
@@ -0,0 +1,217 @@
1
+ // What Auto does after the agent says it's done, before the task lands in your queue:
2
+ //
3
+ // check wisp runs the project's tests itself (server/check.js). Only new failures count.
4
+ // repair tests fail → the same session gets the failures back, up to `repairs` rounds.
5
+ // escalate still failing (or the run errored) → the task goes to a stronger harness · model,
6
+ // in its own worktree, next to the first run (one task, two rows).
7
+ // judge best-of-2 (goal Best): both runs are checked, then the better one is marked
8
+ // (server/judge.js): tests first, then a cheap model reading both diffs.
9
+ //
10
+ // The turn's presentation gets wisp's own tests block, and when the agent said "all green" but
11
+ // wisp's run disagrees, a callout saying so. The router learns from the verdicts.
12
+ //
13
+ // State lives on the session as `auto`: { strategy, busy, stage, label, repairs, checks[],
14
+ // escalatedTo, escalatedFrom, pick }. The runner keeps a session in Working while `busy`.
15
+
16
+ import { check as runCheck } from './check.js';
17
+ import { judge, diffOf } from './judge.js';
18
+ import { workdir } from './runner.js';
19
+ import { infraError, tierOf } from './router.js';
20
+
21
+ // What an errored turn said (the terminal block the runner puts on it).
22
+ const errorText = (turn) => (turn.presentation?.blocks || []).filter((b) => b.type === 'terminal').map((b) => b.output || '').join('\n');
23
+ const MAX_MOVES = 4; // a task moved this many times because harnesses were at their limit stops there
24
+
25
+ export const AUTO_DEFAULTS = { check: 'on', repairs: 1, escalate: 'on', best2: 'off', scout: 'big' };
26
+
27
+ export function autoSettings(d = {}) {
28
+ const r = d.router || {};
29
+ return {
30
+ check: r.check !== 'off',
31
+ repairs: [0, 1, 2].includes(Number(r.repairs)) ? Number(r.repairs) : AUTO_DEFAULTS.repairs,
32
+ escalate: r.escalate !== 'off',
33
+ best2: r.best2 === 'on', // opt-in: it runs every Best task twice
34
+ scout: ['off', 'big', 'all'].includes(r.scout) ? r.scout : AUTO_DEFAULTS.scout,
35
+ };
36
+ }
37
+
38
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
39
+
40
+ export class Pipeline {
41
+ // deps: { store, runner, router, settings(), agents(), projectOf(session), launchSibling(session, pick),
42
+ // onSettled(session), broadcast(event, data) }
43
+ constructor(deps) {
44
+ Object.assign(this, deps);
45
+ this.tokens = new Map(); // sessionId -> token of the step in flight; a user's move cancels it
46
+ }
47
+
48
+ // Called by the runner as a turn ends: true keeps the session in Working while wisp checks.
49
+ wants(session, turn, ev) {
50
+ const a = session.auto;
51
+ if (!a || session.parentId || ev.stopped || session.mode === 'plan') return false;
52
+ const s = this.settings();
53
+ // At its limit: not the model's fault, so the task moves on whatever the hand-over setting says.
54
+ if (ev.isError) return !!infraError(ev.text) || (s.escalate && this.#canEscalate(session));
55
+ return s.check;
56
+ }
57
+
58
+ // A follow-up or Stop from the user: whatever wisp was about to do is off.
59
+ cancel(session) {
60
+ if (!session.auto?.busy) return;
61
+ this.tokens.delete(session.id);
62
+ Object.assign(session.auto, { busy: false, stage: 'cancelled', label: '' });
63
+ }
64
+
65
+ async run(session, turn) {
66
+ const a = session.auto;
67
+ const token = Symbol('step');
68
+ this.tokens.set(session.id, token);
69
+ const live = () => this.tokens.get(session.id) === token && this.store.get(session.id) === session;
70
+ const s = this.settings();
71
+ try {
72
+ let result;
73
+ const limit = turn.status === 'error' && infraError(errorText(turn));
74
+ if (limit) return await this.#moveOn(session, turn, limit, live);
75
+ if (turn.status === 'error') result = { verdict: 'error' };
76
+ else {
77
+ this.#stage(session, turn, 'checking', 'Checking with the tests');
78
+ result = await runCheck({ dir: workdir(session), project: this.projectOf(session), worktree: session.worktree });
79
+ if (!live()) return;
80
+ a.checks.push({ turnId: turn.id, ...result, output: undefined });
81
+ this.#present(session, turn, result);
82
+ }
83
+ this.router.signal(session.id, { check: result.verdict, repaired: a.repairs > 0 || undefined });
84
+
85
+ const failing = result.verdict === 'fail' || result.verdict === 'error';
86
+ // Repairs and hand-overs are for the task as you sent it: once you've followed up yourself,
87
+ // wisp still checks and shows the result, but leaves the next move to you.
88
+ const yours = session.turns.some((t, i) => i > 0 && t.by !== 'wisp');
89
+ if (result.verdict === 'fail' && a.repairs < s.repairs && !yours) {
90
+ a.repairs++;
91
+ this.#stage(session, turn, 'repairing', `Fixing ${plural(result.newFailures.length || result.failed || 1, 'failing test')}`);
92
+ this.runner.reply(session, repairPrompt(result, a.repairs, s.repairs), [], { by: 'wisp' });
93
+ return; // the repair turn comes back through wants()/run()
94
+ }
95
+ if (failing && s.escalate && this.#canEscalate(session)) {
96
+ // A step up: a stronger tier than this run's (or the strongest there is, if it was already top).
97
+ const tier = tierOf(session.route?.key || '');
98
+ const pick = this.router.choose({ prompt: session.turns[0].prompt, agents: this.agents(), goal: 'best', important: true, minTier: Math.min(3, tier + 1), exclude: [session.route?.key, ...(a.tried || [])].filter(Boolean), needDelegate: !!session.subagents?.length });
99
+ if (!pick || tierOf(pick.key) < tier) this.#note(session, turn, 'check', 'No stronger harness · model to hand it to');
100
+ else {
101
+ this.#stage(session, turn, 'escalating', `Handing it to ${pick.label}`);
102
+ const sib = await this.launchSibling(session, pick, { reason: result.verdict === 'error' ? 'error' : 'tests' });
103
+ if (!live()) return;
104
+ a.escalatedTo = sib.id;
105
+ a.reason = result.verdict === 'error' ? 'error' : 'tests';
106
+ this.#note(session, turn, 'check', `wisp handed this task to ${pick.label}: ${result.verdict === 'error' ? 'the run ended with an error' : 'the tests still fail'}`);
107
+ return this.#settle(session, { quiet: true });
108
+ }
109
+ }
110
+ this.#settle(session);
111
+ if (a.strategy === 'best2') await this.#judgeGroup(session);
112
+ } catch (err) {
113
+ console.warn(`wisp: auto pipeline: ${err.stack || err.message}`);
114
+ if (live()) this.#settle(session);
115
+ }
116
+ }
117
+
118
+ // The harness was at a usage or rate limit: the run doesn't count against it, it cools down until
119
+ // the limit resets, and the task moves to the best pick that can run now.
120
+ async #moveOn(session, turn, limit, live) {
121
+ const a = session.auto;
122
+ const why = errorText(turn).split('\n').find((l) => l.trim())?.trim() || 'at its limit';
123
+ const cooled = this.router.cool(session.route?.key || `${session.agent}:${session.model || ''}`, { ...limit, why });
124
+ this.router.void(session.id);
125
+ const name = cooled.endsWith(':*') ? session.route?.label?.split(' · ')[0] || session.agent : session.route?.label || session.agent;
126
+ const tried = [...(a.tried || []), session.route?.key].filter(Boolean);
127
+ const pick = tried.length < MAX_MOVES ? this.router.choose({ prompt: session.turns[0].prompt, agents: this.agents(), goal: session.route?.goal || 'balanced', exclude: tried, needDelegate: !!session.subagents?.length }) : null;
128
+ if (!pick) {
129
+ this.#note(session, turn, 'check', `${name} is at its limit, and nothing else Auto may use can run right now`);
130
+ return this.#settle(session);
131
+ }
132
+ this.#stage(session, turn, 'moving', `${name} is at its limit · moving to ${pick.label}`);
133
+ const sib = await this.launchSibling(session, pick, { reason: 'limit', tried });
134
+ if (!live()) return;
135
+ a.escalatedTo = sib.id;
136
+ a.reason = 'limit';
137
+ return this.#settle(session, { quiet: true });
138
+ }
139
+
140
+ // Escalate once per task, and only while the user hasn't stepped in with follow-ups.
141
+ #canEscalate(session) {
142
+ const a = session.auto;
143
+ // A fresh worktree from the same base, or the second run would build on the first one's mess.
144
+ return a.strategy === 'single' && !a.escalatedTo && !!session.worktree && !session.turns.some((t, i) => i > 0 && t.by !== 'wisp');
145
+ }
146
+
147
+ #stage(session, turn, stage, label) {
148
+ Object.assign(session.auto, { busy: true, stage, label });
149
+ this.#note(session, turn, 'check', `${label}…`);
150
+ }
151
+
152
+ #note(session, turn, kind, label) {
153
+ this.runner.note(session, turn, { kind, label });
154
+ }
155
+
156
+ #settle(session, { quiet = false } = {}) {
157
+ this.tokens.delete(session.id);
158
+ Object.assign(session.auto, { busy: false, stage: 'done', label: '' });
159
+ this.runner.settle(session, { quiet });
160
+ this.onSettled?.(session);
161
+ }
162
+
163
+ // wisp's tests block goes right under the hero; a "done" that wisp's run contradicts is called out.
164
+ #present(session, turn, r) {
165
+ const blocks = turn.presentation?.blocks;
166
+ if (!blocks) return;
167
+ const label = `${r.command ? `\`${r.command}\`` : 'the tests'}`;
168
+ if (r.verdict === 'skip') {
169
+ this.#note(session, turn, 'check', `No tests to run: ${r.reason}`);
170
+ return;
171
+ }
172
+ const failures = (r.newFailures.length ? r.newFailures : r.verdict === 'fail' ? ['(see output)'] : []).slice(0, 8).map((name) => ({ name, message: '' }));
173
+ const block = { type: 'tests', by: 'wisp', command: r.command, passed: r.passed ?? undefined, failed: r.verdict === 'pass' ? 0 : r.newFailures.length || r.failed, skipped: r.skipped ?? undefined, durationMs: r.durationMs, failures, note: r.note || undefined };
174
+ if (r.verdict === 'pass' && block.passed == null) block.passed = 1;
175
+ const at = Math.max(0, blocks.findIndex((b) => b.type === 'hero') + 1);
176
+ blocks.splice(at, 0, block);
177
+ const hero = blocks.find((b) => b.type === 'hero');
178
+ const claimed = blocks.some((b) => b.type === 'tests' && b.by !== 'wisp' && !(Number(b.failed) > 0)) || hero?.status === 'done';
179
+ if (r.verdict === 'fail') {
180
+ if (hero && hero.status === 'done') (hero.agentStatus = hero.status), (hero.status = 'partial');
181
+ if (claimed) blocks.splice(at + 1, 0, { type: 'callout', tone: 'danger', by: 'wisp', title: "wisp's test run disagrees", text: `The agent reported this as done, but ${label} has ${plural(r.newFailures.length || r.failed, 'failing test')} that passed before the change.` });
182
+ }
183
+ this.#note(session, turn, 'check', r.verdict === 'pass' ? `Tests pass: ${label}${r.note ? ` (${r.note})` : ''}` : r.verdict === 'fail' ? `Tests fail: ${plural(r.newFailures.length || r.failed, 'new failure')} in ${label}` : `Tests inconclusive: ${r.note || label}`);
184
+ this.store.save(session);
185
+ this.broadcast('turn', { sessionId: session.id, turnId: turn.id });
186
+ }
187
+
188
+ // Best-of-2: once both runs have settled, mark the better one.
189
+ async #judgeGroup(session) {
190
+ const group = this.store.all().filter((x) => x.groupId === session.groupId && x.auto?.strategy === 'best2');
191
+ if (group.length < 2 || group.some((x) => x.auto.busy || x.state === 'running') || group.some((x) => x.auto.pick)) return;
192
+ group.forEach((x) => (x.auto.pick = { pending: true }));
193
+ const candidates = group.slice(0, 2).map((x) => ({ id: x.id, label: x.route?.label || x.agent, check: x.auto.checks.at(-1), diff: diffOf(workdir(x), x.worktree?.head || 'HEAD') }));
194
+ const j = await judge({ prompt: group[0].turns[0].prompt, candidates, agents: this.agents() });
195
+ for (const x of group) {
196
+ x.auto.pick = { winner: x.id === j.winner, why: j.why, by: j.by };
197
+ this.store.save(x);
198
+ this.broadcast('summary', this.summarize(x));
199
+ }
200
+ const w = group.find((x) => x.id === j.winner);
201
+ if (w) this.#note(w, w.turns.at(-1), 'check', `wisp's pick of the two runs: ${j.why}`);
202
+ for (const x of group) this.router.signal(x.id, { picked: x.id === j.winner });
203
+ }
204
+ }
205
+
206
+ function repairPrompt(r, round, max) {
207
+ const list = r.newFailures.length ? r.newFailures.slice(0, 20).map((t) => `- ${t}`).join('\n') : '';
208
+ return [
209
+ `wisp ran \`${r.command}\` on your change and it fails${list ? ':' : '.'}`,
210
+ list,
211
+ r.output ? `Output (tail):\n\`\`\`\n${r.output.slice(-3500)}\n\`\`\`` : '',
212
+ 'These tests passed before your change. Fix the code so they pass again: do not delete, skip or weaken tests. Run the tests yourself, then finish with an updated presentation.',
213
+ max > 1 ? `(Repair round ${round} of ${max}.)` : '',
214
+ ]
215
+ .filter(Boolean)
216
+ .join('\n\n');
217
+ }
@@ -0,0 +1,76 @@
1
+ // One quick question to a cheap model, through whichever harness is installed: the scout and the
2
+ // judge use it. Read-only (plan mode), no wisp protocol, a hard time limit, and the reply's JSON.
3
+
4
+ import { adapters } from './adapters/index.js';
5
+
6
+ // Cheapest first: what the scout and judge run on when it's installed.
7
+ const CHEAP = [
8
+ ['claude', 'haiku'],
9
+ ['antigravity', 'gemini-3.8-flash-low'],
10
+ ['codex', ''],
11
+ ['opencode', ''],
12
+ ['grok', ''],
13
+ ['claude', ''],
14
+ ];
15
+
16
+ export function cheapest(agents) {
17
+ for (const [id, model] of CHEAP) {
18
+ const a = agents.find((x) => x.id === id && x.available);
19
+ if (a && (!model || (a.models || []).some((m) => m.id === model))) return { agent: id, model };
20
+ }
21
+ return null;
22
+ }
23
+
24
+ // Resolves with the parsed JSON object from the reply, or null (no harness, timeout, no JSON).
25
+ export function askJson({ agents, prompt, cwd, timeoutMs = 90_000, pick = cheapest(agents) }) {
26
+ if (!pick || !adapters[pick.agent]) return Promise.resolve(null);
27
+ return new Promise((resolve) => {
28
+ let text = '';
29
+ let handle;
30
+ try {
31
+ handle = adapters[pick.agent].run({
32
+ prompt,
33
+ cwd,
34
+ mode: 'plan',
35
+ model: pick.model,
36
+ effort: '',
37
+ systemPrompt: 'You are a helper inside wisp. Answer with one JSON object and nothing else.',
38
+ attachments: [],
39
+ onEvent: (e) => {
40
+ if (e.type === 'result') text = e.isError ? '' : e.text || '';
41
+ },
42
+ });
43
+ } catch {
44
+ return resolve(null);
45
+ }
46
+ const timer = setTimeout(() => handle.stop(), timeoutMs);
47
+ handle.done.then(() => {
48
+ clearTimeout(timer);
49
+ resolve(readJson(text));
50
+ });
51
+ });
52
+ }
53
+
54
+ export function readJson(text = '') {
55
+ const s = String(text);
56
+ for (let i = s.indexOf('{'); i >= 0; i = s.indexOf('{', i + 1)) {
57
+ let depth = 0;
58
+ let inStr = false;
59
+ for (let j = i; j < s.length; j++) {
60
+ const c = s[j];
61
+ if (inStr) {
62
+ if (c === '\\') j++;
63
+ else if (c === '"') inStr = false;
64
+ } else if (c === '"') inStr = true;
65
+ else if (c === '{') depth++;
66
+ else if (c === '}' && !--depth) {
67
+ try {
68
+ return JSON.parse(s.slice(i, j + 1));
69
+ } catch {
70
+ break;
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return null;
76
+ }
package/server/router.js CHANGED
@@ -1,6 +1,9 @@
1
- // Auto: wisp picks the harness and model for a task, and learns from how it went.
1
+ // Auto: wisp picks the harness, model and thinking level for a task, and learns from how it went.
2
2
  //
3
- // Every harness × model the user has is an "arm". For each kind of task (bug, ui, docs, …) wisp
3
+ // Every harness × model × thinking level the user has is an "arm" (claude:opus:high; no level is
4
+ // the harness's default). The goal picks the band of levels: Cheap runs low, Balanced the default
5
+ // or medium, Best high. Within the band the levels are learned like models are, and a model's
6
+ // record at its other levels counts half. For each kind of task (bug, ui, docs, …) wisp
4
7
  // keeps a Beta(wins, losses) estimate per arm, stacked from three sources: the shipped priors
5
8
  // (./router-priors.json, from wisp-bench), the user's history on every kind of task, and the
6
9
  // history on this kind. A launch draws a chance of success from each estimate (Thompson
@@ -25,14 +28,16 @@ const PRIORS = JSON.parse(fs.readFileSync(new URL('./router-priors.json', import
25
28
  export const GOALS = {
26
29
  cheap: { label: 'Cheap', value: 1, hint: 'Lowest cost that still gets it done' },
27
30
  balanced: { label: 'Balanced', value: 5, hint: 'Best result per dollar' },
28
- best: { label: 'Best', value: 50, hint: 'Best result; cost only breaks ties' },
31
+ best: { label: 'Best', value: 50, hint: 'Frontier models only; cost only breaks ties' },
29
32
  };
30
33
  export const TYPES = ['bug', 'feature', 'ui', 'test', 'refactor', 'docs', 'release', 'question'];
31
34
  const MINUTE_USD = 0.05; // what a minute of waiting costs you, in the utility
32
- const PRIOR_CAP = 8; // shipped priors count as at most this many tasks, so your own soon outweigh them
35
+ const PRIOR_CAP = 4; // shipped priors count as at most this many tasks, so your own soon outweigh them
36
+ const COLD_START = 20; // until Auto has this many of your outcomes, Balanced and Best stay on stronger tiers
33
37
  const ALL_WEIGHT = 0.5; // your history on other kinds of task counts half
34
38
  const HALF_LIFE_MS = 30 * 864e5; // models change: older outcomes fade
35
39
  const NEW_ARM_SHARE = 0.25; // how often a new arm on probation may take an exploring slot
40
+ const LEVEL_WEIGHT = 0.5; // the same model's record at its other thinking levels counts half
36
41
  const MAX_ROUTES = 2000;
37
42
 
38
43
  // --- task features ---------------------------------------------------------------------------
@@ -58,15 +63,78 @@ export function classify(prompt = '', { attachments = 0 } = {}) {
58
63
 
59
64
  // --- arms ------------------------------------------------------------------------------------
60
65
 
61
- export const armKey = (agent, model = '') => `${agent}:${model || ''}`;
62
- const splitKey = (k) => {
66
+ // Thinking levels, by how hard they think, against the harness's default (''): a shift in the
67
+ // chance of success and multipliers on cost and time. Only a starting guess; outcomes take over.
68
+ export const EFFORTS = {
69
+ minimal: { chance: -0.1, usd: 0.55, min: 0.65 },
70
+ low: { chance: -0.04, usd: 0.6, min: 0.7 },
71
+ medium: { chance: -0.01, usd: 0.85, min: 0.85 },
72
+ '': { chance: 0, usd: 1, min: 1 },
73
+ high: { chance: 0.02, usd: 1.3, min: 1.3 },
74
+ xhigh: { chance: 0.02, usd: 1.7, min: 1.6 },
75
+ max: { chance: 0.03, usd: 2.2, min: 2 },
76
+ };
77
+ // The levels each goal may run at, where the harness has them; none of them → the default.
78
+ const BANDS = { cheap: ['minimal', 'low'], balanced: ['', 'medium'], best: ['high', 'xhigh'] };
79
+ export const levelsFor = (efforts = [], goal = 'balanced') => {
80
+ const have = (BANDS[goal] || BANDS.balanced).filter((e) => e === '' || efforts.includes(e));
81
+ return have.length ? have : [''];
82
+ };
83
+ const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
84
+
85
+ export const armKey = (agent, model = '', effort = '') => `${agent}:${model || ''}${effort ? `:${effort}` : ''}`;
86
+ // agent:model[:effort]. A trailing segment is a level only if it's one wisp knows, so a model id
87
+ // with a colon in it (ollama's llama3:8b) stays whole.
88
+ export const splitKey = (k) => {
63
89
  const i = k.indexOf(':');
64
- return { agent: k.slice(0, i), model: k.slice(i + 1) };
90
+ const rest = k.slice(i + 1);
91
+ const j = rest.lastIndexOf(':');
92
+ const effort = j >= 0 && rest.slice(j + 1) && Object.hasOwn(EFFORTS, rest.slice(j + 1)) ? rest.slice(j + 1) : '';
93
+ return { agent: k.slice(0, i), model: effort ? rest.slice(0, j) : rest, effort };
94
+ };
95
+ // The harness · model an arm runs on, whatever its level: probation, cooling and compare go by it.
96
+ export const baseOf = (k) => {
97
+ const { agent, model } = splitKey(k);
98
+ return armKey(agent, model);
65
99
  };
66
100
  // Versions stripped: claude-sonnet-5-5 and claude-sonnet-5-6 are one family, and a new version
67
101
  // starts from what the family already showed.
68
102
  const family = (k) => k.replace(/[-.]?\d+([.-]\d+)*/g, '');
69
103
 
104
+ // How strong a model is, roughly: 1 small/fast/free, 2 everyday, 3 frontier. Best runs on tier 3
105
+ // only, hand-overs step up a tier, and until Auto has your own outcomes Balanced stays on tier 2+.
106
+ // The shipped priors come from a small pilot that flattered cheap models; tiers keep them honest.
107
+ const FRONTIER_DEFAULTS = new Set(['codex:']); // a harness whose default model is its frontier one
108
+ export function tierOf(key) {
109
+ const { agent, model } = splitKey(key);
110
+ const m = model.toLowerCase();
111
+ // Whole name segments only: "gemini" is not "mini".
112
+ const seg = m.split(/[-/.:_]/);
113
+ if (seg.some((x) => ['haiku', 'flash', 'mini', 'nano', 'lite', 'small', 'free', 'fast', 'low'].includes(x))) return 1;
114
+ if (seg.some((x) => ['opus', 'argon', 'ultra', 'pro', 'xhigh', 'high', 'max'].includes(x)) || FRONTIER_DEFAULTS.has(key) || (agent === 'grok' && /^grok-\d/.test(m))) return 3;
115
+ return 2;
116
+ }
117
+
118
+ // An error that says the harness can't run right now (a usage limit, a rate limit, no credit),
119
+ // not that the model got the task wrong. Returns { scope: 'agent' | 'arm', ms } or null.
120
+ export function infraError(text = '') {
121
+ const t = String(text);
122
+ if (/session limit|usage limit|limit reached|resets \d|quota|exceeded your|insufficient (credit|balance|quota)|balance exhausted|payment required|\b402\b|billing/i.test(t)) return { scope: 'agent', ms: resetIn(t) ?? 60 * 60_000 };
123
+ if (/rate[- ]?limit|too many requests|\b429\b|overloaded|\b529\b|try again in a few minutes/i.test(t)) return { scope: 'arm', ms: 10 * 60_000 };
124
+ return null;
125
+ }
126
+ // "resets 9pm" / "resets at 21:00" → ms until then (today, else tomorrow), capped at 6 h.
127
+ function resetIn(t) {
128
+ const m = t.match(/resets?(?: at)? (\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
129
+ if (!m) return null;
130
+ let h = Number(m[1]) % 12 + (/pm/i.test(m[3] || '') ? 12 : 0);
131
+ if (!m[3]) h = Number(m[1]);
132
+ const at = new Date();
133
+ at.setHours(h, Number(m[2] || 0), 0, 0);
134
+ if (at <= new Date()) at.setDate(at.getDate() + 1);
135
+ return Math.min(6 * 3600_000, at - Date.now() + 60_000);
136
+ }
137
+
70
138
  // The arms wisp may route to, from listAgents(): each harness's default model, the models the
71
139
  // priors know, models with history here, and new models in a known family (on probation).
72
140
  function candidates(agents, known) {
@@ -77,7 +145,8 @@ function candidates(agents, known) {
77
145
  for (const id of models) {
78
146
  const k = armKey(a.id, id);
79
147
  if (id === '' || PRIORS.arms[k] || known.has(k) || [...known, ...Object.keys(PRIORS.arms)].some((x) => family(x) === family(k) && x !== k && x.startsWith(`${a.id}:`))) {
80
- out.push({ key: k, agent: a.id, model: id, label: `${a.name}${id ? ` · ${(a.models || []).find((m) => m.id === id)?.label || id}` : ''}`, delegates: a.delegates !== false });
148
+ const m = (a.models || []).find((x) => x.id === id);
149
+ out.push({ key: k, agent: a.id, model: id, label: `${a.name}${id ? ` · ${m?.label || id}` : ''}`, efforts: m?.efforts ?? a.efforts ?? [], delegates: a.delegates !== false });
81
150
  }
82
151
  }
83
152
  }
@@ -128,6 +197,15 @@ export class Router {
128
197
  }
129
198
 
130
199
  #prior(k) {
200
+ const { agent, model, effort } = splitKey(k);
201
+ if (effort) {
202
+ // A level starts from its model's record, shifted by how hard it thinks.
203
+ const base = this.#prior(armKey(agent, model)) || { a: 1, b: 1 };
204
+ const e = EFFORTS[effort];
205
+ const n = base.a + base.b;
206
+ const mean = Math.min(0.95, Math.max(0.05, base.a / n + e.chance));
207
+ return { ...base, a: mean * n, b: (1 - mean) * n, usd: (base.usd ?? 0.3) * e.usd, min: (base.min ?? 5) * e.min };
208
+ }
131
209
  const p = PRIORS.arms[k];
132
210
  if (p) return p;
133
211
  // A new version inherits its family's record, with less weight.
@@ -147,32 +225,75 @@ export class Router {
147
225
  return { a: s.a * f, b: s.b * f, n: s.n, usd: s.usdN ? s.usd / s.usdN : null, ms: s.n ? s.ms / s.n : null };
148
226
  }
149
227
 
228
+ // The same model at its other thinking levels, on this kind of task.
229
+ #levels(type, key) {
230
+ const base = baseOf(key);
231
+ const out = { a: 0, b: 0 };
232
+ for (const sk of Object.keys(this.state.stats)) {
233
+ const i = sk.indexOf('|');
234
+ const k = sk.slice(i + 1);
235
+ if (sk.slice(0, i) !== type || k === key || baseOf(k) !== base) continue;
236
+ const c = this.#counts(type, k);
237
+ out.a += c.a;
238
+ out.b += c.b;
239
+ }
240
+ return out;
241
+ }
242
+
150
243
  // Beta posterior for an arm on a kind of task, plus what it usually costs.
151
244
  estimate(type, key) {
152
245
  const prior = this.#prior(key) || { a: 1, b: 1 };
153
246
  const scale = Math.min(1, PRIOR_CAP / Math.max(1, prior.a + prior.b));
154
247
  const mine = this.#counts(type, key);
155
248
  const all = this.#counts('*', key);
156
- const a = 1 + prior.a * scale + (all.a - mine.a) * ALL_WEIGHT + mine.a;
157
- const b = 1 + prior.b * scale + (all.b - mine.b) * ALL_WEIGHT + mine.b;
249
+ const kin = this.#levels(type, key);
250
+ const a = 1 + prior.a * scale + (all.a - mine.a) * ALL_WEIGHT + kin.a * LEVEL_WEIGHT + mine.a;
251
+ const b = 1 + prior.b * scale + (all.b - mine.b) * ALL_WEIGHT + kin.b * LEVEL_WEIGHT + mine.b;
158
252
  // Cost and time: the prior's guess, pulled toward what this user's runs actually cost as they add up.
159
253
  const blend = (own, n, guess) => (own === null ? guess : (guess * 3 + own * n) / (3 + n));
160
254
  const usd = blend(all.usd, all.n, prior.usd ?? 0.3);
161
255
  const minutes = blend(all.ms === null ? null : all.ms / 60_000, all.n, prior.min ?? 5);
162
- return { a, b, mean: a / (a + b), n: mine.n, nAll: all.n, usd: round(usd, 4), minutes, prior: !!PRIORS.arms[key], inherited: !!prior.inherited };
256
+ return { a, b, mean: a / (a + b), n: mine.n, nAll: all.n, usd: round(usd, 4), minutes, prior: !!PRIORS.arms[baseOf(key)], inherited: !!prior.inherited };
163
257
  }
164
258
 
165
- // Pick an arm. agents: listAgents() of the place it runs. goal: cheap | balanced | best.
166
- // important: no exploring. exclude: arm keys already taken (compare). needDelegate: the lead
259
+ // Pick an arm. agents: listAgents() of the place it runs. goal: cheap | balanced | best, which
260
+ // also sets the band of thinking levels (levelsFor).
261
+ // important: no exploring. exclude: arm keys already taken (compare, hand-overs): every level of
262
+ // that harness · model is out. needDelegate: the lead
167
263
  // of a team must be a harness that can delegate. rng: for tests.
168
- choose({ prompt = '', attachments = 0, agents = [], goal = 'balanced', important = false, explore = true, exclude = [], needDelegate = false, allowed = null, rng = Math.random } = {}) {
264
+ // scouted: { type, size } from the scout, which beats the keyword guess.
265
+ // minTier: only arms at least that strong (a hand-over steps up). Arms cooling down after a usage
266
+ // or rate limit are skipped while any other arm can run.
267
+ // Best runs frontier models only: if any is installed and allowed, Best never steps down a tier,
268
+ // even when they're all taken or at their limit (null then). With none installed, the strongest.
269
+ choose({ prompt = '', attachments = 0, agents = [], goal = 'balanced', important = false, explore = true, exclude = [], needDelegate = false, allowed = null, scouted = null, minTier = 0, rng = Math.random } = {}) {
169
270
  if (!this.synced) this.sync(agents);
170
- const { type, size } = classify(prompt, { attachments });
271
+ const guess = classify(prompt, { attachments });
272
+ const type = scouted?.type || guess.type;
273
+ const size = scouted?.size || guess.size;
171
274
  const V = (GOALS[goal] || GOALS.balanced).value;
172
- let arms = candidates(agents, new Set(Object.keys(this.state.arms)))
173
- .filter((c) => !exclude.includes(c.key) && (!needDelegate || c.delegates) && (!allowed || allowed.includes(c.agent)))
174
- .map((c) => ({ ...c, est: this.estimate(type, c.key), state: this.state.arms[c.key]?.state || 'new' }));
275
+ const out = new Set(exclude.map(baseOf));
276
+ const usable = candidates(agents, new Set(Object.keys(this.state.arms))).filter((c) => (!needDelegate || c.delegates) && (!allowed || allowed.includes(c.agent)));
277
+ const frontierOnly = goal === 'best' && usable.some((c) => tierOf(c.key) >= 3);
278
+ let arms = usable
279
+ .filter((c) => !out.has(c.key) && (!frontierOnly || tierOf(c.key) >= 3))
280
+ .flatMap((c) => levelsFor(c.efforts, goal).map((effort) => ({ ...c, key: armKey(c.agent, c.model, effort), effort, label: `${c.label}${effort ? ` · ${effortLabel(effort)}` : ''}`, base: c.key })))
281
+ .map((c) => ({ ...c, est: this.estimate(type, c.key), state: this.state.arms[c.base]?.state || 'new' }));
175
282
  if (!arms.length) return null;
283
+ const free = arms.filter((x) => !this.cooling(x.key));
284
+ if (!free.length) return null; // everything allowed is at its limit: nothing to route to now
285
+ arms = free;
286
+ // Cold start: until there are enough of your own outcomes, lean to quality (the shipped priors
287
+ // are thin). Cheap is left alone: that goal asked for cheap.
288
+ const cold = this.learned() < COLD_START;
289
+ const floorTier = Math.max(minTier, goal === 'best' ? 3 : cold && goal !== 'cheap' ? 2 : 0);
290
+ for (let t = floorTier; t > 0; t--) {
291
+ const strong = arms.filter((x) => tierOf(x.key) >= t);
292
+ if (strong.length) {
293
+ arms = strong;
294
+ break;
295
+ }
296
+ }
176
297
  // Quality floor: an arm that keeps failing this kind of task here is left out.
177
298
  const floor = arms.filter((x) => !(x.est.n >= 5 && x.est.mean < 0.35));
178
299
  if (floor.length) arms = floor;
@@ -198,6 +319,7 @@ export class Router {
198
319
  return {
199
320
  agent: pick.agent,
200
321
  model: pick.model,
322
+ effort: pick.effort,
201
323
  key: pick.key,
202
324
  label: pick.label,
203
325
  route: {
@@ -205,6 +327,7 @@ export class Router {
205
327
  type,
206
328
  size,
207
329
  key: pick.key,
330
+ effort: pick.effort,
208
331
  label: pick.label,
209
332
  chance: round(pick.est.mean),
210
333
  n: pick.est.n,
@@ -212,6 +335,8 @@ export class Router {
212
335
  minutes: round(pick.est.minutes, 1),
213
336
  explored: byMean[0].key !== pick.key,
214
337
  trying: pick.state === 'new',
338
+ tier: tierOf(pick.key),
339
+ ...(cold && goal !== 'cheap' && goal !== 'best' && { coldStart: true }),
215
340
  propensity: round(hits / 200),
216
341
  alternatives: byMean.filter((x) => x.key !== pick.key).slice(0, 3).map((x) => ({ key: x.key, label: x.label, chance: round(x.est.mean), usd: round(x.est.usd) })),
217
342
  at: this.now(),
@@ -231,7 +356,7 @@ export class Router {
231
356
  // rolledBack, switched, costUsd, ms }. The reward is recomputed and the stats move by the difference.
232
357
  signal(id, patch) {
233
358
  const route = this.state.routes[id];
234
- if (!route) return null;
359
+ if (!route || route.void) return route || null;
235
360
  Object.assign(route.signals, Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)));
236
361
  const r = reward(route.signals);
237
362
  if (r === null) return this.save(), route;
@@ -251,9 +376,11 @@ export class Router {
251
376
  s.at = this.now();
252
377
  }
253
378
  route.r = r;
254
- // Probation ends once an arm has a few outcomes of its own.
255
- const arm = this.state.arms[route.key];
256
- if (arm?.state === 'new' && (this.state.stats[`*|${route.key}`]?.n || 0) >= 3) arm.state = 'ready';
379
+ // Probation ends once a harness · model has a few outcomes of its own, at any level.
380
+ const base = baseOf(route.key);
381
+ const arm = this.state.arms[base];
382
+ const n = Object.entries(this.state.stats).reduce((sum, [k, x]) => sum + (k.startsWith('*|') && baseOf(k.slice(2)) === base ? x.n : 0), 0);
383
+ if (arm?.state === 'new' && n >= 3) arm.state = 'ready';
257
384
  this.save();
258
385
  return route;
259
386
  }
@@ -262,22 +389,63 @@ export class Router {
262
389
  return this.state.routes[id] || null;
263
390
  }
264
391
 
392
+ // Outcomes of your own tasks the router has learned from.
393
+ learned() {
394
+ return Object.values(this.state.routes).filter((r) => r.r !== null).length;
395
+ }
396
+
397
+ // A harness (or one model, at every level) hit a usage or rate limit: skip it until it resets.
398
+ cool(key, { scope = 'arm', ms = 10 * 60_000, why = '' } = {}) {
399
+ const target = scope === 'agent' ? `${splitKey(key).agent}:*` : baseOf(key);
400
+ (this.state.cooling ||= {})[target] = { until: this.now() + ms, why: String(why).slice(0, 160) };
401
+ this.save();
402
+ return target;
403
+ }
404
+
405
+ cooling(key) {
406
+ const c = this.state.cooling || {};
407
+ const hit = c[key] || c[baseOf(key)] || c[`${splitKey(key).agent}:*`];
408
+ return hit && hit.until > this.now() ? hit : null;
409
+ }
410
+
411
+ // The run never got to work (the harness was at its limit): forget it ever counted.
412
+ void(id) {
413
+ const route = this.state.routes[id];
414
+ if (!route) return;
415
+ if (route.r !== null) {
416
+ for (const type of [route.type, '*']) {
417
+ const s = this.state.stats[`${type}|${route.key}`];
418
+ if (!s) continue;
419
+ s.a -= route.r;
420
+ s.b -= 1 - route.r;
421
+ s.n = Math.max(0, s.n - 1);
422
+ }
423
+ }
424
+ route.r = null;
425
+ route.void = true;
426
+ this.save();
427
+ }
428
+
265
429
  reset() {
266
430
  this.state.stats = {};
267
431
  this.state.routes = {};
268
432
  this.save();
269
433
  }
270
434
 
271
- // Settings → Auto: every arm with its record overall and per kind, and the latest routes.
435
+ // Settings → Auto: every harness · model, the thinking levels it has a record at, each with its
436
+ // record overall and per kind, and the latest routes.
272
437
  report(agents = []) {
273
- const keys = new Set([...Object.keys(this.state.arms), ...candidates(agents, new Set(Object.keys(this.state.arms))).map((c) => c.key)]);
274
- const labels = Object.fromEntries(candidates(agents, keys).map((c) => [c.key, c.label]));
438
+ const levels = Object.keys(this.state.stats).map((sk) => sk.slice(sk.indexOf('|') + 1)).filter((k) => splitKey(k).effort);
439
+ const keys = new Set([...Object.keys(this.state.arms), ...candidates(agents, new Set(Object.keys(this.state.arms))).map((c) => c.key), ...levels]);
440
+ const names = Object.fromEntries(candidates(agents, new Set([...keys].map(baseOf))).map((c) => [c.key, c.label]));
441
+ const labels = Object.fromEntries([...keys].filter((k) => names[baseOf(k)]).map((k) => [k, `${names[baseOf(k)]}${splitKey(k).effort ? ` · ${effortLabel(splitKey(k).effort)}` : ''}`]));
275
442
  const arms = [...keys]
276
443
  .map((k) => {
277
444
  const e = this.estimate('*', k);
278
445
  const byType = Object.fromEntries(TYPES.map((t) => [t, this.#counts(t, k)]).filter(([, c]) => c.n).map(([t]) => [t, { n: this.#counts(t, k).n, chance: round(this.estimate(t, k).mean) }]));
279
- const state = this.state.arms[k]?.state || (labels[k] ? 'ready' : 'gone');
280
- return { key: k, ...splitKey(k), label: labels[k] || k, state: state === 'ready' && !e.prior && !e.inherited && !e.nAll ? 'untried' : state, chance: round(e.mean), n: e.n, usd: round(e.usd), minutes: round(e.minutes, 1), prior: e.prior, byType };
446
+ const state = this.state.arms[baseOf(k)]?.state || (labels[k] ? 'ready' : 'gone');
447
+ const cool = this.cooling(k);
448
+ return { key: k, ...splitKey(k), label: labels[k] || k, tier: tierOf(k), ...(cool && { coolingUntil: cool.until, coolingWhy: cool.why }), state: cool && state !== 'gone' ? 'cooling' : state === 'ready' && !e.prior && !e.inherited && !e.nAll ? 'untried' : state, chance: round(e.mean), n: e.n, usd: round(e.usd), minutes: round(e.minutes, 1), prior: e.prior, byType };
281
449
  })
282
450
  .filter((a) => a.state !== 'gone' || a.n)
283
451
  .sort((p, q) => q.chance - p.chance);
@@ -285,6 +453,7 @@ export class Router {
285
453
  return {
286
454
  priors: { version: PRIORS.version, source: PRIORS.source },
287
455
  learned: routes.filter((r) => r.r !== null).length,
456
+ coldStart: { needed: COLD_START, have: this.learned() },
288
457
  routed: routes.length,
289
458
  arms,
290
459
  recent: routes.sort((p, q) => q.at - p.at).slice(0, 30).map((r) => ({ id: r.id, key: r.key, label: r.label, type: r.type, goal: r.goal, chance: r.chance, r: r.r, explored: r.explored, trying: r.trying, at: r.at, signals: r.signals })),
@@ -300,7 +469,11 @@ export function reward(sig) {
300
469
  if (sig.feedback === 'down') return 0;
301
470
  if (sig.closed) return 0.2;
302
471
  if (sig.rolledBack) return 0.1;
303
- if (sig.error) return 0;
472
+ if (sig.error || sig.check === 'error') return 0;
473
+ // wisp's own test run beats the agent's word; needing a repair round costs some of the credit,
474
+ // and losing a best-of-2 to the other run a little more.
475
+ if (sig.check === 'fail') return 0.1;
476
+ if (sig.check === 'pass') return round(Math.max(0.3, (sig.repaired ? 0.6 : 0.8) - (sig.picked === false ? 0.15 : 0) - 0.1 * (sig.followups || 0)));
304
477
  const base = { done: 0.6, partial: 0.3, blocked: 0.1, info: 0.5 }[sig.hero];
305
478
  if (base === undefined) return null;
306
479
  return round(Math.max(0.05, base - 0.1 * (sig.followups || 0)));