wisp-agents 0.18.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.
package/server/runner.js CHANGED
@@ -95,9 +95,9 @@ export function summarize(s, store) {
95
95
  updatedAt: s.updatedAt,
96
96
  turnCount: s.turns.length,
97
97
  queued: s.queue?.length || 0,
98
- status: last?.status === 'running' ? 'running' : hero?.status || (last?.status === 'error' ? 'blocked' : 'info'),
98
+ status: last?.status === 'running' || s.auto?.busy ? 'running' : hero?.status || (last?.status === 'error' ? 'blocked' : 'info'),
99
99
  preview: hero?.summary || '',
100
- live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : '',
100
+ live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : s.auto?.busy ? s.auto.label : '',
101
101
  asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
102
102
  question: s.ask?.question || '',
103
103
  approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
@@ -117,8 +117,23 @@ export function summarize(s, store) {
117
117
  }),
118
118
  ),
119
119
  ...(s.parentId && { parentId: s.parentId, member: s.member }),
120
+ followups: s.turns.slice(1).filter((t) => t.by !== 'wisp').length, // yours, not Auto's repair rounds
121
+ // Auto's steps after the run: the last test check, repairs, a hand-over, the best-of-2 pick.
122
+ auto: s.auto
123
+ ? {
124
+ strategy: s.auto.strategy,
125
+ busy: !!s.auto.busy,
126
+ stage: s.auto.stage,
127
+ repairs: s.auto.repairs,
128
+ check: s.auto.checks.at(-1) ? (({ verdict, command, passed, failed, newFailures, note }) => ({ verdict, command, passed, failed, newFailures: newFailures?.length || 0, note }))(s.auto.checks.at(-1)) : null,
129
+ escalatedTo: s.auto.escalatedTo || null,
130
+ escalatedFrom: s.auto.escalatedFrom || null,
131
+ reason: s.auto.reason || null, // why it moved on: tests | error | limit
132
+ pick: s.auto.pick && !s.auto.pick.pending ? s.auto.pick : null,
133
+ }
134
+ : null,
120
135
  // Auto: what it picked and why, for the card's tooltip.
121
- 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, ...(s.route.switched && { switched: true }) } : null,
136
+ route: s.route ? { goal: s.route.goal, type: s.route.type, label: s.route.label, effort: s.route.effort, chance: s.route.chance, n: s.route.n, usd: s.route.usd, explored: s.route.explored, trying: s.route.trying, alternatives: s.route.alternatives, ...(s.route.switched && { switched: true }) } : null,
122
137
  };
123
138
  }
124
139
 
