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,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,123 @@ 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 fresh = (wt, cwd) => (wt ? createWorktree({ 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);
113
+ const worktree = await fresh(s.worktree, s.cwd);
114
+ // The task's other folders too: fresh worktrees from the same bases.
115
+ const also = await Promise.all(
116
+ (s.also || []).map(async (a) => {
117
+ const tree = await fresh(a.worktree, a.cwd);
118
+ return tree ? { cwd: a.cwd, worktree: tree } : { cwd: a.cwd };
119
+ }),
120
+ );
121
+ const sib = app.runner.create({
122
+ id,
123
+ prompt: first.prompt,
124
+ cwd: s.cwd,
125
+ also,
126
+ agent: pick.agent,
127
+ model: pick.model,
128
+ effort: pick.effort || '',
129
+ mode: s.mode,
130
+ attachments: app.uploads.resolve((first.attachments || []).map((a) => a.id)),
131
+ worktree,
132
+ groupId: s.groupId,
133
+ origin: s.origin || null,
134
+ subagents: s.subagents || [],
135
+ route: { ...pick.route, escalated: reason !== 'limit', ...(reason === 'limit' && { moved: true }) },
136
+ 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))] },
137
+ scout: s.scout || null,
70
138
  });
71
- return { ...body, ...out[0], compare: out.slice(1) };
139
+ // Now one task with two runs: it's named after the prompt, like a compare task.
140
+ if (s.titleAuto) s.title = sib.title;
141
+ app.sessionChanged(s);
142
+ return sib;
72
143
  };
73
144
 
145
+ // Auto's steps after a run: check with the tests, repair, hand over, judge best-of-2.
146
+ const pipeline = new Pipeline({
147
+ store,
148
+ runner: app.runner,
149
+ router,
150
+ settings: () => autoSettings(store.config.defaults),
151
+ agents: () => agentsHere,
152
+ projectOf: (s) => store.projectFor(s.cwd),
153
+ launchSibling,
154
+ broadcast: (e, d) => app.broadcast(e, d),
155
+ summarize: (s) => summarize(s, store),
156
+ onSettled: (s) => {
157
+ signals(summarize(s, store));
158
+ const a = s.auto;
159
+ const c = a.checks.at(-1);
160
+ 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 });
161
+ },
162
+ });
163
+ app.runner.pipeline = pipeline;
164
+
165
+ // The test command per project (here only), for the Auto view.
166
+ const projects = () =>
167
+ store.config.projects
168
+ .filter((p) => !p.machine && fs.existsSync(p.path))
169
+ .map((p) => ({ id: p.id, name: p.name, color: p.color, testCommand: p.testCommand || '', detected: scanProject(p.path, { ...p, testCommand: '' }).tests }));
170
+
74
171
  // 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()) });
172
+ 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
173
 
77
174
  return { detect, agents, listed, opts, goalOf, signals, resolve, view };
78
175
  }
@@ -2,6 +2,7 @@
2
2
  // runs), here or on a machine, plus the follow-ups and "done" that every surface shares.
3
3
 
4
4
  import fs from 'node:fs';
5
+ import path from 'node:path';
5
6
  import { adapters } from '../adapters/index.js';
6
7
  import { createWorktree, worktreeSource, ensureWorktree, removeWorktree } from '../git.js';
7
8
  import { qualify } from '../machines.js';
@@ -11,11 +12,32 @@ import { normalizeTeam } from '../subagents.js';
11
12
  import { bad, expand, MODES, newId } from '../util.js';
12
13
 
13
14
  export const MAX_COMPARE = 4;
