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