wisp-agents 0.6.0 → 0.8.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 +52 -1
- package/README.md +90 -1
- package/bin/wisp.js +52 -5
- package/package.json +2 -2
- package/server/adapters/claude.js +4 -0
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/index.js +2 -1
- package/server/adapters/opencode.js +4 -0
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +473 -21
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +7 -1
- package/server/openapi.js +168 -4
- package/server/preferences.js +296 -0
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +279 -0
- package/server/render.js +18 -0
- package/server/runner.js +153 -13
- package/server/store.js +9 -1
- package/server/subagents.js +85 -0
- package/server/triggers.js +268 -0
- package/server/update.js +136 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +3 -2
- package/web/css/app.css +637 -14
- package/web/index.html +15 -3
- package/web/js/app.js +2060 -87
- package/web/js/blocks.js +396 -22
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Per-turn git checkpoints, single-file revert, turn rollback, and action command execution.
|
|
2
|
+
|
|
3
|
+
import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { killTree } from './proc.js';
|
|
7
|
+
import { MAX_SNAPSHOT, rel } from './adapters/common.js';
|
|
8
|
+
|
|
9
|
+
const MAX_EXEC_OUTPUT = 32 * 1024;
|
|
10
|
+
const EXEC_TIMEOUT_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
function git(cwd, args, opts = {}) {
|
|
13
|
+
return execFileSync('git', args, {
|
|
14
|
+
cwd,
|
|
15
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
16
|
+
timeout: 5000,
|
|
17
|
+
...opts,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tryGit(cwd, args, opts) {
|
|
22
|
+
try {
|
|
23
|
+
return git(cwd, args, opts).toString('utf8').trim();
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Snapshot the working tree before a turn starts. `git stash create` writes a commit object
|
|
30
|
+
// holding tracked working-tree and index changes without touching HEAD, the index, or the stash
|
|
31
|
+
// reflog; if the tree is clean it prints nothing and HEAD is the snapshot. Pinning the SHA under
|
|
32
|
+
// refs/wisp/checkpoints/<session>/<turn> keeps `git gc` from collecting it.
|
|
33
|
+
export function captureCheckpoint(cwd, sessionId, turnId) {
|
|
34
|
+
if (!cwd || !fs.existsSync(cwd)) return null;
|
|
35
|
+
const head = tryGit(cwd, ['rev-parse', '--verify', 'HEAD']);
|
|
36
|
+
if (!head) return null;
|
|
37
|
+
const stash = tryGit(cwd, ['stash', 'create']);
|
|
38
|
+
const commit = stash || head;
|
|
39
|
+
if (sessionId && turnId) {
|
|
40
|
+
tryGit(cwd, ['update-ref', `refs/wisp/checkpoints/${sessionId}/${turnId}`, commit]);
|
|
41
|
+
}
|
|
42
|
+
const branch = tryGit(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']) || '';
|
|
43
|
+
const untrackedRaw = tryGit(cwd, ['ls-files', '--others', '--exclude-standard'], { maxBuffer: 2 * 1024 * 1024 });
|
|
44
|
+
const untracked = untrackedRaw ? untrackedRaw.split('\n').filter(Boolean).slice(0, 500) : [];
|
|
45
|
+
return {
|
|
46
|
+
commit,
|
|
47
|
+
head,
|
|
48
|
+
dirty: !!stash,
|
|
49
|
+
branch,
|
|
50
|
+
untracked,
|
|
51
|
+
at: Date.now(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The first turn in a worktree made for it: clean, at its base commit, so the checkpoint is known
|
|
56
|
+
// without asking git (captureCheckpoint's synchronous calls held up every launch, once per run).
|
|
57
|
+
// The base commit stays reachable from the base branch, so it needs no pinning ref.
|
|
58
|
+
export function freshCheckpoint(wt) {
|
|
59
|
+
return { commit: wt.head, head: wt.head, dirty: false, branch: wt.branch, untracked: [], at: Date.now() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function cleanupCheckpoints(cwd, sessionId) {
|
|
63
|
+
if (!cwd || !sessionId || !fs.existsSync(cwd)) return;
|
|
64
|
+
const refs = tryGit(cwd, ['for-each-ref', '--format=%(refname)', `refs/wisp/checkpoints/${sessionId}/`]);
|
|
65
|
+
if (!refs) return;
|
|
66
|
+
for (const ref of refs.split('\n').filter(Boolean)) {
|
|
67
|
+
tryGit(cwd, ['update-ref', '-d', ref]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readGitFile(cwd, commit, file) {
|
|
72
|
+
if (!cwd || !commit || !file || path.isAbsolute(file)) return null;
|
|
73
|
+
const gitPath = `./${file.split(path.sep).join('/')}`;
|
|
74
|
+
try {
|
|
75
|
+
const out = git(cwd, ['show', `${commit}:${gitPath}`], { maxBuffer: 8 * MAX_SNAPSHOT });
|
|
76
|
+
return out.length <= MAX_SNAPSHOT ? out.toString('utf8') : '';
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readDiskSnapshot(abs) {
|
|
83
|
+
try {
|
|
84
|
+
const st = fs.statSync(abs);
|
|
85
|
+
if (!st.isFile()) return null;
|
|
86
|
+
return st.size <= MAX_SNAPSHOT ? fs.readFileSync(abs, 'utf8') : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeDiskSnapshot(abs, content) {
|
|
93
|
+
if (content === null || content === undefined) {
|
|
94
|
+
fs.rmSync(abs, { force: true });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
98
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveInsideCwd(cwd, file) {
|
|
102
|
+
if (!file || typeof file !== 'string') {
|
|
103
|
+
throw Object.assign(new Error('file path required'), { status: 400 });
|
|
104
|
+
}
|
|
105
|
+
const abs = path.resolve(cwd, file);
|
|
106
|
+
const relPath = path.relative(cwd, abs);
|
|
107
|
+
if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
|
|
108
|
+
throw Object.assign(new Error(`File lies outside the project folder: ${file}`), { status: 400 });
|
|
109
|
+
}
|
|
110
|
+
return { abs, relPath: rel(file, cwd) || relPath };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function findChangeEntry(turn, file, cwd) {
|
|
114
|
+
if (!turn?.changes?.length) return null;
|
|
115
|
+
const r = rel(file, cwd);
|
|
116
|
+
return (
|
|
117
|
+
turn.changes.find((c) => c.file === file || c.file === r) ||
|
|
118
|
+
turn.changes.find((c) => c.file.endsWith('/' + file) || file.endsWith('/' + c.file))
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const sessionDir = (session) => session.worktree?.cwd || session.cwd;
|
|
123
|
+
|
|
124
|
+
// Revert (or re-apply when restore=true) a single file's changes from `turn`.
|
|
125
|
+
export function revertFileChange(session, turn, file, { restore = false } = {}) {
|
|
126
|
+
const cwd = sessionDir(session);
|
|
127
|
+
const { abs, relPath } = resolveInsideCwd(cwd, file);
|
|
128
|
+
const entry = findChangeEntry(turn, file, cwd);
|
|
129
|
+
const commit = turn.checkpoint?.commit;
|
|
130
|
+
if (!entry && !commit) {
|
|
131
|
+
throw Object.assign(new Error(`No captured changes or checkpoint for ${relPath}`), { status: 404 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (restore) {
|
|
135
|
+
if (entry?.afterSnapshot !== undefined) {
|
|
136
|
+
writeDiskSnapshot(abs, entry.afterSnapshot);
|
|
137
|
+
} else if (entry?.edits?.length) {
|
|
138
|
+
if (entry.op === 'create' || entry.op === 'write') {
|
|
139
|
+
writeDiskSnapshot(abs, entry.edits.at(-1).after ?? '');
|
|
140
|
+
} else {
|
|
141
|
+
let cur = readDiskSnapshot(abs) ?? '';
|
|
142
|
+
for (const e of entry.edits) {
|
|
143
|
+
if (e.before && cur.includes(e.before)) cur = cur.replace(e.before, e.after ?? '');
|
|
144
|
+
}
|
|
145
|
+
writeDiskSnapshot(abs, cur);
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
throw Object.assign(new Error(`Nothing to re-apply for ${relPath}`), { status: 400 });
|
|
149
|
+
}
|
|
150
|
+
if (entry) entry.reverted = false;
|
|
151
|
+
if (turn.changes?.every((c) => !c.reverted)) delete turn.rolledBackAt;
|
|
152
|
+
return { file: entry?.file || relPath, reverted: false };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Save what is currently on disk so the user can Undo / Re-apply in one click.
|
|
156
|
+
const currentOnDisk = readDiskSnapshot(abs);
|
|
157
|
+
if (entry) entry.afterSnapshot = currentOnDisk;
|
|
158
|
+
|
|
159
|
+
// 1. Prefer the exact pre-turn file content recorded when the turn first touched the file.
|
|
160
|
+
if (entry && entry.beforeFile !== undefined) {
|
|
161
|
+
writeDiskSnapshot(abs, entry.op === 'create' ? null : entry.beforeFile);
|
|
162
|
+
} else if (commit) {
|
|
163
|
+
// 2. Otherwise check the turn's git checkpoint commit.
|
|
164
|
+
const fromGit = readGitFile(cwd, commit, entry?.file || relPath);
|
|
165
|
+
if (fromGit !== null) {
|
|
166
|
+
writeDiskSnapshot(abs, fromGit);
|
|
167
|
+
} else if (entry?.op === 'create' || !(turn.checkpoint?.untracked || []).includes(entry?.file || relPath)) {
|
|
168
|
+
writeDiskSnapshot(abs, null);
|
|
169
|
+
} else if (entry?.edits?.length) {
|
|
170
|
+
reverseEditsOnDisk(abs, entry);
|
|
171
|
+
}
|
|
172
|
+
} else if (entry) {
|
|
173
|
+
// 3. Fallback for non-git folders or legacy turns.
|
|
174
|
+
if (entry.op === 'create') {
|
|
175
|
+
writeDiskSnapshot(abs, null);
|
|
176
|
+
} else if (entry.op === 'write' && entry.edits?.[0]?.before !== undefined) {
|
|
177
|
+
writeDiskSnapshot(abs, entry.edits[0].before);
|
|
178
|
+
} else {
|
|
179
|
+
reverseEditsOnDisk(abs, entry);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (entry) entry.reverted = true;
|
|
184
|
+
return { file: entry?.file || relPath, reverted: true };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function reverseEditsOnDisk(abs, entry) {
|
|
188
|
+
const cur = readDiskSnapshot(abs);
|
|
189
|
+
if (cur === null) return;
|
|
190
|
+
let next = cur;
|
|
191
|
+
for (const e of [...(entry.edits || [])].reverse()) {
|
|
192
|
+
if (e.after && next.includes(e.after)) {
|
|
193
|
+
next = next.replace(e.after, e.before ?? '');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
writeDiskSnapshot(abs, next);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Restore the project working tree to the state before `turn` ran (or undo that rollback when restore=true).
|
|
200
|
+
export function rollbackToTurn(session, turn, { restore = false } = {}) {
|
|
201
|
+
const cwd = sessionDir(session);
|
|
202
|
+
const idx = session.turns.indexOf(turn);
|
|
203
|
+
if (idx < 0) throw Object.assign(new Error('No such turn'), { status: 404 });
|
|
204
|
+
const affected = session.turns.slice(idx);
|
|
205
|
+
const touchedFiles = [...new Set(affected.flatMap((t) => (t.changes || []).map((c) => c.file)))];
|
|
206
|
+
|
|
207
|
+
if (restore) {
|
|
208
|
+
const pre = turn.preRollback;
|
|
209
|
+
if (!pre) {
|
|
210
|
+
for (const t of affected) {
|
|
211
|
+
for (const c of t.changes || []) {
|
|
212
|
+
revertFileChange(session, t, c.file, { restore: true });
|
|
213
|
+
}
|
|
214
|
+
delete t.rolledBackAt;
|
|
215
|
+
}
|
|
216
|
+
return { files: touchedFiles, rolledBack: false };
|
|
217
|
+
}
|
|
218
|
+
if (pre.commit) {
|
|
219
|
+
tryGit(cwd, ['checkout', pre.commit, '--', '.']);
|
|
220
|
+
tryGit(cwd, ['read-tree', 'HEAD']);
|
|
221
|
+
}
|
|
222
|
+
for (const [relFile, content] of Object.entries(pre.files || {})) {
|
|
223
|
+
const { abs } = resolveInsideCwd(cwd, relFile);
|
|
224
|
+
writeDiskSnapshot(abs, content);
|
|
225
|
+
}
|
|
226
|
+
for (const t of affected) {
|
|
227
|
+
delete t.rolledBackAt;
|
|
228
|
+
for (const c of t.changes || []) c.reverted = false;
|
|
229
|
+
}
|
|
230
|
+
return { files: Object.keys(pre.files || {}).length ? Object.keys(pre.files) : touchedFiles, rolledBack: false };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const cp = turn.checkpoint;
|
|
234
|
+
const preUntracked = new Set(cp?.untracked || []);
|
|
235
|
+
const curUntrackedRaw = cp?.commit ? tryGit(cwd, ['ls-files', '--others', '--exclude-standard'], { maxBuffer: 2 * 1024 * 1024 }) : null;
|
|
236
|
+
const curUntracked = curUntrackedRaw ? curUntrackedRaw.split('\n').filter(Boolean).slice(0, 500) : [];
|
|
237
|
+
const newUntracked = curUntracked.filter((f) => !preUntracked.has(f));
|
|
238
|
+
|
|
239
|
+
// Snapshot current state of all affected files + new untracked files + git tree before rolling back so Undo works.
|
|
240
|
+
const preFiles = {};
|
|
241
|
+
for (const f of [...new Set([...touchedFiles, ...newUntracked])]) {
|
|
242
|
+
try {
|
|
243
|
+
const { abs } = resolveInsideCwd(cwd, f);
|
|
244
|
+
preFiles[f] = readDiskSnapshot(abs);
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
const preCp = captureCheckpoint(cwd, session.id, `${turn.id}-undo`);
|
|
248
|
+
turn.preRollback = { commit: preCp?.commit || null, files: preFiles };
|
|
249
|
+
|
|
250
|
+
if (cp?.commit) {
|
|
251
|
+
// Find any tracked files added since the checkpoint commit so we can remove them too.
|
|
252
|
+
const addedRaw = tryGit(cwd, ['diff', '--name-only', '--diff-filter=D', 'HEAD', cp.commit]) || '';
|
|
253
|
+
const addedTracked = addedRaw.split('\n').filter(Boolean);
|
|
254
|
+
|
|
255
|
+
// Restore tracked files from the checkpoint commit.
|
|
256
|
+
tryGit(cwd, ['checkout', cp.commit, '--', '.']);
|
|
257
|
+
// Keep the git index aligned with HEAD rather than the stash commit's tree.
|
|
258
|
+
tryGit(cwd, ['read-tree', 'HEAD']);
|
|
259
|
+
|
|
260
|
+
for (const f of [...addedTracked, ...newUntracked]) {
|
|
261
|
+
try {
|
|
262
|
+
const { abs } = resolveInsideCwd(cwd, f);
|
|
263
|
+
fs.rmSync(abs, { force: true });
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Restore or remove untracked files touched in the affected turns.
|
|
268
|
+
for (const t of affected.slice().reverse()) {
|
|
269
|
+
for (const c of t.changes || []) {
|
|
270
|
+
try {
|
|
271
|
+
const { abs, relPath } = resolveInsideCwd(cwd, c.file);
|
|
272
|
+
const inGit = readGitFile(cwd, cp.commit, relPath) !== null;
|
|
273
|
+
if (!inGit) {
|
|
274
|
+
if (c.op === 'create' || (!preUntracked.has(relPath) && c.beforeFile === null)) {
|
|
275
|
+
fs.rmSync(abs, { force: true });
|
|
276
|
+
} else if (typeof c.beforeFile === 'string') {
|
|
277
|
+
writeDiskSnapshot(abs, c.beforeFile);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch {}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
// Non-git folder or turn without a git checkpoint: revert each turn's captured file changes in reverse.
|
|
285
|
+
for (const t of affected.slice().reverse()) {
|
|
286
|
+
for (const c of [...(t.changes || [])].reverse()) {
|
|
287
|
+
revertFileChange(session, t, c.file, { restore: false });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const now = Date.now();
|
|
293
|
+
for (const t of affected) {
|
|
294
|
+
t.rolledBackAt = now;
|
|
295
|
+
for (const c of t.changes || []) c.reverted = true;
|
|
296
|
+
}
|
|
297
|
+
return { files: touchedFiles, rolledBack: true, commit: cp?.head || cp?.commit || null };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Run a shell command from an actionable presentation block inside `cwd`.
|
|
301
|
+
export function execCommand(cwd, command, { timeoutMs = EXEC_TIMEOUT_MS } = {}) {
|
|
302
|
+
const cmd = String(command || '').trim();
|
|
303
|
+
if (!cmd) return Promise.reject(Object.assign(new Error('command required'), { status: 400 }));
|
|
304
|
+
const t0 = Date.now();
|
|
305
|
+
|
|
306
|
+
return new Promise((resolve) => {
|
|
307
|
+
const child = nodeSpawn(cmd, {
|
|
308
|
+
cwd,
|
|
309
|
+
env: { ...process.env, PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', CI: '1' },
|
|
310
|
+
shell: true,
|
|
311
|
+
windowsHide: true,
|
|
312
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
313
|
+
});
|
|
314
|
+
let out = '';
|
|
315
|
+
const append = (chunk) => {
|
|
316
|
+
if (out.length < MAX_EXEC_OUTPUT) out += chunk.toString('utf8').slice(0, MAX_EXEC_OUTPUT - out.length);
|
|
317
|
+
};
|
|
318
|
+
child.stdout.on('data', append);
|
|
319
|
+
child.stderr.on('data', append);
|
|
320
|
+
|
|
321
|
+
let timedOut = false;
|
|
322
|
+
const timer = setTimeout(() => {
|
|
323
|
+
timedOut = true;
|
|
324
|
+
killTree(child);
|
|
325
|
+
}, timeoutMs);
|
|
326
|
+
|
|
327
|
+
child.on('error', (err) => {
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
resolve({
|
|
330
|
+
command: cmd,
|
|
331
|
+
output: (out ? `${out}\n` : '') + err.message,
|
|
332
|
+
exitCode: 1,
|
|
333
|
+
durationMs: Date.now() - t0,
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
child.on('close', (code) => {
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
const suffix = timedOut ? `\n[timed out after ${Math.round(timeoutMs / 1000)}s]` : '';
|
|
340
|
+
resolve({
|
|
341
|
+
command: cmd,
|
|
342
|
+
output: (out.replace(/\s+$/, '') + suffix).trim(),
|
|
343
|
+
exitCode: timedOut ? 124 : (code ?? 1),
|
|
344
|
+
durationMs: Date.now() - t0,
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
package/server/client.js
CHANGED
|
@@ -115,20 +115,44 @@ export class WispClient {
|
|
|
115
115
|
remove = (id) => this.request('DELETE', `/sessions/${id}`);
|
|
116
116
|
stop = (id) => this.request('POST', `/sessions/${id}/stop`, {});
|
|
117
117
|
answer = (id, text) => this.request('POST', `/sessions/${id}/answer`, { text });
|
|
118
|
+
revert = (id, fileOrOpts, opts = {}) => {
|
|
119
|
+
const body = typeof fileOrOpts === 'string' ? { file: fileOrOpts, ...opts } : fileOrOpts || {};
|
|
120
|
+
return this.request('POST', `/sessions/${id}/revert`, body);
|
|
121
|
+
};
|
|
122
|
+
rollback = (id, { turnId, restore = false } = {}) => this.request('POST', `/sessions/${id}/rollback`, { turnId, restore });
|
|
123
|
+
exec = (id, cmdOrOpts, opts = {}) => {
|
|
124
|
+
const body = typeof cmdOrOpts === 'string' ? { command: cmdOrOpts, ...opts } : cmdOrOpts || {};
|
|
125
|
+
return this.request('POST', `/sessions/${id}/exec`, body);
|
|
126
|
+
};
|
|
118
127
|
takeover = (id, { open = false } = {}) => this.request(open ? 'POST' : 'GET', `/sessions/${id}/takeover`, open ? {} : undefined);
|
|
119
128
|
|
|
120
129
|
// { prompt, cwd, agent, mode, model, effort, attachments: [uploadId] }. Missing agent/mode/model
|
|
121
130
|
// fall back to the launcher's remembered defaults, like `wisp "…"` does; effort only if given.
|
|
122
|
-
|
|
131
|
+
// worktree: true/false overrides the default (on in git repos). compare: [{ agent, model, effort }]
|
|
132
|
+
// runs the same task on more harnesses at once, each in its own worktree. subagents:
|
|
133
|
+
// [{ name, agent, model, effort, use }] (or preset: a saved team's id) the lead can delegate to.
|
|
134
|
+
async launch({ prompt, cwd = process.cwd(), agent, mode, model, effort, attachments, worktree, compare, subagents, preset } = {}) {
|
|
123
135
|
if (!agent || !mode || model === undefined) {
|
|
124
136
|
const { defaults = {} } = await this.config();
|
|
125
137
|
agent ||= defaults.agent || 'claude';
|
|
126
138
|
mode ||= defaults.mode || 'edits';
|
|
127
139
|
model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
|
|
128
140
|
}
|
|
129
|
-
return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), agent, mode, model, effort, attachments });
|
|
141
|
+
return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), agent, mode, model, effort, attachments, worktree, compare, subagents, preset });
|
|
130
142
|
}
|
|
131
143
|
|
|
144
|
+
// Ship it: commit the session's worktree, push its branch, open (or update) the PR.
|
|
145
|
+
ship = (id, { title, body, draft } = {}) => this.request('POST', `/sessions/${id}/ship`, { title, body, draft });
|
|
146
|
+
merge = (id) => this.request('POST', `/sessions/${id}/merge`, {});
|
|
147
|
+
|
|
148
|
+
// Recipes: saved prompts with {params}. key is the id or the name.
|
|
149
|
+
recipes = () => this.request('GET', '/recipes');
|
|
150
|
+
saveRecipe = (recipe) => this.request(recipe.id ? 'PATCH' : 'POST', `/recipes${recipe.id ? `/${recipe.id}` : ''}`, recipe);
|
|
151
|
+
runRecipe = (key, { params, words, cwd = process.cwd(), agent, model, effort, mode } = {}) =>
|
|
152
|
+
this.request('POST', `/recipes/${encodeURIComponent(key)}/run`, { params, words, cwd: path.resolve(cwd), agent, model, effort, mode });
|
|
153
|
+
triggers = () => this.request('GET', '/triggers');
|
|
154
|
+
usage = ({ days } = {}) => this.request('GET', `/usage${days ? `?days=${days}` : ''}`);
|
|
155
|
+
|
|
132
156
|
// A follow-up. If the agent is blocked on ask_user, this answers it. queue: wait for the
|
|
133
157
|
// current run instead of interrupting it (default: the user's setting).
|
|
134
158
|
reply = (id, text, { attachments, queue } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue });
|
package/server/git.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Git and GitHub plumbing for worktree sessions and Ship it. Everything shells out to `git` and
|
|
2
|
+
// `gh`. The user's own checkout is only read, except for the fast-forward after a merge.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execFile } from './proc.js';
|
|
7
|
+
|
|
8
|
+
const GIT = () => process.env.WISP_GIT_BIN || 'git';
|
|
9
|
+
const GH = () => process.env.WISP_GH_BIN || 'gh';
|
|
10
|
+
// Never wait on a credential or confirmation prompt nobody can see.
|
|
11
|
+
const QUIET_ENV = { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', GIT_EDITOR: 'true' };
|
|
12
|
+
|
|
13
|
+
export function run(bin, args, { cwd, timeout = 60_000 } = {}) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
execFile(bin, args, { cwd, timeout, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, ...QUIET_ENV } }, (err, stdout, stderr) => {
|
|
16
|
+
if (!err) return resolve(String(stdout).trim());
|
|
17
|
+
// git and gh lead with "fatal:" / "error:"; otherwise the tail is the useful part.
|
|
18
|
+
const lines = String(stderr || '').trim().split('\n').filter(Boolean);
|
|
19
|
+
const why = lines.find((l) => /^(fatal|error)\b/i.test(l)) || lines.slice(-3).join(' ') || err.message;
|
|
20
|
+
reject(Object.assign(new Error(why), { code: err.code }));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export const git = (cwd, args, opts) => run(GIT(), args, { cwd, ...opts });
|
|
25
|
+
export const gh = (cwd, args, opts) => run(GH(), args, { cwd, timeout: 120_000, ...opts });
|
|
26
|
+
const quiet = (p) => p.catch(() => '');
|
|
27
|
+
|
|
28
|
+
export async function repoRoot(cwd) {
|
|
29
|
+
const top = await quiet(git(cwd, ['rev-parse', '--show-toplevel']));
|
|
30
|
+
return top ? path.resolve(top) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// What a worktree session branches from: the remote's default branch, freshly fetched, else HEAD.
|
|
34
|
+
export async function baseRef(root) {
|
|
35
|
+
const remotes = (await quiet(git(root, ['remote']))).split('\n');
|
|
36
|
+
if (remotes.includes('origin')) {
|
|
37
|
+
await quiet(git(root, ['fetch', '--quiet', 'origin'], { timeout: 20_000 })); // offline: use what we have
|
|
38
|
+
const head = await quiet(git(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']));
|
|
39
|
+
for (const ref of [head, 'origin/main', 'origin/master']) {
|
|
40
|
+
if (ref && (await quiet(git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])))) return { ref, branch: ref.replace(/^origin\//, ''), remote: 'origin' };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const sha = await quiet(git(root, ['rev-parse', '--verify', '--quiet', 'HEAD']));
|
|
44
|
+
if (!sha) return null; // a repo without commits has nothing to branch from
|
|
45
|
+
return { ref: sha, branch: await quiet(git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])), remote: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const slug = (s) =>
|
|
49
|
+
String(s || '')
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
52
|
+
.replace(/^-+|-+$/g, '')
|
|
53
|
+
.slice(0, 32)
|
|
54
|
+
.replace(/-+$/, '');
|
|
55
|
+
|
|
56
|
+
const realpath = (p) => {
|
|
57
|
+
try {
|
|
58
|
+
return fs.realpathSync.native(p);
|
|
59
|
+
} catch {
|
|
60
|
+
return path.resolve(p);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// A fresh worktree + branch for one session, under <home>/worktrees. Returns null outside a repo.
|
|
65
|
+
// Where a launch's worktrees come from: { root, base }, or null outside a repo with commits.
|
|
66
|
+
// Resolved once per launch, so compare runs share one fetch.
|
|
67
|
+
export async function worktreeSource(cwd) {
|
|
68
|
+
const root = await repoRoot(cwd);
|
|
69
|
+
const base = root && (await baseRef(root));
|
|
70
|
+
if (!base) return null;
|
|
71
|
+
base.sha = await quiet(git(root, ['rev-parse', '--verify', '--quiet', `${base.ref}^{commit}`]));
|
|
72
|
+
return { root, base };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function createWorktree({ cwd, home, id, title, source }) {
|
|
76
|
+
const { root, base } = source || (await worktreeSource(cwd)) || {};
|
|
77
|
+
if (!base) return null;
|
|
78
|
+
// Short and readable; the full id only if that name is taken (a leftover branch, a prefix clash).
|
|
79
|
+
let branch = `wisp/${slug(title) || 'task'}-${id.slice(0, 6)}`;
|
|
80
|
+
if (await quiet(git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]))) branch = `wisp/${slug(title) || 'task'}-${id}`;
|
|
81
|
+
const dir = path.join(home, 'worktrees', `${path.basename(root)}-${id}`);
|
|
82
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
83
|
+
await git(root, ['worktree', 'add', '--quiet', '--no-track', '-b', branch, dir, base.ref]);
|
|
84
|
+
// Launched from a subfolder of the repo: keep working in that subfolder.
|
|
85
|
+
const sub = path.relative(realpath(root), realpath(cwd));
|
|
86
|
+
return { path: dir, cwd: sub && !sub.startsWith('..') ? path.join(dir, sub) : dir, branch, base: base.branch, baseRef: base.ref, head: base.sha || null, remote: base.remote, root, createdAt: Date.now() };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Archiving removes a clean worktree; a follow-up brings it back from its branch.
|
|
90
|
+
export async function ensureWorktree(wt) {
|
|
91
|
+
if (!wt || fs.existsSync(wt.path)) return;
|
|
92
|
+
await quiet(git(wt.root, ['worktree', 'prune']));
|
|
93
|
+
await git(wt.root, ['worktree', 'add', '--quiet', wt.path, wt.branch]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// { dirty: changed paths, ahead: commits past the base, exists }
|
|
97
|
+
export async function worktreeStatus(wt) {
|
|
98
|
+
if (!wt || !fs.existsSync(wt.path)) return { exists: false, dirty: 0, ahead: 0 };
|
|
99
|
+
const porcelain = await quiet(git(wt.path, ['status', '--porcelain']));
|
|
100
|
+
const ahead = Number(await quiet(git(wt.path, ['rev-list', '--count', `${wt.baseRef}..HEAD`]))) || 0;
|
|
101
|
+
return { exists: true, dirty: porcelain ? porcelain.split('\n').length : 0, ahead };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// force: drop uncommitted work too. The branch goes only when nothing would be lost with it.
|
|
105
|
+
export async function removeWorktree(wt, { force = false, branch = false } = {}) {
|
|
106
|
+
if (!wt) return { removed: false };
|
|
107
|
+
if (fs.existsSync(wt.path)) {
|
|
108
|
+
const st = await worktreeStatus(wt);
|
|
109
|
+
if (st.dirty && !force) return { removed: false, dirty: st.dirty };
|
|
110
|
+
await quiet(git(wt.root, ['worktree', 'remove', '--force', wt.path]));
|
|
111
|
+
fs.rmSync(wt.path, { recursive: true, force: true });
|
|
112
|
+
await quiet(git(wt.root, ['worktree', 'prune']));
|
|
113
|
+
}
|
|
114
|
+
let branchDeleted = false;
|
|
115
|
+
if (branch) {
|
|
116
|
+
const unique = Number(await quiet(git(wt.root, ['rev-list', '--count', `${wt.baseRef}..${wt.branch}`]))) || 0;
|
|
117
|
+
if (!unique || branch === 'force') branchDeleted = !!(await quiet(git(wt.root, ['branch', '-D', wt.branch]).then(() => 'ok')));
|
|
118
|
+
}
|
|
119
|
+
return { removed: true, branchDeleted };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Ship it: commit, push, open a PR, follow it, merge it -----------------------------------
|
|
123
|
+
|
|
124
|
+
const PR_FIELDS = 'number,url,title,state,isDraft,reviewDecision,mergeStateStatus,statusCheckRollup,headRefOid,baseRefName,mergedAt';
|
|
125
|
+
const PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
126
|
+
const FAIL = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'STALE']);
|
|
127
|
+
|
|
128
|
+
export function prSummary(j) {
|
|
129
|
+
const checks = { pass: 0, fail: 0, pending: 0 };
|
|
130
|
+
for (const c of j.statusCheckRollup || []) {
|
|
131
|
+
const v = String(c.conclusion || c.state || '').toUpperCase();
|
|
132
|
+
if (PASS.has(v)) checks.pass++;
|
|
133
|
+
else if (FAIL.has(v)) checks.fail++;
|
|
134
|
+
else checks.pending++;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
number: j.number,
|
|
138
|
+
url: j.url,
|
|
139
|
+
title: j.title,
|
|
140
|
+
state: j.state, // OPEN | CLOSED | MERGED
|
|
141
|
+
draft: !!j.isDraft,
|
|
142
|
+
review: j.reviewDecision || '', // APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | ''
|
|
143
|
+
mergeState: j.mergeStateStatus || '',
|
|
144
|
+
head: j.headRefOid,
|
|
145
|
+
base: j.baseRefName,
|
|
146
|
+
mergedAt: j.mergedAt || null,
|
|
147
|
+
checks,
|
|
148
|
+
checkedAt: Date.now(),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function prView(cwd, ref) {
|
|
153
|
+
return prSummary(JSON.parse(await gh(cwd, ['pr', 'view', String(ref), '--json', PR_FIELDS])));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Commit whatever the session left uncommitted, push its branch and open (or update) its PR.
|
|
157
|
+
export async function ship(wt, { title, body = '', draft = false, pr }) {
|
|
158
|
+
await ensureWorktree(wt);
|
|
159
|
+
const dir = wt.path;
|
|
160
|
+
if (await quiet(git(dir, ['status', '--porcelain']))) {
|
|
161
|
+
if (!String(title || '').trim()) throw Object.assign(new Error('A commit message is required.'), { status: 400 });
|
|
162
|
+
await git(dir, ['add', '-A']);
|
|
163
|
+
await git(dir, ['commit', '--quiet', '--no-verify', '-m', title.trim(), ...(body.trim() ? ['-m', body.trim()] : [])]);
|
|
164
|
+
}
|
|
165
|
+
const { ahead } = await worktreeStatus(wt);
|
|
166
|
+
if (!ahead) throw Object.assign(new Error('Nothing to ship: this branch has no changes.'), { status: 400 });
|
|
167
|
+
if (!wt.remote) throw Object.assign(new Error('This repo has no origin remote to push to.'), { status: 400 });
|
|
168
|
+
await git(dir, ['push', '--quiet', '-u', wt.remote, `HEAD:refs/heads/${wt.branch}`], { timeout: 120_000 });
|
|
169
|
+
if (pr?.state === 'OPEN') return prView(dir, pr.number);
|
|
170
|
+
const url = (await gh(dir, ['pr', 'create', '--base', wt.base, '--head', wt.branch, '--title', title.trim(), '--body', body.trim() || ' ', ...(draft ? ['--draft'] : [])])).split('\n').pop();
|
|
171
|
+
return prView(dir, url);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Squash-merge the exact head that was reviewed, then tidy up and catch the user's checkout up.
|
|
175
|
+
export async function merge(wt, number) {
|
|
176
|
+
const dir = fs.existsSync(wt.path) ? wt.path : wt.root;
|
|
177
|
+
let pr = await prView(dir, number);
|
|
178
|
+
if (pr.state !== 'OPEN') throw Object.assign(new Error(`PR #${number} is ${pr.state.toLowerCase()}.`), { status: 400 });
|
|
179
|
+
if (pr.draft) await gh(dir, ['pr', 'ready', String(number)]);
|
|
180
|
+
await gh(dir, ['pr', 'merge', String(number), '--squash', '--match-head-commit', pr.head]);
|
|
181
|
+
pr = await prView(dir, number);
|
|
182
|
+
const sync = await syncBase(wt.root, wt.base);
|
|
183
|
+
await removeWorktree(wt, { force: true, branch: 'force' });
|
|
184
|
+
await quiet(git(wt.root, ['push', '--quiet', wt.remote || 'origin', '--delete', wt.branch]));
|
|
185
|
+
return { pr, sync };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Fast-forward the user's base branch to the remote, never anything more invasive.
|
|
189
|
+
export async function syncBase(root, base) {
|
|
190
|
+
try {
|
|
191
|
+
await git(root, ['fetch', '--quiet', 'origin']);
|
|
192
|
+
const current = await quiet(git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']));
|
|
193
|
+
if (current === base) await git(root, ['merge', '--ff-only', '--quiet', `origin/${base}`]);
|
|
194
|
+
else await git(root, ['fetch', '--quiet', 'origin', `${base}:${base}`]); // not checked out: move the ref (ff only)
|
|
195
|
+
return { ok: true, text: `Local ${base} is up to date with origin/${base}.` };
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return { ok: false, text: `Local ${base} was not updated: ${err.message}` };
|
|
198
|
+
}
|
|
199
|
+
}
|