wisp-agents 0.18.0 → 0.21.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 (52) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +23 -7
  3. package/bin/wisp.js +4 -3
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +2 -1
  6. package/server/adapters/claude.js +7 -5
  7. package/server/adapters/codex.js +7 -3
  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/client.js +3 -2
  14. package/server/index.js +2 -1
  15. package/server/judge.js +78 -0
  16. package/server/mcp-wisp.js +2 -1
  17. package/server/openapi.js +4 -3
  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/sessions.js +15 -8
  23. package/server/routes/settings.js +2 -0
  24. package/server/routes/ship.js +27 -27
  25. package/server/runner.js +83 -17
  26. package/server/scout.js +71 -0
  27. package/server/services/auto.js +111 -14
  28. package/server/services/launch.js +45 -8
  29. package/server/services/ship.js +132 -23
  30. package/skills/wisp/SKILL.md +2 -1
  31. package/web/css/auto.css +22 -0
  32. package/web/css/feed.css +3 -0
  33. package/web/css/launcher.css +12 -0
  34. package/web/css/palette.css +6 -0
  35. package/web/css/ship.css +17 -1
  36. package/web/index.html +1 -0
  37. package/web/js/blocks.js +1 -1
  38. package/web/js/core/state.js +4 -2
  39. package/web/js/features/actions.js +3 -5
  40. package/web/js/features/auto.js +38 -7
  41. package/web/js/features/composer.js +2 -4
  42. package/web/js/features/feed.js +40 -10
  43. package/web/js/features/launcher.js +62 -18
  44. package/web/js/features/live.js +2 -7
  45. package/web/js/features/places.js +10 -0
  46. package/web/js/features/project-list.js +20 -5
  47. package/web/js/features/projects.js +6 -8
  48. package/web/js/features/runners.js +7 -6
  49. package/web/js/features/settings.js +34 -8
  50. package/web/js/features/ship.js +95 -31
  51. package/web/js/features/turns.js +6 -4
  52. 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
  });
@@ -10,7 +10,7 @@ import { execCommand } from '../proc.js';
10
10
  import { ensureWorktree, removeWorktree } from '../git.js';
11
11
  import { MIME } from '../http/static.js';
12
12
  import { blocksToMarkdown, sessionToMarkdown } from '../render.js';
13
- import { summarize, workdir } from '../runner.js';
13
+ import { alsoDirs, summarize, workdir } from '../runner.js';
14
14
  import { normalizeTeam } from '../subagents.js';
15
15
  import { openInTerminal, shq } from '../terminal.js';
16
16
  import { bad, clip, expand, MODES } from '../util.js';
@@ -46,6 +46,10 @@ export function register(r, app) {
46
46
  }
47
47
  return { agent, ...(model !== undefined && { model: String(model || '') }), ...(effort !== undefined && { effort: String(effort || '') }) };
48
48
  };
49
+ // Archiving removes clean worktrees: the task's own and its other folders' come back before it runs again.
50
+ const ensureTrees = async (s) => {
51
+ for (const x of [s, ...(s.also || [])]) await ensureWorktree(x.worktree);
52
+ };
49
53
  const turnOf = (s, turnId) => (turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1));
50
54
 
