wisp-agents 0.18.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 +51 -1
- package/README.md +19 -7
- 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/index.js +2 -1
- package/server/judge.js +78 -0
- package/server/openapi.js +3 -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/settings.js +2 -0
- package/server/routes/ship.js +9 -4
- package/server/runner.js +59 -13
- package/server/scout.js +71 -0
- package/server/services/auto.js +103 -14
- package/server/services/launch.js +11 -6
- package/server/services/ship.js +36 -14
- package/web/css/auto.css +22 -0
- package/web/css/ship.css +5 -1
- package/web/index.html +1 -0
- package/web/js/blocks.js +1 -1
- package/web/js/features/actions.js +2 -4
- package/web/js/features/auto.js +38 -7
- package/web/js/features/composer.js +2 -4
- package/web/js/features/feed.js +31 -1
- package/web/js/features/live.js +2 -7
- package/web/js/features/projects.js +2 -5
- package/web/js/features/runners.js +7 -6
- package/web/js/features/settings.js +34 -8
- package/web/js/features/ship.js +30 -3
- package/web/js/features/turns.js +4 -3
- package/web/js/ui/popover.js +10 -0
package/server/runner.js
CHANGED
|
@@ -95,9 +95,9 @@ export function summarize(s, store) {
|
|
|
95
95
|
updatedAt: s.updatedAt,
|
|
96
96
|
turnCount: s.turns.length,
|
|
97
97
|
queued: s.queue?.length || 0,
|
|
98
|
-
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'),
|
|
99
99
|
preview: hero?.summary || '',
|
|
100
|
-
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 : '',
|
|
101
101
|
asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
|
|
102
102
|
question: s.ask?.question || '',
|
|
103
103
|
approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
|
|
@@ -117,8 +117,23 @@ export function summarize(s, store) {
|
|
|
117
117
|
}),
|
|
118
118
|
),
|
|
119
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,
|
|
120
135
|
// 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,
|
|
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,
|
|
122
137
|
};
|
|
123
138
|
}
|
|
124
139
|
|
|
@@ -134,11 +149,13 @@ export class Runner extends EventEmitter {
|
|
|
134
149
|
this.answers = new Map(); // askId -> answer, until the MCP server picks it up
|
|
135
150
|
this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
|
|
136
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)
|
|
137
153
|
|
|
138
154
|
// Anything that was mid-flight when wisp last died is now orphaned.
|
|
139
155
|
for (const s of store.all()) {
|
|
140
156
|
const t = s.turns.at(-1);
|
|
141
157
|
delete s.ask;
|
|
158
|
+
if (s.auto?.busy) Object.assign(s.auto, { busy: false, stage: 'cancelled', label: '' }); // Auto's step died with wisp
|
|
142
159
|
if (s.state === 'running' || s.state === 'waiting' || t?.status === 'running') {
|
|
143
160
|
if (t?.status === 'running') this.#finishTurn(s, t, { isError: true, stopped: true, text: '' }, 'wisp was restarted while this was running.');
|
|
144
161
|
s.state = 'review';
|
|
@@ -148,7 +165,7 @@ export class Runner extends EventEmitter {
|
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
// subagents: the team a lead may delegate to. parentId/member: this session is one of them.
|
|
151
|
-
create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null }) {
|
|
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 }) {
|
|
152
169
|
if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
|
|
153
170
|
const now = Date.now();
|
|
154
171
|
const session = {
|
|
@@ -172,8 +189,10 @@ export class Runner extends EventEmitter {
|
|
|
172
189
|
...(groupId && { groupId }),
|
|
173
190
|
...(origin && { origin }),
|
|
174
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)
|
|
175
194
|
...(parentId && { parentId, member }),
|
|
176
|
-
...(route && { route }), // how Auto picked agent and
|
|
195
|
+
...(route && { route }), // how Auto picked agent, model and level (server/router.js)
|
|
177
196
|
};
|
|
178
197
|
this.store.put(session);
|
|
179
198
|
if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
|
|
@@ -185,10 +204,13 @@ export class Runner extends EventEmitter {
|
|
|
185
204
|
// follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
|
|
186
205
|
// switches the harness, model and thinking level the same way (see #switchRun).
|
|
187
206
|
// grants / once: a retry of what the last turn's permissions blocked (see approveBlocked).
|
|
188
|
-
|
|
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 } = {}) {
|
|
189
210
|
if (!MODES.has(mode) || mode === session.mode) mode = undefined;
|
|
190
211
|
run = changedRun(session, run);
|
|
191
|
-
|
|
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 }) };
|
|
192
214
|
if (session.ask && !Object.keys(extra).length) {
|
|
193
215
|
this.answer(session, text);
|
|
194
216
|
return { answered: true };
|
|
@@ -249,7 +271,7 @@ export class Runner extends EventEmitter {
|
|
|
249
271
|
if (session.agentSessionId) session.agentSessions[session.agent] = session.agentSessionId;
|
|
250
272
|
for (const t of session.turns) t.agent ||= session.agent;
|
|
251
273
|
// 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;
|
|
274
|
+
if (session.route && (agent !== session.agent || model !== session.model || effort !== (session.effort || ''))) session.route.switched = true;
|
|
253
275
|
if (agent !== session.agent) {
|
|
254
276
|
session.agent = agent;
|
|
255
277
|
session.agentSessionId = session.agentSessions[agent] || null;
|
|
@@ -383,7 +405,7 @@ export class Runner extends EventEmitter {
|
|
|
383
405
|
out.push({ subagent: want, error: 'Auto found no harness to run this on.' });
|
|
384
406
|
continue;
|
|
385
407
|
}
|
|
386
|
-
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 };
|
|
387
409
|
child = this.create({ prompt, cwd: workdir(lead), ...run, mode: lead.mode, parentId: lead.id, member: slot });
|
|
388
410
|
(lead.children ||= {})[slot] = child.id;
|
|
389
411
|
}
|
|
@@ -413,6 +435,7 @@ export class Runner extends EventEmitter {
|
|
|
413
435
|
}
|
|
414
436
|
|
|
415
437
|
stop(session) {
|
|
438
|
+
this.pipeline?.cancel(session);
|
|
416
439
|
session.queue = [];
|
|
417
440
|
delete session.ask;
|
|
418
441
|
for (const cid of Object.values(session.children || {})) {
|
|
@@ -492,9 +515,10 @@ export class Runner extends EventEmitter {
|
|
|
492
515
|
|
|
493
516
|
// grants: harness rules allowing what the user approved, for this turn only. once: a mode for
|
|
494
517
|
// this turn only (an approved retry the harness can't scope runs in Full auto).
|
|
495
|
-
#startTurn(session, prompt, attachments = [], { grants = [], once } = {}) {
|
|
518
|
+
#startTurn(session, prompt, attachments = [], { grants = [], once, by } = {}) {
|
|
496
519
|
const mode = MODES.has(once) ? once : session.mode;
|
|
497
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
|
|
498
522
|
session.turns.push(turn);
|
|
499
523
|
session.state = 'running';
|
|
500
524
|
session.unread = false;
|
|
@@ -679,15 +703,37 @@ export class Runner extends EventEmitter {
|
|
|
679
703
|
if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
|
|
680
704
|
}
|
|
681
705
|
if (!ev.stopped) blockedCard(turn);
|
|
682
|
-
session
|
|
683
|
-
|
|
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;
|
|
684
710
|
this.store.save(session, { now: true });
|
|
685
711
|
this.#touch(session, { turn: turn.id });
|
|
686
712
|
this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
|
|
687
713
|
this.#touchLead(session);
|
|
714
|
+
if (holding) setImmediate(() => this.pipeline.run(session, turn));
|
|
688
715
|
// The follow-up starts right away; don't notify for the run it replaced. A subagent reports
|
|
689
716
|
// to its lead, not to the user.
|
|
690
|
-
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 });
|
|
691
737
|
}
|
|
692
738
|
|
|
693
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;
|
|
@@ -48,31 +53,115 @@ 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
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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,
|
|
70
130
|
});
|
|
71
|
-
|
|
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;
|
|
72
135
|
};
|
|
73
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
|
+
|
|
74
163
|
// 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()) });
|
|
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()) });
|
|
76
165
|
|
|
77
166
|
return { detect, agents, listed, opts, goalOf, signals, resolve, view };
|
|
78
167
|
}
|
|
@@ -29,12 +29,16 @@ export function createLaunch(app) {
|
|
|
29
29
|
const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
|
|
30
30
|
// compare: more { agent, model, effort } to run the same task on, side by side. 'auto' is
|
|
31
31
|
// 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
|
-
|
|
32
|
+
const resolved = await app.auto.resolve(body, await app.auto.agents(), { team: subagents.length > 0, cwd });
|
|
33
|
+
// A launch routed by another wisp (this is a machine) arrives with its route: Auto's steps
|
|
34
|
+
// (check, repair) still run here, where the files are.
|
|
35
|
+
const isRoute = (x) => x && typeof x === 'object' && typeof x.key === 'string';
|
|
36
|
+
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
37
|
const agent = r.agent || (i ? d.agent : '') || 'claude';
|
|
35
38
|
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 }) };
|
|
39
|
+
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
40
|
});
|
|
41
|
+
const strategy = ['single', 'best2', 'compare'].includes(resolved.autoStrategy) ? resolved.autoStrategy : runners.length > 1 ? 'compare' : 'single';
|
|
38
42
|
const mode = MODES.has(body.mode) ? body.mode : 'auto';
|
|
39
43
|
// Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
|
|
40
44
|
// runs need one each, or the harnesses would edit the same files.
|
|
@@ -62,7 +66,7 @@ export function createLaunch(app) {
|
|
|
62
66
|
}
|
|
63
67
|
if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
|
|
64
68
|
}
|
|
65
|
-
return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
|
|
69
|
+
return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents, ...(r.route && { auto: { strategy }, scout: resolved.scout || null }) }));
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
// Where a launch runs: body.machine, else a project folder that only exists on a machine
|
|
@@ -89,8 +93,9 @@ export function createLaunch(app) {
|
|
|
89
93
|
// back through the summaries it streams (auto.signals), so one router learns from every place.
|
|
90
94
|
const routed = await app.auto.resolve(body, machines.agentsOf(mid) || [], { team: !!(body.subagents?.length || body.preset) });
|
|
91
95
|
const routes = [routed.route, ...(routed.compare || []).map((c) => c.route)];
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
// The machine runs Auto's check and repair on these picks (an older wisp there ignores them).
|
|
97
|
+
const sendBody = { ...routed };
|
|
98
|
+
delete sendBody.scout;
|
|
94
99
|
if (!sendBody.compare?.length && !body.compare) delete sendBody.compare;
|
|
95
100
|
const out = await machines.launch(mid, { ...sendBody, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
|
|
96
101
|
(out.group || [out]).forEach((x, i) => routes[i] && app.router.record(qualify(x.id, mid), routes[i]));
|
package/server/services/ship.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
-
//
|
|
4
|
-
//
|
|
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
8
|
import { worktreeStatus, prView, ship, baseMerged, conflictsWithBase } from '../git.js';
|
|
@@ -9,6 +10,8 @@ import { blocksToMarkdown } from '../render.js';
|
|
|
9
10
|
import { summarize } from '../runner.js';
|
|
10
11
|
|
|
11
12
|
const PR_POLL_MS = 60_000;
|
|
13
|
+
const PR_FAST_POLL_MS = 10_000; // while GitHub is still working something out: checks running, mergeability unknown, a push just landed
|
|
14
|
+
const PR_TICK_MS = 5_000;
|
|
12
15
|
const SHIP_RECHECK_MS = 5_000;
|
|
13
16
|
const MAX_CONFLICT_FIXES = 3; // per task, so a base that keeps moving can't run up the bill
|
|
14
17
|
|
|
@@ -75,20 +78,24 @@ export function createShip(app) {
|
|
|
75
78
|
return pr;
|
|
76
79
|
};
|
|
77
80
|
|
|
78
|
-
// The PR conflicts with its base: send the task a follow-up to merge the base in.
|
|
79
|
-
//
|
|
81
|
+
// The PR conflicts with its base: send the task a follow-up to merge the base in. Only when the
|
|
82
|
+
// user clicks Fix conflicts (force), unless defaults.fixConflicts is 'on': then on its own, once
|
|
83
|
+
// per PR head, so a fix that didn't take isn't retried until something changes the branch.
|
|
80
84
|
const starting = new Set(); // a poll and a refresh at once must not both send it
|
|
81
|
-
const fixConflicts = async (s) => {
|
|
85
|
+
const fixConflicts = async (s, { force = false } = {}) => {
|
|
82
86
|
const pr = s.pr;
|
|
83
|
-
if (store.config.defaults.fixConflicts
|
|
87
|
+
if ((!force && store.config.defaults.fixConflicts !== 'on') || pr?.state !== 'OPEN' || !pr.conflict || !s.worktree?.remote) return;
|
|
84
88
|
if (runner.live.has(s.id) || s.shipping || s.ask || s.archived || starting.has(s.id)) return;
|
|
85
89
|
const tries = s.conflictFix?.tries || 0;
|
|
86
|
-
if (s.conflictFix?.head === pr.head || tries >= MAX_CONFLICT_FIXES) return;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
if (!force && (s.conflictFix?.head === pr.head || tries >= MAX_CONFLICT_FIXES)) return;
|
|
91
|
+
// The user saw the conflict and asked: no fetch first, the follow-up starts at once.
|
|
92
|
+
if (!force) {
|
|
93
|
+
starting.add(s.id);
|
|
94
|
+
try {
|
|
95
|
+
if ((await conflictsWithBase(s.worktree)) === false || runner.live.has(s.id)) return;
|
|
96
|
+
} finally {
|
|
97
|
+
starting.delete(s.id);
|
|
98
|
+
}
|
|
92
99
|
}
|
|
93
100
|
s.conflictFix = { step: 'fixing', head: pr.head, tries: tries + 1, startedAt: Date.now() };
|
|
94
101
|
await app.launcher.replyTo(s, conflictPrompt(s), [], true);
|
|
@@ -116,6 +123,7 @@ export function createShip(app) {
|
|
|
116
123
|
const onStep = (step) => ((s.shipping = { ...s.shipping, step }), app.sessionChanged(s));
|
|
117
124
|
try {
|
|
118
125
|
s.pr = await ship(s.worktree, { title: `Merge ${s.worktree.remote}/${s.worktree.base} into ${s.worktree.branch}`, pr: s.pr, onStep });
|
|
126
|
+
s.pushedAt = Date.now();
|
|
119
127
|
fix.step = 'done';
|
|
120
128
|
} catch (err) {
|
|
121
129
|
Object.assign(fix, { step: 'failed', why: `Push failed: ${err.message}` });
|
|
@@ -152,9 +160,23 @@ export function createShip(app) {
|
|
|
152
160
|
}
|
|
153
161
|
for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheck(s);
|
|
154
162
|
|
|
163
|
+
// Open PRs are checked every minute, and every few seconds while their state is still settling,
|
|
164
|
+
// so a check finishing or a pushed fix clearing the conflict shows up quickly.
|
|
165
|
+
const settling = (s) => {
|
|
166
|
+
const pr = s.pr;
|
|
167
|
+
return !!(pr.checks?.pending || pr.mergeState === 'UNKNOWN' || ['pushing', 'done'].includes(s.conflictFix?.step) || Date.now() - (s.pushedAt || 0) < 5 * 60_000);
|
|
168
|
+
};
|
|
169
|
+
let polling = false;
|
|
155
170
|
const timer = setInterval(async () => {
|
|
156
|
-
|
|
157
|
-
|
|
171
|
+
if (polling) return;
|
|
172
|
+
polling = true;
|
|
173
|
+
try {
|
|
174
|
+
const due = store.all().filter((s) => s.pr?.state === 'OPEN' && Date.now() - (s.pr.checkedAt || 0) >= (settling(s) ? PR_FAST_POLL_MS : PR_POLL_MS));
|
|
175
|
+
await Promise.all(due.map((s) => refreshPr(s).catch(() => {})));
|
|
176
|
+
} finally {
|
|
177
|
+
polling = false;
|
|
178
|
+
}
|
|
179
|
+
}, PR_TICK_MS);
|
|
158
180
|
timer.unref();
|
|
159
181
|
|
|
160
182
|
return { recheck, refreshPr, fixConflicts, draft, stop: () => clearInterval(timer) };
|
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/ship.css
CHANGED
|
@@ -63,6 +63,10 @@
|
|
|
63
63
|
.pr-line { color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
64
64
|
.pr-strip .ghost .i { width: 13px; height: 13px; }
|
|
65
65
|
.pr-strip .pr-merge { color: var(--good); font-weight: 500; }
|
|
66
|
+
.pr-strip .pr-fix { color: var(--bad); font-weight: 500; }
|
|
67
|
+
.pr-strip > .ghost { flex: none; white-space: nowrap; }
|
|
68
|
+
.pr-strip [data-pr="refresh"].busy .i { animation: pr-spin 0.8s linear infinite; }
|
|
69
|
+
@keyframes pr-spin { to { transform: rotate(-360deg); } }
|
|
66
70
|
@keyframes pulse { 50% { opacity: 0.35; } }
|
|
67
71
|
|
|
68
72
|
.ship-modal, .run-modal { width: min(620px, 100%); }
|
|
@@ -128,7 +132,7 @@
|
|
|
128
132
|
.pill.ship-pill { --tone: var(--accent); }
|
|
129
133
|
.pill.ship-pill i { animation: pulse 1.1s ease-in-out infinite; }
|
|
130
134
|
@keyframes ship-in { from { opacity: 0; transform: translateY(6px) scale(0.98); } }
|
|
131
|
-
@keyframes ship-fly { 50% { transform:
|
|
135
|
+
@keyframes ship-fly { 50% { transform: translateY(-2.5px); } }
|
|
132
136
|
@keyframes ship-sweep { from { background-position: -40% 0; } to { background-position: 140% 0; } }
|
|
133
137
|
@keyframes ship-land { from { box-shadow: 0 0 0 3px color-mix(in srgb, var(--good) 30%, transparent); transform: scale(1.015); } }
|
|
134
138
|
@media (max-width: 640px) { .ship-track span { display: none; } }
|
package/web/index.html
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
<symbol id="i-pen" viewBox="0 0 24 24"><path d="M4 20l1-4L16 5l3 3L8 19l-4 1z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
|
48
48
|
<symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6L12 3zM18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2z" fill="currentColor"/></symbol>
|
|
49
49
|
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="2" fill="currentColor"/></symbol>
|
|
50
|
+
<symbol id="i-merge" viewBox="0 0 24 24"><circle cx="6" cy="5" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="6" cy="19" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="18" cy="15" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M6 7.2v9.6M6 7.2c0 4.4 4.4 7.8 9.8 7.8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
|
50
51
|
<symbol id="i-send" viewBox="0 0 24 24"><path d="M12 19V5m-6 6l6-6 6 6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
|
51
52
|
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M19 5v7a3 3 0 01-3 3H5m4-4l-4 4 4 4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
|
52
53
|
<symbol id="i-queue" viewBox="0 0 24 24"><path d="M4 6h12M4 12h12M4 18h7M17 15v6m-3-3h6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
package/web/js/blocks.js
CHANGED
|
@@ -512,7 +512,7 @@ R.tests = (b, ctx) => {
|
|
|
512
512
|
</svg>
|
|
513
513
|
<div class="ring-label">${icon(ok ? 'check' : 'x')}</div>
|
|
514
514
|
<div class="tests-text">
|
|
515
|
-
<div class="verdict">${ok ? 'All green' : `${failed} failing`}</div>
|
|
515
|
+
<div class="verdict">${ok ? 'All green' : `${failed} failing`}${b.by === 'wisp' ? `<span class="tests-by" title="wisp ran the project's tests itself after the agent finished${b.note ? `: ${esc(b.note)}` : ''}">Run by wisp</span>` : ''}</div>
|
|
516
516
|
<div class="counts"><span class="pass"><b class="v" data-v="${passed}"></b> passed</span>${failed ? `<span class="fail"><b class="v" data-v="${failed}"></b> failed</span>` : ''}${skipped ? `<span class="skip"><b class="v" data-v="${skipped}"></b> skipped</span>` : ''}${b.durationMs ? `<span class="dur">${fmtDuration(b.durationMs)}</span>` : ''}</div>
|
|
517
517
|
${cmd ? `<div class="tests-cmd"><code class="cmdline">$ ${esc(cmd)}</code>${ctx.exec ? `<button class="ghost xs run-tests" title="Re-run ${esc(cmd)}">${icon('rerun')}<span>Re-run</span></button>` : ''}</div>` : ''}
|
|
518
518
|
</div>
|
|
@@ -8,7 +8,7 @@ import { $, el, icon } from '../core/dom.js';
|
|
|
8
8
|
import { S } from '../core/state.js';
|
|
9
9
|
import { keyHint } from '../ui/kbd.js';
|
|
10
10
|
import { overlay } from '../ui/overlay.js';
|
|
11
|
-
import { popover } from '../ui/popover.js';
|
|
11
|
+
import { clampToViewport, popover } from '../ui/popover.js';
|
|
12
12
|
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
13
13
|
import { autosize } from './attachments.js';
|
|
14
14
|
import { saveAsRecipe } from './automations.js';
|
|
@@ -208,9 +208,7 @@ export function taskMenu(id, e) {
|
|
|
208
208
|
if (a === 'delete') deleteSession(id);
|
|
209
209
|
};
|
|
210
210
|
const close = popover(menu);
|
|
211
|
-
|
|
212
|
-
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
213
|
-
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
211
|
+
clampToViewport(menu, e.clientX, e.clientY);
|
|
214
212
|
}
|
|
215
213
|
|
|
216
214
|
export async function deleteSession(id) {
|