wisp-agents 0.17.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +20 -7
  3. package/bin/wisp.js +4 -2
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +2 -1
  6. package/server/adapters/claude.js +2 -1
  7. package/server/adapters/codex.js +2 -1
  8. package/server/adapters/commands.js +808 -0
  9. package/server/adapters/cursor.js +2 -1
  10. package/server/adapters/grok.js +2 -1
  11. package/server/adapters/opencode.js +2 -1
  12. package/server/check.js +205 -0
  13. package/server/git.js +114 -2
  14. package/server/index.js +2 -1
  15. package/server/judge.js +78 -0
  16. package/server/machines.js +2 -2
  17. package/server/openapi.js +19 -2
  18. package/server/pipeline.js +217 -0
  19. package/server/quick.js +76 -0
  20. package/server/router.js +201 -28
  21. package/server/routes/auto.js +8 -1
  22. package/server/routes/meta.js +1 -1
  23. package/server/routes/settings.js +53 -4
  24. package/server/routes/ship.js +29 -2
  25. package/server/runner.js +65 -15
  26. package/server/scout.js +71 -0
  27. package/server/services/auto.js +115 -15
  28. package/server/services/launch.js +22 -13
  29. package/server/services/ship.js +107 -6
  30. package/server/store.js +15 -0
  31. package/web/css/auto.css +22 -0
  32. package/web/css/launcher.css +17 -0
  33. package/web/css/layout.css +10 -2
  34. package/web/css/modals.css +8 -0
  35. package/web/css/palette.css +22 -0
  36. package/web/css/ship.css +44 -0
  37. package/web/index.html +16 -5
  38. package/web/js/app.js +14 -3
  39. package/web/js/blocks.js +1 -1
  40. package/web/js/core/state.js +2 -1
  41. package/web/js/features/actions.js +2 -4
  42. package/web/js/features/auto.js +38 -7
  43. package/web/js/features/clones.js +79 -0
  44. package/web/js/features/composer.js +2 -4
  45. package/web/js/features/feed.js +34 -4
  46. package/web/js/features/launcher.js +139 -19
  47. package/web/js/features/live.js +5 -10
  48. package/web/js/features/project-list.js +99 -0
  49. package/web/js/features/projects.js +220 -39
  50. package/web/js/features/runners.js +7 -6
  51. package/web/js/features/settings.js +47 -7
  52. package/web/js/features/setup.js +3 -3
  53. package/web/js/features/ship.js +128 -25
  54. package/web/js/features/turns.js +4 -3
  55. package/web/js/ui/popover.js +10 -0
@@ -1,4 +1,5 @@
1
- // Auto: what the router learned, and its settings (goal, trying new models, allowed harnesses).
1
+ // Auto: what the router learned, and its settings (goal, trying new models, allowed harnesses,
2
+ // and the steps after a run: check, repairs, hand-over, best of two, scout).
2
3
 
3
4
  import { adapters } from '../adapters/index.js';
4
5
  import { GOALS } from '../router.js';
@@ -18,6 +19,12 @@ export function register(r, app) {
18
19
  d.router = { ...d.router };
19
20
  if (body.explore !== undefined) d.router.explore = body.explore ? 'on' : 'off';
20
21
  if (body.allowed !== undefined) d.router.allowed = Array.isArray(body.allowed) ? body.allowed.filter((a) => adapters[a]) : null;
22
+ for (const k of ['check', 'escalate', 'best2']) if (body[k] !== undefined) d.router[k] = body[k] ? 'on' : 'off';
23
+ if (body.repairs !== undefined) d.router.repairs = Math.max(0, Math.min(2, Math.round(Number(body.repairs)) || 0));
24
+ if (body.scout !== undefined) {
25
+ if (!['off', 'big', 'all'].includes(body.scout)) return c.send(400, { error: 'scout must be off, big or all' });
26
+ d.router.scout = body.scout;
27
+ }
21
28
  app.configChanged();
22
29
  c.send(200, await auto.view());
23
30
  });