51
55
  r.route('GET', '/sessions', async (c) => {
@@ -57,8 +61,10 @@ export function register(r, app) {
57
61
  let list = [...here, ...(await machines.sessions(q))].sort((a, b) => b.updatedAt - a.updatedAt);
58
62
  if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
59
63
  if (q.agent) list = list.filter((x) => x.agent === q.agent);
60
- if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
61
- if (cwd) list = list.filter((x) => x.cwd === cwd || x.cwd?.startsWith(cwd + path.sep));
64
+ // A task in several folders (also) shows under each of their projects.
65
+ const dirs = (x) => [x.cwd, ...(x.also || []).map((a) => a.cwd)];
66
+ if (q.project) list = list.filter((x) => [x, ...(x.also || [])].some((p) => p.projectId === q.project || p.project === q.project));
67
+ if (cwd) list = list.filter((x) => dirs(x).some((d) => d === cwd || d?.startsWith(cwd + path.sep)));
62
68
  if (q.archived !== undefined) list = list.filter((x) => x.archived === (q.archived === 'true' || q.archived === '1'));
63
69
  if (Number(q.limit) > 0) list = list.slice(0, Number(q.limit));
64
70
  c.send(200, list);
@@ -100,7 +106,8 @@ export function register(r, app) {
100
106
  if (s.state === 'done' || s.archived) app.retro.schedule(s, 0); // finished with it: learn now
101
107
  // Archived: a clean worktree goes (its branch stays, a follow-up brings it back).
102
108
  // discardWorktree: a compare run you didn't keep; its changes go too.
103
- if (s.worktree && !runner.live.has(s.id) && (s.archived || body.discardWorktree)) await removeWorktree(s.worktree, { force: !!body.discardWorktree, branch: !!body.discardWorktree && !s.pr && 'force' });
109
+ if (!runner.live.has(s.id) && (s.archived || body.discardWorktree))
110
+ for (const x of [s, ...(s.also || [])]) if (x.worktree) await removeWorktree(x.worktree, { force: !!body.discardWorktree, branch: !!body.discardWorktree && !x.pr && 'force' });
104
111
  app.sessionChanged(s);
105
112
  c.send(200, summarize(s, store));
106
113
  }),
@@ -117,7 +124,7 @@ export function register(r, app) {
117
124
  if (store.get(cid)) (store.delete(cid), app.broadcast('removed', { id: cid }));
118
125
  }
119
126
  // Its worktree goes with it; the branch only if it holds nothing beyond its base.
120
- if (s.worktree) await removeWorktree(s.worktree, { force: true, branch: true }).catch(() => {});
127
+ for (const x of [s, ...(s.also || [])]) if (x.worktree) await removeWorktree(x.worktree, { force: true, branch: true }).catch(() => {});
121
128
  store.delete(s.id);
122
129
  app.broadcast('removed', { id: s.id });
123
130
  c.send(200, { ok: true });
@@ -213,7 +220,7 @@ export function register(r, app) {
213
220
  one(async (c, s) => {
214
221
  const { turnId, allow } = await c.body();
215
222
  notTour(s, 'approve');
216
- await ensureWorktree(s.worktree);
223
+ await ensureTrees(s);
217
224
  c.send(200, runner.approveBlocked(s, { turnId, allow: allow !== false }));
218
225
  }),
219
226
  );
@@ -295,8 +302,8 @@ export function register(r, app) {
295
302
  one(async (c, s) => {
296
303
  const adapter = adapters[s.agent];
297
304
  if (!adapter?.resumeCommand || !s.agentSessionId) return c.send(400, { error: 'This session cannot be resumed in a terminal.' });
298
- const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
299
- await ensureWorktree(s.worktree);
305
+ const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort, dirs: alsoDirs(s) });
306
+ await ensureTrees(s);
300
307
  const command = `cd ${shq(workdir(s))} && ${argv.map(shq).join(' ')}`;
301
308
  await fn(s, argv);
302
309
  c.send(200, { command });
@@ -157,6 +157,8 @@ export function register(r, app) {
157
157
  const body = await c.body();
158
158
  if (typeof body.name === 'string' && body.name.trim()) p.name = body.name.trim();
159
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;
160
162
  app.configChanged();
161
163
  c.send(200, p);
162
164
  });
@@ -1,7 +1,9 @@
1
1
  // Ship it. GET: what would ship (branch, changes, draft message). POST { title, body, draft }:
2
- // commit, push, open or update the PR. Then follow it (pr) and squash-merge it (merge).
2
+ // commit, push, open or update the PR. Then follow it (pr) and squash-merge it (merge). A task in
3
+ // several repos (also) ships, follows and merges a PR in each (services/ship.js).
3
4
 
4
- import { ship, merge, worktreeStatus } from '../git.js';
5
+ import { worktreeStatus } from '../git.js';
6
+ import { reposOf } from '../services/ship.js';
5
7
 
6
8
  export function register(r, app) {
7
9
  const { store, runner, telemetry } = app;
@@ -11,58 +13,57 @@ export function register(r, app) {
11
13
  return fn(c, s);
12
14
  };
13
15
  const NO_TREE = 'Ship it works on worktree sessions: launch with a worktree to get one.';
16
+ const hasTrees = (s) => !!(s.worktree || s.also?.some((a) => a.worktree));
14
17
 
15
18
  r.route(
16
19
  'GET',
17
20
  '/sessions/:id/ship',
18
21
  one(async (c, s) => {
19
- if (!s.worktree) return c.send(400, { error: NO_TREE });
22
+ if (!hasTrees(s)) return c.send(400, { error: NO_TREE });
20
23
  app.ship.recheck(s);
21
- c.send(200, { branch: s.worktree.branch, base: s.worktree.base, remote: s.worktree.remote, ...(await worktreeStatus(s.worktree)), pr: s.pr || null, ...app.ship.draft(s) });
24
+ const repos = await Promise.all(reposOf(s, store).map(async (r) => ({ repo: r.name, branch: r.wt.branch, base: r.wt.base, remote: r.wt.remote, ...(await worktreeStatus(r.wt)), pr: r.at.pr || null })));
25
+ // The first repo's fields at the top level (as before one task could span repos), each in `repos`.
26
+ const sum = (k) => repos.reduce((n, r) => n + r[k], 0);
27
+ c.send(200, { ...repos[0], ...(repos.length > 1 && { dirty: sum('dirty'), ahead: sum('ahead'), repos }), ...app.ship.draft(s) });
22
28
  }),
23
29
  );
24
30
  r.route(
25
31
  'POST',
26
32
  '/sessions/:id/ship',
27
33
  one(async (c, s) => {
28
- if (!s.worktree) return c.send(400, { error: NO_TREE });
34
+ if (!hasTrees(s)) return c.send(400, { error: NO_TREE });
29
35
  if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
30
36
  if (s.shipping) return c.send(409, { error: 'Already shipping: wait for it to land.' });
31
37
  const body = await c.body();
32
38
  const draft = app.ship.draft(s);
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
- } finally {
40
- delete s.shipping;
41
- app.sessionChanged(s, { now: true });
42
- }
43
- telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
44
- app.ship.recheck(s);
45
- c.send(200, s.pr);
39
+ // The card follows along: s.shipping names the stage (and repo) running now, sent with each summary.
40
+ const pr = await app.ship.shipAll(s, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft });
41
+ telemetry.track('ship', { agent: s.agent, draft: !!body.draft, ...(pr.prs && { repos: pr.prs.length }) });
42
+ c.send(200, pr);
46
43
  }),
47
44
  );
48
45
  r.route(
49
46
  'POST',
50
47
  '/sessions/:id/pr',
51
48
  one(async (c, s) => {
52
- if (!s.pr) return c.send(404, { error: 'No pull request yet.' });
49
+ if (!s.pr && !s.also?.some((a) => a.pr)) return c.send(404, { error: 'No pull request yet.' });
53
50
  c.send(200, await app.ship.refreshPr(s));
54
51
  }),
55
52
  );
56
- // Try the automatic conflict fix again after it gave up (see services/ship.js).
53
+ // Fix conflicts on the PR row: send the task a follow-up to merge the base in now (see
54
+ // services/ship.js). Trusts the conflict wisp last saw, so it starts at once; asks GitHub only if it saw none.
57
55
  r.route(
58
56
  'POST',
59
57
  '/sessions/:id/fix-conflicts',
60
58
  one(async (c, s) => {
61
59
  if (s.pr?.state !== 'OPEN' || !s.worktree) return c.send(400, { error: 'No open pull request.' });
62
60
  if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
63
- s.conflictFix = { tries: 0 };
64
- await app.ship.refreshPr(s);
65
- if (s.conflictFix?.step !== 'fixing') return c.send(409, { error: s.pr.conflict ? 'It merges cleanly with the latest base here: GitHub may not have caught up yet.' : 'The PR has no merge conflicts now.' });
61
+ if (!s.pr.conflict) await app.ship.refreshPr(s);
62
+ if (s.conflictFix?.step !== 'fixing') await app.ship.fixConflicts(s, { force: true });
63
+ if (s.conflictFix?.step !== 'fixing') {
64
+ 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.';
65
+ return c.send(409, { error: why });
66
+ }
66
67
  c.send(200, { conflictFix: s.conflictFix });
67
68
  }),
68
69
  );
@@ -71,13 +72,12 @@ export function register(r, app) {
71
72
  'POST',
72
73
  '/sessions/:id/merge',
73
74
  one(async (c, s) => {
74
- if (!s.pr || !s.worktree) return c.send(400, { error: 'No pull request to merge.' });
75
- const { pr, sync } = await merge(s.worktree, s.pr.number);
76
- s.pr = pr;
75
+ if (!reposOf(s, store).some((r) => r.at.pr)) return c.send(400, { error: 'No pull request to merge.' });
76
+ const out = await app.ship.mergeAll(s);
77
77
  app.launcher.markDone(s);
78
78
  telemetry.track('merge', { agent: s.agent });
79
79
  app.sessionChanged(s, { now: true });
80
- c.send(200, { pr, sync });
80
+ c.send(200, out);
81
81
  }),
82
82
  );
83
83
  }
