wisp-agents 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -13,7 +13,9 @@ import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js
13
13
  import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
14
14
 
15
15
  const MAX_ACTIVITY = 400;
16
+ const MODES = new Set(['plan', 'edits', 'auto']);
16
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
17
19
  const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
18
20
  // The delegate tool lives on the same injected server, so harnesses name it like ask_user.
19
21
  const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
@@ -70,7 +72,7 @@ export function summarize(s, store) {
70
72
  toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
71
73
  pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks } : null,
72
74
  groupId: s.groupId || null,
73
- origin: s.origin || null, // { kind: 'recipe' | 'trigger', id, name }
75
+ origin: s.origin || null, // { kind: 'recipe' | 'trigger' | 'telegram', id, name }
74
76
  // The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
75
77
  team: (s.subagents || []).flatMap((m) =>
76
78
  copies(m).map((name) => {
@@ -79,6 +81,8 @@ export function summarize(s, store) {
79
81
  }),
80
82
  ),
81
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,
82
86
  };
83
87
  }
84
88
 
@@ -93,6 +97,7 @@ export class Runner extends EventEmitter {
93
97
  this.interrupting = new Set();
94
98
  this.answers = new Map(); // askId -> answer, until the MCP server picks it up
95
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
96
101
 
97
102
  // Anything that was mid-flight when wisp last died is now orphaned.
98
103
  for (const s of store.all()) {
@@ -107,7 +112,7 @@ export class Runner extends EventEmitter {
107
112
  }
108
113
 
109
114
  // subagents: the team a lead may delegate to. parentId/member: this session is one of them.
110
- 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 }) {
111
116
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
112
117
  const now = Date.now();
113
118
  const session = {
@@ -131,6 +136,7 @@ export class Runner extends EventEmitter {
131
136
  ...(origin && { origin }),
132
137
  ...(subagents.length && { subagents, children: {} }),
133
138
  ...(parentId && { parentId, member }),
139
+ ...(route && { route }), // how Auto picked agent and model (server/router.js)
134
140
  };
135
141
  this.store.put(session);
136
142
  if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
@@ -138,8 +144,11 @@ export class Runner extends EventEmitter {
138
144
  return session;
139
145
  }
140
146
 
141
- reply(session, text, attachments = [], { queue = false } = {}) {
142
- if (session.ask) {
147
+ // mode: switch the permission mode (plan / edits / auto) from this follow-up on. A queued
148
+ // follow-up keeps its own, so the run before it isn't affected.
149
+ reply(session, text, attachments = [], { queue = false, mode } = {}) {
150
+ if (!MODES.has(mode) || mode === session.mode) mode = undefined;
151
+ if (session.ask && !mode) {
143
152
  this.answer(session, text);
144
153
  return { answered: true };
145
154
  }
@@ -149,22 +158,23 @@ export class Runner extends EventEmitter {
149
158
  // The run already produced its result and the CLI is only lingering on its way out:
150
159
  // there's nothing to wait for, so let it go and start the follow-up now.
151
160
  if (turn?.status !== 'running') {
152
- session.queue.push({ id: newId(), text, attachments });
161
+ session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
153
162
  handle.release();
154
163
  return { queued: session.queue.length > 0 };
155
164
  }
156
165
  // Interrupting needs an agent session to resume; before the agent reports one, queue.
157
166
  if (queue || !session.agentSessionId) {
158
- session.queue.push({ id: newId(), text, attachments });
167
+ session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
159
168
  this.#touch(session);
160
169
  return { queued: true, ahead: session.queue.length - 1 };
161
170
  }
162
171
  // Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
163
172
  const at = session.queue.findIndex((q) => !q.now);
164
- session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true });
173
+ session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true, ...(mode && { mode }) });
165
174
  this.#interrupt(session);
166
175
  return { interrupted: true };
167
176
  }
177
+ if (mode) session.mode = mode;
168
178
  this.#startTurn(session, text, attachments);
169
179
  return { queued: false };
170
180
  }
@@ -252,12 +262,20 @@ export class Runner extends EventEmitter {
252
262
  let child = this.store.get(lead.children?.[slot]);
253
263
  if (child) {
254
264
  child.cwd = workdir(lead); // follows the lead if its worktree moved
265
+ child.mode = lead.mode; // and its mode, if a follow-up switched it
255
266
  this.#startTurn(child, prompt);
256
267
  } else {
257
- 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 });
258
276
  (lead.children ||= {})[slot] = child.id;
259
277
  }
260
- 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() };
261
279
  (turn.delegations ||= []).push(d);
262
280
  out.push(d);
263
281
  }
@@ -317,6 +335,7 @@ export class Runner extends EventEmitter {
317
335
  agent: session.agent,
318
336
  model: session.model,
319
337
  effort: session.effort || '',
338
+ mode: session.mode,
320
339
  attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
321
340
  startedAt: Date.now(),
322
341
  endedAt: null,
@@ -373,16 +392,22 @@ export class Runner extends EventEmitter {
373
392
  : {},
374
393
  onEvent: (ev) => {
375
394
  if (released) return; // a lingering process we already let go of
376
- this.#onEvent(session, turn, ev);
395
+ const waiting = this.#onEvent(session, turn, ev) === 'waiting';
377
396
  // A CLI can outlive its result (flushing, or a background process it spawned holding
378
- // stdout open, so 'close' never fires). Don't hold the session hostage to that.
379
- 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
+ }
380
403
  },
381
404
  });
382
405
  // Ends the run exactly once: on exit, LINGER_MS after the result, or when a follow-up arrives.
383
406
  const release = () => {
384
407
  if (released) return;
385
- 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.' });
386
411
  released = true;
387
412
  clearTimeout(linger);
388
413
  handle.stop();
@@ -391,6 +416,7 @@ export class Runner extends EventEmitter {
391
416
  this.interrupting.delete(session.id);
392
417
  this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
393
418
  const next = session.queue.shift();
419
+ if (next?.mode) session.mode = next.mode;
394
420
  if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
395
421
  else this.#touch(session);
396
422
  };
@@ -439,14 +465,41 @@ export class Runner extends EventEmitter {
439
465
  if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
440
466
  return;
441
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
+ }
442
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
+ }
443
483
  this.#finishTurn(session, turn, ev);
444
484
  return;
445
485
  }
446
486
  }
447
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
+
448
499
  #finishTurn(session, turn, ev, reason) {
449
500
  turn.endedAt = Date.now();
501
+ delete turn.interim;
502
+ delete turn.background;
450
503
  turn.stats = ev.stats || null;
451
504
  delete session.ask;
452
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) {