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