package/server/runner.js CHANGED
@@ -46,11 +46,21 @@ export { newId };
46
46
  // Where the agent actually works: its own worktree when it has one, else the project folder.
47
47
  export const workdir = (s) => s.worktree?.cwd || s.cwd;
48
48
 
49
- // Worktree sessions get told where they are, so they don't go looking for the main checkout.
49
+ // Where the agent works in the task's other folders (also): their worktrees, else the folders.
50
+ export const alsoDirs = (s) => (s.also || []).map((a) => a.worktree?.cwd || a.cwd);
51
+
52
+ // Worktree sessions get told where they are, so they don't go looking for the main checkout. A task
53
+ // with other folders (also) gets their paths: that's where the work in them goes.
50
54
  function workspaceNote(s) {
51
55
  const wt = s.worktree;
52
- if (!wt) return '';
53
- return `\n\n## Workspace\n\nYou are in an isolated git worktree (${wt.path}) on branch \`${wt.branch}\`, created from \`${wt.baseRef}\` for this task only. Other agents may be working in the user's main checkout, so make every change here. Dependencies and build output (node_modules, virtualenvs, dist/) are not shared with the main checkout: install them if a command needs them. Leave your changes uncommitted unless the user asks you to commit; the user ships them from wisp (commit, push, pull request).`;
56
+ const also = s.also || [];
57
+ const out = [];
58
+ if (wt) out.push(`You are in an isolated git worktree (${wt.path}) on branch \`${wt.branch}\`, created from \`${wt.baseRef}\` for this task only. Other agents may be working in the user's main checkout, so make every change here. Dependencies and build output (node_modules, virtualenvs, dist/) are not shared with the main checkout: install them if a command needs them. Leave your changes uncommitted unless the user asks you to commit; the user ships them from wisp (commit, push, pull request).`);
59
+ if (also.length) {
60
+ const line = (a) => (a.worktree ? `- \`${a.worktree.cwd}\`: a worktree of \`${a.cwd}\` on branch \`${a.worktree.branch}\` (from \`${a.worktree.baseRef}\`). Work here, not in \`${a.cwd}\`.` : `- \`${a.cwd}\``);
61
+ out.push(`This task spans ${also.length + 1} folders. Besides your working directory (${workdir(s)}), you work in:\n${also.map(line).join('\n')}\n\nRead and change files in all of them as the task needs, using absolute paths outside your working directory. Run each folder's commands (tests, builds) from inside that folder.${also.some((a) => a.worktree) ? ' Leave the changes there uncommitted too; Ship it opens a pull request per repo.' : ''}`);
62
+ }
63
+ return out.length ? `\n\n## Workspace\n\n${out.join('\n\n')}` : '';
54
64
  }
