wisp-agents 0.13.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.
@@ -0,0 +1,340 @@
1
+ // Auto: wisp picks the harness and model for a task, and learns from how it went.
2
+ //
3
+ // Every harness × model the user has is an "arm". For each kind of task (bug, ui, docs, …) wisp
4
+ // keeps a Beta(wins, losses) estimate per arm, stacked from three sources: the shipped priors
5
+ // (./router-priors.json, from wisp-bench), the user's history on every kind of task, and the
6
+ // history on this kind. A launch draws a chance of success from each estimate (Thompson
7
+ // sampling: arms with little data sometimes draw high and get tried) and picks the best value
8
+ // for the goal: value × chance − expected $ − a price on your waiting time.
9
+ //
10
+ // What happened afterwards is the reward, strongest signal first: merged (1) / PR closed (0.2),
11
+ // 👍 (1) / 👎 (0), rolled back (0.1), else the presentation's hero (done 0.6, partial 0.3,
12
+ // blocked 0.1, error 0) less 0.1 per follow-up. A later, stronger signal replaces an earlier one.
13
+ //
14
+ // State lives in ~/.wisp/router.json; nothing leaves the machine except the anonymous telemetry
15
+ // fields the caller adds. Routes are recorded for local sessions and for sessions launched on
16
+ // machines (id@machine): one router per user, fed by every place it runs.
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ const PRIORS = JSON.parse(fs.readFileSync(new URL('./router-priors.json', import.meta.url), 'utf8'));
22
+
23
+ export const GOALS = {
24
+ cheap: { label: 'Cheap', value: 1, hint: 'Lowest cost that still gets it done' },
25
+ balanced: { label: 'Balanced', value: 5, hint: 'Best result per dollar' },
26
+ best: { label: 'Best', value: 50, hint: 'Best result; cost only breaks ties' },
27
+ };
28
+ export const TYPES = ['bug', 'feature', 'ui', 'test', 'refactor', 'docs', 'release', 'question'];
29
+ const MINUTE_USD = 0.05; // what a minute of waiting costs you, in the utility
30
+ const PRIOR_CAP = 8; // shipped priors count as at most this many tasks, so your own soon outweigh them
31
+ const ALL_WEIGHT = 0.5; // your history on other kinds of task counts half
32
+ const HALF_LIFE_MS = 30 * 864e5; // models change: older outcomes fade
33
+ const NEW_ARM_SHARE = 0.25; // how often a new arm on probation may take an exploring slot
34
+ const MAX_ROUTES = 2000;
35
+
36
+ // --- task features ---------------------------------------------------------------------------
37
+
38
+ const RULES = [
39
+ ['release', /\b(release|bump(ed)? (the )?version|publish|changelog for|tag v?\d)/i],
40
+ ['docs', /\b(readme|docs?|documentation|typos?|comment(s)? in|jsdoc|docstring)\b/i],
41
+ ['test', /\b(tests?|coverage|flaky|spec(s)?|e2e|unit test)\b/i],
42
+ ['bug', /\b(fix|bug|broken|crash(es|ing)?|error|exception|fails?|failing|regression|doesn'?t work|not working|wrong|issue)\b/i],
43
+ ['ui', /\b(ui|ux|button|css|layout|style|styling|color|colour|dark mode|dialog|modal|popup|screen|page|responsive|icon|animation|card|sidebar|toolbar)\b/i],
44
+ ['refactor', /\b(refactor|clean ?up|rename|simplif(y|ies)|extract|reorgani[sz]e|dedupe|split)\b/i],
45
+ ['question', /^(how|why|what|where|when|which|can|could|should|is|are|does|do)\b.*\?\s*$|\b(explain|investigate|look into|research|compare)\b/i],
46
+ ];
47
+
48
+ // { type, size }: a coarse kind of task (the router learns per kind) and a size bucket.
49
+ export function classify(prompt = '', { attachments = 0 } = {}) {
50
+ const text = String(prompt);
51
+ const type = RULES.find(([, re]) => re.test(text))?.[0] || 'feature';
52
+ const files = (text.match(/[\w./-]+\.(js|ts|tsx|jsx|py|go|rs|rb|java|css|html|md|json|ya?ml)\b/g) || []).length;
53
+ const score = text.length / 400 + files + attachments;
54
+ return { type, size: score < 1 ? 'S' : score < 4 ? 'M' : 'L' };
55
+ }
56
+
57
+ // --- arms ------------------------------------------------------------------------------------
58
+
59
+ export const armKey = (agent, model = '') => `${agent}:${model || ''}`;
60
+ const splitKey = (k) => {
61
+ const i = k.indexOf(':');
62
+ return { agent: k.slice(0, i), model: k.slice(i + 1) };
63
+ };
64
+ // Versions stripped: claude-sonnet-5-5 and claude-sonnet-5-6 are one family, and a new version
65
+ // starts from what the family already showed.
66
+ const family = (k) => k.replace(/[-.]?\d+([.-]\d+)*/g, '');
67
+
68
+ // The arms wisp may route to, from listAgents(): each harness's default model, the models the
69
+ // priors know, models with history here, and new models in a known family (on probation).
70
+ function candidates(agents, known) {
71
+ const out = [];
72
+ for (const a of agents) {
73
+ if (!a.available) continue;
74
+ const models = new Set(['', ...(a.models || []).map((m) => m.id)]);
75
+ for (const id of models) {
76
+ const k = armKey(a.id, id);
77
+ 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}:`))) {
78
+ 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 });
79
+ }
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // --- the router ------------------------------------------------------------------------------
86
+
87
+ export class Router {
88
+ constructor(home, { now = Date.now } = {}) {
89
+ this.file = path.join(home, 'router.json');
90
+ this.now = now;
91
+ this.state = { version: 1, arms: {}, stats: {}, routes: {} };
92
+ try {
93
+ Object.assign(this.state, JSON.parse(fs.readFileSync(this.file, 'utf8')));
94
+ } catch {}
95
+ this.synced = false;
96
+ }
97
+
98
+ save() {
99
+ try {
100
+ fs.writeFileSync(this.file, JSON.stringify(this.state));
101
+ } catch (err) {
102
+ console.warn(`wisp: router: ${err.message}`);
103
+ }
104
+ }
105
+
106
+ // New harnesses and models since the last look. The first look only learns what's there.
107
+ // Returns [{ key, label }] of arms that just showed up (they start on probation).
108
+ sync(agents) {
109
+ const first = !Object.keys(this.state.arms).length;
110
+ const known = new Set(Object.keys(this.state.arms));
111
+ const added = [];
112
+ const seen = new Set();
113
+ for (const c of candidates(agents, known)) {
114
+ seen.add(c.key);
115
+ const arm = this.state.arms[c.key];
116
+ if (!arm) {
117
+ // Something new later on is on probation until it has shown what it can do.
118
+ this.state.arms[c.key] = { firstSeen: this.now(), state: first || this.#prior(c.key) ? 'ready' : 'new' };
119
+ if (!first) added.push({ key: c.key, label: c.label, state: this.state.arms[c.key].state });
120
+ } else if (arm.state === 'gone') arm.state = 'ready';
121
+ }
122
+ for (const [k, arm] of Object.entries(this.state.arms)) if (!seen.has(k) && arm.state !== 'gone' && agents.some((a) => a.id === splitKey(k).agent)) arm.state = 'gone';
123
+ this.synced = true;
124
+ this.save();
125
+ return added;
126
+ }
127
+
128
+ #prior(k) {
129
+ const p = PRIORS.arms[k];
130
+ if (p) return p;
131
+ // A new version inherits its family's record, with less weight.
132
+ const kin = Object.entries(PRIORS.arms).filter(([x]) => family(x) === family(k) && splitKey(x).agent === splitKey(k).agent);
133
+ if (!kin.length) return null;
134
+ const [, best] = kin.sort((a, b) => b[1].a + b[1].b - (a[1].a + a[1].b))[0];
135
+ const s = Math.min(1, 4 / (best.a + best.b));
136
+ return { ...best, a: best.a * s, b: best.b * s, inherited: true };
137
+ }
138
+
139
+ // Decayed counts for (kind, arm) from this user's routes.
140
+ #counts(type, key) {
141
+ const s = this.state.stats[`${type}|${key}`];
142
+ if (!s) return { a: 0, b: 0, n: 0, usd: null, ms: null };
143
+ const age = this.now() - (s.at || this.now());
144
+ const f = Math.pow(0.5, age / HALF_LIFE_MS);
145
+ 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 };
146
+ }
147
+
148
+ // Beta posterior for an arm on a kind of task, plus what it usually costs.
149
+ estimate(type, key) {
150
+ const prior = this.#prior(key) || { a: 1, b: 1 };
151
+ const scale = Math.min(1, PRIOR_CAP / Math.max(1, prior.a + prior.b));
152
+ const mine = this.#counts(type, key);
153
+ const all = this.#counts('*', key);
154
+ const a = 1 + prior.a * scale + (all.a - mine.a) * ALL_WEIGHT + mine.a;
155
+ const b = 1 + prior.b * scale + (all.b - mine.b) * ALL_WEIGHT + mine.b;
156
+ // Cost and time: the prior's guess, pulled toward what this user's runs actually cost as they add up.
157
+ const blend = (own, n, guess) => (own === null ? guess : (guess * 3 + own * n) / (3 + n));
158
+ const usd = blend(all.usd, all.n, prior.usd ?? 0.3);
159
+ const minutes = blend(all.ms === null ? null : all.ms / 60_000, all.n, prior.min ?? 5);
160
+ 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 };
161
+ }
162
+
163
+ // Pick an arm. agents: listAgents() of the place it runs. goal: cheap | balanced | best.
164
+ // important: no exploring. exclude: arm keys already taken (compare). needDelegate: the lead
165
+ // of a team must be a harness that can delegate. rng: for tests.
166
+ choose({ prompt = '', attachments = 0, agents = [], goal = 'balanced', important = false, explore = true, exclude = [], needDelegate = false, allowed = null, rng = Math.random } = {}) {
167
+ if (!this.synced) this.sync(agents);
168
+ const { type, size } = classify(prompt, { attachments });
169
+ const V = (GOALS[goal] || GOALS.balanced).value;
170
+ let arms = candidates(agents, new Set(Object.keys(this.state.arms)))
171
+ .filter((c) => !exclude.includes(c.key) && (!needDelegate || c.delegates) && (!allowed || allowed.includes(c.agent)))
172
+ .map((c) => ({ ...c, est: this.estimate(type, c.key), state: this.state.arms[c.key]?.state || 'new' }));
173
+ if (!arms.length) return null;
174
+ // Quality floor: an arm that keeps failing this kind of task here is left out.
175
+ const floor = arms.filter((x) => !(x.est.n >= 5 && x.est.mean < 0.35));
176
+ if (floor.length) arms = floor;
177
+ const utility = (x, theta) => V * theta - x.est.usd - MINUTE_USD * x.est.minutes;
178
+ const draw = (r) => {
179
+ const pool = arms.filter((x) => x.state !== 'new' || (explore && !important && r() < NEW_ARM_SHARE));
180
+ const list = pool.length ? pool : arms;
181
+ let best = null;
182
+ let bu = -Infinity;
183
+ for (const x of list) {
184
+ const theta = important || !explore ? x.est.mean : betaSample(x.est.a, x.est.b, r);
185
+ const u = utility(x, theta);
186
+ if (u > bu) (best = x), (bu = u);
187
+ }
188
+ return best;
189
+ };
190
+ const pick = draw(rng);
191
+ // How likely this pick was: logged so the router's own choices can be corrected for later.
192
+ let hits = 0;
193
+ const sim = mulberry32(Math.floor(rng() * 2 ** 31));
194
+ for (let i = 0; i < 200; i++) if (draw(sim).key === pick.key) hits++;
195
+ const byMean = [...arms].sort((p, q) => utility(q, q.est.mean) - utility(p, p.est.mean));
196
+ return {
197
+ agent: pick.agent,
198
+ model: pick.model,
199
+ key: pick.key,
200
+ label: pick.label,
201
+ route: {
202
+ goal: GOALS[goal] ? goal : 'balanced',
203
+ type,
204
+ size,
205
+ key: pick.key,
206
+ label: pick.label,
207
+ chance: round(pick.est.mean),
208
+ n: pick.est.n,
209
+ usd: round(pick.est.usd),
210
+ minutes: round(pick.est.minutes, 1),
211
+ explored: byMean[0].key !== pick.key,
212
+ trying: pick.state === 'new',
213
+ propensity: round(hits / 200),
214
+ 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) })),
215
+ at: this.now(),
216
+ },
217
+ };
218
+ }
219
+
220
+ // A launch went out with this route. id: the session id (id@machine for one on a machine).
221
+ record(id, route) {
222
+ this.state.routes[id] = { ...route, signals: {}, r: null };
223
+ const ids = Object.keys(this.state.routes);
224
+ if (ids.length > MAX_ROUTES) for (const old of ids.slice(0, ids.length - MAX_ROUTES)) delete this.state.routes[old];
225
+ this.save();
226
+ }
227
+
228
+ // Something happened to a routed session: { hero, error, followups, feedback, merged, closed,
229
+ // rolledBack, costUsd, ms }. The reward is recomputed and the stats move by the difference.
230
+ signal(id, patch) {
231
+ const route = this.state.routes[id];
232
+ if (!route) return null;
233
+ Object.assign(route.signals, Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)));
234
+ const r = reward(route.signals);
235
+ if (r === null) return this.save(), route;
236
+ for (const type of [route.type, '*']) {
237
+ const k = `${type}|${route.key}`;
238
+ const s = (this.state.stats[k] ||= { a: 0, b: 0, n: 0, usd: 0, usdN: 0, ms: 0, at: this.now() });
239
+ if (route.r === null) {
240
+ s.n++;
241
+ if (typeof route.signals.costUsd === 'number') (s.usd += route.signals.costUsd), s.usdN++;
242
+ s.ms += route.signals.ms || 0;
243
+ } else {
244
+ s.a -= route.r;
245
+ s.b -= 1 - route.r;
246
+ }
247
+ s.a += r;
248
+ s.b += 1 - r;
249
+ s.at = this.now();
250
+ }
251
+ route.r = r;
252
+ // Probation ends once an arm has a few outcomes of its own.
253
+ const arm = this.state.arms[route.key];
254
+ if (arm?.state === 'new' && (this.state.stats[`*|${route.key}`]?.n || 0) >= 3) arm.state = 'ready';
255
+ this.save();
256
+ return route;
257
+ }
258
+
259
+ route(id) {
260
+ return this.state.routes[id] || null;
261
+ }
262
+
263
+ reset() {
264
+ this.state.stats = {};
265
+ this.state.routes = {};
266
+ this.save();
267
+ }
268
+
269
+ // Settings → Auto: every arm with its record overall and per kind, and the latest routes.
270
+ report(agents = []) {
271
+ const keys = new Set([...Object.keys(this.state.arms), ...candidates(agents, new Set(Object.keys(this.state.arms))).map((c) => c.key)]);
272
+ const labels = Object.fromEntries(candidates(agents, keys).map((c) => [c.key, c.label]));
273
+ const arms = [...keys]
274
+ .map((k) => {
275
+ const e = this.estimate('*', k);
276
+ 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) }]));
277
+ const state = this.state.arms[k]?.state || (labels[k] ? 'ready' : 'gone');
278
+ 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 };
279
+ })
280
+ .filter((a) => a.state !== 'gone' || a.n)
281
+ .sort((p, q) => q.chance - p.chance);
282
+ const routes = Object.entries(this.state.routes).map(([id, r]) => ({ id, ...r }));
283
+ return {
284
+ priors: { version: PRIORS.version, source: PRIORS.source },
285
+ learned: routes.filter((r) => r.r !== null).length,
286
+ routed: routes.length,
287
+ arms,
288
+ 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 })),
289
+ };
290
+ }
291
+ }
292
+
293
+ export function reward(sig) {
294
+ if (sig.merged) return 1;
295
+ if (sig.feedback === 'up') return 1;
296
+ if (sig.feedback === 'down') return 0;
297
+ if (sig.closed) return 0.2;
298
+ if (sig.rolledBack) return 0.1;
299
+ if (sig.error) return 0;
300
+ const base = { done: 0.6, partial: 0.3, blocked: 0.1, info: 0.5 }[sig.hero];
301
+ if (base === undefined) return null;
302
+ return round(Math.max(0.05, base - 0.1 * (sig.followups || 0)));
303
+ }
304
+
305
+ const round = (x, d = 2) => Math.round(x * 10 ** d) / 10 ** d;
306
+
307
+ function mulberry32(a) {
308
+ return () => {
309
+ a |= 0;
310
+ a = (a + 0x6d2b79f5) | 0;
311
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
312
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
313
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
314
+ };
315
+ }
316
+
317
+ // Beta(a, b) from two Gamma draws (Marsaglia–Tsang).
318
+ function gammaSample(k, r) {
319
+ if (k < 1) return gammaSample(k + 1, r) * Math.pow(r() || 1e-12, 1 / k);
320
+ const d = k - 1 / 3;
321
+ const c = 1 / Math.sqrt(9 * d);
322
+ for (;;) {
323
+ let x;
324
+ let v;
325
+ do {
326
+ const u1 = r() || 1e-12;
327
+ const u2 = r();
328
+ x = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
329
+ v = 1 + c * x;
330
+ } while (v <= 0);
331
+ v = v * v * v;
332
+ const u = r() || 1e-12;
333
+ if (Math.log(u) < 0.5 * x * x + d - d * v + d * Math.log(v)) return d * v;
334
+ }
335
+ }
336
+ export function betaSample(a, b, r = Math.random) {
337
+ const x = gammaSample(a, r);
338
+ const y = gammaSample(b, r);
339
+ return x / (x + y);
340
+ }
package/server/runner.js CHANGED
@@ -15,6 +15,7 @@ import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
15
15
  const MAX_ACTIVITY = 400;
16
16
  const MODES = new Set(['plan', 'edits', 'auto']);
17
17
  const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
18
+ const BACKGROUND_MAX_MS = 60 * 60_000; // how long a turn may wait on its background tasks
18
19
  const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
19
20
  // The delegate tool lives on the same injected server, so harnesses name it like ask_user.
20
21
  const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
@@ -80,6 +81,8 @@ export function summarize(s, store) {
80
81
  }),
81
82
  ),
82
83
  ...(s.parentId && { parentId: s.parentId, member: s.member }),
84
+ // Auto: what it picked and why, for the card's tooltip.
85
+ route: s.route ? { goal: s.route.goal, type: s.route.type, label: s.route.label, chance: s.route.chance, n: s.route.n, usd: s.route.usd, explored: s.route.explored, trying: s.route.trying, alternatives: s.route.alternatives } : null,
83
86
  };
84
87
  }
85
88
 
@@ -94,6 +97,7 @@ export class Runner extends EventEmitter {
94
97
  this.interrupting = new Set();
95
98
  this.answers = new Map(); // askId -> answer, until the MCP server picks it up
96
99
  this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
100
+ this.routeAuto = null; // (prompt, { cwd }) => { agent, model, route } for subagents set to Auto
97
101
 
98
102
  // Anything that was mid-flight when wisp last died is now orphaned.
99
103
  for (const s of store.all()) {
@@ -108,7 +112,7 @@ export class Runner extends EventEmitter {
108
112
  }
109
113
 
110
114
  // subagents: the team a lead may delegate to. parentId/member: this session is one of them.
111
- create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '' }) {
115
+ create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null }) {
112
116
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
113
117
  const now = Date.now();
114
118
  const session = {
@@ -132,6 +136,7 @@ export class Runner extends EventEmitter {
132
136
  ...(origin && { origin }),
133
137
  ...(subagents.length && { subagents, children: {} }),
134
138
  ...(parentId && { parentId, member }),
139
+ ...(route && { route }), // how Auto picked agent and model (server/router.js)
135
140
  };
136
141
  this.store.put(session);
137
142
  if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
@@ -260,10 +265,17 @@ export class Runner extends EventEmitter {
260
265
  child.mode = lead.mode; // and its mode, if a follow-up switched it
261
266
  this.#startTurn(child, prompt);
262
267
  } else {
263
- child = this.create({ prompt, cwd: workdir(lead), agent: m.agent, mode: lead.mode, model: m.model, effort: m.effort, parentId: lead.id, member: slot });
268
+ // An Auto member is routed on its first task and keeps that pick, so later tasks resume it.
269
+ const pick = m.agent === 'auto' ? this.routeAuto?.(prompt, { cwd: workdir(lead) }) : null;
270
+ if (m.agent === 'auto' && !pick) {
271
+ out.push({ subagent: want, error: 'Auto found no harness to run this on.' });
272
+ continue;
273
+ }
274
+ const run = pick ? { agent: pick.agent, model: pick.model, effort: '', route: pick.route } : { agent: m.agent, model: m.model, effort: m.effort };
275
+ child = this.create({ prompt, cwd: workdir(lead), ...run, mode: lead.mode, parentId: lead.id, member: slot });
264
276
  (lead.children ||= {})[slot] = child.id;
265
277
  }
266
- const d = { id: newId(), name: slot, member: m.name, agent: m.agent, model: m.model, effort: m.effort, sessionId: child.id, turnId: child.turns.at(-1).id, prompt, startedAt: Date.now() };
278
+ const d = { id: newId(), name: slot, member: m.name, agent: child.agent, model: child.model, effort: child.effort, sessionId: child.id, turnId: child.turns.at(-1).id, prompt, startedAt: Date.now() };
267
279
  (turn.delegations ||= []).push(d);
268
280
  out.push(d);
269
281
  }
@@ -380,16 +392,22 @@ export class Runner extends EventEmitter {
380
392
  : {},
381
393
  onEvent: (ev) => {
382
394
  if (released) return; // a lingering process we already let go of
383
- this.#onEvent(session, turn, ev);
395
+ const waiting = this.#onEvent(session, turn, ev) === 'waiting';
384
396
  // A CLI can outlive its result (flushing, or a background process it spawned holding
385
- // stdout open, so 'close' never fires). Don't hold the session hostage to that.
386
- if (ev.type === 'result') linger = setTimeout(release, LINGER_MS);
397
+ // stdout open, so 'close' never fires). Don't hold the session hostage to that. A result
398
+ // that left background tasks running gets up to BACKGROUND_MAX_MS for its next one.
399
+ if (ev.type === 'result') {
400
+ clearTimeout(linger);
401
+ linger = setTimeout(release, waiting ? BACKGROUND_MAX_MS : LINGER_MS);
402
+ }
387
403
  },
388
404
  });
389
405
  // Ends the run exactly once: on exit, LINGER_MS after the result, or when a follow-up arrives.
390
406
  const release = () => {
391
407
  if (released) return;
392
- if (turn.status === 'running') this.#finishTurn(session, turn, { isError: true, text: 'The agent exited without a result.' });
408
+ // Gone while waiting on background work (stopped, interrupted, or it died): the result it
409
+ // gave before waiting is the best we have.
410
+ if (turn.status === 'running') this.#finishTurn(session, turn, turn.interim || { isError: true, text: 'The agent exited without a result.' });
393
411
  released = true;
394
412
  clearTimeout(linger);
395
413
  handle.stop();
@@ -447,14 +465,41 @@ export class Runner extends EventEmitter {
447
465
  if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
448
466
  return;
449
467
  }
468
+ case 'background': {
469
+ turn.background = ev.tasks;
470
+ if (turn.interim) this.#waitItem(session, turn);
471
+ else this.store.save(session);
472
+ return;
473
+ }
450
474
  case 'result':
475
+ // It ended its turn in plain text with background tasks still running ("I'll report once
476
+ // it's done"): the CLI wakes it up when they finish and it reports again, so the turn stays
477
+ // open. A full presentation is a finished task that left something up (a dev server).
478
+ if (ev.background > 0 && !ev.isError && parsePresentation(ev.text).fallback) {
479
+ turn.interim = { text: ev.text, stats: ev.stats };
480
+ this.#waitItem(session, turn);
481
+ return 'waiting';
482
+ }
451
483
  this.#finishTurn(session, turn, ev);
452
484
  return;
453
485
  }
454
486
  }
455
487
 
488
+ // The live line while a turn waits on its background tasks.
489
+ #waitItem(session, turn) {
490
+ const tasks = turn.background || [];
491
+ const label = !tasks.length
492
+ ? 'Background work finished, wrapping up'
493
+ : tasks.length === 1
494
+ ? `Waiting on background task: ${tasks[0]}`
495
+ : `Waiting on ${tasks.length} background tasks`;
496
+ this.#onEvent(session, turn, { type: 'tool', kind: 'background', label: label.slice(0, 200) });
497
+ }
498
+
456
499
  #finishTurn(session, turn, ev, reason) {
457
500
  turn.endedAt = Date.now();
501
+ delete turn.interim;
502
+ delete turn.background;
458
503
  turn.stats = ev.stats || null;
459
504
  delete session.ask;
460
505
  if (this.stopping.delete(session.id)) ev = { ...ev, stopped: true };
package/server/store.js CHANGED
@@ -8,7 +8,7 @@ import { parsePresentation } from './protocol.js';
8
8
 
9
9
  const DEFAULT_CONFIG = {
10
10
  projects: [], // [{ id, name, path, color, addedAt, machine? }]; machine: the server/machines.js id it lives on
11
- defaults: { agent: 'claude', mode: 'edits', model: '', effort: '' },
11
+ defaults: { agent: 'auto', mode: 'edits', model: '', effort: '' }, // auto: wisp picks harness and model (server/router.js)
12
12
  recipes: [], // saved prompts with {params}: server/recipes.js
13
13
  triggers: [], // schedules and GitHub watchers: server/triggers.js
14
14
  teams: [], // saved subagent presets: server/subagents.js
@@ -57,9 +57,17 @@ export class Store {
57
57
  cfg.teams ||= [];
58
58
  cfg.remote ||= {};
59
59
  cfg.machines ||= [];
60
+ // Auto became the default harness: switch once, after that the user's pick sticks.
61
+ if (!cfg.autoIntroduced) {
62
+ if (cfg.defaults.agent !== 'auto') cfg.defaults.agentBeforeAuto = cfg.defaults.agent;
63
+ cfg.defaults.agent = 'auto';
64
+ cfg.defaults.model = cfg.defaults.models?.auto || '';
65
+ cfg.defaults.effort = '';
66
+ cfg.autoIntroduced = true;
67
+ }
60
68
  return cfg;
61
69
  } catch {
62
- return structuredClone(DEFAULT_CONFIG);
70
+ return { ...structuredClone(DEFAULT_CONFIG), autoIntroduced: true };
63
71
  }
64
72
  }
65
73
 
@@ -19,7 +19,8 @@ export function normalizeTeam(list, adapters) {
19
19
  const taken = new Set();
20
20
  const team = list.slice(0, MAX_SUBAGENTS).map((m) => {
21
21
  const agent = String(m?.agent || '');
22
- if (!adapters[agent] || agent === 'wisp') throw Object.assign(new Error(`unknown subagent harness: ${agent}`), { status: 400 });
22
+ // auto: wisp picks the harness and model when this member gets its first task.
23
+ if ((!adapters[agent] && agent !== 'auto') || agent === 'wisp') throw Object.assign(new Error(`unknown subagent harness: ${agent}`), { status: 400 });
23
24
  let base = slug(m.name) || agent;
24
25
  let name = base;
25
26
  for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
@@ -45,7 +46,10 @@ export function normalizePreset(body, adapters, prev = {}) {
45
46
  return { id: prev.id || slug(name) + '-' + Math.random().toString(36).slice(2, 6), name, members, updatedAt: Date.now() };
46
47
  }
47
48
 
48
- const who = (m, adapters) => [adapters[m.agent]?.name || m.agent, m.model || 'default model', m.effort && `${m.effort} thinking`].filter(Boolean).join(' · ');
49
+ const who = (m, adapters) =>
50
+ m.agent === 'auto'
51
+ ? 'Auto (wisp picks the harness and model for the first task you give it)'
52
+ : [adapters[m.agent]?.name || m.agent, m.model || 'default model', m.effort && `${m.effort} thinking`].filter(Boolean).join(' · ');
49
53
 
50
54
  // Appended to the lead's system prompt. `tool` is how its harness names the delegate tool.
51
55
  export function teamNote(team, adapters, tool) {
package/web/css/app.css CHANGED
@@ -1869,3 +1869,33 @@ body.electron.mac .topbar { padding-left: 88px; }
1869
1869
  .sub-task p { margin: 3px 0 0; font-size: 13px; color: var(--ink-2); white-space: pre-wrap; }
1870
1870
  .sub-wait { margin: 0; font-size: 13px; }
1871
1871
  @container (max-width: 520px) { .sub-files, .sub-dur, .sub-model { display: none; } }
1872
+
1873
+ /* Auto: wisp's router as a harness (picker, card badge, Settings → Auto) */
1874
+ .auto-mark { display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
1875
+ .auto-mark .i { width: 100%; height: 100%; }
1876
+ .model-logo.routed::before { content: ''; position: absolute; right: -3px; bottom: -3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 1.5px var(--surface); }
1877
+ .pk-note { margin: 8px 10px 4px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
1878
+ .pk-note a { color: var(--accent-ink); }
1879
+ .ro-goal { flex: none; }
1880
+ .ro-allowed { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; max-width: 360px; }
1881
+ .chip-tog { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 7px; border: 1px solid var(--line); font-size: 12px; color: var(--muted); }
1882
+ .chip-tog:hover { color: var(--ink); }
1883
+ .chip-tog.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-ink); }
1884
+ .router-modal { width: min(720px, 100%); position: relative; padding-bottom: 20px; }
1885
+ .router-modal .hint { margin: 0 0 12px; }
1886
+ .router-modal h4 { margin: 16px 0 6px; }
1887
+ .ro-table td small.ro-types { display: block; margin: 2px 0 0 19px; font-size: 11px; color: var(--faint); }
1888
+ .ro-table .sx-state { justify-content: flex-end; }
1889
+ .sx-state.ok { color: var(--good); }
1890
+ .sx-state.warn { color: var(--warn); }
1891
+ .dot.warn { background: var(--warn); }
1892
+ .ro-recent { list-style: none; margin: 0; padding: 0; }
1893
+ .ro-recent li { display: flex; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--line); font-size: 12.5px; }
1894
+ .ro-recent .ro-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
1895
+ .ro-recent small { flex: none; color: var(--faint); }
1896
+ .ro-recent .ok { color: var(--good); }
1897
+ .ro-recent .warn { color: var(--warn); }
1898
+ .ro-recent .bad { color: var(--bad); }
1899
+ .ro-recent .dim { color: var(--faint); }
1900
+ .ro-foot { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
1901
+ .ro-foot .hint { margin: 0; }