wisp-agents 0.17.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +20 -7
  3. package/bin/wisp.js +4 -2
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +2 -1
  6. package/server/adapters/claude.js +2 -1
  7. package/server/adapters/codex.js +2 -1
  8. package/server/adapters/commands.js +808 -0
  9. package/server/adapters/cursor.js +2 -1
  10. package/server/adapters/grok.js +2 -1
  11. package/server/adapters/opencode.js +2 -1
  12. package/server/check.js +205 -0
  13. package/server/git.js +114 -2
  14. package/server/index.js +2 -1
  15. package/server/judge.js +78 -0
  16. package/server/machines.js +2 -2
  17. package/server/openapi.js +19 -2
  18. package/server/pipeline.js +217 -0
  19. package/server/quick.js +76 -0
  20. package/server/router.js +201 -28
  21. package/server/routes/auto.js +8 -1
  22. package/server/routes/meta.js +1 -1
  23. package/server/routes/settings.js +53 -4
  24. package/server/routes/ship.js +29 -2
  25. package/server/runner.js +65 -15
  26. package/server/scout.js +71 -0
  27. package/server/services/auto.js +115 -15
  28. package/server/services/launch.js +22 -13
  29. package/server/services/ship.js +107 -6
  30. package/server/store.js +15 -0
  31. package/web/css/auto.css +22 -0
  32. package/web/css/launcher.css +17 -0
  33. package/web/css/layout.css +10 -2
  34. package/web/css/modals.css +8 -0
  35. package/web/css/palette.css +22 -0
  36. package/web/css/ship.css +44 -0
  37. package/web/index.html +16 -5
  38. package/web/js/app.js +14 -3
  39. package/web/js/blocks.js +1 -1
  40. package/web/js/core/state.js +2 -1
  41. package/web/js/features/actions.js +2 -4
  42. package/web/js/features/auto.js +38 -7
  43. package/web/js/features/clones.js +79 -0
  44. package/web/js/features/composer.js +2 -4
  45. package/web/js/features/feed.js +34 -4
  46. package/web/js/features/launcher.js +139 -19
  47. package/web/js/features/live.js +5 -10
  48. package/web/js/features/project-list.js +99 -0
  49. package/web/js/features/projects.js +220 -39
  50. package/web/js/features/runners.js +7 -6
  51. package/web/js/features/settings.js +47 -7
  52. package/web/js/features/setup.js +3 -3
  53. package/web/js/features/ship.js +128 -25
  54. package/web/js/features/turns.js +4 -3
  55. package/web/js/ui/popover.js +10 -0
@@ -4,6 +4,7 @@
4
4
  // ask_user tool isn't available here; the protocol tells the agent to end with a question block.
5
5
 