55
65
 
56
66
  // A follow-up's { agent, model, effort }, or null when it's what the session already runs on.
@@ -95,13 +105,20 @@ export function summarize(s, store) {
95
105
  updatedAt: s.updatedAt,
96
106
  turnCount: s.turns.length,
97
107
  queued: s.queue?.length || 0,
98
- status: last?.status === 'running' ? 'running' : hero?.status || (last?.status === 'error' ? 'blocked' : 'info'),
108
+ status: last?.status === 'running' || s.auto?.busy ? 'running' : hero?.status || (last?.status === 'error' ? 'blocked' : 'info'),
99
109
  preview: hero?.summary || '',
100
- live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : '',
110
+ live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : s.auto?.busy ? s.auto.label : '',
101
111
  asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
102
112
  question: s.ask?.question || '',
103
113
  approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
104
114
  branch: s.worktree?.branch || '',
115
+ // The task's other folders: their project, worktree branch and PR, for the card and the filter.
116
+ ...(s.also?.length && {
117
+ also: s.also.map((a) => {
118
+ const p = store?.projectFor(a.cwd);
119
+ return { cwd: a.cwd, project: p?.name || path.basename(a.cwd), projectId: p?.id || null, color: p?.color || 'slate', branch: a.worktree?.branch || '', pr: a.pr ? { number: a.pr.number, url: a.pr.url, state: a.pr.state, draft: a.pr.draft, review: a.pr.review, checks: a.pr.checks, conflict: !!a.pr.conflict } : null };
120
+ }),
121
+ }),
105
122
  toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
106
123
  shipping: s.shipping || null, // { step: 'start' | 'commit' | 'push' | 'pr', update } while Ship it runs
107
124
  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,
@@ -117,8 +134,23 @@ export function summarize(s, store) {
117
134
  }),
