wisp-agents 0.7.0 → 0.9.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 +57 -1
- package/README.md +99 -8
- package/bin/wisp.js +53 -6
- package/package.json +2 -2
- package/server/adapters/claude.js +7 -1
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/grok.js +251 -0
- package/server/adapters/index.js +4 -2
- package/server/adapters/opencode.js +7 -1
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +559 -20
- package/server/machines.js +672 -0
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +9 -3
- package/server/openapi.js +164 -5
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +283 -0
- package/server/render.js +18 -0
- package/server/runner.js +154 -13
- package/server/store.js +20 -7
- package/server/subagents.js +86 -0
- package/server/triggers.js +268 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +5 -4
- package/web/css/app.css +702 -34
- package/web/index.html +15 -3
- package/web/js/app.js +2298 -134
- package/web/js/blocks.js +396 -22
- package/web/logos/grok.svg +1 -0
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
|
+
}
|