6
6
  import { attachmentNote, clip, detectVersion, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
7
+ import { describeCommand } from './commands.js';
7
8
 
8
9
  const BIN = process.env.WISP_CURSOR_BIN || 'cursor-agent';
9
10
 
@@ -130,7 +131,7 @@ function describeTool(name, a, cwd) {
130
131
  case 'deleteToolCall':
131
132
  return { kind: 'edit', label: `Deleting ${f}`, file: f };
132
133
  case 'shellToolCall':
133
- return { kind: 'run', label: a.description ? clip(a.description) : `Running ${clip(a.command, 60)}`, detail: a.command };
134
+ return { kind: 'run', label: a.description ? clip(a.description) : describeCommand(a.command, cwd), detail: a.command };
134
135
  case 'grepToolCall':
135
136
  return { kind: 'search', label: `Searching for “${clip(a.pattern, 40)}”` };
136
137
  case 'globToolCall':
@@ -10,6 +10,7 @@ import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, flatten, quietOutput, rel, spawnJsonl } from './common.js';
13
+ import { describeCommand } from './commands.js';
13
14
 
14
15
  const BIN = process.env.WISP_GROK_BIN || 'grok';
15
16
 
@@ -195,7 +196,7 @@ function describeTool(name, input, cwd) {
195
196
  return { kind: 'edit', label: `Writing ${f}`, file: f };
196
197
  case 'run_terminal_command':
197
198
  case 'run_terminal_cmd':
198
- return { kind: 'run', label: input.description ? clip(input.description) : `Running ${clip(input.command, 60)}`, detail: input.command };
199
+ return { kind: 'run', label: input.description ? clip(input.description) : describeCommand(input.command, cwd), detail: input.command };
199
200
  case 'grep':
200
201
  return { kind: 'search', label: `Searching for “${clip(input.pattern, 40)}”` };
201
202
  case 'list_dir':
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { toolsDir } from '../util.js';
7
7
  import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
8
+ import { describeCommand } from './commands.js';
8
9
 
9
10
  const exe = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
10
11
  // Where opencode lives when it isn't on PATH: the copy wisp installs (POST /agents/install),
@@ -213,7 +214,7 @@ function describeTool(name, input, cwd) {
213
214
  return { kind: 'edit', label: `Writing ${f}`, file: f };
214
215
  case 'bash':
215
216
  case 'shell':
216
- return { kind: 'run', label: input.description ? clip(input.description) : `Running ${clip(input.command, 60)}`, detail: input.command };
217
+ return { kind: 'run', label: input.description ? clip(input.description) : describeCommand(input.command, cwd), detail: input.command };
217
218
  case 'grep':
218
219
  return { kind: 'search', label: `Searching for “${clip(input.pattern, 40)}”` };
219
220
  case 'glob':
@@ -0,0 +1,205 @@
1
+ // wisp's own test check: after an Auto turn, wisp runs the project's tests itself instead of taking
2
+ // the agent's word for it (in the wisp-bench pilot, 19 of 20 failed runs still said "done").
3
+ //
4
+ // detectTests(dir, project) finds the command; runCheck(dir, command) runs it and reads which tests
5
+ // failed from the usual reporters (node --test, jest/vitest, pytest, go, cargo, rspec). A failure
6
+ // only counts against the agent when it's new: check() compares with the same command on the
7
+ // task's base commit (a throwaway worktree, cached per base and command), and runs a failing
8
+ // command twice so a flaky test doesn't send the agent off to fix nothing.
9
+
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { spawn } from 'node:child_process';
14
+ import { git } from './git.js';
15
+ import { killTree } from './proc.js';
16
+
17
+ const MAX_OUTPUT = 400 * 1024;
18
+ export const CHECK_TIMEOUT_MS = Number(process.env.WISP_CHECK_TIMEOUT_MS || 10 * 60_000);
19
+
20
+ const read = (f) => {
21
+ try {
22
+ return fs.readFileSync(f, 'utf8');
23
+ } catch {
24
+ return null;
25
+ }
26
+ };
27
+ const has = (dir, f) => fs.existsSync(path.join(dir, f));
28
+
29
+ // { command, kind, why } | { skip: reason } | null (nothing that looks like tests).
30
+ // project.testCommand overrides it; 'off' turns the check off for that project.
31
+ export function detectTests(dir, project = null) {
32
+ const custom = String(project?.testCommand || '').trim();
33
+ if (custom === 'off') return { skip: 'Turned off for this project' };
34
+ if (custom) return { command: custom, kind: 'custom', why: 'Set for this project' };
35
+
36
+ const pkg = JSON.parse(read(path.join(dir, 'package.json')) || 'null');
37
+ const script = pkg?.scripts?.test;
38
+ if (script && !/no test specified/.test(script)) {
39
+ const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
40
+ if (deps.length && !has(dir, 'node_modules')) return { skip: 'Dependencies are not installed in this worktree (no node_modules)' };
41
+ const pm = has(dir, 'pnpm-lock.yaml') ? 'pnpm' : has(dir, 'yarn.lock') ? 'yarn' : has(dir, 'bun.lockb') || has(dir, 'bun.lock') ? 'bun' : 'npm';
42
+ return { command: `${pm} test`, kind: 'node', why: 'package.json "test" script' };
43
+ }
44
+ const py = ['pytest.ini', 'tox.ini', 'conftest.py'].some((f) => has(dir, f)) || /\[tool\.pytest/.test(read(path.join(dir, 'pyproject.toml')) || '') || /\[tool:pytest\]/.test(read(path.join(dir, 'setup.cfg')) || '') || (has(dir, 'tests') && fs.readdirSync(path.join(dir, 'tests')).some((f) => /^test_.*\.py$|_test\.py$/.test(f)));
45
+ if (py) {
46
+ const python = ['.venv/bin/python', 'venv/bin/python', '.venv/Scripts/python.exe'].find((p) => has(dir, p)) || 'python3';
47
+ return { command: `${python} -m pytest -q -rA`, kind: 'pytest', why: 'pytest configuration' };
48
+ }
49
+ if (has(dir, 'go.mod')) return { command: 'go test ./...', kind: 'go', why: 'go.mod' };
50
+ if (has(dir, 'Cargo.toml')) return { command: 'cargo test', kind: 'cargo', why: 'Cargo.toml' };
51
+ if (has(dir, 'Gemfile') && has(dir, 'spec')) return { command: 'bundle exec rspec', kind: 'rspec', why: 'Gemfile and spec/' };
52
+ if (/^test:/m.test(read(path.join(dir, 'Makefile')) || '')) return { command: 'make test', kind: 'make', why: 'Makefile test target' };
53
+ return null;
54
+ }
55
+
56
+ // Failing test names and pass/fail counts from whatever reporter printed `out`.
57
+ export function parseResults(out) {
58
+ const failed = new Set();
59
+ let passed = null;
60
+ let failedN = null;
61
+ let skipped = null;
62
+ const add = (x) => x && failed.add(x.trim().replace(/\s+\(\d+(\.\d+)?m?s\)$/, ''));
63
+ for (const line of out.split('\n')) {
64
+ let m;
65
+ if ((m = line.match(/^\s*✖ (.+?)(?: \([\d.]+m?s\))?$/)) && !/^failing tests:$/.test(m[1])) add(m[1]); // node --test spec
66
+ else if ((m = line.match(/^not ok \d+ - (.+?)(?: # .*)?$/))) add(m[1]); // TAP, top level
67
+ else if ((m = line.match(/^(?:FAILED|ERROR) (\S+)/))) add(m[1]); // pytest -rA
68
+ else if ((m = line.match(/^\s+[✕×] (.+?)(?: \(\d+ ?ms\))?$/))) add(m[1]); // jest / vitest
69
+ else if ((m = line.match(/^--- FAIL: (\S+)/))) add(m[1]); // go
70
+ else if ((m = line.match(/^test (\S+) \.\.\. FAILED/))) add(m[1]); // cargo
71
+ else if ((m = line.match(/^rspec (\.\/\S+)/))) add(m[1]); // rspec
72
+ if ((m = line.match(/^ℹ pass (\d+)/))) passed = +m[1];
73
+ if ((m = line.match(/^ℹ fail (\d+)/))) failedN = +m[1];
74
+ if ((m = line.match(/^ℹ skipped (\d+)/))) skipped = +m[1];
75
+ if ((m = line.match(/^# pass (\d+)/))) passed = +m[1];
76
+ if ((m = line.match(/^# fail (\d+)/))) failedN = +m[1];
77
+ if ((m = line.match(/^=+ (.*) in [\d.]+s/))) {
78
+ // pytest summary: "== 3 failed, 41 passed, 2 skipped in 1.2s =="
79
+ passed = +(m[1].match(/(\d+) passed/)?.[1] || 0);
80
+ failedN = +(m[1].match(/(\d+) failed/)?.[1] || 0) + +(m[1].match(/(\d+) errors?/)?.[1] || 0);
81
+ skipped = +(m[1].match(/(\d+) skipped/)?.[1] || 0) || null;
82
+ }
83
+ if ((m = line.match(/^Tests:\s+(.*)$/))) {
84
+ // jest / vitest: "Tests: 1 failed, 12 passed, 13 total"
85
+ passed = +(m[1].match(/(\d+) passed/)?.[1] || 0);
86
+ failedN = +(m[1].match(/(\d+) failed/)?.[1] || 0);
87
+ skipped = +(m[1].match(/(\d+) skipped/)?.[1] || 0) || null;
88
+ }
89
+ if ((m = line.match(/^test result: \w+\. (\d+) passed; (\d+) failed/))) {
90
+ passed = (passed || 0) + +m[1];
91
+ failedN = (failedN || 0) + +m[2];
92
+ }
93
+ }
94
+ return { failed: [...failed].slice(0, 200), passed, failedCount: failedN ?? failed.size, skipped };
95
+ }
96
+
97
+ // Run `command` in dir: { exitCode, output, durationMs, timedOut, ...parseResults }.
98
+ export function runCheck(dir, command, { timeout = CHECK_TIMEOUT_MS, env = {} } = {}) {
99
+ return new Promise((resolve) => {
100
+ const started = Date.now();
101
+ let output = '';
102
+ let timedOut = false;
103
+ // Not inherited: a test runner's own markers (wisp under `node --test` would make the project's
104
+ // `node --test` report to it and exit 0) and wisp's.
105
+ const base = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(NODE_TEST_CONTEXT|NODE_UNIQUE_ID|WISP_|FAKE_)/.test(k)));
106
+ const child = spawn(process.platform === 'win32' ? 'cmd.exe' : 'sh', process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command], {
107
+ cwd: dir,
108
+ env: { ...base, CI: '1', FORCE_COLOR: '0', NO_COLOR: '1', ...env },
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ detached: process.platform !== 'win32',
111
+ });
112
+ const take = (d) => {
113
+ output += d;
114
+ if (output.length > MAX_OUTPUT * 2) output = output.slice(-MAX_OUTPUT);
115
+ };
116
+ child.stdout.on('data', take);
117
+ child.stderr.on('data', take);
118
+ const timer = setTimeout(() => {
119
+ timedOut = true;
120
+ killTree(child);
121
+ }, timeout);
122
+ const done = (exitCode) => {
123
+ clearTimeout(timer);
124
+ output = output.replace(/\x1b\[[0-9;]*m/g, '').slice(-MAX_OUTPUT);
125
+ resolve({ exitCode, output, durationMs: Date.now() - started, timedOut, ...parseResults(output) });
126
+ };
127
+ child.on('error', (err) => ((output += `\n${err.message}`), done(127)));
128
+ child.on('close', (code) => done(timedOut ? 124 : code ?? 1));
129
+ });
130
+ }
131
+
132
+ // The same command at the base commit, once per (repo, base, command).
133
+ const baselines = new Map();
134
+ export async function baseline(wt, command) {
135
+ if (!wt?.root || !wt.head) return null;
136
+ const key = `${wt.root}\0${wt.head}\0${command}`;
137
+ if (!baselines.has(key)) {
138
+ baselines.set(
139
+ key,
140
+ (async () => {
141
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wisp-base-'));
142
+ try {
143
+ await git(wt.root, ['worktree', 'add', '--quiet', '--detach', dir, wt.head]);
144
+ // node_modules and virtualenvs aren't in git: borrow the task's, so the base can run too.
145
+ for (const d of ['node_modules', '.venv', 'venv']) {
146
+ const from = path.join(wt.path, d);
147
+ if (fs.existsSync(from) && !fs.existsSync(path.join(dir, d))) fs.symlinkSync(from, path.join(dir, d), 'dir');
148
+ }
149
+ const sub = path.relative(wt.path, wt.cwd || wt.path);
150
+ return await runCheck(path.join(dir, sub), command);
151
+ } catch {
152
+ return null;
153
+ } finally {
154
+ await git(wt.root, ['worktree', 'remove', '--force', dir]).catch(() => {});
155
+ fs.rmSync(dir, { recursive: true, force: true });
156
+ }
157
+ })(),
158
+ );
159
+ }
160
+ return baselines.get(key);
161
+ }
162
+
163
+ // The verdict wisp acts on. pass: exit 0, or every failure was failing on the base already.
164
+ // fail: tests that passed on the base fail now (twice). unknown: it failed and we can't tell why
165
+ // (no base to compare with, or a reporter we can't read). skip: nothing to run.
166
+ export async function check({ dir, project, worktree, timeout } = {}) {
167
+ const found = detectTests(dir, project);
168
+ if (!found) return { verdict: 'skip', reason: 'No test command found', command: null };
169
+ if (found.skip) return { verdict: 'skip', reason: found.skip, command: null };
170
+ const { command } = found;
171
+ let run = await runCheck(dir, command, { timeout });
172
+ if (run.exitCode === 0 && !run.timedOut) return summary('pass', command, run, [], []);
173
+ // Flaky? Only failures that happen twice count.
174
+ const again = await runCheck(dir, command, { timeout });
175
+ if (again.exitCode === 0 && !again.timedOut) return summary('pass', command, again, [], [], 'Passed on a second run (a flaky test)');
176
+ const twice = run.failed.filter((t) => again.failed.includes(t));
177
+ run = { ...again, failed: twice.length || !again.failed.length ? twice : again.failed };
178
+ const base = await baseline(worktree, command);
179
+ if (!base) return summary(run.failed.length || run.timedOut ? 'fail' : 'unknown', command, run, run.failed, [], 'No base to compare with');
180
+ if (base.exitCode === 0) return summary('fail', command, run, run.failed, []);
181
+ const before = new Set(base.failed);
182
+ const fresh = run.failed.filter((t) => !before.has(t));
183
+ const old = run.failed.filter((t) => before.has(t));
184
+ if (!run.failed.length || !base.failed.length) return summary('unknown', command, run, [], [], 'Fails on the base commit too, and the failures could not be read');
185
+ return summary(fresh.length ? 'fail' : 'pass', command, run, fresh, old, fresh.length ? '' : `${old.length} failing on the base commit too`);
186
+ }
187
+
188
+ function summary(verdict, command, run, fresh, old, note = '') {
189
+ const tail = run.output.split('\n').filter((l) => l.trim()).slice(-60).join('\n');
190
+ return {
191
+ verdict,
192
+ command,
193
+ exitCode: run.exitCode,
194
+ passed: run.passed,
195
+ failed: run.failedCount || run.failed.length || (run.exitCode ? 1 : 0),
196
+ skipped: run.skipped,
197
+ newFailures: fresh.slice(0, 50),
198
+ preexisting: old.slice(0, 50),
199
+ timedOut: run.timedOut,
200
+ durationMs: run.durationMs,
201
+ note,
202
+ output: verdict === 'pass' ? '' : tail.slice(-6000),
203
+ at: Date.now(),
204
+ };
205
+ }
package/server/git.js CHANGED
@@ -129,7 +129,7 @@ export async function removeWorktree(wt, { force = false, branch = false } = {})
129
129
 
130
130
  // --- Ship it: commit, push, open a PR, follow it, merge it -----------------------------------
131
131
 
132
- const PR_FIELDS = 'number,url,title,state,isDraft,reviewDecision,mergeStateStatus,statusCheckRollup,headRefOid,baseRefName,mergedAt';
132
+ const PR_FIELDS = 'number,url,title,state,isDraft,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup,headRefOid,baseRefName,mergedAt';
133
133
  const PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
134
134
  const FAIL = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'STALE']);
135
135
 
@@ -149,6 +149,8 @@ export function prSummary(j) {
149
149
  draft: !!j.isDraft,
150
150
  review: j.reviewDecision || '', // APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | ''
151
151
  mergeState: j.mergeStateStatus || '',
152
+ // GitHub can't merge it into its base: both sides changed the same lines (UNKNOWN while it computes).
153
+ conflict: j.mergeable === 'CONFLICTING' || j.mergeStateStatus === 'DIRTY',
152
154
  head: j.headRefOid,
153
155
  base: j.baseRefName,
154
156
  mergedAt: j.mergedAt || null,
@@ -162,23 +164,58 @@ export async function prView(cwd, ref) {
162
164
  }
163
165
 
164
166
  // Commit whatever the session left uncommitted, push its branch and open (or update) its PR.
165
- export async function ship(wt, { title, body = '', draft = false, pr }) {
167
+ // `onStep` hears each stage as it starts ('commit', 'push', 'pr'), so the card can follow along.
168
+ export async function ship(wt, { title, body = '', draft = false, pr, onStep = () => {} }) {
166
169
  await ensureWorktree(wt);
167
170
  const dir = wt.path;
168
171
  if (await quiet(git(dir, ['status', '--porcelain']))) {
169
172
  if (!String(title || '').trim()) throw bad('A commit message is required.');
173
+ onStep('commit');
170
174
  await git(dir, ['add', '-A']);
171
175
  await git(dir, ['commit', '--quiet', '--no-verify', '-m', title.trim(), ...(body.trim() ? ['-m', body.trim()] : [])]);
172
176
  }
173
177
  const { ahead } = await worktreeStatus(wt);
174
178
  if (!ahead) throw bad('Nothing to ship: this branch has no changes.');
175
179
  if (!wt.remote) throw bad('This repo has no origin remote to push to.');
180
+ onStep('push');
176
181
  await git(dir, ['push', '--quiet', '-u', wt.remote, `HEAD:refs/heads/${wt.branch}`], { timeout: 120_000 });
182
+ onStep('pr');
177
183
  if (pr?.state === 'OPEN') return prView(dir, pr.number);
178
184
  const url = (await gh(dir, ['pr', 'create', '--base', wt.base, '--head', wt.branch, '--title', title.trim(), '--body', body.trim() || ' ', ...(draft ? ['--draft'] : [])])).split('\n').pop();
179
185
  return prView(dir, url);
180
186
  }
181
187
 
188
+ // Does the branch really conflict with its freshly fetched base? Checked before spending an agent
189
+ // run on GitHub's word, which can lag a push. true / false, or null when this git can't tell (< 2.38).
190
+ export async function conflictsWithBase(wt) {
191
+ if (!wt?.remote || !fs.existsSync(wt.path)) return null;
192
+ await quiet(git(wt.path, ['fetch', '--quiet', wt.remote, wt.base], { timeout: 30_000 }));
193
+ return new Promise((resolve) => {
194
+ execFile(GIT(), ['merge-tree', '--write-tree', '--name-only', '--no-messages', `refs/remotes/${wt.remote}/${wt.base}`, 'HEAD'], { cwd: wt.path, timeout: 30_000, env: { ...process.env, ...QUIET_ENV } }, (err) =>
195
+ resolve(!err ? false : err.code === 1 ? true : null),
196
+ );
197
+ });
198
+ }
199
+
200
+ // After an agent was asked to merge the base into its branch: is it in, with nothing left half
201
+ // done? A merge it resolved but left uncommitted is committed here. { ok } or { ok: false, why }.
202
+ export async function baseMerged(wt) {
203
+ const dir = wt.path;
204
+ const unmerged = await quiet(git(dir, ['diff', '--name-only', '--diff-filter=U']));
205
+ if (unmerged) return { ok: false, why: `Still conflicted: ${unmerged.split('\n').join(', ')}` };
206
+ if (await quiet(git(dir, ['rev-parse', '--quiet', '--verify', 'MERGE_HEAD']))) {
207
+ await git(dir, ['add', '-A']);
208
+ await git(dir, ['commit', '--quiet', '--no-verify', '--no-edit']);
209
+ }
210
+ const base = `refs/remotes/${wt.remote}/${wt.base}`;
211
+ const merged = await git(dir, ['merge-base', '--is-ancestor', base, 'HEAD']).then(() => true, () => false);
212
+ if (!merged) return { ok: false, why: `${wt.remote}/${wt.base} is not merged into the branch.` };
213
+ // Conflict markers staged as if resolved: git no longer sees them, so look in the PR's diff.
214
+ const marked = await quiet(git(dir, ['diff', '--name-only', '-G^<<<<<<< ', `${base}...HEAD`]));
215
+ if (marked) return { ok: false, why: `Conflict markers left in ${marked.split('\n').join(', ')}` };
216
+ return { ok: true };
217
+ }
218
+
182
219
  // Squash-merge the exact head that was reviewed, then tidy up and catch the user's checkout up.
183
220
  export async function merge(wt, number) {
184
221
  const dir = fs.existsSync(wt.path) ? wt.path : wt.root;
@@ -205,3 +242,78 @@ export async function syncBase(root, base) {
205
242
  return { ok: false, text: `Local ${base} was not updated: ${err.message}` };
206
243
  }
207
244
  }
245
+
246
+ // --- cloning: "add a project" from a repo URL instead of a folder ---
247
+
248
+ // A repo the user pasted: https://github.com/o/r(/tree/…), github.com/o/r, git@host:o/r.git,
249
+ // ssh:// git:// file:// URLs, and (shorthand) o/r for GitHub. Null when it's not a repo, e.g. a path.
250
+ export function parseRepo(input, { shorthand = false } = {}) {
251
+ const s = String(input || '').trim();
252
+ const strip = (p) => p.replace(/\/+$/, '').replace(/\.git$/i, '');
253
+ const of = (url, host, repoPath) => {
254
+ const parts = strip(repoPath).split('/').filter(Boolean);
255
+ if (!parts.length) return null;
256
+ const gh = host === 'github.com' && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null;
257
+ return { url, host, name: gh ? parts[1] : parts.at(-1), gh, path: parts.join('/') };
258
+ };
259
+ let m;
260
+ if (/^(https?|ssh|git|file):\/\//i.test(s) || /^(www\.)?github\.com\//i.test(s)) {
261
+ let u;
262
+ try {
263
+ u = new URL(/^\w+:\/\//.test(s) ? s : `https://${s}`);
264
+ } catch {
265
+ return null;
266
+ }
267
+ const host = u.hostname.replace(/^www\./, '').toLowerCase();
268
+ if (u.protocol === 'file:') return of(u.href, '', u.pathname);
269
+ if (!host) return null;
270
+ // A GitHub page inside the repo (/tree/main, /pull/3) still means the repo.
271
+ if (host === 'github.com') {
272
+ const [o, r] = u.pathname.split('/').filter(Boolean);
273
+ return o && r ? of(`https://github.com/${o}/${strip(r)}.git`, host, `${o}/${r}`) : null;
274
+ }
275
+ return u.pathname.split('/').filter(Boolean).length >= 2 || u.protocol !== 'https:' ? of(s, host, u.pathname) : null;
276
+ }
277
+ if ((m = s.match(/^[\w.-]+@([\w.-]+):(?!\/\/)(.+)$/))) return of(s, m[1].toLowerCase(), m[2]);
278
+ if (shorthand && (m = s.match(/^([\w-]+)\/([\w.-]+)$/))) return of(`https://github.com/${m[1]}/${strip(m[2])}.git`, 'github.com', `${m[1]}/${m[2]}`);
279
+ return null;
280
+ }
281
+
282
+ // The same repo, however it was spelled (https vs ssh, .git or not, case of the host).
283
+ const sameRepo = (a, b) => {
284
+ const key = (u) => {
285
+ const r = parseRepo(u);
286
+ return r && (r.gh ? `github.com/${r.gh.toLowerCase()}` : `${r.host}/${r.path}`);
287
+ };
288
+ return !!key(a) && key(a) === key(b);
289
+ };
290
+
291
+ // Clone `repo` (from parseRepo) into `parent`/<name>. A folder there that already is this repo is
292
+ // reused; any other one gets a -2, -3… suffix. GitHub repos go through `gh repo clone` when gh is
293
+ // there, so private repos work with the user's gh login; otherwise (or if gh fails) plain git.
294
+ export async function cloneRepo(repo, parent, { timeout = 10 * 60_000 } = {}) {
295
+ fs.mkdirSync(parent, { recursive: true });
296
+ let dir = path.join(parent, repo.name);
297
+ for (let n = 2; fs.existsSync(dir); n++) {
298
+ const origin = await quiet(git(dir, ['remote', 'get-url', 'origin']));
299
+ if (origin && (await repoRoot(dir)) === fs.realpathSync(dir) && sameRepo(origin, repo.url)) return { dir, existed: true };
300
+ dir = path.join(parent, `${repo.name}-${n}`);
301
+ }
302
+ const attempts = [];
303
+ if (repo.gh) attempts.push(() => gh(parent, ['repo', 'clone', repo.gh, dir], { timeout }));
304
+ attempts.push(() => run(GIT(), ['clone', '--quiet', repo.url, dir], { cwd: parent, timeout }));
305
+ let last;
306
+ for (const attempt of attempts) {
307
+ try {
308
+ await attempt();
309
+ return { dir, existed: false };
310
+ } catch (err) {
311
+ last = err;
312
+ fs.rmSync(dir, { recursive: true, force: true }); // a half clone would block the next try
313
+ }
314
+ }
315
+ const what = repo.gh || repo.url;
316
+ if (/not found|does not exist|could not read username|authentication|permission denied|403/i.test(last.message))
317
+ throw bad(`Couldn’t clone ${what}: it doesn’t exist, or this login can’t see it.${repo.gh ? ' Private repo? Check `gh auth status`.' : ''}`);
318
+ throw bad(`Couldn’t clone ${what}: ${last.message}`);
319
+ }
package/server/index.js CHANGED
@@ -25,6 +25,7 @@ import { Routes } from './http/router.js';
25
25
  import { send, eventStream } from './http/respond.js';
26
26
  import { serveStatic } from './http/static.js';
27
27
  import { createAuto } from './services/auto.js';
28
+ import { scoutNote } from './scout.js';
28
29
  import { createFeedback } from './services/feedback.js';
29
30
  import { createInstaller } from './services/install.js';
30
31
  import { createLaunch } from './services/launch.js';
@@ -119,7 +120,7 @@ export async function startServer({ port = DEFAULT_PORT, host = '127.0.0.1', han
119
120
  app.prefs = new Preferences(store.home);
120
121
  app.learning = () => store.config.defaults.learn !== 'off';
121
122
  const projectOf = (cwd) => store.projectFor(cwd)?.path || cwd;
122
- runner.promptExtra = (s) => (app.learning() && s.agent !== 'wisp' ? app.prefs.prompt(projectOf(s.cwd)) : '');
123
+ runner.promptExtra = (s) => (app.learning() && s.agent !== 'wisp' ? app.prefs.prompt(projectOf(s.cwd)) : '') + scoutNote(s.scout);
123
124
  app.retro = new Retro({
124
125
  store,
125
126
  runner,
@@ -0,0 +1,78 @@
1
+ // Picking between two runs of the same task (best-of-2). Tests decide when they can: a run whose
2
+ // check passed beats one whose check failed. Otherwise a cheap model reads both diffs, and if
3
+ // that isn't available either, fewer new failures and then the smaller diff win.
4
+
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { askJson } from './quick.js';
10
+
11
+ const MAX_DIFF = 24_000;
12
+
13
+ // The run's changes against its base: tracked diff plus new files.
14
+ export function diffOf(dir, base = 'HEAD') {
15
+ const run = (args) => {
16
+ try {
17
+ return execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20, timeout: 15_000 }).toString();
18
+ } catch {
19
+ return '';
20
+ }
21
+ };
22
+ let out = run(['diff', base, '--', '.', ':(exclude)node_modules']);
23
+ for (const f of run(['ls-files', '--others', '--exclude-standard']).split('\n').filter(Boolean).slice(0, 30)) {
24
+ try {
25
+ out += `\n--- /dev/null\n+++ b/${f}\n${fs.readFileSync(path.join(dir, f), 'utf8').slice(0, 4000).split('\n').map((l) => `+${l}`).join('\n')}`;
26
+ } catch {}
27
+ }
28
+ return out;
29
+ }
30
+
31
+ const changed = (d) => d.split('\n').filter((l) => /^[+-](?![+-])/.test(l)).length;
32
+ const SCORE = { pass: 3, unknown: 2, skip: 2, fail: 0 };
33
+
34
+ // candidates: [{ id, label, check, diff }]. Resolves with { winner, why, by }.
35
+ export async function judge({ prompt, candidates, agents }) {
36
+ const [a, b] = candidates;
37
+ const sa = SCORE[a.check?.verdict] ?? 1;
38
+ const sb = SCORE[b.check?.verdict] ?? 1;
39
+ if (sa !== sb) {
40
+ const w = sa > sb ? a : b;
41
+ return { winner: w.id, by: 'tests', why: `${w.label}'s changes pass the tests; the other run's don't.` };
42
+ }
43
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'wisp-judge-'));
44
+ try {
45
+ const ask = `Two coding agents did the same task. Judge which change is better: correct, complete, minimal, and in the style of the code around it. Tests: run A ${a.check?.verdict || 'not checked'}, run B ${b.check?.verdict || 'not checked'}.
46
+
47
+ <task>
48
+ ${String(prompt).slice(0, 4000)}
49
+ </task>
50
+
51
+ <run id="A" label="${a.label}">
52
+ ${a.diff.slice(0, MAX_DIFF) || '(no changes)'}
53
+ </run>
54
+
55
+ <run id="B" label="${b.label}">
56
+ ${b.diff.slice(0, MAX_DIFF) || '(no changes)'}
57
+ </run>
58
+
59
+ Reply with one JSON object: {"winner": "A" | "B", "why": "one sentence"}`;
60
+ const j = await askJson({ agents, prompt: ask, cwd, timeoutMs: 120_000 });
61
+ if (j && (j.winner === 'A' || j.winner === 'B')) return { winner: (j.winner === 'A' ? a : b).id, by: 'model', why: String(j.why || '').slice(0, 300) };
62
+ } finally {
63
+ fs.rmSync(cwd, { recursive: true, force: true });
64
+ }
65
+ const fa = a.check?.newFailures?.length ?? 0;
66
+ const fb = b.check?.newFailures?.length ?? 0;
67
+ if (fa !== fb) {
68
+ const w = fa < fb ? a : b;
69
+ return { winner: w.id, by: 'rule', why: `${w.label} leaves fewer new failing tests.` };
70
+ }
71
+ const empty = (c) => !c.diff.trim();
72
+ if (empty(a) !== empty(b)) {
73
+ const w = empty(a) ? b : a;
74
+ return { winner: w.id, by: 'rule', why: `${w.label} changed something; the other run didn't.` };
75
+ }
76
+ const w = changed(a.diff) <= changed(b.diff) ? a : b;
77
+ return { winner: w.id, by: 'rule', why: `${w.label} made the smaller change.` };
78
+ }
@@ -500,7 +500,7 @@ export class Machines extends EventEmitter {
500
500
  summary(mid, s) {
501
501
  const m = this.get(mid);
502
502
  const q = (id) => qualify(id, mid);
503
- const p = this.store.projectFor(s.cwd || '', mid);
503
+ const p = s.scratch ? null : this.store.projectFor(s.cwd || '', mid);
504
504
  return {
505
505
  ...s,
506
506
  id: q(s.id),
@@ -578,7 +578,7 @@ export class Machines extends EventEmitter {
578
578
  }
579
579
 
580
580
  async addProject(mid, dir, name) {
581
- const p = await this.json(mid, 'POST', '/projects', { path: dir });
581
+ const p = await this.json(mid, 'POST', '/projects', { path: dir }, { timeout: 10 * 60_000 }); // dir may be a repo URL to clone
582
582
  const local = this.store.addProject(p.path, { name: name || p.name, machine: mid });
583
583
  return local;
584
584
  }
package/server/openapi.js CHANGED
@@ -47,7 +47,7 @@ export function openapi({ version, port }) {
47
47
  '/agents': { get: { summary: 'Installed harnesses with their models and thinking levels (?fresh=1 detects again)', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
48
48
  '/router': {
49
49
  get: { summary: 'Auto: the goal, settings, every harness · model with its learned chance of success and cost, and recent routes', responses: { 200: json({ type: 'object' }) } },
50
- patch: { summary: 'Auto settings', requestBody: body({ type: 'object', properties: { goal: { enum: ['cheap', 'balanced', 'best'] }, explore: { type: 'boolean', description: 'Let Auto try new models on a few tasks' }, allowed: { type: 'array', items: { type: 'string' }, description: 'Harness ids Auto may pick; empty or null = all' } } }), responses: { 200: json({ type: 'object' }) } },
50
+ patch: { summary: 'Auto settings', requestBody: body({ type: 'object', properties: { goal: { enum: ['cheap', 'balanced', 'best'] }, explore: { type: 'boolean', description: 'Let Auto try new models on a few tasks' }, allowed: { type: 'array', items: { type: 'string' }, description: 'Harness ids Auto may pick; empty or null = all' }, check: { type: 'boolean', description: 'Run the project tests after an Auto run; only new failures count' }, repairs: { type: 'integer', minimum: 0, maximum: 2, description: 'Times failing tests go back to the same agent' }, escalate: { type: 'boolean', description: 'Still failing: run the task again on a stronger pick, in its own worktree' }, best2: { type: 'boolean', description: 'Goal Best: two picks side by side, the better one marked' }, scout: { enum: ['off', 'big', 'all'], description: 'A cheap model looks at the repo first' } } }), responses: { 200: json({ type: 'object' }) } },
51
51
  },
52
52
  '/router/reset': { post: { summary: 'Forget what Auto learned here (the shipped starting point stays)', responses: { 200: json({ type: 'object' }) } } },
53
53
  '/agents/install': {
@@ -90,7 +90,19 @@ export function openapi({ version, port }) {
90
90
  },
91
91
  '/projects': {
92
92
  get: { summary: 'List projects', responses: { 200: json({ type: 'array', items: ref('Project') }) } },
93
- post: { summary: 'Add a folder as a project', requestBody: body({ type: 'object', required: ['path'], properties: { path: { type: 'string' }, name: { type: 'string' } } }), responses: { 201: json(ref('Project')), 400: err[400] } },
93
+ post: {
94
+ summary: 'Add a folder as a project, or clone a repo URL and add the clone',
95
+ requestBody: body({
96
+ type: 'object',
97
+ properties: {
98
+ path: { type: 'string', description: 'A folder, or a repo URL (https://github.com/o/r, git@host:o/r.git, o/r)' },
99
+ repo: { type: 'string', description: 'A repo URL to clone into defaults.cloneDir (default ~/code), then add' },
100
+ name: { type: 'string' },
101
+ machine: { type: 'string', description: 'Add (or clone) it on this machine' },
102
+ },
103
+ }),
104
+ responses: { 201: json(ref('Project')), 400: err[400] },
105
+ },
94
106
  },
95
107
  '/projects/{pid}': {
96
108
  parameters: [{ name: 'pid', in: 'path', required: true, schema: { type: 'string' } }],
@@ -132,6 +144,7 @@ export function openapi({ version, port }) {
132
144
  },
133
145
  },
134
146
  '/sessions/{id}/pr': { parameters: [id], post: { summary: 'Refresh the PR status (checks, review, state) now; it is also polled every minute', responses: { 200: json(ref('PullRequest')), 404: err[404] } } },
147
+ '/sessions/{id}/fix-conflicts': { parameters: [id], post: { summary: 'Fix the PR\'s merge conflicts now (the PR row\'s Fix conflicts button): a follow-up merges the base in, then wisp pushes to the PR', responses: { 200: json({ type: 'object' }), 400: err[400] } } },
135
148
  '/sessions/{id}/merge': {
136
149
  parameters: [id],
137
150
  post: { summary: 'Mark the PR ready if draft, squash-merge it with --match-head-commit, fast-forward the local base branch, remove the worktree', responses: { 200: json({ type: 'object', properties: { pr: ref('PullRequest'), sync: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' } } } } }), 400: err[400] } },
@@ -291,6 +304,7 @@ export function openapi({ version, port }) {
291
304
  properties: {
292
305
  prompt: { type: 'string' },
293
306
  cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
307
+ scratch: { type: 'boolean', description: 'No project folder: run in an empty scratch directory (no worktree). Overrides cwd' },
294
308
  agent: { enum: ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'auto', description: 'auto: wisp picks the harness and model from what worked before; model is then the goal (cheap, balanced, best)' },
295
309
  important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
296
310
  mode: { enum: ['plan', 'edits', 'auto'], default: 'auto' },
@@ -312,6 +326,7 @@ export function openapi({ version, port }) {
312
326
  draft: { type: 'boolean' },
313
327
  review: { type: 'string', description: 'APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | ""' },
314
328
  checks: { type: 'object', properties: { pass: { type: 'integer' }, fail: { type: 'integer' }, pending: { type: 'integer' } } },
329
+ conflict: { type: 'boolean', description: 'GitHub cannot merge it into its base: merge conflicts' },
315
330
  head: { type: 'string' },
316
331
  base: { type: 'string' },
317
332
  },
@@ -393,6 +408,8 @@ export function openapi({ version, port }) {
393
408
  updatedAt: { type: 'integer' },
394
409
  branch: { type: 'string', description: 'Worktree branch, if it has one' },
395
410
  pr: { type: ['object', 'null'], description: 'Its pull request, once shipped' },
411
+ shipping: { type: ['object', 'null'], description: "{ step: start | commit | push | pr, update } while Ship it runs" },
412
+ conflictFix: { type: ['object', 'null'], description: "{ step: fixing | pushing | done | failed, why } while wisp fixes the PR's merge conflicts: after Fix conflicts, or on its own with defaults.fixConflicts: 'on'" },
396
413
  groupId: { type: ['string', 'null'], description: 'Shared by sessions launched together with compare' },
397
414
  origin: { type: ['object', 'null'], description: '{ kind: recipe | trigger, id, name } when not launched by hand' },
398
415
  },