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.
- package/CHANGELOG.md +62 -1
- package/README.md +23 -7
- package/bin/wisp.js +4 -3
- package/package.json +2 -2
- package/server/adapters/antigravity.js +2 -1
- package/server/adapters/claude.js +7 -5
- package/server/adapters/codex.js +7 -3
- package/server/adapters/commands.js +808 -0
- package/server/adapters/cursor.js +2 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/opencode.js +2 -1
- package/server/check.js +205 -0
- package/server/client.js +3 -2
- package/server/index.js +2 -1
- package/server/judge.js +78 -0
- package/server/mcp-wisp.js +2 -1
- package/server/openapi.js +4 -3
- package/server/pipeline.js +217 -0
- package/server/quick.js +76 -0
- package/server/router.js +201 -28
- package/server/routes/auto.js +8 -1
- package/server/routes/sessions.js +15 -8
- package/server/routes/settings.js +2 -0
- package/server/routes/ship.js +27 -27
- package/server/runner.js +83 -17
- package/server/scout.js +71 -0
- package/server/services/auto.js +111 -14
- package/server/services/launch.js +45 -8
- package/server/services/ship.js +132 -23
- package/skills/wisp/SKILL.md +2 -1
- package/web/css/auto.css +22 -0
- package/web/css/feed.css +3 -0
- package/web/css/launcher.css +12 -0
- package/web/css/palette.css +6 -0
- package/web/css/ship.css +17 -1
- package/web/index.html +1 -0
- package/web/js/blocks.js +1 -1
- package/web/js/core/state.js +4 -2
- package/web/js/features/actions.js +3 -5
- package/web/js/features/auto.js +38 -7
- package/web/js/features/composer.js +2 -4
- package/web/js/features/feed.js +40 -10
- package/web/js/features/launcher.js +62 -18
- package/web/js/features/live.js +2 -7
- package/web/js/features/places.js +10 -0
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +6 -8
- package/web/js/features/runners.js +7 -6
- package/web/js/features/settings.js +34 -8
- package/web/js/features/ship.js +95 -31
- package/web/js/features/turns.js +6 -4
- package/web/js/ui/popover.js +10 -0
package/server/routes/auto.js
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
});
|
package/server/routes/ship.js
CHANGED
|
@@ -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 {
|
|
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
|
|
22
|
+
if (!hasTrees(s)) return c.send(400, { error: NO_TREE });
|
|
20
23
|
app.ship.recheck(s);
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
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.
|
|
64
|
-
await app.ship.
|
|
65
|
-
if (s.conflictFix?.step !== 'fixing')
|
|
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.
|
|
75
|
-
const
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
683
|
-
|
|
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 } = {}) {
|
package/server/scout.js
ADDED
|
@@ -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
|
+
}
|