@@ -34,7 +34,7 @@ export function register(r, app) {
34
34
 
35
35
  r.route('GET', '/openapi.json', (c) => c.send(200, openapi({ version: VERSION, port })), { doc: false });
36
36
 
37
- r.route('GET', '/agents', async (c) => c.send(200, await app.auto.detect({ fresh: c.query.has('fresh') })));
37
+ r.route('GET', '/agents', async (c) => c.send(200, await (c.query.has('fresh') ? app.auto.detect({ fresh: true }) : app.auto.listed())));
38
38
  // Re-check: detect harnesses and models again, here and on every online machine.
39
39
  r.route('POST', '/agents/recheck', async (c) => {
40
40
  const [agents] = await Promise.all([app.auto.detect({ fresh: true }), machines.recheckAgents()]);
@@ -9,16 +9,27 @@ import { normalizePreset } from '../subagents.js';
9
9
  import { envDisabled, feedbackAvailable } from '../telemetry.js';
10
10
  import { TERMINALS } from '../terminal.js';
11
11
  import { normalizeWebhooks } from '../services/webhooks.js';
12
+ import { cloneRepo, gh, git, parseRepo } from '../git.js';
12
13
  import { expand } from '../util.js';
13
14
 
14
15
  // Where people keep their repos, for one-click "add project".
15
16
  const REPO_ROOTS = ['code', 'Code', 'dev', 'Developer', 'projects', 'Projects', 'src', 'repos', 'work', 'git', 'GitHub', 'Documents', 'Downloads', 'Desktop'];
16
17
 
18
+ // Where a pasted repo URL is cloned: defaults.cloneDir, else the first usual repo folder that
19
+ // exists (not Documents/Downloads/Desktop), else ~/code.
20
+ export function cloneRoot(config) {
21
+ if (config.defaults?.cloneDir) return expand(config.defaults.cloneDir);
22
+ const dir = REPO_ROOTS.slice(0, -3)
23
+ .map((r) => path.join(os.homedir(), r))
24
+ .find((d) => fs.existsSync(d) && fs.statSync(d).isDirectory());
25
+ return dir || path.join(os.homedir(), 'code');
26
+ }
27
+
17
28
  export function register(r, app) {
18
29
  const { store, telemetry, prefs, retro, remote, machines } = app;
19
30
 
20
31
  r.route('GET', '/config', (c) =>
21
- c.send(200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
32
+ c.send(200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), cloneDir: cloneRoot(store.config), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
22
33
  );
23
34
  r.route('PATCH', '/config', async (c) => {
24
35
  const body = await c.body();
@@ -91,18 +102,54 @@ export function register(r, app) {
91
102
  r.route('POST', '/projects', async (c) => {
92
103
  const body = await c.body();
93
104
  if (body.machine) {
94
- const p = await machines.addProject(String(body.machine), String(body.path || '').trim(), body.name);
105
+ const p = await machines.addProject(String(body.machine), String(body.repo || body.path || '').trim(), body.name);
95
106
  app.broadcast('config', {});
96
107
  return c.send(201, p);
97
108
  }
98
- const dir = expand(body.path || '');
109
+ // A repo URL (in `repo`, or pasted into `path`): clone it, then add the clone.
110
+ const typed = String(body.repo || body.path || '').trim();
111
+ const local = !body.repo && (/^[~./\\]|^[a-z]:/i.test(typed) || fs.existsSync(expand(typed)));
112
+ const repo = !local && parseRepo(typed, { shorthand: true });
113
+ if (body.repo && !repo) return c.send(400, { error: `Not a repo URL: ${typed}` });
114
+ let dir = expand(typed);
115
+ let cloned = null;
116
+ if (repo) {
117
+ const out = await cloneRepo(repo, cloneRoot(store.config));
118
+ dir = out.dir;
119
+ cloned = { url: repo.url, existed: out.existed };
120
+ }
99
121
  if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return c.send(400, { error: `Not a folder: ${dir}` });
100
122
  const p = store.addProject(dir, { name: body.name });
101
123
  app.broadcast('config', {});
102
- c.send(201, p);
124
+ c.send(201, cloned ? { ...p, cloned } : p);
103
125
  });
104
126
  // Find git repos in the usual places so adding projects is one click.
105
127
  r.route('GET', '/projects/discover', (c) => c.send(200, discoverRepos(new Set(store.config.projects.map((p) => p.path)))), { doc: false });
128
+ // The user's GitHub repos (gh's login), newest first, minus ones already added: one click clones.
129
+ // Empty when gh isn't installed or signed in.
130
+ r.route(
131
+ 'GET',
132
+ '/projects/github',
133
+ async (c) => {
134
+ const list = await gh(os.homedir(), ['repo', 'list', '--limit', '40', '--json', 'nameWithOwner,description,isPrivate,isFork,pushedAt,url'], { timeout: 20_000 })
135
+ .then((out) => JSON.parse(out || '[]'))
136
+ .catch(() => null);
137
+ if (!list) return c.send(200, []);
138
+ const origins = await Promise.all(
139
+ store.config.projects.filter((p) => !p.machine).map((p) => git(p.path, ['remote', 'get-url', 'origin'], { timeout: 5000 }).catch(() => '')),
140
+ );
141
+ const have = new Set(origins.map((o) => parseRepo(o)?.gh?.toLowerCase()).filter(Boolean));
142
+ c.send(
143
+ 200,
144
+ list
145
+ .filter((x) => !have.has(x.nameWithOwner.toLowerCase()))
146
+ .sort((a, b) => String(b.pushedAt).localeCompare(String(a.pushedAt)))
147
+ .slice(0, 24)
148
+ .map((x) => ({ repo: x.nameWithOwner, url: x.url, description: x.description || '', private: !!x.isPrivate, fork: !!x.isFork, pushedAt: x.pushedAt })),
149
+ );
150
+ },
151
+ { doc: false },
152
+ );
106
153
  const project = (c) => store.config.projects.find((x) => x.id === c.params.id);
107
154
  r.route('PATCH', '/projects/:id', async (c) => {
108
155
  const p = project(c);
@@ -110,6 +157,8 @@ export function register(r, app) {
110
157
  const body = await c.body();
111
158
  if (typeof body.name === 'string' && body.name.trim()) p.name = body.name.trim();
112
159
  if (typeof body.color === 'string') p.color = body.color;
160
+ // Auto's test check: the command to run, '' to detect it, 'off' for none.
161
+ if (typeof body.testCommand === 'string') body.testCommand.trim() ? (p.testCommand = body.testCommand.trim().slice(0, 500)) : delete p.testCommand;
113
162
  app.configChanged();
114
163
  c.send(200, p);
115
164
  });
@@ -27,11 +27,21 @@ export function register(r, app) {
27
27
  one(async (c, s) => {
28
28
  if (!s.worktree) return c.send(400, { error: NO_TREE });
29
29
  if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
30
+ if (s.shipping) return c.send(409, { error: 'Already shipping: wait for it to land.' });
30
31
  const body = await c.body();
31
32
  const draft = app.ship.draft(s);
32
- s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr });
33
+ // The card follows along: s.shipping names the stage running now, sent with each summary.
34
+ s.shipping = { step: 'start', update: s.pr?.state === 'OPEN', startedAt: Date.now() };
35
+ app.sessionChanged(s);
36
+ const onStep = (step) => ((s.shipping = { ...s.shipping, step }), app.sessionChanged(s));
37
+ try {
38
+ s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr, onStep });
39
+ s.pushedAt = Date.now(); // the PR is polled fast for a while, so its checks show up as they start
40
+ } finally {
41
+ delete s.shipping;
42
+ app.sessionChanged(s, { now: true });
43
+ }
33
44
  telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
34
- app.sessionChanged(s, { now: true });
35
45
  app.ship.recheck(s);
36
46
  c.send(200, s.pr);
37
47
  }),
@@ -44,6 +54,23 @@ export function register(r, app) {
44
54
  c.send(200, await app.ship.refreshPr(s));
45
55
  }),
46
56
  );
57
+ // Fix conflicts on the PR row: send the task a follow-up to merge the base in now (see
58
+ // services/ship.js). Trusts the conflict wisp last saw, so it starts at once; asks GitHub only if it saw none.
59
+ r.route(
60
+ 'POST',
61
+ '/sessions/:id/fix-conflicts',
62
+ one(async (c, s) => {
63
+ if (s.pr?.state !== 'OPEN' || !s.worktree) return c.send(400, { error: 'No open pull request.' });
64
+ if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
65
+ if (!s.pr.conflict) await app.ship.refreshPr(s);
66
+ if (s.conflictFix?.step !== 'fixing') await app.ship.fixConflicts(s, { force: true });
67
+ if (s.conflictFix?.step !== 'fixing') {
68
+ const why = !s.pr.conflict ? 'The PR has no merge conflicts now.' : !s.worktree.remote ? 'This repo has no origin remote to push to.' : s.ask ? 'The task is waiting for your answer first.' : s.shipping ? 'Already shipping: wait for it to land.' : 'Could not start the fix: try again in a moment.';
69
+ return c.send(409, { error: why });
70
+ }
71
+ c.send(200, { conflictFix: s.conflictFix });
72
+ }),
73
+ );
47
74
  // Squash-merge the reviewed head, sync the user's base branch, clean up the worktree.
48
75
  r.route(
49
76
  'POST',
package/server/runner.js CHANGED
@@ -81,7 +81,8 @@ export function summarize(s, store) {
81
81
  title: s.title,
82
82
  agent: s.agent,
83
83
  cwd: s.cwd,
84
- project: s.agent === 'wisp' ? 'tour' : project?.name || path.basename(s.cwd || ''),
84
+ scratch: store?.isScratch?.(s.cwd) || undefined,
85
+ project: s.agent === 'wisp' ? 'tour' : (store?.isScratch?.(s.cwd) ? 'General' : project?.name) || path.basename(s.cwd || ''),
85
86
  projectId: project?.id || null,
86
87
  color: project?.color || 'slate',
87
88
  mode: s.mode,
@@ -94,15 +95,18 @@ export function summarize(s, store) {
94
95
  updatedAt: s.updatedAt,
95
96
  turnCount: s.turns.length,
96
97
  queued: s.queue?.length || 0,
97
- 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'),
98
99
  preview: hero?.summary || '',
99
- 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 : '',
100
101
  asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
101
102
  question: s.ask?.question || '',
102
103
  approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
103
104
  branch: s.worktree?.branch || '',
104
105
  toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
105
- 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,
106
+ shipping: s.shipping || null, // { step: 'start' | 'commit' | 'push' | 'pr', update } while Ship it runs
107
+ 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, conflict: !!s.pr.conflict } : null,
108
+ // wisp fixing the PR's merge conflicts on its own: { step: 'fixing' | 'pushing' | 'done' | 'failed', why }
109
+ conflictFix: s.conflictFix ? { step: s.conflictFix.step, why: s.conflictFix.why || '' } : null,
106
110
  groupId: s.groupId || null,
107
111
  origin: s.origin || null, // { kind: 'recipe' | 'trigger' | 'telegram', id, name }
108
112
  // The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
@@ -113,8 +117,23 @@ export function summarize(s, store) {
113
117
  }),
114
118
  ),
115
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,
116
135
  // Auto: what it picked and why, for the card's tooltip.
117
- 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,
118
137
  };
119
138
  }
120
139
 
@@ -130,11 +149,13 @@ export class Runner extends EventEmitter {
130
149
  this.answers = new Map(); // askId -> answer, until the MCP server picks it up
131
150
  this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
132
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)
133
153
 
134
154
  // Anything that was mid-flight when wisp last died is now orphaned.
135
155
  for (const s of store.all()) {
136
156
  const t = s.turns.at(-1);
137
157
  delete s.ask;
158
+ if (s.auto?.busy) Object.assign(s.auto, { busy: false, stage: 'cancelled', label: '' }); // Auto's step died with wisp
138
159
  if (s.state === 'running' || s.state === 'waiting' || t?.status === 'running') {
139
160
  if (t?.status === 'running') this.#finishTurn(s, t, { isError: true, stopped: true, text: '' }, 'wisp was restarted while this was running.');
140
161
  s.state = 'review';
@@ -144,7 +165,7 @@ export class Runner extends EventEmitter {
144
165
  }
145
166
 
146
167
  // subagents: the team a lead may delegate to. parentId/member: this session is one of them.
147
- 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 }) {
148
169
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
149
170
  const now = Date.now();
150
171
  const session = {
@@ -168,8 +189,10 @@ export class Runner extends EventEmitter {
168
189
  ...(groupId && { groupId }),
169
190
  ...(origin && { origin }),
170
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)
171
194
  ...(parentId && { parentId, member }),
172
- ...(route && { route }), // how Auto picked agent and model (server/router.js)
195
+ ...(route && { route }), // how Auto picked agent, model and level (server/router.js)
173
196
  };
174
197
  this.store.put(session);
175
198
  if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
@@ -181,10 +204,13 @@ export class Runner extends EventEmitter {
181
204
  // follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
182
205
  // switches the harness, model and thinking level the same way (see #switchRun).
183
206
  // grants / once: a retry of what the last turn's permissions blocked (see approveBlocked).
184
- 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 } = {}) {
185
210
  if (!MODES.has(mode) || mode === session.mode) mode = undefined;
186
211
  run = changedRun(session, run);
187
- 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 }) };
188
214
  if (session.ask && !Object.keys(extra).length) {
189
215
  this.answer(session, text);
190
216
  return { answered: true };
@@ -245,7 +271,7 @@ export class Runner extends EventEmitter {
245
271
  if (session.agentSessionId) session.agentSessions[session.agent] = session.agentSessionId;
246
272
  for (const t of session.turns) t.agent ||= session.agent;
247
273
  // Auto's pick was overruled: the card stops crediting it, and the router learns (services/auto.js).
248
- 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;
249
275
  if (agent !== session.agent) {
250
276
  session.agent = agent;
251
277
  session.agentSessionId = session.agentSessions[agent] || null;
@@ -379,7 +405,7 @@ export class Runner extends EventEmitter {
379
405
  out.push({ subagent: want, error: 'Auto found no harness to run this on.' });
380
406
  continue;
381
407
  }
382
- 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 };
383
409
  child = this.create({ prompt, cwd: workdir(lead), ...run, mode: lead.mode, parentId: lead.id, member: slot });
384
410
  (lead.children ||= {})[slot] = child.id;
385
411
  }
@@ -409,6 +435,7 @@ export class Runner extends EventEmitter {
409
435
  }
410
436
 
411
437
  stop(session) {
438
+ this.pipeline?.cancel(session);
412
439
  session.queue = [];
413
440
  delete session.ask;
414
441
  for (const cid of Object.values(session.children || {})) {
@@ -488,9 +515,10 @@ export class Runner extends EventEmitter {
488
515
 
489
516
  // grants: harness rules allowing what the user approved, for this turn only. once: a mode for
490
517
  // this turn only (an approved retry the harness can't scope runs in Full auto).
491
- #startTurn(session, prompt, attachments = [], { grants = [], once } = {}) {
518
+ #startTurn(session, prompt, attachments = [], { grants = [], once, by } = {}) {
492
519
  const mode = MODES.has(once) ? once : session.mode;
493
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
494
522
  session.turns.push(turn);
495
523
  session.state = 'running';
496
524
  session.unread = false;
@@ -675,15 +703,37 @@ export class Runner extends EventEmitter {
675
703
  if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
676
704
  }
677
705
  if (!ev.stopped) blockedCard(turn);
678
- session.state = 'review';
679
- 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;
680
710
  this.store.save(session, { now: true });
681
711
  this.#touch(session, { turn: turn.id });
682
712
  this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
683
713
  this.#touchLead(session);
714
+ if (holding) setImmediate(() => this.pipeline.run(session, turn));
684
715
  // The follow-up starts right away; don't notify for the run it replaced. A subagent reports
685
716
  // to its lead, not to the user.
686
- 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 });
687
737
  }
688
738
 
689
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;
@@ -19,6 +24,17 @@ export function createAuto(app) {
19
24
  };
20
25
  // This computer's harnesses: the last ones detected, else detected now.
21
26
  const agents = async () => (agentsHere.length ? agentsHere : await detect());
27
+ // What the page asks for on every load: the last detection right away, checked again in the
28
+ // background (a cold probe takes seconds: opencode lists its models), and an 'agents' event
29
+ // if that changed anything, so the launcher never waits on it.
30
+ const listed = async () => {
31
+ if (!agentsHere.length) return detect();
32
+ const before = JSON.stringify(agentsHere);
33
+ detect()
34
+ .then((now) => JSON.stringify(now) !== before && app.broadcast('agents', {}))
35
+ .catch(() => {});
36
+ return agentsHere;
37
+ };
22
38
 
23
39
  const opts = () => {
24
40
  const r = store.config.defaults.router || {};
@@ -37,31 +53,115 @@ export function createAuto(app) {
37
53
  const pr = sum.pr?.state;
38
54
  router.signal(sum.id, {
39
55
  hero: ['done', 'partial', 'blocked', 'info'].includes(sum.status) ? sum.status : undefined,
40
- 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,
41
58
  merged: pr === 'MERGED' || undefined,
42
59
  closed: pr === 'CLOSED' || undefined,
43
60
  switched: sum.route?.switched || undefined,
44
61
  });
45
62
  };
46
63
 
47
- // 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
48
65
  // `agents`: this computer's, or the machine's the launch goes to. Model is the goal for Auto.
49
- 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 } = {}) => {
50
68
  const want = [{ agent: body.agent, model: body.model, effort: body.effort }, ...(Array.isArray(body.compare) ? body.compare : [])];
51
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 });
52
87
  const taken = [];
53
- const out = want.map((r, i) => {
54
- if (r.agent !== 'auto') return r;
55
- 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() });
56
- if (!pick) throw bad('Auto found no installed harness to run this on.');
57
- taken.push(pick.key);
58
- 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,
59
130
  });
60
- 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;
61
135
  };
62
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
+
63
163
  // What the Auto view shows: goals, settings, and what the router learned.
64
- 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()) });
65
165
 
66
- return { detect, agents, opts, goalOf, signals, resolve, view };
166
+ return { detect, agents, listed, opts, goalOf, signals, resolve, view };
67
167
  }