15
+ export const MAX_ALSO = 8; // extra folders one task may work in
16
+
17
+ // also: more folders the task works in besides cwd (a backend and its web app, say). Each must be
18
+ // a directory; repeats, cwd itself and folders inside one already listed are dropped.
19
+ export function alsoDirs(list, cwd) {
20
+ if (list === undefined || list === null) return [];
21
+ if (!Array.isArray(list)) throw bad('also must be a list of folders.');
22
+ const inside = (a, b) => a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep);
23
+ const out = [];
24
+ for (const x of list) {
25
+ const raw = typeof x === 'string' ? x : x?.cwd;
26
+ if (!String(raw || '').trim()) continue;
27
+ const dir = path.resolve(expand(String(raw).trim()));
28
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw bad(`Not a directory: ${dir}`);
29
+ if (inside(dir, cwd) || out.some((d) => inside(dir, d))) continue;
30
+ for (let i = out.length - 1; i >= 0; i--) if (inside(out[i], dir)) out.splice(i, 1);
31
+ out.push(dir);
32
+ }
33
+ if (out.length > MAX_ALSO) throw bad(`A task can work in up to ${MAX_ALSO + 1} folders.`);
34
+ return out;
35
+ }
14
36
 
15
37
  export function createLaunch(app) {
16
38
  const { store, runner, machines } = app;
17
39
 
18
- // body: { prompt, cwd, scratch: true = no folder, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
40
+ // body: { prompt, cwd, also: [folder], scratch: true = no folder, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
19
41
  // subagents: [{ name, agent, model, effort, use }] | preset: teamId }
20
42
  async function launch(body, origin = null) {
21
43
  if (!String(body.prompt || '').trim()) throw bad('prompt required');
@@ -23,18 +45,23 @@ export function createLaunch(app) {
23
45
  const scratch = body.scratch === true;
24
46
  const cwd = scratch ? store.scratchDir(newId()) : expand(body.cwd || process.cwd());
25
47
  if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
48
+ const also = scratch ? [] : alsoDirs(body.also, cwd);
26
49
  const d = store.config.defaults;
27
50
  const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
28
51
  if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
29
52
  const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
30
53
  // compare: more { agent, model, effort } to run the same task on, side by side. 'auto' is
31
54
  // 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) => {
55
+ const resolved = await app.auto.resolve(body, await app.auto.agents(), { team: subagents.length > 0, cwd });
56
+ // A launch routed by another wisp (this is a machine) arrives with its route: Auto's steps
57
+ // (check, repair) still run here, where the files are.
58
+ const isRoute = (x) => x && typeof x === 'object' && typeof x.key === 'string';
59
+ 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
60
  const agent = r.agent || (i ? d.agent : '') || 'claude';
35
61
  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 }) };
62
+ 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
63
  });
64
+ const strategy = ['single', 'best2', 'compare'].includes(resolved.autoStrategy) ? resolved.autoStrategy : runners.length > 1 ? 'compare' : 'single';
38
65
  const mode = MODES.has(body.mode) ? body.mode : 'auto';
39
66
  // Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
40
67
  // runs need one each, or the harnesses would edit the same files.
@@ -62,7 +89,16 @@ export function createLaunch(app) {
62
89
  }
63
90
  if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
64
91
  }
65
- return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
92
+ // The other folders: a worktree of their own per run where they're git repos (so parallel
93
+ // tasks don't collide there either), else the folder itself.
94
+ const extras = await Promise.all(
95
+ also.map(async (dir) => {
96
+ const source = wantTree && (await worktreeSource(dir).catch(() => null));
97
+ const made = await Promise.all(ids.map((id) => (source ? createWorktree({ cwd: dir, home: store.home, id, title: body.prompt, source }).catch((err) => console.warn(`wisp: no worktree for ${dir}: ${err.message}`)) : null)));
98
+ return made.map((worktree) => ({ cwd: dir, ...(worktree && { worktree }) }));
99
+ }),
100
+ );
101
+ return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, also: extras.map((x) => x[i]), ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents, ...(r.route && { auto: { strategy }, scout: resolved.scout || null }) }));
66
102
  }
67
103
 
68
104
  // Where a launch runs: body.machine, else a project folder that only exists on a machine