@@ -134,11 +149,13 @@ export class Runner extends EventEmitter {
134
149
  this.answers = new Map(); // askId -> answer, until the MCP server picks it up
135
150
  this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
136
151
  this.routeAuto = null; // (prompt, { cwd }) => { agent, model, route } for subagents set to Auto
152
+ this.pipeline = null; // Auto's check / repair / hand-over steps (server/pipeline.js)
137
153
 
138
154
  // Anything that was mid-flight when wisp last died is now orphaned.
139
155
  for (const s of store.all()) {
140
156
  const t = s.turns.at(-1);
141
157
  delete s.ask;
158
+ if (s.auto?.busy) Object.assign(s.auto, { busy: false, stage: 'cancelled', label: '' }); // Auto's step died with wisp
142
159
  if (s.state === 'running' || s.state === 'waiting' || t?.status === 'running') {
143
160
  if (t?.status === 'running') this.#finishTurn(s, t, { isError: true, stopped: true, text: '' }, 'wisp was restarted while this was running.');
144
161
  s.state = 'review';
@@ -148,7 +165,7 @@ export class Runner extends EventEmitter {
148
165
  }
149
166
 
150
167
  // subagents: the team a lead may delegate to. parentId/member: this session is one of them.
151
- create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null }) {
168
+ create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null, auto = null, scout = null }) {
152
169
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
153
170
  const now = Date.now();
154
171
  const session = {
@@ -172,8 +189,10 @@ export class Runner extends EventEmitter {
172
189
  ...(groupId && { groupId }),
173
190
  ...(origin && { origin }),
174
191
  ...(subagents.length && { subagents, children: {} }),
192
+ ...(auto && { auto: { strategy: 'single', busy: false, stage: '', label: '', repairs: 0, checks: [], ...auto } }), // server/pipeline.js
193
+ ...(scout && { scout }), // what the scout found before it started (server/scout.js)
175
194
  ...(parentId && { parentId, member }),
176
- ...(route && { route }), // how Auto picked agent and model (server/router.js)
195
+ ...(route && { route }), // how Auto picked agent, model and level (server/router.js)
177
196
  };
178
197
  this.store.put(session);
179
198
  if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
@@ -185,10 +204,13 @@ export class Runner extends EventEmitter {
185
204
  // follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
186
205
  // switches the harness, model and thinking level the same way (see #switchRun).
187
206
  // grants / once: a retry of what the last turn's permissions blocked (see approveBlocked).
188
- reply(session, text, attachments = [], { queue = false, mode, run, grants, once } = {}) {
207
+ // by: 'wisp' for a follow-up wisp sends itself (Auto's repair round). Anything else is the
208
+ // user's, and cancels whatever Auto was about to do next.
209
+ reply(session, text, attachments = [], { queue = false, mode, run, grants, once, by } = {}) {
189
210
  if (!MODES.has(mode) || mode === session.mode) mode = undefined;
190
211
  run = changedRun(session, run);
191
- const extra = { ...(mode && { mode }), ...(run && { run }), ...(grants?.length && { grants }), ...(MODES.has(once) && { once }) };
212
+ if (by !== 'wisp') this.pipeline?.cancel(session);
213
+ const extra = { ...(mode && { mode }), ...(run && { run }), ...(grants?.length && { grants }), ...(MODES.has(once) && { once }), ...(by === 'wisp' && { by }) };
192
214
  if (session.ask && !Object.keys(extra).length) {
193
215
  this.answer(session, text);
194
216
  return { answered: true };
@@ -249,7 +271,7 @@ export class Runner extends EventEmitter {
249
271
  if (session.agentSessionId) session.agentSessions[session.agent] = session.agentSessionId;
250
272
  for (const t of session.turns) t.agent ||= session.agent;
251
273
  // Auto's pick was overruled: the card stops crediting it, and the router learns (services/auto.js).
252
- if (session.route && (agent !== session.agent || model !== session.model)) session.route.switched = true;
274
+ if (session.route && (agent !== session.agent || model !== session.model || effort !== (session.effort || ''))) session.route.switched = true;
253
275
  if (agent !== session.agent) {
254
276
  session.agent = agent;
255
277
  session.agentSessionId = session.agentSessions[agent] || null;
@@ -383,7 +405,7 @@ export class Runner extends EventEmitter {
383
405
  out.push({ subagent: want, error: 'Auto found no harness to run this on.' });
384
406
  continue;
385
407
  }
386
- const run = pick ? { agent: pick.agent, model: pick.model, effort: '', route: pick.route } : { agent: m.agent, model: m.model, effort: m.effort };
408
+ const run = pick ? { agent: pick.agent, model: pick.model, effort: pick.effort || '', route: pick.route } : { agent: m.agent, model: m.model, effort: m.effort };
387
409
  child = this.create({ prompt, cwd: workdir(lead), ...run, mode: lead.mode, parentId: lead.id, member: slot });
388
410
  (lead.children ||= {})[slot] = child.id;
389
411
  }
@@ -413,6 +435,7 @@ export class Runner extends EventEmitter {
413
435
  }
414
436
 
415
437
  stop(session) {
438
+ this.pipeline?.cancel(session);
416
439
  session.queue = [];
417
440
  delete session.ask;
418
441
  for (const cid of Object.values(session.children || {})) {
@@ -492,9 +515,10 @@ export class Runner extends EventEmitter {
492
515
 
493
516
  // grants: harness rules allowing what the user approved, for this turn only. once: a mode for
494
517
  // this turn only (an approved retry the harness can't scope runs in Full auto).
495
- #startTurn(session, prompt, attachments = [], { grants = [], once } = {}) {
518
+ #startTurn(session, prompt, attachments = [], { grants = [], once, by } = {}) {
496
519
  const mode = MODES.has(once) ? once : session.mode;
497
520
  const turn = this.#newTurn(session, prompt, attachments, mode);
521
+ if (by === 'wisp') turn.by = 'wisp'; // sent by wisp (Auto's repair round), not by you
498
522
  session.turns.push(turn);
499
523
  session.state = 'running';
500
524
  session.unread = false;
@@ -679,15 +703,37 @@ export class Runner extends EventEmitter {
679
703
  if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
680
704
  }
681
705
  if (!ev.stopped) blockedCard(turn);
682
- session.state = 'review';
683
- session.unread = !interrupted;
706
+ // Auto checks the work before it reaches you: the session stays in Working meanwhile.
707
+ const holding = !interrupted && !session.queue.length && !!this.pipeline?.wants(session, turn, ev);
708
+ session.state = holding ? 'running' : 'review';
709
+ session.unread = !interrupted && !holding;
684
710
  this.store.save(session, { now: true });
685
711
  this.#touch(session, { turn: turn.id });
686
712
  this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
687
713
  this.#touchLead(session);
714
+ if (holding) setImmediate(() => this.pipeline.run(session, turn));
688
715
  // The follow-up starts right away; don't notify for the run it replaced. A subagent reports
689
716
  // to its lead, not to the user.
690
- if (!interrupted && !session.parentId) this.emit('finished', { session: summarize(session, this.store) });
717
+ else if (!interrupted && !session.parentId) this.emit('finished', { session: summarize(session, this.store) });
718
+ }
719
+
720
+ // Auto is done with a session it held: it lands in your queue (quiet: handed to another run,
721
+ // which will be the one to notify).
722
+ settle(session, { quiet = false } = {}) {
723
+ if (session.state !== 'running' || (this.live.has(session.id) && session.turns.at(-1)?.status === 'running')) return this.#touch(session);
724
+ session.state = 'review';
725
+ session.unread = !quiet;
726
+ this.store.save(session, { now: true });
727
+ this.#touch(session, { turn: session.turns.at(-1)?.id });
728
+ if (!quiet && !session.parentId) this.emit('finished', { session: summarize(session, this.store) });
729
+ }
730
+
731
+ // A line in a turn's activity from wisp itself (Auto's check, repair, hand-over).
732
+ note(session, turn, { kind = 'check', label }) {
733
+ const item = { t: Date.now(), kind, label: String(label).slice(0, 300), by: 'wisp' };
734
+ turn.activity.push(item);
735
+ this.emit('activity', { sessionId: session.id, turnId: turn.id, item });
736
+ this.#touch(session, { turn: turn.id });
691
737
  }
692
738
 
693
739
  #touch(session, { turn, quiet } = {}) {
@@ -0,0 +1,71 @@
1
+ // The scout: a quick look before an Auto task starts. scanProject() is free (files on disk: the
2
+ // test command, languages, size) and cached; scoutTask() asks a cheap model, read-only, which kind
3
+ // of task it is, how big, and which files matter. Auto routes on that, and the agent gets the
4
+ // file list and test command as a head start.
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import path from 'node:path';
8
+ import { detectTests } from './check.js';
9
+ import { askJson } from './quick.js';
10
+ import { TYPES } from './router.js';
11
+
12
+ const scans = new Map(); // dir -> { at, value }
13
+ const SCAN_TTL_MS = 10 * 60_000;
14
+
15
+ export function scanProject(dir, project = null) {
16
+ const key = `${dir}\0${project?.testCommand || ''}`;
17
+ const hit = scans.get(key);
18
+ if (hit && Date.now() - hit.at < SCAN_TTL_MS) return hit.value;
19
+ let files = [];
20
+ try {
21
+ files = execFileSync('git', ['ls-files'], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20, timeout: 5000 }).toString().split('\n').filter(Boolean);
22
+ } catch {}
23
+ const ext = {};
24
+ for (const f of files) {
25
+ const e = path.extname(f).slice(1).toLowerCase();
26
+ if (e && e.length <= 5) ext[e] = (ext[e] || 0) + 1;
27
+ }
28
+ const value = {
29
+ tests: detectTests(dir, project),
30
+ languages: Object.entries(ext).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([e]) => e),
31
+ files: files.length,
32
+ };
33
+ scans.set(key, { at: Date.now(), value });
34
+ return value;
35
+ }
36
+
37
+ // { type, size, files, tests, notes } from a cheap model, or null. Read-only, at most timeoutMs.
38
+ export async function scoutTask({ prompt, cwd, agents, timeoutMs = 75_000, scan = null }) {
39
+ const ask = `A coding agent is about to work on the task below in this repository. Take a quick, read-only look (list and read a few files; don't run anything slow) and reply with one JSON object:
40
+
41
+ {"type": one of ${TYPES.map((t) => `"${t}"`).join(', ')},
42
+ "size": "S" (a few lines in one file) | "M" (a few files) | "L" (many files or a design decision),
43
+ "files": [up to 8 repo-relative paths that will most likely change or matter],
44
+ "tests": "the command that runs the tests that cover this, or empty",
45
+ "notes": "one sentence the agent should know before starting, or empty"}
46
+ ${scan?.tests?.command ? `\nThe project's test command is \`${scan.tests.command}\`.` : ''}
47
+ <task>
48
+ ${String(prompt).slice(0, 6000)}
49
+ </task>`;
50
+ const j = await askJson({ agents, prompt: ask, cwd, timeoutMs });
51
+ if (!j) return null;
52
+ const files = (Array.isArray(j.files) ? j.files : []).map(String).filter((f) => f && !f.startsWith('/') && !f.includes('..')).slice(0, 8);
53
+ return {
54
+ type: TYPES.includes(j.type) ? j.type : null,
55
+ size: ['S', 'M', 'L'].includes(j.size) ? j.size : null,
56
+ files,
57
+ tests: typeof j.tests === 'string' ? j.tests.slice(0, 200) : '',
58
+ notes: typeof j.notes === 'string' ? j.notes.slice(0, 300) : '',
59
+ };
60
+ }
61
+
62
+ // What the agent is told, appended to its system prompt.
63
+ export function scoutNote(scout) {
64
+ if (!scout) return '';
65
+ const lines = [];
66
+ if (scout.files?.length) lines.push(`Likely relevant files: ${scout.files.map((f) => `\`${f}\``).join(', ')}.`);
67
+ const tests = scout.tests || scout.testCommand;
68
+ if (tests) lines.push(`Tests: \`${tests}\`. When you finish, wisp runs the project's tests itself and sends any new failures back to you.`);
69
+ if (scout.notes) lines.push(scout.notes);
70
+ return lines.length ? `\n\n## Scouted by wisp\n\n${lines.join('\n')}` : '';
71
+ }
@@ -1,10 +1,15 @@
1
- // Auto: wisp picks harness and model per task and learns from outcomes (server/router.js).
1
+ // Auto: wisp picks harness, model and thinking level per task and learns from outcomes (server/router.js).
2
2
  // This wires the router to the rest of the server: which harnesses are installed here, the
3
3
  // user's Auto settings, and the outcome signals that come back from summaries.
4
4
 
5
+ import fs from 'node:fs';
5
6
  import { listAgents } from '../adapters/index.js';
6
- import { GOALS } from '../router.js';
7
- import { bad } from '../util.js';
7
+ import { GOALS, classify } from '../router.js';
8
+ import { createWorktree, repoRoot } from '../git.js';
9
+ import { Pipeline, autoSettings } from '../pipeline.js';
10
+ import { scanProject, scoutTask } from '../scout.js';
11
+ import { summarize } from '../runner.js';
12
+ import { bad, newId } from '../util.js';
8
13
 
9
14
  export function createAuto(app) {
10
15
  const { store, router } = app;
@@ -48,31 +53,115 @@ export function createAuto(app) {
48
53
  const pr = sum.pr?.state;
49
54
  router.signal(sum.id, {
50
55
  hero: ['done', 'partial', 'blocked', 'info'].includes(sum.status) ? sum.status : undefined,
51
- followups: Math.max(0, (sum.turnCount || 1) - 1),
56
+ followups: sum.followups ?? Math.max(0, (sum.turnCount || 1) - 1),
57
+ check: sum.auto?.check?.verdict && sum.auto.check.verdict !== 'skip' ? sum.auto.check.verdict : undefined,
52
58
  merged: pr === 'MERGED' || undefined,
53
59
  closed: pr === 'CLOSED' || undefined,
54
60
  switched: sum.route?.switched || undefined,
55
61
  });
56
62
  };
57
63
 
58
- // agent 'auto' (and compare entries set to it) → a harness and model picked by the router from
64
+ // agent 'auto' (and compare entries set to it) → a harness, model and thinking level picked by the router from
59
65
  // `agents`: this computer's, or the machine's the launch goes to. Model is the goal for Auto.
60
- const resolve = async (body, agents, { team = false } = {}) => {
66
+ // cwd (launches here only): the scout looks at it first, and best-of-2 needs it to be a repo.
67
+ const resolve = async (body, agents, { team = false, cwd = null } = {}) => {
61
68
  const want = [{ agent: body.agent, model: body.model, effort: body.effort }, ...(Array.isArray(body.compare) ? body.compare : [])];
62
69
  if (!want.some((r) => r.agent === 'auto')) return body;
70
+ const set = autoSettings(store.config.defaults);
71
+ // The scout: the scan is free; the cheap-model look runs for bigger tasks, every task, or never.
72
+ let scout = null;
73
+ if (cwd) {
74
+ const scan = scanProject(cwd, store.projectFor(cwd));
75
+ scout = { testCommand: scan.tests?.command || '', languages: scan.languages };
76
+ const big = classify(body.prompt).size !== 'S' || String(body.prompt).length > 400;
77
+ if (set.scout === 'all' || (set.scout === 'big' && big)) {
78
+ const found = await scoutTask({ prompt: body.prompt, cwd, agents, scan }).catch(() => null);
79
+ if (found) Object.assign(scout, found, { by: 'model' });
80
+ }
81
+ }
82
+ // Best-of-2: the Best goal (or an important task) gets two different picks side by side, each
83
+ // in its own worktree, and wisp marks the better one once both are checked.
84
+ const goal = goalOf(want[0].model);
85
+ const best2 = !!cwd && set.best2 && want.length === 1 && want[0].agent === 'auto' && (goal === 'best' || !!body.important) && !team && !!(await repoRoot(cwd).catch(() => null));
86
+ if (best2) want.push({ agent: 'auto', model: want[0].model });
63
87
  const taken = [];
64
- const out = want.map((r, i) => {
65
- if (r.agent !== 'auto') return r;
66
- const pick = router.choose({ prompt: body.prompt, attachments: (body.attachments || []).length, agents, goal: goalOf(r.model), important: !!body.important, exclude: taken, needDelegate: i === 0 && team, ...opts() });
67
- if (!pick) throw bad('Auto found no installed harness to run this on.');
68
- taken.push(pick.key);
69
- return { agent: pick.agent, model: pick.model, effort: '', route: pick.route };
88
+ const out = want
89
+ .map((r, i) => {
90
+ if (r.agent !== 'auto') return r;
91
+ const pick = router.choose({ prompt: body.prompt, attachments: (body.attachments || []).length, agents, goal: goalOf(r.model), important: !!body.important || (best2 && i === 0), exclude: taken, needDelegate: i === 0 && team, scouted: scout?.by ? scout : null, ...opts() });
92
+ if (!pick) {
93
+ if (best2 && i === 1) return null; // only one option to pick from: a single run then
94
+ throw bad('Auto found no installed harness to run this on.');
95
+ }
96
+ taken.push(pick.key);
97
+ return { agent: pick.agent, model: pick.model, effort: pick.effort, route: pick.route };
98
+ })
99
+ .filter(Boolean);
100
+ const autoStrategy = best2 && out.length === 2 ? 'best2' : out.length > 1 ? 'compare' : 'single';
101
+ return { ...body, ...out[0], compare: out.slice(1), scout, autoStrategy };
102
+ };
103
+
104
+ // Escalation: the same task again on a stronger pick, in its own worktree from the same base,
105
+ // next to the first run (one task, two rows).
106
+ // reason: 'tests' | 'error' (a hand-over to a stronger pick) or 'limit' (the harness was at its
107
+ // usage limit: the new run is a fresh start that may still repair and hand over itself).
108
+ const launchSibling = async (s, pick, { reason = 'tests', tried = [] } = {}) => {
109
+ const first = s.turns[0];
110
+ const id = newId();
111
+ if (!s.groupId) s.groupId = newId();
112
+ const wt = s.worktree;
113
+ const worktree = wt ? await createWorktree({ cwd: s.cwd, home: store.home, id, title: first.prompt, source: { root: wt.root, base: { ref: wt.head || wt.baseRef, branch: wt.base, remote: wt.remote, sha: wt.head } } }).catch(() => null) : null;
114
+ const sib = app.runner.create({
115
+ id,
116
+ prompt: first.prompt,
117
+ cwd: s.cwd,
118
+ agent: pick.agent,
119
+ model: pick.model,
120
+ effort: pick.effort || '',
121
+ mode: s.mode,
122
+ attachments: app.uploads.resolve((first.attachments || []).map((a) => a.id)),
123
+ worktree,
124
+ groupId: s.groupId,
125
+ origin: s.origin || null,
126
+ subagents: s.subagents || [],
127
+ route: { ...pick.route, escalated: reason !== 'limit', ...(reason === 'limit' && { moved: true }) },
128
+ auto: { strategy: reason === 'limit' ? s.auto?.strategy || 'single' : 'escalated', escalatedFrom: s.id, reason, tried: [...new Set([...tried, ...(s.auto?.tried || []), s.route?.key].filter(Boolean))] },
129
+ scout: s.scout || null,
70
130
  });
71
- return { ...body, ...out[0], compare: out.slice(1) };
131
+ // Now one task with two runs: it's named after the prompt, like a compare task.
132
+ if (s.titleAuto) s.title = sib.title;
133
+ app.sessionChanged(s);
134
+ return sib;
72
135
  };
73
136
 
137
+ // Auto's steps after a run: check with the tests, repair, hand over, judge best-of-2.
138
+ const pipeline = new Pipeline({
139
+ store,
140
+ runner: app.runner,
141
+ router,
142
+ settings: () => autoSettings(store.config.defaults),
143
+ agents: () => agentsHere,
144
+ projectOf: (s) => store.projectFor(s.cwd),
145
+ launchSibling,
146
+ broadcast: (e, d) => app.broadcast(e, d),
147
+ summarize: (s) => summarize(s, store),
148
+ onSettled: (s) => {
149
+ signals(summarize(s, store));
150
+ const a = s.auto;
151
+ const c = a.checks.at(-1);
152
+ app.telemetry.track('auto_settled', { strategy: a.strategy, verdict: c?.verdict || 'none', repairs: a.repairs, escalated: !!a.escalatedTo, escalation: !!a.escalatedFrom, checkMs: c?.durationMs, routeType: s.route?.type });
153
+ },
154
+ });
155
+ app.runner.pipeline = pipeline;
156
+
157
+ // The test command per project (here only), for the Auto view.
158
+ const projects = () =>
159
+ store.config.projects
160
+ .filter((p) => !p.machine && fs.existsSync(p.path))
161
+ .map((p) => ({ id: p.id, name: p.name, color: p.color, testCommand: p.testCommand || '', detected: scanProject(p.path, { ...p, testCommand: '' }).tests }));
162
+
74
163
  // What the Auto view shows: goals, settings, and what the router learned.
75
- const view = async () => ({ goals: GOALS, goal: goalOf(), settings: { explore: opts().explore, allowed: opts().allowed }, ...router.report(await agents()) });
164
+ const view = async () => ({ goals: GOALS, goal: goalOf(), settings: { explore: opts().explore, allowed: opts().allowed, ...autoSettings(store.config.defaults) }, projects: projects(), ...router.report(await agents()) });
76
165
 
77
166
  return { detect, agents, listed, opts, goalOf, signals, resolve, view };
78
167
  }
@@ -29,12 +29,16 @@ export function createLaunch(app) {
29
29
  const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
30
30
  // compare: more { agent, model, effort } to run the same task on, side by side. 'auto' is
31
31
  // resolved here: the router picks a harness and model (a different one per compare run).
32
- const resolved = await app.auto.resolve(body, await app.auto.agents(), { team: subagents.length > 0 });
33
- const runners = [{ agent: resolved.agent, model: resolved.model, effort: resolved.effort, route: resolved.route }, ...(resolved.compare || [])].slice(0, MAX_COMPARE).map((r, i) => {
32
+ const resolved = await app.auto.resolve(body, await app.auto.agents(), { team: subagents.length > 0, cwd });
33
+ // A launch routed by another wisp (this is a machine) arrives with its route: Auto's steps
34
+ // (check, repair) still run here, where the files are.
35
+ const isRoute = (x) => x && typeof x === 'object' && typeof x.key === 'string';
36
+ const runners = [{ agent: resolved.agent, model: resolved.model, effort: resolved.effort, route: resolved.route ?? body.route }, ...(resolved.compare || [])].slice(0, MAX_COMPARE).map((r, i) => {
34
37
  const agent = r.agent || (i ? d.agent : '') || 'claude';
35
38
  if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
36
- return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '', ...(r.route && { route: r.route }) };
39
+ return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '', ...(isRoute(r.route) && { route: r.route }) };
37
40
  });
41
+ const strategy = ['single', 'best2', 'compare'].includes(resolved.autoStrategy) ? resolved.autoStrategy : runners.length > 1 ? 'compare' : 'single';
38
42
  const mode = MODES.has(body.mode) ? body.mode : 'auto';
39
43
  // Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
40
44
  // runs need one each, or the harnesses would edit the same files.
@@ -62,7 +66,7 @@ export function createLaunch(app) {
62
66
  }
63
67
  if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
64
68
  }
65
- return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
69
+ return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents, ...(r.route && { auto: { strategy }, scout: resolved.scout || null }) }));
66
70
  }
67
71
 
68
72
  // Where a launch runs: body.machine, else a project folder that only exists on a machine
@@ -89,8 +93,9 @@ export function createLaunch(app) {
89
93
  // back through the summaries it streams (auto.signals), so one router learns from every place.
90
94
  const routed = await app.auto.resolve(body, machines.agentsOf(mid) || [], { team: !!(body.subagents?.length || body.preset) });
91
95
  const routes = [routed.route, ...(routed.compare || []).map((c) => c.route)];
92
- const sendBody = { ...routed, compare: routed.compare?.map(({ route, ...c }) => c) };
93
- delete sendBody.route;
96
+ // The machine runs Auto's check and repair on these picks (an older wisp there ignores them).
97
+ const sendBody = { ...routed };
98
+ delete sendBody.scout;
94
99
  if (!sendBody.compare?.length && !body.compare) delete sendBody.compare;
95
100
  const out = await machines.launch(mid, { ...sendBody, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
96
101
  (out.group || [out]).forEach((x, i) => routes[i] && app.router.record(qualify(x.id, mid), routes[i]));
@@ -1,7 +1,8 @@
1
1
  // Ship it: whether a worktree session has something to ship, the draft commit message and PR
2
2
  // text, and following open PRs (checks, reviews, merges) so their cards stay current. A PR that
3
- // starts conflicting with its base gets fixed on its own: the task is sent a follow-up to merge the
4
- // base in, and the result is pushed to the PR when it's done (defaults.fixConflicts: 'off' to stop).
3
+ // conflicts with its base gets fixed when the user clicks Fix conflicts on its row (or on its own
4
+ // with defaults.fixConflicts: 'on'): the task is sent a follow-up to merge the base in, and the
5
+ // result is pushed to the PR when it's done.
5
6
 
6
7
  import fs from 'node:fs';
7
8
  import { worktreeStatus, prView, ship, baseMerged, conflictsWithBase } from '../git.js';
@@ -9,6 +10,8 @@ import { blocksToMarkdown } from '../render.js';
9
10
  import { summarize } from '../runner.js';
10
11
 
11
12
  const PR_POLL_MS = 60_000;
13
+ const PR_FAST_POLL_MS = 10_000; // while GitHub is still working something out: checks running, mergeability unknown, a push just landed
14
+ const PR_TICK_MS = 5_000;
12
15
  const SHIP_RECHECK_MS = 5_000;
13
16
  const MAX_CONFLICT_FIXES = 3; // per task, so a base that keeps moving can't run up the bill
14
17
 
@@ -75,20 +78,24 @@ export function createShip(app) {
75
78
  return pr;
76
79
  };
77
80
 
78
- // The PR conflicts with its base: send the task a follow-up to merge the base in. Once per PR
79
- // head, so a fix that didn't take isn't retried until something changes the branch.
81
+ // The PR conflicts with its base: send the task a follow-up to merge the base in. Only when the
82
+ // user clicks Fix conflicts (force), unless defaults.fixConflicts is 'on': then on its own, once
83
+ // per PR head, so a fix that didn't take isn't retried until something changes the branch.
80
84
  const starting = new Set(); // a poll and a refresh at once must not both send it
81
- const fixConflicts = async (s) => {
85
+ const fixConflicts = async (s, { force = false } = {}) => {
82
86
  const pr = s.pr;
83
- if (store.config.defaults.fixConflicts === 'off' || pr?.state !== 'OPEN' || !pr.conflict || !s.worktree?.remote) return;
87
+ if ((!force && store.config.defaults.fixConflicts !== 'on') || pr?.state !== 'OPEN' || !pr.conflict || !s.worktree?.remote) return;
84
88
  if (runner.live.has(s.id) || s.shipping || s.ask || s.archived || starting.has(s.id)) return;
85
89
  const tries = s.conflictFix?.tries || 0;
86
- if (s.conflictFix?.head === pr.head || tries >= MAX_CONFLICT_FIXES) return;
87
- starting.add(s.id);
88
- try {
89
- if ((await conflictsWithBase(s.worktree)) === false || runner.live.has(s.id)) return;
90
- } finally {
91
- starting.delete(s.id);
90
+ if (!force && (s.conflictFix?.head === pr.head || tries >= MAX_CONFLICT_FIXES)) return;
91
+ // The user saw the conflict and asked: no fetch first, the follow-up starts at once.
92
+ if (!force) {
93
+ starting.add(s.id);
94
+ try {
95
+ if ((await conflictsWithBase(s.worktree)) === false || runner.live.has(s.id)) return;
96
+ } finally {
97
+ starting.delete(s.id);
98
+ }
92
99
  }
93
100
  s.conflictFix = { step: 'fixing', head: pr.head, tries: tries + 1, startedAt: Date.now() };
94
101
  await app.launcher.replyTo(s, conflictPrompt(s), [], true);
@@ -116,6 +123,7 @@ export function createShip(app) {
116
123
  const onStep = (step) => ((s.shipping = { ...s.shipping, step }), app.sessionChanged(s));
117
124
  try {
118
125
  s.pr = await ship(s.worktree, { title: `Merge ${s.worktree.remote}/${s.worktree.base} into ${s.worktree.branch}`, pr: s.pr, onStep });
126
+ s.pushedAt = Date.now();
119
127
  fix.step = 'done';
120
128
  } catch (err) {
121
129
  Object.assign(fix, { step: 'failed', why: `Push failed: ${err.message}` });
@@ -152,9 +160,23 @@ export function createShip(app) {
152
160
  }
153
161
  for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheck(s);
154
162
 
163
+ // Open PRs are checked every minute, and every few seconds while their state is still settling,
164
+ // so a check finishing or a pushed fix clearing the conflict shows up quickly.
165
+ const settling = (s) => {
166
+ const pr = s.pr;
167
+ return !!(pr.checks?.pending || pr.mergeState === 'UNKNOWN' || ['pushing', 'done'].includes(s.conflictFix?.step) || Date.now() - (s.pushedAt || 0) < 5 * 60_000);
168
+ };
169
+ let polling = false;
155
170
  const timer = setInterval(async () => {
156
- for (const s of store.all()) if (s.pr?.state === 'OPEN') await refreshPr(s).catch(() => {});
157
- }, PR_POLL_MS);
171
+ if (polling) return;
172
+ polling = true;
173
+ try {
174
+ const due = store.all().filter((s) => s.pr?.state === 'OPEN' && Date.now() - (s.pr.checkedAt || 0) >= (settling(s) ? PR_FAST_POLL_MS : PR_POLL_MS));
175
+ await Promise.all(due.map((s) => refreshPr(s).catch(() => {})));
176
+ } finally {
177
+ polling = false;
178
+ }
179
+ }, PR_TICK_MS);
158
180
  timer.unref();
159
181
 
160
182
  return { recheck, refreshPr, fixConflicts, draft, stop: () => clearInterval(timer) };
package/web/css/auto.css CHANGED
@@ -27,3 +27,25 @@
27
27
  .ro-recent .dim { color: var(--faint); }
28
28
  .ro-foot { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
29
29
  .ro-foot .hint { margin: 0; }
30
+
31
+ /* Auto's steps after a run: check, repair, hand-over, best of two */
32
+ .pill.auto-ck { gap: 4px; }
33
+ .pill.auto-ck .i, .pill.auto-pick .i { width: 11px; height: 11px; }
34
+ .pill.auto-ck.ck-pass { --tone: var(--good); color: var(--good); }
35
+ .pill.auto-ck.ck-fail { --tone: var(--bad); color: var(--bad); }
36
+ .pill.auto-ck.ck-unknown { color: var(--muted); }
37
+ .pill.auto-pick { gap: 4px; color: var(--accent-ink); }
38
+ .pill.auto-handed { color: var(--muted); }
39
+ .ask.by-wisp { background: color-mix(in srgb, var(--accent) 6%, var(--bg-2)); }
40
+ .ask-by { display: flex; align-items: center; gap: 5px; margin-bottom: 4px; font-size: 11.5px; font-weight: 500; color: var(--accent-ink); }
41
+ .ask-by .i { width: 12px; height: 12px; }
42
+ .t-by { font-weight: 600; color: var(--accent-ink); }
43
+ .tests-by { margin-left: 8px; padding: 1px 6px; border-radius: 5px; font-size: 11px; font-weight: 500; vertical-align: middle; color: var(--accent-ink); background: var(--accent-soft); }
44
+ .ro-repairs, .ro-scout { flex: none; }
45
+ .ro-tests { display: flex; flex-direction: column; }
46
+ .ro-test { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--line); }
47
+ .ro-test .us-proj { width: 160px; flex: none; font-size: 12.5px; }
48
+ .ro-test .input { flex: 1; min-width: 0; font-size: 12px; }
49
+ .router-modal .hint code { font-size: 11px; padding: 0 4px; border-radius: 4px; background: var(--bg-2); }
50
+ .ro-tier { margin-left: 6px; padding: 0 5px; border-radius: 4px; font-style: normal; font-size: 10.5px; color: var(--faint); background: var(--bg-2); }
51
+ .ro-tier.t3 { color: var(--accent-ink); background: var(--accent-soft); }
package/web/css/ship.css CHANGED
@@ -63,6 +63,10 @@
63
63
  .pr-line { color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
64
64
  .pr-strip .ghost .i { width: 13px; height: 13px; }
65
65
  .pr-strip .pr-merge { color: var(--good); font-weight: 500; }
66
+ .pr-strip .pr-fix { color: var(--bad); font-weight: 500; }
67
+ .pr-strip > .ghost { flex: none; white-space: nowrap; }
68
+ .pr-strip [data-pr="refresh"].busy .i { animation: pr-spin 0.8s linear infinite; }
69
+ @keyframes pr-spin { to { transform: rotate(-360deg); } }
66
70
  @keyframes pulse { 50% { opacity: 0.35; } }
67
71
 
68
72
  .ship-modal, .run-modal { width: min(620px, 100%); }
@@ -128,7 +132,7 @@
128
132
  .pill.ship-pill { --tone: var(--accent); }
129
133
  .pill.ship-pill i { animation: pulse 1.1s ease-in-out infinite; }
130
134
  @keyframes ship-in { from { opacity: 0; transform: translateY(6px) scale(0.98); } }
131
- @keyframes ship-fly { 50% { transform: translate(2px, -2px); } }
135
+ @keyframes ship-fly { 50% { transform: translateY(-2.5px); } }
132
136
  @keyframes ship-sweep { from { background-position: -40% 0; } to { background-position: 140% 0; } }
133
137
  @keyframes ship-land { from { box-shadow: 0 0 0 3px color-mix(in srgb, var(--good) 30%, transparent); transform: scale(1.015); } }
134
138
  @media (max-width: 640px) { .ship-track span { display: none; } }
package/web/index.html CHANGED
@@ -47,6 +47,7 @@
47
47
  <symbol id="i-pen" viewBox="0 0 24 24"><path d="M4 20l1-4L16 5l3 3L8 19l-4 1z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
48
48
  <symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6L12 3zM18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2z" fill="currentColor"/></symbol>
49
49
  <symbol id="i-stop" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="2" fill="currentColor"/></symbol>
50
+ <symbol id="i-merge" viewBox="0 0 24 24"><circle cx="6" cy="5" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="6" cy="19" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="18" cy="15" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M6 7.2v9.6M6 7.2c0 4.4 4.4 7.8 9.8 7.8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
50
51
  <symbol id="i-send" viewBox="0 0 24 24"><path d="M12 19V5m-6 6l6-6 6 6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
51
52
  <symbol id="i-enter" viewBox="0 0 24 24"><path d="M19 5v7a3 3 0 01-3 3H5m4-4l-4 4 4 4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
52
53
  <symbol id="i-queue" viewBox="0 0 24 24"><path d="M4 6h12M4 12h12M4 18h7M17 15v6m-3-3h6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
package/web/js/blocks.js CHANGED
@@ -512,7 +512,7 @@ R.tests = (b, ctx) => {
512
512
  </svg>
513
513
  <div class="ring-label">${icon(ok ? 'check' : 'x')}</div>
514
514
  <div class="tests-text">
515
- <div class="verdict">${ok ? 'All green' : `${failed} failing`}</div>
515
+ <div class="verdict">${ok ? 'All green' : `${failed} failing`}${b.by === 'wisp' ? `<span class="tests-by" title="wisp ran the project's tests itself after the agent finished${b.note ? `: ${esc(b.note)}` : ''}">Run by wisp</span>` : ''}</div>
516
516
  <div class="counts"><span class="pass"><b class="v" data-v="${passed}"></b> passed</span>${failed ? `<span class="fail"><b class="v" data-v="${failed}"></b> failed</span>` : ''}${skipped ? `<span class="skip"><b class="v" data-v="${skipped}"></b> skipped</span>` : ''}${b.durationMs ? `<span class="dur">${fmtDuration(b.durationMs)}</span>` : ''}</div>
517
517
  ${cmd ? `<div class="tests-cmd"><code class="cmdline">$ ${esc(cmd)}</code>${ctx.exec ? `<button class="ghost xs run-tests" title="Re-run ${esc(cmd)}">${icon('rerun')}<span>Re-run</span></button>` : ''}</div>` : ''}
518
518
  </div>
@@ -8,7 +8,7 @@ import { $, el, icon } from '../core/dom.js';
8
8
  import { S } from '../core/state.js';
9
9
  import { keyHint } from '../ui/kbd.js';
10
10
  import { overlay } from '../ui/overlay.js';
11
- import { popover } from '../ui/popover.js';
11
+ import { clampToViewport, popover } from '../ui/popover.js';
12
12
  import { toast, toastError, tryApi } from '../ui/toast.js';
13
13
  import { autosize } from './attachments.js';
14
14
  import { saveAsRecipe } from './automations.js';
@@ -208,9 +208,7 @@ export function taskMenu(id, e) {
208
208
  if (a === 'delete') deleteSession(id);
209
209
  };
210
210
  const close = popover(menu);
211
- const r = menu.getBoundingClientRect();
212
- if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
213
- if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
211
+ clampToViewport(menu, e.clientX, e.clientY);
214
212
  }
215
213
 
216
214
  export async function deleteSession(id) {