118
135
  ),
119
136
  ...(s.parentId && { parentId: s.parentId, member: s.member }),
137
+ followups: s.turns.slice(1).filter((t) => t.by !== 'wisp').length, // yours, not Auto's repair rounds
138
+ // Auto's steps after the run: the last test check, repairs, a hand-over, the best-of-2 pick.
139
+ auto: s.auto
140
+ ? {
141
+ strategy: s.auto.strategy,
142
+ busy: !!s.auto.busy,
143
+ stage: s.auto.stage,
144
+ repairs: s.auto.repairs,
145
+ 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,
146
+ escalatedTo: s.auto.escalatedTo || null,
147
+ escalatedFrom: s.auto.escalatedFrom || null,
148
+ reason: s.auto.reason || null, // why it moved on: tests | error | limit
149
+ pick: s.auto.pick && !s.auto.pick.pending ? s.auto.pick : null,
150
+ }
151
+ : null,
120
152
  // 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,
153
+ 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
154
  };
123
155
  }
124
156
 
@@ -134,11 +166,13 @@ export class Runner extends EventEmitter {
134
166
  this.answers = new Map(); // askId -> answer, until the MCP server picks it up
135
167
  this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
136
168
  this.routeAuto = null; // (prompt, { cwd }) => { agent, model, route } for subagents set to Auto
169
+ this.pipeline = null; // Auto's check / repair / hand-over steps (server/pipeline.js)
137
170
 
138
171
  // Anything that was mid-flight when wisp last died is now orphaned.
139
172
  for (const s of store.all()) {
140
173
  const t = s.turns.at(-1);
141
174
  delete s.ask;
175
+ if (s.auto?.busy) Object.assign(s.auto, { busy: false, stage: 'cancelled', label: '' }); // Auto's step died with wisp
142
176
  if (s.state === 'running' || s.state === 'waiting' || t?.status === 'running') {
143
177
  if (t?.status === 'running') this.#finishTurn(s, t, { isError: true, stopped: true, text: '' }, 'wisp was restarted while this was running.');
144
178
  s.state = 'review';
@@ -148,7 +182,8 @@ export class Runner extends EventEmitter {
148
182
  }
149
183
 
150
184
  // 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 }) {
185
+ // also: the task's other folders, [{ cwd, worktree }] (services/launch.js).
186
+ create({ id = newId(), prompt, cwd, also = [], agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null, auto = null, scout = null }) {
152
187
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
153
188
  const now = Date.now();
154
189
  const session = {
@@ -169,14 +204,17 @@ export class Runner extends EventEmitter {
169
204
  queue: [],
170
205
  turns: [],
171
206
  ...(worktree && { worktree }),
207
+ ...(also.length && { also }),
172
208
  ...(groupId && { groupId }),
173
209
  ...(origin && { origin }),
174
210
  ...(subagents.length && { subagents, children: {} }),
211
+ ...(auto && { auto: { strategy: 'single', busy: false, stage: '', label: '', repairs: 0, checks: [], ...auto } }), // server/pipeline.js
212
+ ...(scout && { scout }), // what the scout found before it started (server/scout.js)
175
213
  ...(parentId && { parentId, member }),
176
- ...(route && { route }), // how Auto picked agent and model (server/router.js)
214
+ ...(route && { route }), // how Auto picked agent, model and level (server/router.js)
177
215
  };
178
216
  this.store.put(session);
179
- if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
217
+ if (!parentId) for (const dir of [...also.map((a) => a.cwd), cwd]) this.store.touchProject(dir); // a subagent's cwd may be the lead's worktree
180
218
  this.#startTurn(session, prompt, attachments);
181
219
  return session;
182
220
  }
@@ -185,10 +223,13 @@ export class Runner extends EventEmitter {
185
223
  // follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
186
224
  // switches the harness, model and thinking level the same way (see #switchRun).
187
225
  // 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 } = {}) {
226
+ // by: 'wisp' for a follow-up wisp sends itself (Auto's repair round). Anything else is the
227
+ // user's, and cancels whatever Auto was about to do next.
228
+ reply(session, text, attachments = [], { queue = false, mode, run, grants, once, by } = {}) {
189
229
  if (!MODES.has(mode) || mode === session.mode) mode = undefined;
190
230
  run = changedRun(session, run);
191
- const extra = { ...(mode && { mode }), ...(run && { run }), ...(grants?.length && { grants }), ...(MODES.has(once) && { once }) };
231
+ if (by !== 'wisp') this.pipeline?.cancel(session);
232
+ const extra = { ...(mode && { mode }), ...(run && { run }), ...(grants?.length && { grants }), ...(MODES.has(once) && { once }), ...(by === 'wisp' && { by }) };
192
233
  if (session.ask && !Object.keys(extra).length) {
193
234
  this.answer(session, text);
194
235
  return { answered: true };
@@ -249,7 +290,7 @@ export class Runner extends EventEmitter {
249
290
  if (session.agentSessionId) session.agentSessions[session.agent] = session.agentSessionId;
250
291
  for (const t of session.turns) t.agent ||= session.agent;
251
292
  // 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;
293
+ if (session.route && (agent !== session.agent || model !== session.model || effort !== (session.effort || ''))) session.route.switched = true;
253
294
  if (agent !== session.agent) {
254
295
  session.agent = agent;
255
296
  session.agentSessionId = session.agentSessions[agent] || null;
@@ -383,7 +424,7 @@ export class Runner extends EventEmitter {
383
424
  out.push({ subagent: want, error: 'Auto found no harness to run this on.' });
384
425
  continue;
385
426
  }
386
- const run = pick ? { agent: pick.agent, model: pick.model, effort: '', route: pick.route } : { agent: m.agent, model: m.model, effort: m.effort };
427
+ 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
428
  child = this.create({ prompt, cwd: workdir(lead), ...run, mode: lead.mode, parentId: lead.id, member: slot });
388
429
  (lead.children ||= {})[slot] = child.id;
389
430
  }
@@ -413,6 +454,7 @@ export class Runner extends EventEmitter {
413
454
  }
414
455
 
415
456
  stop(session) {
457
+ this.pipeline?.cancel(session);
416
458
  session.queue = [];
417
459
  delete session.ask;
418
460
  for (const cid of Object.values(session.children || {})) {
@@ -492,9 +534,10 @@ export class Runner extends EventEmitter {
492
534
 
493
535
  // grants: harness rules allowing what the user approved, for this turn only. once: a mode for
494
536
  // this turn only (an approved retry the harness can't scope runs in Full auto).
495
- #startTurn(session, prompt, attachments = [], { grants = [], once } = {}) {
537
+ #startTurn(session, prompt, attachments = [], { grants = [], once, by } = {}) {
496
538
  const mode = MODES.has(once) ? once : session.mode;
497
539
  const turn = this.#newTurn(session, prompt, attachments, mode);
540
+ if (by === 'wisp') turn.by = 'wisp'; // sent by wisp (Auto's repair round), not by you
498
541
  session.turns.push(turn);
499
542
  session.state = 'running';
500
543
  session.unread = false;
@@ -517,6 +560,7 @@ export class Runner extends EventEmitter {
517
560
  prompt: handoff ? `${handoff}\n\n${text}` : text,
518
561
  attachments,
519
562
  cwd: workdir(session),
563
+ dirs: alsoDirs((session.parentId && this.store.get(session.parentId)) || session), // the task's other folders (a subagent's are its lead's)
520
564
  resumeId: session.agentSessionId,
521
565
  mode,
522
566
  grants,
@@ -679,15 +723,37 @@ export class Runner extends EventEmitter {
679
723
  if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
680
724
  }
681
725
  if (!ev.stopped) blockedCard(turn);
682
- session.state = 'review';
683
- session.unread = !interrupted;
726
+ // Auto checks the work before it reaches you: the session stays in Working meanwhile.
727
+ const holding = !interrupted && !session.queue.length && !!this.pipeline?.wants(session, turn, ev);
728
+ session.state = holding ? 'running' : 'review';
729
+ session.unread = !interrupted && !holding;
684
730
  this.store.save(session, { now: true });
685
731
  this.#touch(session, { turn: turn.id });
686
732
  this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
687
733
  this.#touchLead(session);
734
+ if (holding) setImmediate(() => this.pipeline.run(session, turn));
688
735
  // The follow-up starts right away; don't notify for the run it replaced. A subagent reports
689
736
  // to its lead, not to the user.
690
- if (!interrupted && !session.parentId) this.emit('finished', { session: summarize(session, this.store) });
737
+ else if (!interrupted && !session.parentId) this.emit('finished', { session: summarize(session, this.store) });
738
+ }
739
+
740
+ // Auto is done with a session it held: it lands in your queue (quiet: handed to another run,
741
+ // which will be the one to notify).
742
+ settle(session, { quiet = false } = {}) {
743
+ if (session.state !== 'running' || (this.live.has(session.id) && session.turns.at(-1)?.status === 'running')) return this.#touch(session);
744
+ session.state = 'review';
745
+ session.unread = !quiet;
746
+ this.store.save(session, { now: true });
747
+ this.#touch(session, { turn: session.turns.at(-1)?.id });
748
+ if (!quiet && !session.parentId) this.emit('finished', { session: summarize(session, this.store) });
749
+ }
750
+
751
+ // A line in a turn's activity from wisp itself (Auto's check, repair, hand-over).
752
+ note(session, turn, { kind = 'check', label }) {
753
+ const item = { t: Date.now(), kind, label: String(label).slice(0, 300), by: 'wisp' };
754
+ turn.activity.push(item);
755
+ this.emit('activity', { sessionId: session.id, turnId: turn.id, item });
756
+ this.#touch(session, { turn: turn.id });
691
757
  }
692
758
 
693
759
  #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
+ }