@@ -89,8 +125,9 @@ export function createLaunch(app) {
89
125
  // back through the summaries it streams (auto.signals), so one router learns from every place.
90
126
  const routed = await app.auto.resolve(body, machines.agentsOf(mid) || [], { team: !!(body.subagents?.length || body.preset) });
91
127
  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;
128
+ // The machine runs Auto's check and repair on these picks (an older wisp there ignores them).
129
+ const sendBody = { ...routed };
130
+ delete sendBody.scout;
94
131
  if (!sendBody.compare?.length && !body.compare) delete sendBody.compare;
95
132
  const out = await machines.launch(mid, { ...sendBody, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
96
133
  (out.group || [out]).forEach((x, i) => routes[i] && app.router.record(qualify(x.id, mid), routes[i]));
@@ -105,7 +142,7 @@ export function createLaunch(app) {
105
142
 
106
143
  // A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
107
144
  async function replyTo(s, text, attachments = [], queue, mode, run) {
108
- await ensureWorktree(s.worktree);
145
+ for (const x of [s, ...(s.also || [])]) await ensureWorktree(x.worktree);
109
146
  return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue', mode, run });
110
147
  }
111
148
 
@@ -1,14 +1,19 @@
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
- import { worktreeStatus, prView, ship, baseMerged, conflictsWithBase } from '../git.js';
8
+ import path from 'node:path';
9
+ import { worktreeStatus, prView, ship, merge, baseMerged, conflictsWithBase } from '../git.js';
8
10
  import { blocksToMarkdown } from '../render.js';
9
11
  import { summarize } from '../runner.js';
12
+ import { bad } from '../util.js';
10
13
 
11
14
  const PR_POLL_MS = 60_000;
15
+ const PR_FAST_POLL_MS = 10_000; // while GitHub is still working something out: checks running, mergeability unknown, a push just landed
16
+ const PR_TICK_MS = 5_000;
12
17
  const SHIP_RECHECK_MS = 5_000;
13
18
  const MAX_CONFLICT_FIXES = 3; // per task, so a base that keeps moving can't run up the bill
14
19
 
@@ -26,15 +31,28 @@ export const conflictPrompt = (s) => {
26
31
  ].join('\n');
27
32
  };
28
33
 
34
+ // The repos a task ships: its own worktree, then those of its other folders (also). Each is
35
+ // { name, wt, at }, where `at` holds that repo's PR (the session for its own, else the also entry).
36
+ export function reposOf(s, store) {
37
+ const name = (dir) => store?.projectFor?.(dir)?.name || path.basename(dir || '');
38
+ return [s.worktree && { name: name(s.cwd), wt: s.worktree, at: s }, ...(s.also || []).map((a) => a.worktree && { name: name(a.cwd), wt: a.worktree, at: a })].filter(Boolean);
39
+ }
40
+ // Something new for this repo's PR: uncommitted changes, or commits its open PR (or a new one) lacks.
41
+ const pending = (st, pr) => !!(st.dirty || (pr?.state === 'OPEN' ? st.unpushed : st.ahead));
42
+ const hasTrees = (s) => !!(s?.worktree || s?.also?.some((a) => a.worktree));
43
+
29
44
  export function createShip(app) {
30
45
  const { store, runner } = app;
31
46
 
32
47
  // Ship it shows only when the worktree has something to ship: uncommitted changes, or commits
33
- // not yet pushed. Rechecked whenever wisp might have changed the worktree.
48
+ // not yet pushed. Rechecked whenever wisp might have changed the worktree. A task in several
49
+ // repos adds theirs up, and `repos` counts the ones with something to ship.
34
50
  const check = async (s) => {
35
- if (!s?.worktree || s.parentId) return;
36
- const { dirty, ahead, unpushed } = await worktreeStatus(s.worktree);
37
- const next = { dirty, ahead, unpushed };
51
+ if (!hasTrees(s) || s.parentId) return;
52
+ const repos = reposOf(s, store);
53
+ const all = await Promise.all(repos.map((r) => worktreeStatus(r.wt)));
54
+ const sum = (k) => all.reduce((n, st) => n + st[k], 0);
55
+ const next = { dirty: sum('dirty'), ahead: sum('ahead'), unpushed: sum('unpushed'), ...(repos.length > 1 && { repos: all.filter((st, i) => pending(st, repos[i].at.pr)).length }) };
38
56
  if (JSON.stringify(next) === JSON.stringify(s.toShip)) return;
39
57
  s.toShip = next;
40
58
  app.sessionChanged(s);
@@ -44,7 +62,7 @@ export function createShip(app) {
44
62
  const checks = new Map(); // id → true while checking, 'again' if another check is due
45
63
  const checkedAt = new Map();
46
64
  const recheck = (s, { lazy = false } = {}) => {
47
- if (!s?.worktree || s.parentId) return;
65
+ if (!hasTrees(s) || s.parentId) return;
48
66
  if (lazy && (runner.live.has(s.id) || checks.has(s.id) || Date.now() - (checkedAt.get(s.id) || 0) < SHIP_RECHECK_MS)) return;
49
67
  if (checks.has(s.id)) return void checks.set(s.id, 'again');
50
68
  checks.set(s.id, true);
@@ -58,8 +76,25 @@ export function createShip(app) {
58
76
  });
59
77
  };
60
78
 
79
+ // The PRs of the task's other repos: their state, checks and reviews, for the card.
80
+ const refreshAlso = async (s) => {
81
+ const open = (s.also || []).filter((a) => a.pr && a.worktree && a.pr.state === 'OPEN');
82
+ if (!open.length) return false;
83
+ let news = false;
84
+ await Promise.all(
85
+ open.map(async (a) => {
86
+ const prev = a.pr;
87
+ a.pr = await prView(fs.existsSync(a.worktree.path) ? a.worktree.path : a.worktree.root, prev.number).catch(() => ({ ...prev, checkedAt: Date.now() }));
88
+ if (a.pr.state !== prev.state || a.pr.review !== prev.review || (!a.pr.checks.pending && prev.checks?.pending)) news = true;
89
+ }),
90
+ );
91
+ if (news) s.unread = true;
92
+ app.sessionChanged(s);
93
+ return true;
94
+ };
61
95
  const refreshPr = async (s) => {
62
- if (!s.pr || !s.worktree) return s.pr;
96
+ await refreshAlso(s);
97
+ if (!s.pr || !s.worktree) return s.pr || s.also?.find((a) => a.pr)?.pr;
63
98
  const prev = s.pr;
64
99
  const dir = fs.existsSync(s.worktree.path) ? s.worktree.path : s.worktree.root;
65
100
  const pr = await prView(dir, prev.number);
@@ -75,20 +110,24 @@ export function createShip(app) {
75
110
  return pr;
76
111
  };
77
112
 
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.
113
+ // The PR conflicts with its base: send the task a follow-up to merge the base in. Only when the
114
+ // user clicks Fix conflicts (force), unless defaults.fixConflicts is 'on': then on its own, once
115
+ // per PR head, so a fix that didn't take isn't retried until something changes the branch.
80
116
  const starting = new Set(); // a poll and a refresh at once must not both send it
81
- const fixConflicts = async (s) => {
117
+ const fixConflicts = async (s, { force = false } = {}) => {
82
118
  const pr = s.pr;
83
- if (store.config.defaults.fixConflicts === 'off' || pr?.state !== 'OPEN' || !pr.conflict || !s.worktree?.remote) return;
119
+ if ((!force && store.config.defaults.fixConflicts !== 'on') || pr?.state !== 'OPEN' || !pr.conflict || !s.worktree?.remote) return;
84
120
  if (runner.live.has(s.id) || s.shipping || s.ask || s.archived || starting.has(s.id)) return;
85
121
  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);
122
+ if (!force && (s.conflictFix?.head === pr.head || tries >= MAX_CONFLICT_FIXES)) return;
123
+ // The user saw the conflict and asked: no fetch first, the follow-up starts at once.
124
+ if (!force) {
125
+ starting.add(s.id);
126
+ try {
127
+ if ((await conflictsWithBase(s.worktree)) === false || runner.live.has(s.id)) return;
128
+ } finally {
129
+ starting.delete(s.id);
130
+ }
92
131
  }
93
132
  s.conflictFix = { step: 'fixing', head: pr.head, tries: tries + 1, startedAt: Date.now() };
94
133
  await app.launcher.replyTo(s, conflictPrompt(s), [], true);
@@ -116,6 +155,7 @@ export function createShip(app) {
116
155
  const onStep = (step) => ((s.shipping = { ...s.shipping, step }), app.sessionChanged(s));
117
156
  try {
118
157
  s.pr = await ship(s.worktree, { title: `Merge ${s.worktree.remote}/${s.worktree.base} into ${s.worktree.branch}`, pr: s.pr, onStep });
158
+ s.pushedAt = Date.now();
119
159
  fix.step = 'done';
120
160
  } catch (err) {
121
161
  Object.assign(fix, { step: 'failed', why: `Push failed: ${err.message}` });
@@ -127,6 +167,59 @@ export function createShip(app) {
127
167
  recheck(s);
128
168
  };
129
169
 
170
+ // Ship it: commit, push and open (or update) the PR, in each of the task's repos with something
171
+ // new. One repo ships as before, even with nothing new (git says why). Several: one PR each,
172
+ // each noting the others; a failure names its repo and leaves the PRs already opened in place.
173
+ const shipAll = async (s, { title, body = '', draft = false }) => {
174
+ const repos = reposOf(s, store);
175
+ let todo = repos;
176
+ if (repos.length > 1) {
177
+ const st = await Promise.all(repos.map((r) => worktreeStatus(r.wt)));
178
+ todo = repos.filter((r, i) => pending(st[i], r.at.pr));
179
+ if (!todo.length) throw bad('Nothing to ship: none of the task’s repos have new changes.');
180
+ }
181
+ const note = repos.length > 1 ? `\n\nPart of one task across ${repos.map((r) => `\`${r.name}\``).join(', ')}: wisp opens a pull request in each.` : '';
182
+ s.shipping = { step: 'start', update: todo.every((r) => r.at.pr?.state === 'OPEN'), startedAt: Date.now(), ...(todo.length > 1 && { of: todo.length }) };
183
+ app.sessionChanged(s);
184
+ try {
185
+ for (const [i, r] of todo.entries()) {
186
+ const onStep = (step) => ((s.shipping = { ...s.shipping, step, ...(todo.length > 1 && { repo: r.name, at: i + 1 }) }), app.sessionChanged(s));
187
+ try {
188
+ r.at.pr = await ship(r.wt, { title, body: `${body}${note}`.trim(), draft, pr: r.at.pr, onStep });
189
+ } catch (err) {
190
+ throw repos.length > 1 ? Object.assign(new Error(`${r.name}: ${err.message}`), { status: err.status }) : err;
191
+ }
192
+ }
193
+ s.pushedAt = Date.now(); // the PR is polled fast for a while, so its checks show up as they start
194
+ } finally {
195
+ delete s.shipping;
196
+ app.sessionChanged(s, { now: true });
197
+ }
198
+ recheck(s);
199
+ return { ...(s.pr || todo[0].at.pr), ...(repos.length > 1 && { prs: prsOf(s) }) };
200
+ };
201
+ // Every PR the task opened, its own first: { repo, number, url, state }.
202
+ const prsOf = (s) => reposOf(s, store).filter((r) => r.at.pr).map((r) => ({ repo: r.name, number: r.at.pr.number, url: r.at.pr.url, state: r.at.pr.state }));
203
+
204
+ // Merge: squash-merge each open PR of the task, its own first. Stops at the first that fails.
205
+ const mergeAll = async (s) => {
206
+ const open = reposOf(s, store).filter((r) => r.at.pr?.state === 'OPEN');
207
+ if (!open.length) throw bad('No pull request to merge.');
208
+ const synced = [];
209
+ for (const r of open) {
210
+ try {
211
+ const { pr, sync } = await merge(r.wt, r.at.pr.number);
212
+ r.at.pr = pr;
213
+ synced.push(sync);
214
+ } catch (err) {
215
+ app.sessionChanged(s, { now: true });
216
+ throw open.length > 1 ? Object.assign(new Error(`${r.name}: ${err.message}`), { status: err.status }) : err;
217
+ }
218
+ }
219
+ const sync = { ok: synced.every((x) => x.ok), text: synced.map((x) => x.text).join(' ') };
220
+ return { pr: s.pr || open[0].at.pr, sync, ...(reposOf(s, store).length > 1 && { prs: prsOf(s) }) };
221
+ };
222
+
130
223
  // Default commit message and PR text, from the session's latest presentation.
131
224
  const draft = (s) => {
132
225
  const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
@@ -150,12 +243,28 @@ export function createShip(app) {
150
243
  delete s.shipping; // a ship cut short by a restart isn't running anymore
151
244
  if (s.conflictFix?.step === 'pushing') Object.assign(s.conflictFix, { step: 'failed', why: 'wisp restarted while pushing the fix.' });
152
245
  }
153
- for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheck(s);
246
+ for (const s of store.all()) if (hasTrees(s) && !s.archived && s.pr?.state !== 'MERGED') recheck(s);
154
247
 
248
+ // Open PRs are checked every minute, and every few seconds while their state is still settling,
249
+ // so a check finishing or a pushed fix clearing the conflict shows up quickly.
250
+ const settling = (s) => {
251
+ const pr = s.pr;
252
+ return !!(pr.checks?.pending || pr.mergeState === 'UNKNOWN' || ['pushing', 'done'].includes(s.conflictFix?.step) || Date.now() - (s.pushedAt || 0) < 5 * 60_000);
253
+ };
254
+ let polling = false;
155
255
  const timer = setInterval(async () => {
156
- for (const s of store.all()) if (s.pr?.state === 'OPEN') await refreshPr(s).catch(() => {});
157
- }, PR_POLL_MS);
256
+ if (polling) return;
257
+ polling = true;
258
+ try {
259
+ // A task's other repos' PRs ride along with its own (or poll on their own when it has none open).
260
+ const lead = (s) => (s.pr?.state === 'OPEN' ? s.pr : s.also?.find((a) => a.pr?.state === 'OPEN')?.pr);
261
+ const due = store.all().filter((s) => lead(s) && Date.now() - (lead(s).checkedAt || 0) >= (s.pr?.state === 'OPEN' && settling(s) ? PR_FAST_POLL_MS : PR_POLL_MS));
262
+ await Promise.all(due.map((s) => refreshPr(s).catch(() => {})));
263
+ } finally {
264
+ polling = false;
265
+ }
266
+ }, PR_TICK_MS);
158
267
  timer.unref();
159
268
 
160
- return { recheck, refreshPr, fixConflicts, draft, stop: () => clearInterval(timer) };
269
+ return { recheck, refreshPr, fixConflicts, draft, shipAll, mergeAll, prsOf, stop: () => clearInterval(timer) };
161
270
  }
@@ -23,7 +23,8 @@ wisp "add pagination to /api/users" --cwd ~/code/api --wait # block until do
23
23
  ```
24
24
 
25
25
  Options: `--agent claude|codex|opencode|cursor|antigravity|grok`, `--mode plan|edits|auto`, `--model ID`,
26
- `--effort low|medium|high`, `--cwd DIR` (default: current folder), `--wait`, `--json`.
26
+ `--effort low|medium|high`, `--cwd DIR` (default: current folder), `--also DIR` (another folder the
27
+ same agent works in, repeatable: e.g. the API and its web app), `--wait`, `--json`.
27
28
  `--json` prints the session summary; its `id` is what every other command takes.
28
29
 
29
30
  Write the prompt as a self-contained brief: the agent cannot see your conversation. Include the
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/feed.css CHANGED
@@ -78,6 +78,9 @@
78
78
  font-size: 11px; font-weight: 500; padding: 0 7px; border-radius: 5px; line-height: 19px; white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis;
79
79
  color: color-mix(in srgb, var(--pc) 65%, var(--ink)); background: color-mix(in srgb, var(--pc) 10%, transparent);
80
80
  }
81
+ /* a task in several folders: the others after its own, each in its colour */
82
+ .q-proj.multi { max-width: 180px; }
83
+ .q-also { margin-left: 4px; color: color-mix(in srgb, var(--pc) 65%, var(--ink)); }
81
84
  .q-time { font-size: 11.5px; color: var(--faint); min-width: 26px; text-align: right; font-variant-numeric: tabular-nums; }
82
85
  .q-queued { font-size: 11px; color: var(--accent-ink); font-weight: 600; }
83
86
  /* one-click done on collapsed review rows; revealed on hover/focus */
@@ -41,6 +41,18 @@
41
41
  .lp-chip.more:hover, .lp-chip.more.open { color: var(--ink); border-color: var(--line-2); }
42
42
  .lp-chip.more .i { width: 12px; height: 12px; }
43
43
  .l-projects .lp-chip[hidden] { display: none; }
44
+ /* one task, several folders: a small badge on a chip's corner adds it (+) or leaves it out (×) */
45
+ .l-projects .lp-chip { position: relative; }
46
+ .lp-also {
47
+ position: absolute; top: -6px; right: -5px; display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
48
+ background: var(--surface); border: 1px solid var(--line-2); color: var(--muted); box-shadow: var(--shadow-1);
49
+ opacity: 0; transform: scale(0.6); transition: opacity 0.12s, transform 0.12s, color 0.12s, background 0.12s; pointer-events: none;
50
+ }
51
+ .lp-also .i { width: 10px; height: 10px; }
52
+ .lp-chip:is(:hover, :focus-visible) .lp-also { opacity: 1; transform: none; pointer-events: auto; }
53
+ .lp-also:hover { color: #fff; background: var(--accent); border-color: var(--accent); }
54
+ .lp-also.x:hover { background: var(--bad); border-color: var(--bad); }
55
+ @media (hover: none) { .lp-also:not(.x) { opacity: 1; transform: none; pointer-events: auto; } }
44
56
  /* narrow, with "+N" (whose list has No folder too): it goes icon-only unless picked */
45
57
  @container launcher (max-width: 520px) { .l-projects:has(.more:not([hidden])) .lp-chip.none:not(.on) span { display: none; } }
46
58
  /* recipes: one-click prompts under the launch bar */
@@ -73,6 +73,12 @@
73
73
  .pm-row .p-run { flex: none; }
74
74
  .pm-row .pm-check { flex: none; width: 14px; height: 14px; color: var(--accent); visibility: hidden; }
75
75
  .pm-row.on .pm-check { visibility: visible; }
76
+ /* the launcher's list: + adds a project to the task, ✓ shows it's in */
77
+ .pm-also { flex: none; display: grid; place-items: center; width: 20px; height: 20px; margin: -2px -4px -2px 0; border-radius: 6px; color: var(--faint); }
78
+ .pm-also .i { width: 13px; height: 13px; }
79
+ .pm-also:hover { color: var(--ink); background: var(--surface-2); }
80
+ .pm-row.in .pm-also { color: var(--accent); }
81
+ .pm-row.in .pm-name { font-weight: 500; color: var(--ink); }
76
82
  .pm-x .i { width: 14px; height: 14px; color: var(--muted); }
77
83
  .pm-empty { padding: 18px 10px; text-align: center; font-size: 13px; color: var(--muted); }
78
84
  .menu .danger:hover { color: var(--bad); background: var(--del-bg); }
package/web/css/ship.css CHANGED
@@ -42,6 +42,7 @@
42
42
  .pill.st-running { --tone: var(--accent); }
43
43
  .it-meta .m.branch, .it-meta .m:has(> .i) { display: inline-flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
44
44
  .it-meta .m.branch { font: 11px var(--mono); color: var(--ink-2); }
45
+ .it-meta .m.also { display: inline-flex; align-items: center; gap: 5px; color: var(--ink-2); }
45
46
  .it-meta .m .i { width: 12px; height: 12px; flex: none; color: var(--faint); }
46
47
  .it-meta .m.m-plan, .it-meta .m.m-plan .i { color: var(--accent-ink); }
47
48
  .it-meta .m.m-auto, .it-meta .m.m-auto .i { color: var(--warn); }
@@ -60,9 +61,21 @@
60
61
  .pr-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--tone); flex: none; }
61
62
  .pr-strip.pr-pending .pr-dot { animation: pulse 1.4s ease-in-out infinite; }
62
63
  .pr-num { font-weight: 600; color: var(--ink); white-space: nowrap; }
64
+ /* a task in several repos: their PRs after the first, each with its own tone */
65
+ .pr-also { --tone: var(--good); display: inline-flex; align-items: center; gap: 5px; flex: none; padding: 1px 7px; border-radius: 6px; font-weight: 500; color: var(--ink); white-space: nowrap; background: color-mix(in srgb, var(--tone) 10%, transparent); }
66
+ .pr-also i { width: 6px; height: 6px; border-radius: 50%; background: var(--tone); }
67
+ .pr-also:hover { background: color-mix(in srgb, var(--tone) 18%, transparent); }
68
+ .pr-also.pr-pending { --tone: var(--warn); }
69
+ .pr-also.pr-fail { --tone: var(--bad); }
70
+ .pr-also.pr-merged { --tone: #8250df; }
71
+ .pr-also.pr-closed { --tone: var(--faint); }
63
72
  .pr-line { color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
64
73
  .pr-strip .ghost .i { width: 13px; height: 13px; }
65
74
  .pr-strip .pr-merge { color: var(--good); font-weight: 500; }
75
+ .pr-strip .pr-fix { color: var(--bad); font-weight: 500; }
76
+ .pr-strip > .ghost { flex: none; white-space: nowrap; }
77
+ .pr-strip [data-pr="refresh"].busy .i { animation: pr-spin 0.8s linear infinite; }
78
+ @keyframes pr-spin { to { transform: rotate(-360deg); } }
66
79
  @keyframes pulse { 50% { opacity: 0.35; } }
67
80
 
68
81
  .ship-modal, .run-modal { width: min(620px, 100%); }
@@ -70,12 +83,15 @@
70
83
  .ship-route code { font: 12px var(--mono); padding: 1px 6px; border-radius: 6px; background: var(--bg-2); }
71
84
  .ship-route .i { width: 13px; height: 13px; color: var(--faint); }
72
85
  .ship-route .muted { margin-left: auto; }
86
+ .ship-route .ship-repo { font-weight: 600; color: var(--ink); }
87
+ .ship-route + .ship-route { margin-top: -6px; }
73
88
  .ship-field { display: flex; flex-direction: column; gap: 6px; margin: 12px 0 0; }
74
89
  .ship-field > span { font-size: 12.5px; font-weight: 500; color: var(--ink-2); }
75
90
  .ship-field select { width: 100%; }
76
91
  .ship-check { display: flex; margin-top: 10px; }
77
92
  .ship-foot { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
78
93
  .ship-foot > .muted { margin-right: auto; }
94
+ .ship-foot > .primary { flex: none; white-space: nowrap; }
79
95
  .ship-foot code { font-size: 11px; }
80
96
  .hint.bad { color: var(--bad); }
81
97
 
@@ -128,7 +144,7 @@
128
144
  .pill.ship-pill { --tone: var(--accent); }
129
145
  .pill.ship-pill i { animation: pulse 1.1s ease-in-out infinite; }
130
146
  @keyframes ship-in { from { opacity: 0; transform: translateY(6px) scale(0.98); } }
131
- @keyframes ship-fly { 50% { transform: translate(2px, -2px); } }
147
+ @keyframes ship-fly { 50% { transform: translateY(-2.5px); } }
132
148
  @keyframes ship-sweep { from { background-position: -40% 0; } to { background-position: 140% 0; } }
133
149
  @keyframes ship-land { from { box-shadow: 0 0 0 3px color-mix(in srgb, var(--good) 30%, transparent); transform: scale(1.015); } }
134
150
  @media (max-width: 640px) { .ship-track span { display: none; } }