wisp-agents 0.20.0 → 0.22.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 +35 -1
- package/README.md +17 -8
- package/bin/wisp.js +14 -6
- package/package.json +4 -3
- package/server/adapters/claude.js +12 -7
- package/server/adapters/codex.js +13 -7
- package/server/adapters/cursor.js +4 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/index.js +7 -2
- package/server/adapters/opencode.js +5 -2
- package/server/btw.js +76 -0
- package/server/client.js +22 -2
- package/server/git.js +80 -4
- package/server/handoff.js +13 -7
- package/server/index.js +4 -0
- package/server/machines.js +15 -4
- package/server/mcp-wisp.js +2 -1
- package/server/openapi.js +28 -2
- package/server/remote.js +4 -2
- package/server/routes/machines.js +1 -1
- package/server/routes/proxy.js +6 -0
- package/server/routes/sessions.js +47 -10
- package/server/routes/settings.js +24 -38
- package/server/routes/ship.js +19 -24
- package/server/routes/uploads.js +2 -0
- package/server/runner.js +178 -12
- package/server/services/auto.js +10 -2
- package/server/services/launch.js +67 -7
- package/server/services/move.js +130 -0
- package/server/services/phone.js +20 -1
- package/server/services/places.js +150 -0
- package/server/services/ship.js +97 -10
- package/server/services/uploads.js +23 -4
- package/server/store.js +39 -6
- package/skills/wisp/SKILL.md +3 -1
- package/web/css/blocks.css +2 -0
- package/web/css/composer.css +23 -1
- package/web/css/feed.css +52 -1
- package/web/css/launcher.css +12 -0
- package/web/css/machines.css +4 -0
- package/web/css/modals.css +19 -0
- package/web/css/palette.css +6 -0
- package/web/css/ship.css +14 -0
- package/web/css/transcript.css +115 -0
- package/web/index.html +2 -0
- package/web/js/core/state.js +5 -2
- package/web/js/features/actions.js +42 -10
- package/web/js/features/btw.js +145 -0
- package/web/js/features/clones.js +7 -6
- package/web/js/features/commands.js +3 -1
- package/web/js/features/composer.js +162 -18
- package/web/js/features/feed.js +10 -10
- package/web/js/features/keys.js +2 -1
- package/web/js/features/launcher.js +121 -67
- package/web/js/features/place-menu.js +44 -0
- package/web/js/features/places.js +25 -5
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +16 -13
- package/web/js/features/settings.js +24 -3
- package/web/js/features/ship.js +66 -29
- package/web/js/features/slash.js +63 -0
- package/web/js/features/transcript.js +290 -0
- package/web/js/features/turns.js +101 -29
- package/web/js/slash-text.js +22 -0
- package/web/js/ui/kbd.js +1 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Moving a task to another place (this computer or a machine) with a follow-up. A session lives on
|
|
2
|
+
// the wisp that runs it, so the move starts a new session there that carries the task on:
|
|
3
|
+
//
|
|
4
|
+
// pack the source packs each folder's work (git.js packWork: the branch, plus a snapshot
|
|
5
|
+
// of what isn't committed) into bundles, kept as uploads there.
|
|
6
|
+
// carry the bundles travel through this wisp to the target, like attachments do.
|
|
7
|
+
// launch the target gets the project's checkout (services/places.js), unpacks each bundle into
|
|
8
|
+
// a worktree on the same branch, and starts the follow-up with the earlier turns as
|
|
9
|
+
// its history: the harness there hears them first (handoff.js).
|
|
10
|
+
// retire the old session is archived, pointing at the new one.
|
|
11
|
+
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { listAgents, adapters } from '../adapters/index.js';
|
|
14
|
+
import { packWork, unpackWork } from '../git.js';
|
|
15
|
+
import { splitId } from '../machines.js';
|
|
16
|
+
import { workdir } from '../runner.js';
|
|
17
|
+
import { bad } from '../util.js';
|
|
18
|
+
|
|
19
|
+
export function createMove(app) {
|
|
20
|
+
const { store, machines, uploads } = app;
|
|
21
|
+
// What a place is called in a moved task's history: the machine's name, or this computer's.
|
|
22
|
+
const where = (mid) => (mid ? machines.get(mid).name : os.hostname().replace(/\.local$/, '') || 'this computer');
|
|
23
|
+
|
|
24
|
+
// Source side: the session and its folders' bundles. A running task has nothing settled to pack.
|
|
25
|
+
async function pack(s) {
|
|
26
|
+
if (s.agent === 'wisp') throw bad('This is the tour — launch a real wisp to move it.');
|
|
27
|
+
if (s.parentId) throw bad('A subagent runs where its lead does.');
|
|
28
|
+
if (s.state === 'running' || s.state === 'waiting') throw bad('Stop the task or let it finish first: a running task can’t move.', 409);
|
|
29
|
+
const one = async (dir, wt) => {
|
|
30
|
+
const u = uploads.reserve('work.bundle');
|
|
31
|
+
const out = await packWork(dir, wt, u.file).catch((err) => {
|
|
32
|
+
throw bad(`Couldn’t pack the work in ${dir}: ${err.message}`);
|
|
33
|
+
});
|
|
34
|
+
return out && { ...out, upload: u.add().id };
|
|
35
|
+
};
|
|
36
|
+
const scratch = store.isScratch(s.cwd);
|
|
37
|
+
const packs = scratch ? [] : [await one(workdir(s), s.worktree), ...(await Promise.all((s.also || []).map((a) => one(a.worktree?.cwd || a.cwd, a.worktree))))];
|
|
38
|
+
return { session: s, scratch, packs };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Target side (launch.js): the worktrees for a moved launch, one per folder, from the bundles.
|
|
42
|
+
async function unpack(dirs, packs, { id, title }) {
|
|
43
|
+
return Promise.all(
|
|
44
|
+
dirs.map(async (cwd, i) => {
|
|
45
|
+
const p = packs?.[i];
|
|
46
|
+
const u = p && uploads.get(p.upload);
|
|
47
|
+
if (!p || !u) return null;
|
|
48
|
+
try {
|
|
49
|
+
return await unpackWork({ cwd, home: store.home, id, title, pack: p, file: u.path });
|
|
50
|
+
} finally {
|
|
51
|
+
uploads.remove(u.id);
|
|
52
|
+
}
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A bundle from the source (null = here) as an upload on the target.
|
|
58
|
+
async function carry(upload, from, to) {
|
|
59
|
+
let u = from ? null : uploads.get(upload);
|
|
60
|
+
if (from) u = await uploads.receive(await machines.stream(from, 'GET', `/uploads/${upload}`, { timeout: 10 * 60_000 }), { name: 'work.bundle', type: 'application/octet-stream' });
|
|
61
|
+
if (!u) throw bad('The packed work went missing on its way.', 500);
|
|
62
|
+
if (!to) return u.id;
|
|
63
|
+
const there = await machines.upload(to, u);
|
|
64
|
+
uploads.remove(u.id); // the copy here was only on its way through
|
|
65
|
+
return there.id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The earlier turns, as history for the new session: what ran where, without what only made
|
|
69
|
+
// sense there (checkpoints, attachment files, subagent sessions).
|
|
70
|
+
const history = (s, from) =>
|
|
71
|
+
s.turns.map((t) => {
|
|
72
|
+
const { checkpoint, delegations, attachments, ...rest } = t;
|
|
73
|
+
return { ...rest, moved: t.moved || where(from), checkpoint: null, ...(t.status === 'running' && { status: 'stopped' }) };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// id: the session as this wisp names it (<id>@<machine> on a machine). to: a machine id, or
|
|
77
|
+
// null for this computer. Resolves the new session's summary.
|
|
78
|
+
async function move(id, to, { text, attachments = [], mode, agent, model, effort } = {}) {
|
|
79
|
+
const at = splitId(id);
|
|
80
|
+
const from = at?.machine || null;
|
|
81
|
+
to = to || null;
|
|
82
|
+
if (from === to) throw bad('The task is already there.');
|
|
83
|
+
if (to) machines.get(to);
|
|
84
|
+
if (to && !machines.isOnline(to)) throw bad(`${machines.get(to).name} is offline. Connect it from Machines.`, 503);
|
|
85
|
+
const local = !from && store.get(id);
|
|
86
|
+
if (!from && !local) throw bad('no such session', 404);
|
|
87
|
+
const src = from ? await machines.json(from, 'GET', `/sessions/${at.id}`) : local;
|
|
88
|
+
// The harness the follow-up runs on has to be there.
|
|
89
|
+
const run = { agent: agent || src.agent, model: model ?? (agent && agent !== src.agent ? '' : src.model || ''), effort: effort ?? (agent && agent !== src.agent ? '' : src.effort || '') };
|
|
90
|
+
const there = to ? machines.agentsOf(to) || [] : await listAgents();
|
|
91
|
+
if (!there.some((a) => a.id === run.agent && a.available)) throw bad(`${adapters[run.agent]?.name || run.agent} isn’t installed on ${where(to)}. Pick another harness for this follow-up.`);
|
|
92
|
+
|
|
93
|
+
const packed = from ? await machines.json(from, 'POST', `/sessions/${at.id}/pack`, {}, { timeout: 10 * 60_000 }) : await pack(local);
|
|
94
|
+
const s = from ? machines.session(from, packed.session) : packed.session;
|
|
95
|
+
const packs = await Promise.all(packed.packs.map(async (p) => p && { ...p, upload: await carry(p.upload, from, to) }));
|
|
96
|
+
if (from) for (const p of packed.packs) if (p) machines.json(from, 'DELETE', `/uploads/${p.upload}`).catch(() => {});
|
|
97
|
+
|
|
98
|
+
const body = {
|
|
99
|
+
prompt: text,
|
|
100
|
+
...(packed.scratch ? { scratch: true } : { cwd: s.cwd, also: (s.also || []).map((a) => a.cwd) }),
|
|
101
|
+
machine: to || '',
|
|
102
|
+
agent: run.agent,
|
|
103
|
+
model: run.model,
|
|
104
|
+
effort: run.effort,
|
|
105
|
+
mode: mode || s.mode,
|
|
106
|
+
attachments,
|
|
107
|
+
...(s.groupId && { groupId: s.groupId }),
|
|
108
|
+
...(s.subagents?.length && { subagents: s.subagents }),
|
|
109
|
+
moved: { from: where(from), title: s.title, turns: history(s, from), packs, ...(s.origin && { origin: s.origin }) },
|
|
110
|
+
};
|
|
111
|
+
let next;
|
|
112
|
+
try {
|
|
113
|
+
[next] = await app.launcher.launchAny(body);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
for (const p of packs) if (p) (to ? machines.json(to, 'DELETE', `/uploads/${p.upload}`) : Promise.resolve(uploads.remove(p.upload))).catch(() => {});
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
// The old one steps aside: archived (its worktree stays until you delete it), pointing here.
|
|
119
|
+
const movedTo = { id: next.id, place: where(to) };
|
|
120
|
+
if (from) await machines.json(from, 'PATCH', `/sessions/${at.id}`, { archived: true, movedTo }).catch(() => {});
|
|
121
|
+
else {
|
|
122
|
+
Object.assign(local, { archived: true, unread: false, movedTo });
|
|
123
|
+
app.sessionChanged(local);
|
|
124
|
+
}
|
|
125
|
+
app.telemetry?.track('move_task', { from: from ? 'machine' : 'here', to: to ? 'machine' : 'here', agent: run.agent });
|
|
126
|
+
return { moved: true, from: id, session: next };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { pack, unpack, move };
|
|
130
|
+
}
|
package/server/services/phone.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Phone (ntfy / Telegram): what a tap or a reply there does to a session, here or on a machine.
|
|
2
2
|
|
|
3
|
+
import { BTW_TIMEOUT_MS } from '../btw.js';
|
|
3
4
|
import { splitId } from '../machines.js';
|
|
5
|
+
import { truncate } from '../util.js';
|
|
4
6
|
|
|
5
7
|
export function phoneActions(app) {
|
|
6
8
|
const { store, runner, machines } = app;
|
|
@@ -21,6 +23,17 @@ export function phoneActions(app) {
|
|
|
21
23
|
return s && { s: machines.session(at.machine, s), answer: (a) => call('POST', '/answer', { text: a }), reply: (t) => call('POST', '/reply', { text: t }), done: async () => !live && !!(await call('PATCH', '', { state: 'done' })) };
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
// A /btw from the phone: its answer, once it's in (null if it was dismissed or never came).
|
|
27
|
+
async function btwAnswer(sessionId, btwId) {
|
|
28
|
+
for (const end = Date.now() + BTW_TIMEOUT_MS + 30_000; Date.now() < end; ) {
|
|
29
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
30
|
+
const b = (await sessionOps(sessionId).catch(() => null))?.s.turns.flatMap((t) => t.btw || []).find((x) => x.id === btwId);
|
|
31
|
+
if (!b) return null;
|
|
32
|
+
if (b.status !== 'running') return b;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
return {
|
|
25
38
|
// /new from Telegram: the launcher's remembered harness, model and mode, like `wisp "…"`.
|
|
26
39
|
launch: async ({ project, prompt }) => {
|
|
@@ -31,7 +44,8 @@ export function phoneActions(app) {
|
|
|
31
44
|
app.broadcast('config', {});
|
|
32
45
|
return s;
|
|
33
46
|
},
|
|
34
|
-
|
|
47
|
+
// later(text): a message sent after this one (Telegram), for a /btw's answer.
|
|
48
|
+
act: async ({ sessionId, kind, ref, index, text, later }) => {
|
|
35
49
|
const ops = await sessionOps(sessionId).catch((err) => ({ error: err.message }));
|
|
36
50
|
if (ops?.error) return `Can’t reach it: ${ops.error}`;
|
|
37
51
|
if (!ops) return 'That session is gone.';
|
|
@@ -59,6 +73,11 @@ export function phoneActions(app) {
|
|
|
59
73
|
}
|
|
60
74
|
if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
|
|
61
75
|
const r = await ops.reply(String(text || '').trim());
|
|
76
|
+
if (r.btw) {
|
|
77
|
+
// Not awaited: the bot keeps taking messages while the side run answers.
|
|
78
|
+
btwAnswer(sessionId, r.btw.id).then((b) => b && later?.(truncate(b.status === 'done' ? `btw: ${b.answer}` : `Couldn’t answer that: ${b.error}`, 4000))).catch(() => {});
|
|
79
|
+
return later ? '✓ Asking on the side; the agent keeps working. The answer follows here.' : '✓ Asked on the side; the answer shows on the card in wisp.';
|
|
80
|
+
}
|
|
62
81
|
return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
|
|
63
82
|
},
|
|
64
83
|
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Places: this computer and the machines. A project is one repo that every place can run, so a
|
|
2
|
+
// task launched on a machine that has no checkout of it yet gets one first: a clone of the same
|
|
3
|
+
// repo that's already there (registered or in the usual folders), else a fresh clone of its
|
|
4
|
+
// origin. The checkout is remembered as the project's copy on that machine (store.setCopy).
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { cloneRepo, git, repoOf, sameRepo } from '../git.js';
|
|
10
|
+
import { HERE } from '../store.js';
|
|
11
|
+
import { bad, expand } from '../util.js';
|
|
12
|
+
|
|
13
|
+
const CLONE_TIMEOUT_MS = 10 * 60_000;
|
|
14
|
+
|
|
15
|
+
// Where people keep their repos, for one-click "add project".
|
|
16
|
+
export const REPO_ROOTS = ['code', 'Code', 'dev', 'Developer', 'projects', 'Projects', 'src', 'repos', 'work', 'git', 'GitHub', 'Documents', 'Downloads', 'Desktop'];
|
|
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
|
+
|
|
28
|
+
// Git repos up to two levels under the usual folders, newest first, skipping known ones.
|
|
29
|
+
export function discoverRepos(known = new Set()) {
|
|
30
|
+
const found = [];
|
|
31
|
+
const walk = (dir, depth) => {
|
|
32
|
+
if (found.length >= 60) return;
|
|
33
|
+
let entries;
|
|
34
|
+
try {
|
|
35
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (entries.some((e) => e.name === '.git')) {
|
|
40
|
+
if (!known.has(dir)) found.push({ path: dir, name: path.basename(dir), mtime: fs.statSync(dir).mtimeMs });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (depth <= 0) return;
|
|
44
|
+
for (const e of entries) if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') walk(path.join(dir, e.name), depth - 1);
|
|
45
|
+
};
|
|
46
|
+
for (const r of REPO_ROOTS) walk(path.join(os.homedir(), r), 2);
|
|
47
|
+
return found.sort((a, b) => b.mtime - a.mtime).slice(0, 24);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const originAt = (dir) => git(dir, ['remote', 'get-url', 'origin'], { timeout: 5000 }).catch(() => '');
|
|
51
|
+
|
|
52
|
+
// A checkout of `url` on this computer: a project (or copy) here that is that repo, else one in
|
|
53
|
+
// the usual folders, else a fresh clone into the clone folder. Resolves { dir, existed }.
|
|
54
|
+
export async function checkout(store, url) {
|
|
55
|
+
const repo = repoOf(url);
|
|
56
|
+
if (!repo) throw bad(`Not a repo URL: ${url}`);
|
|
57
|
+
const mine = store.config.projects.map((p) => [p, store.pathAt(p)]).filter(([, dir]) => dir && fs.existsSync(dir));
|
|
58
|
+
for (const [p, dir] of mine) if (sameRepo(p.repo || (await originAt(dir)), url)) return { dir, existed: true };
|
|
59
|
+
const found = discoverRepos();
|
|
60
|
+
const origins = await Promise.all(found.map((f) => originAt(f.path)));
|
|
61
|
+
const hit = found.find((f, i) => sameRepo(origins[i], url));
|
|
62
|
+
if (hit) return { dir: hit.path, existed: true };
|
|
63
|
+
return cloneRepo(repo, cloneRoot(store.config), { timeout: CLONE_TIMEOUT_MS });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createPlaces(app) {
|
|
67
|
+
const { store, machines } = app;
|
|
68
|
+
const placeName = (mid) => (mid ? machines.get(mid).name : 'this computer');
|
|
69
|
+
const inflight = new Map(); // `${project}@${place}` -> promise, so two launches clone once
|
|
70
|
+
|
|
71
|
+
// The project's origin URL, asked of whichever place has a checkout; kept on the project.
|
|
72
|
+
async function originOf(p) {
|
|
73
|
+
if (p.repo) return p.repo;
|
|
74
|
+
const places = [p.machine || null, ...Object.keys(p.copies || {}).map((k) => (k === HERE ? null : k))];
|
|
75
|
+
for (const mid of places) {
|
|
76
|
+
const dir = store.pathAt(p, mid);
|
|
77
|
+
let url = '';
|
|
78
|
+
if (!mid) url = fs.existsSync(dir) ? await originAt(dir) : '';
|
|
79
|
+
else if (machines.isOnline(mid)) url = (await machines.json(mid, 'GET', `/projects/origin?path=${encodeURIComponent(dir)}`).catch(() => null))?.origin || '';
|
|
80
|
+
if (url) {
|
|
81
|
+
p.repo = url;
|
|
82
|
+
store.saveConfig();
|
|
83
|
+
return url;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function remoteCheckout(mid, repo, name) {
|
|
90
|
+
try {
|
|
91
|
+
const r = await machines.json(mid, 'POST', '/projects/checkout', { repo, name }, { timeout: CLONE_TIMEOUT_MS });
|
|
92
|
+
return { dir: r.path, existed: !!r.existed };
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err.status !== 404) throw err;
|
|
95
|
+
}
|
|
96
|
+
// An older wisp there: its POST /projects clones the repo (or reuses a clone of it).
|
|
97
|
+
const r = await machines.json(mid, 'POST', '/projects', { repo }, { timeout: CLONE_TIMEOUT_MS });
|
|
98
|
+
return { dir: r.path, existed: !!r.cloned?.existed };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The project's folder on `mid` (null = this computer). fresh: the one wisp knew is gone.
|
|
102
|
+
async function ensureAt(p, mid = null, { fresh = false } = {}) {
|
|
103
|
+
const known = store.pathAt(p, mid);
|
|
104
|
+
if (known && !fresh && (mid || fs.existsSync(known))) return known;
|
|
105
|
+
const key = `${p.id}@${mid || HERE}`;
|
|
106
|
+
if (!inflight.has(key)) {
|
|
107
|
+
const job = (async () => {
|
|
108
|
+
const repo = await originOf(p);
|
|
109
|
+
if (!repo) throw bad(`${p.name} has no git remote (origin) to clone from, so wisp can’t set it up on ${placeName(mid)}. Push it to a remote first, or add its folder there in Projects.`, 409);
|
|
110
|
+
const out = mid ? await remoteCheckout(mid, repo, p.name) : await checkout(store, repo);
|
|
111
|
+
store.setCopy(p, mid, out.dir);
|
|
112
|
+
app.configChanged();
|
|
113
|
+
return out.dir;
|
|
114
|
+
})().finally(() => inflight.delete(key));
|
|
115
|
+
inflight.set(key, job);
|
|
116
|
+
}
|
|
117
|
+
return inflight.get(key);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A task's folder, for running it on `mid`: the same folder in that place's checkout of its
|
|
121
|
+
// project. Folders that are no project's (and ones already on that place) stay as they are.
|
|
122
|
+
async function dirAt(dir, mid = null) {
|
|
123
|
+
if (!dir) return dir;
|
|
124
|
+
if (!mid) dir = expand(dir);
|
|
125
|
+
if (store.projectFor(dir, mid)) return dir;
|
|
126
|
+
for (const from of [null, ...machines.list.map((m) => m.id)]) {
|
|
127
|
+
if (from === (mid || null)) continue;
|
|
128
|
+
const p = store.projectFor(dir, from);
|
|
129
|
+
if (!p) continue;
|
|
130
|
+
// A folder that's here too (the same layout on both computers) is used as it is, unless the
|
|
131
|
+
// project already has its own checkout here.
|
|
132
|
+
if (!mid && fs.existsSync(dir) && !store.pathAt(p)) return dir;
|
|
133
|
+
const rest = dir.slice(store.pathAt(p, from).length).split(/[\\/]/).filter(Boolean);
|
|
134
|
+
const there = await ensureAt(p, mid);
|
|
135
|
+
return rest.length ? (mid ? path.posix : path).join(there, ...rest) : there;
|
|
136
|
+
}
|
|
137
|
+
return dir;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A launch body with its folders (cwd, also) moved to `mid`.
|
|
141
|
+
async function placeBody(body, mid = null) {
|
|
142
|
+
if (body.scratch === true) return body;
|
|
143
|
+
const out = { ...body };
|
|
144
|
+
if (body.cwd) out.cwd = await dirAt(String(body.cwd), mid);
|
|
145
|
+
if (Array.isArray(body.also)) out.also = await Promise.all(body.also.map((x) => (typeof x === 'string' ? dirAt(x, mid) : x?.cwd ? dirAt(String(x.cwd), mid) : x)));
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { originOf, ensureAt, dirAt, placeBody, placeName };
|
|
150
|
+
}
|
package/server/services/ship.js
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
// result is pushed to the PR when it's done.
|
|
6
6
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
-
import
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { worktreeStatus, prView, ship, merge, baseMerged, conflictsWithBase } from '../git.js';
|
|
9
10
|
import { blocksToMarkdown } from '../render.js';
|
|
10
11
|
import { summarize } from '../runner.js';
|
|
12
|
+
import { bad } from '../util.js';
|
|
11
13
|
|
|
12
14
|
const PR_POLL_MS = 60_000;
|
|
13
15
|
const PR_FAST_POLL_MS = 10_000; // while GitHub is still working something out: checks running, mergeability unknown, a push just landed
|
|
@@ -29,15 +31,28 @@ export const conflictPrompt = (s) => {
|
|
|
29
31
|
].join('\n');
|
|
30
32
|
};
|
|
31
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
|
+
|
|
32
44
|
export function createShip(app) {
|
|
33
45
|
const { store, runner } = app;
|
|
34
46
|
|
|
35
47
|
// Ship it shows only when the worktree has something to ship: uncommitted changes, or commits
|
|
36
|
-
// 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.
|
|
37
50
|
const check = async (s) => {
|
|
38
|
-
if (!s
|
|
39
|
-
const
|
|
40
|
-
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 }) };
|
|
41
56
|
if (JSON.stringify(next) === JSON.stringify(s.toShip)) return;
|
|
42
57
|
s.toShip = next;
|
|
43
58
|
app.sessionChanged(s);
|
|
@@ -47,7 +62,7 @@ export function createShip(app) {
|
|
|
47
62
|
const checks = new Map(); // id → true while checking, 'again' if another check is due
|
|
48
63
|
const checkedAt = new Map();
|
|
49
64
|
const recheck = (s, { lazy = false } = {}) => {
|
|
50
|
-
if (!s
|
|
65
|
+
if (!hasTrees(s) || s.parentId) return;
|
|
51
66
|
if (lazy && (runner.live.has(s.id) || checks.has(s.id) || Date.now() - (checkedAt.get(s.id) || 0) < SHIP_RECHECK_MS)) return;
|
|
52
67
|
if (checks.has(s.id)) return void checks.set(s.id, 'again');
|
|
53
68
|
checks.set(s.id, true);
|
|
@@ -61,8 +76,25 @@ export function createShip(app) {
|
|
|
61
76
|
});
|
|
62
77
|
};
|
|
63
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
|
+
};
|
|
64
95
|
const refreshPr = async (s) => {
|
|
65
|
-
|
|
96
|
+
await refreshAlso(s);
|
|
97
|
+
if (!s.pr || !s.worktree) return s.pr || s.also?.find((a) => a.pr)?.pr;
|
|
66
98
|
const prev = s.pr;
|
|
67
99
|
const dir = fs.existsSync(s.worktree.path) ? s.worktree.path : s.worktree.root;
|
|
68
100
|
const pr = await prView(dir, prev.number);
|
|
@@ -135,6 +167,59 @@ export function createShip(app) {
|
|
|
135
167
|
recheck(s);
|
|
136
168
|
};
|
|
137
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
|
+
|
|
138
223
|
// Default commit message and PR text, from the session's latest presentation.
|
|
139
224
|
const draft = (s) => {
|
|
140
225
|
const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
|
|
@@ -158,7 +243,7 @@ export function createShip(app) {
|
|
|
158
243
|
delete s.shipping; // a ship cut short by a restart isn't running anymore
|
|
159
244
|
if (s.conflictFix?.step === 'pushing') Object.assign(s.conflictFix, { step: 'failed', why: 'wisp restarted while pushing the fix.' });
|
|
160
245
|
}
|
|
161
|
-
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);
|
|
162
247
|
|
|
163
248
|
// Open PRs are checked every minute, and every few seconds while their state is still settling,
|
|
164
249
|
// so a check finishing or a pushed fix clearing the conflict shows up quickly.
|
|
@@ -171,7 +256,9 @@ export function createShip(app) {
|
|
|
171
256
|
if (polling) return;
|
|
172
257
|
polling = true;
|
|
173
258
|
try {
|
|
174
|
-
|
|
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));
|
|
175
262
|
await Promise.all(due.map((s) => refreshPr(s).catch(() => {})));
|
|
176
263
|
} finally {
|
|
177
264
|
polling = false;
|
|
@@ -179,5 +266,5 @@ export function createShip(app) {
|
|
|
179
266
|
}, PR_TICK_MS);
|
|
180
267
|
timer.unref();
|
|
181
268
|
|
|
182
|
-
return { recheck, refreshPr, fixConflicts, draft, stop: () => clearInterval(timer) };
|
|
269
|
+
return { recheck, refreshPr, fixConflicts, draft, shipAll, mergeAll, prsOf, stop: () => clearInterval(timer) };
|
|
183
270
|
}
|
|
@@ -31,6 +31,28 @@ export class Uploads {
|
|
|
31
31
|
return Array.isArray(ids) ? ids.map((i) => this.byId.get(i)).filter(Boolean) : [];
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// A file to fill in here (a moved task's bundle): its path, and add() once it's written.
|
|
35
|
+
reserve(name) {
|
|
36
|
+
const id = newId();
|
|
37
|
+
const file = path.join(this.dir, `${id}-${name}`);
|
|
38
|
+
return { id, file, add: () => this.#record({ id, name, size: fs.statSync(file).size, mime: 'application/octet-stream', path: file }) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
remove(id) {
|
|
42
|
+
const u = this.byId.get(id);
|
|
43
|
+
if (!u) return false;
|
|
44
|
+
this.byId.delete(id);
|
|
45
|
+
fs.rmSync(u.path, { force: true });
|
|
46
|
+
fs.rmSync(path.join(this.dir, `${id}.json`), { force: true });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#record(meta) {
|
|
51
|
+
this.byId.set(meta.id, meta);
|
|
52
|
+
fs.writeFileSync(path.join(this.dir, `${meta.id}.json`), JSON.stringify(meta));
|
|
53
|
+
return meta;
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
async receive(req, { name, type }) {
|
|
35
57
|
name = path.basename(name || 'file').replace(/[^\w.\- ]+/g, '_').slice(-120) || 'file';
|
|
36
58
|
const id = newId();
|
|
@@ -51,9 +73,6 @@ export class Uploads {
|
|
|
51
73
|
out.on('finish', resolve);
|
|
52
74
|
out.on('error', reject);
|
|
53
75
|
});
|
|
54
|
-
|
|
55
|
-
this.byId.set(id, meta);
|
|
56
|
-
fs.writeFileSync(path.join(this.dir, `${id}.json`), JSON.stringify(meta));
|
|
57
|
-
return meta;
|
|
76
|
+
return this.#record({ id, name, size, mime: type || MIME[path.extname(name).toLowerCase()] || 'application/octet-stream', path: file });
|
|
58
77
|
}
|
|
59
78
|
}
|
package/server/store.js
CHANGED
|
@@ -4,11 +4,12 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { sameRepo } from './git.js';
|
|
7
8
|
import { parsePresentation } from './protocol.js';
|
|
8
9
|
import { writeJsonAtomic } from './util.js';
|
|
9
10
|
|
|
10
11
|
const DEFAULT_CONFIG = {
|
|
11
|
-
projects: [], // [{ id, name, path, color, addedAt, machine? }]
|
|
12
|
+
projects: [], // [{ id, name, path, color, addedAt, machine?, repo?, copies? }]: see projectFor()
|
|
12
13
|
defaults: { agent: 'auto', mode: 'auto', model: '', effort: '' }, // agent auto: wisp picks harness and model (server/router.js)
|
|
13
14
|
recipes: [], // saved prompts with {params}: server/recipes.js
|
|
14
15
|
triggers: [], // schedules and GitHub watchers: server/triggers.js
|
|
@@ -20,6 +21,9 @@ const DEFAULT_CONFIG = {
|
|
|
20
21
|
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
21
22
|
const MAX_KEPT_IMAGE = 25 * 1024 * 1024;
|
|
22
23
|
|
|
24
|
+
// The key for this computer in a project's copies (machine ids are 6 hex characters).
|
|
25
|
+
export const HERE = 'here';
|
|
26
|
+
|
|
23
27
|
export const PROJECT_COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
|
|
24
28
|
|
|
25
29
|
export class Store {
|
|
@@ -85,26 +89,47 @@ export class Store {
|
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
// --- projects: plain folders, matched to sessions by path prefix ---
|
|
92
|
+
//
|
|
93
|
+
// A project is one repo, and every place can run it. path (on machine, null = this computer) is
|
|
94
|
+
// where it was added; copies are its checkouts elsewhere, which wisp found or cloned there the
|
|
95
|
+
// first time a task ran there: { [machine id, or HERE]: path }. repo: its origin URL, once known.
|
|
96
|
+
|
|
97
|
+
// The project's folder on `machine` (null = this computer), or null when it has none there yet.
|
|
98
|
+
pathAt(p, machine = null) {
|
|
99
|
+
return (p.machine || null) === (machine || null) ? p.path : p.copies?.[machine || HERE] || null;
|
|
100
|
+
}
|
|
88
101
|
|
|
89
|
-
// machine: a
|
|
102
|
+
// machine: a folder on another computer (paths there are POSIX); null = this one.
|
|
90
103
|
projectFor(cwd = '', machine = null) {
|
|
91
104
|
if (!machine && this.isScratch(cwd)) return null; // never inside a project, even one at ~
|
|
92
105
|
const sep = machine ? '/' : path.sep;
|
|
93
106
|
let best = null;
|
|
94
|
-
|
|
95
|
-
|
|
107
|
+
let len = 0;
|
|
108
|
+
for (const p of this.config.projects) {
|
|
109
|
+
const dir = this.pathAt(p, machine);
|
|
110
|
+
if (dir && (cwd === dir || cwd.startsWith(dir + sep)) && dir.length > len) [best, len] = [p, dir.length];
|
|
111
|
+
}
|
|
96
112
|
return best;
|
|
97
113
|
}
|
|
98
114
|
|
|
99
|
-
|
|
100
|
-
|
|
115
|
+
// repo: the folder's origin URL. A folder of a repo that's already a project becomes that
|
|
116
|
+
// project's copy on this place, so the same repo is one project wherever it's checked out.
|
|
117
|
+
addProject(dir, { name, color, machine, repo } = {}) {
|
|
118
|
+
const existing = this.config.projects.find((p) => this.pathAt(p, machine) === dir);
|
|
101
119
|
if (existing) return existing;
|
|
120
|
+
const same = repo && this.config.projects.find((p) => p.repo && sameRepo(p.repo, repo) && !this.pathAt(p, machine));
|
|
121
|
+
if (same) {
|
|
122
|
+
(same.copies ||= {})[machine || HERE] = dir;
|
|
123
|
+
this.saveConfig();
|
|
124
|
+
return same;
|
|
125
|
+
}
|
|
102
126
|
const used = new Set(this.config.projects.map((p) => p.color));
|
|
103
127
|
const project = {
|
|
104
128
|
id: Math.random().toString(36).slice(2, 10),
|
|
105
129
|
name: name || (machine ? path.posix.basename(dir) : path.basename(dir)) || dir,
|
|
106
130
|
path: dir,
|
|
107
131
|
...(machine && { machine }),
|
|
132
|
+
...(repo && { repo }),
|
|
108
133
|
color: color || PROJECT_COLORS.find((c) => !used.has(c)) || PROJECT_COLORS[this.config.projects.length % PROJECT_COLORS.length],
|
|
109
134
|
addedAt: Date.now(),
|
|
110
135
|
};
|
|
@@ -113,6 +138,14 @@ export class Store {
|
|
|
113
138
|
return project;
|
|
114
139
|
}
|
|
115
140
|
|
|
141
|
+
// A checkout of the project on `machine`, found or cloned there (services/places.js).
|
|
142
|
+
setCopy(p, machine, dir) {
|
|
143
|
+
if (this.pathAt(p, machine) === dir) return;
|
|
144
|
+
if ((p.machine || null) === (machine || null)) p.path = dir;
|
|
145
|
+
else (p.copies ||= {})[machine || HERE] = dir;
|
|
146
|
+
this.saveConfig();
|
|
147
|
+
}
|
|
148
|
+
|
|
116
149
|
// Folder-less tasks run in a throwaway directory of their own under ~/.wisp/scratch.
|
|
117
150
|
scratchDir(id) {
|
|
118
151
|
const dir = path.join(this.home, 'scratch', id);
|
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
|
|
@@ -36,6 +37,7 @@ wisp wait <id> # block until it finishes or asks a question; prints t
|
|
|
36
37
|
wisp show <id> # current state + latest presentation as markdown
|
|
37
38
|
wisp ls --state running,waiting
|
|
38
39
|
wisp reply <id> "also cover the empty-list case" # follow-up; answers a pending question too
|
|
40
|
+
wisp btw <id> "did you touch the migrations?" # side question: answered without interrupting the run
|
|
39
41
|
wisp stop <id>
|
|
40
42
|
```
|
|
41
43
|
|
package/web/css/blocks.css
CHANGED
|
@@ -412,6 +412,8 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
|
|
|
412
412
|
.tests-rerun-out { margin-top: 12px; padding: 10px 12px; border-radius: 10px; background: var(--term-bg); color: var(--term-ink); }
|
|
413
413
|
.tests-rerun-out pre { margin: 0; font: 12px/1.55 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 260px; overflow-y: auto; color: #b4b4b0; }
|
|
414
414
|
.rev-tag { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 5px; font: 500 11px var(--sans); background: var(--bg-2); color: var(--muted); border: 1px solid var(--line); }
|
|
415
|
+
.t-moved { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; color: var(--faint); }
|
|
416
|
+
.t-moved .i { width: 11px; height: 11px; }
|
|
415
417
|
.cp-sha { font: 500 11px var(--mono); color: var(--faint); padding: 1px 5px; border-radius: 5px; background: var(--surface); border: 1px solid var(--line); }
|
|
416
418
|
.b-diff.is-reverted .diff-scroll { opacity: 0.55; }
|
|
417
419
|
.b-diff.is-reverted .path .base, .b-files li.is-reverted .path .base { text-decoration: line-through; color: var(--muted); }
|