wisp-agents 0.17.0 → 0.18.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 CHANGED
@@ -5,6 +5,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.18.0] - 2026-10-19
9
+
10
+ ### Added
11
+
12
+ - **Merge conflicts on shipped PRs fix themselves.** When something lands on `main` and your shipped PR no longer merges, the task used to sit there until you noticed on GitHub. Now wisp sees it on its PR check (every minute, or when you press refresh): GitHub reports the PR as conflicting, and wisp confirms it locally with `git merge-tree` against a fresh fetch of the base, so a stale GitHub answer can't start a run. The task then gets a follow-up asking it to merge `origin/main` in (no rebase, since the branch is pushed), resolve each conflict keeping both sides' intent, run the cheap checks and commit. When that run ends, wisp checks the merge is complete: no unmerged files, the base is in the branch, no leftover `<<<<<<<` markers. Then it pushes to the PR, and the card shows the usual Push → PR track. While it works, the PR row reads *Fixing merge conflicts* and hides Merge. If the agent stops halfway, nothing is pushed: the card comes back unread with *Conflicts not fixed* and the reason, plus a **Fix conflicts** button to try again. wisp tries once per PR head and at most 3 times per task, so a base that keeps moving can't run up the bill. On by default; turn it off in Settings → General → **Fix PR conflicts** (`defaults.fixConflicts: "off"`). API: `conflict` on the PR, `conflictFix` on the session summary, `POST /api/sessions/:id/fix-conflicts`.
13
+
14
+ - **Tasks without a folder.** The launcher has a **No folder** chip next to your projects, for general asks that have nothing to do with a repo (explain a concept, draft a message, research something). The agent runs in an empty scratch directory under `~/.wisp/scratch`, with no worktree, and the task shows under *General*. The folder is not added to your projects. The Worktree toggle hides while No folder is picked, and deleting the task deletes its scratch folder. API: `POST /api/sessions {"prompt","scratch":true}`.
15
+
16
+ - **Add a project from a GitHub URL.** Paste `https://github.com/owner/repo` (or `git@github.com:owner/repo.git`, a link to any page inside the repo, or any other git URL) into Projects and the button turns into **Clone**. Click it and the dialog closes: the launcher already points at the repo (a chip with a spinner, *cloning…*) and the prompt has focus, so you write the task while it clones. Send it before the clone finishes and the task card shows *Cloning owner/repo*, then starts in the clone. If the clone fails, a toast says why, the launcher goes back to the project you had picked and your task comes back into the box. A feed filtered to another project switches to All, so the launcher isn't pulled back to that project. GitHub repos go through `gh repo clone` when gh is installed, so private repos work with your gh login; otherwise plain `git clone`. Repos land in `~/code` (or the first of `~/dev`, `~/Developer`, `~/projects`, `~/src`, `~/repos`… that exists). Click the path in the hint to change it (`defaults.cloneDir`). If the folder there already is that repo, wisp reuses it; another folder with that name gets a `-2` suffix. When gh is signed in, the dialog also lists **Your GitHub repos**, newest push first and minus ones already added, so cloning one is a single click. On a machine tab the clone happens on that machine. CLI: `wisp add https://github.com/owner/repo`. API: `POST /api/projects {"repo":"…"}`; a URL in `path` works too.
17
+
18
+ ### Changed
19
+
20
+ - **Project chips stay on one row with tens of projects.** The launcher's *in* row used to show every project as a chip (30 projects meant 6 rows on desktop and 14 on a phone), and the top bar cut off whatever didn't fit with no way to scroll to it. Now both show your most recent projects as chips, and the rest go behind a **+N** chip. Fewer chips show when the space is narrow, and a chip is never cut in half. The project you picked, the one you're filtering by, and projects with tasks that need you always keep their chip. **+N** opens a searchable list of every project: type to filter by name or path, use ↑/↓ and Enter to pick. It also has *No folder* and *Other folder…* in the launcher, and *All projects* and *Manage projects…* in the top bar. In the top bar, **+N** shows a badge counting the tasks that need you in the projects it hides. When a phone is too narrow even for *All*, the top bar shows one chip with the current filter that opens the list.
21
+
22
+ - **Ship it no longer holds you in its dialog.** Clicking *Commit, push & open PR* used to leave the dialog open with a disabled *Shipping…* button until the push and `gh pr create` were done. Now the dialog closes right away, and the card's PR row (just above the composer) shows a live track: **Commit → Push → Open PR**, with a spinner on the step that's running and a check on each one that's done. The server reports each step as `shipping` on the session summary, so other tabs see it too, and the card head shows a *Shipping* pill while the card is collapsed. When the PR is up, the row turns into the PR row with a short highlight, and a toast links to it. If a step fails, the row turns red and says which step failed and why, with **Retry**, which reopens the dialog with your commit message and description still filled in. A second ship while one is running is refused with 409.
23
+
24
+ ### Fixed
25
+
26
+ - **The launcher is there from the first frame.** On start, the launcher used to sit as an empty strip for one to five seconds, because the page waited for harness detection (`GET /api/agents` runs every CLI, and opencode lists its models) before drawing it. Now `index.html` draws the launcher's shape with a prompt box that already has focus, so you can start typing before the scripts load. The full launcher fills in about 50 ms later, at the same height, and keeps what you typed. Harnesses come from the last list seen (kept in the browser) and refresh when detection answers. `GET /api/agents` now answers from the last detection right away and checks again in the background, sending an `agents` event if anything changed. Warm calls dropped from about 1.2 s to 2 ms. `?fresh=1` and Re-check still wait for a full detection. Empty feed sections no longer flash their headings before the feed loads.
27
+
8
28
  ## [0.17.0] - 2026-10-19
9
29
 
10
30
  ### Added
@@ -314,7 +334,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
314
334
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
315
335
  - Older turns are folded, with a floating collapse-all button and back-to-top.
316
336
 
317
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.17.0...HEAD
337
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.18.0...HEAD
338
+ [0.18.0]: https://github.com/M0xoo/wisp/compare/v0.17.0...v0.18.0
318
339
  [0.17.0]: https://github.com/M0xoo/wisp/compare/v0.16.0...v0.17.0
319
340
  [0.16.0]: https://github.com/M0xoo/wisp/compare/v0.15.0...v0.16.0
320
341
  [0.15.0]: https://github.com/M0xoo/wisp/compare/v0.14.0...v0.15.0
package/README.md CHANGED
@@ -54,7 +54,7 @@ From there you can **follow up**, **answer its questions**, **take it over** in
54
54
  | 🤝 **Subagents** | Give the lead agent a team on other harnesses (e.g. Claude Code hands tests to Codex ×3). It delegates through a `delegate` tool; their edits land in its worktree. See [Subagents](#subagents). |
55
55
  | ⏪ **Checkpoints & rollback** | Every turn is snapshotted first. Restore to before any turn, revert a single file, or re-run a command block from the presentation. |
56
56
  | ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
57
- | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.17.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
57
+ | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.18.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
58
58
  | 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
59
59
  | 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
60
60
  | 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** (no permission prompts, the default) for each launch. `Shift+Tab` in the prompt cycles modes. In Edits mode, anything the permissions block comes to you: Claude Code waits for **Allow / Always / Deny**, and the other harnesses show an **Allow & retry** card. |
@@ -83,7 +83,7 @@ brew install m0xoo/tap/wisp-agents # Homebrew: CLI and browser mode,
83
83
  wisp "fix the flaky auth test"
84
84
  ```
85
85
 
86
- The Homebrew formula lives in [M0xoo/homebrew-tap](https://github.com/M0xoo/homebrew-tap). `install.sh` honors `WISP_VERSION` to pin a release (`curl -fsSL https://mokh.xyz/install.sh | WISP_VERSION=0.17.0 bash`).
86
+ The Homebrew formula lives in [M0xoo/homebrew-tap](https://github.com/M0xoo/homebrew-tap). `install.sh` honors `WISP_VERSION` to pin a release (`curl -fsSL https://mokh.xyz/install.sh | WISP_VERSION=0.18.0 bash`).
87
87
 
88
88
  From source:
89
89
 
@@ -116,6 +116,7 @@ The CLI talks to a running wisp, or starts one if none is running. It works from
116
116
  wisp # open wisp (or focus the running one)
117
117
  wisp "fix the flaky auth test" # launch a task in this folder (it becomes a project)
118
118
  wisp add [path] # add a folder as a project (default: cwd)
119
+ wisp add <repo-url> # clone a repo (GitHub URL, git@…, any git URL) into ~/code, then add it
119
120
  wisp --mode plan "audit deps" # permission mode: plan | edits | auto
120
121
  wisp --agent codex "add a test" # harness: claude | codex | opencode | cursor | antigravity | grok
121
122
  ```
@@ -131,7 +132,7 @@ wisp reply <id> "also do X" # follow up (answers a pending question; --mod
131
132
  wisp stop <id>
132
133
  wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
133
134
  wisp merge <id> # squash-merge its PR, fast-forward your local main
134
- wisp run release 0.17.0 # run a recipe; values fill its {params} (or version=0.17.0)
135
+ wisp run release 0.18.0 # run a recipe; values fill its {params} (or version=0.18.0)
135
136
  wisp recipes # list recipes
136
137
  wisp usage [--days 7] # spend and outcomes
137
138
  wisp mcp # MCP server for other agents and editors
@@ -334,7 +335,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
334
335
  Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
335
336
  ```
336
337
 
337
- Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.17.0` fills the fields in order, `wisp run release version=0.17.0` by name.
338
+ Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.18.0` fills the fields in order, `wisp run release version=0.18.0` by name.
338
339
 
339
340
  **Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
340
341
 
package/bin/wisp.js CHANGED
@@ -236,8 +236,10 @@ async function interactive() {
236
236
 
237
237
  let openPath = '/';
238
238
  if (command === 'add') {
239
- const dir = path.resolve(words[1] || process.cwd());
240
- const p = await api('/projects', { path: dir }).catch((e) => console.error(` ✗ ${e.message}`));
239
+ // A repo URL is cloned by the server (into ~/code unless defaults.cloneDir says otherwise).
240
+ const url = /^(https?:|ssh:|git:|git@|(www\.)?github\.com\/)/i.test(words[1] || '');
241
+ if (url) console.log(` ◌ cloning ${words[1]}…`);
242
+ const p = await api('/projects', url ? { repo: words[1] } : { path: path.resolve(words[1] || process.cwd()) }).catch((e) => console.error(` ✗ ${e.message}`));
241
243
  if (p) console.log(` ✓ project added: ${p.name} (${p.path})`);
242
244
  if (running || electron) process.exit(0);
243
245
  } else if (words.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Launch coding agents, skip the transcript, read the presentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "app": "node bin/wisp.js",
21
21
  "dev": "WISP_DEV=1 node bin/wisp.js",
22
22
  "dev:web": "node --watch-path=server bin/wisp.js --no-open",
23
- "test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js test/routes.test.js test/adapters-exit.test.js test/web-pure.test.js test/install.test.js test/install-script.test.js test/mentions.test.js",
23
+ "test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js test/routes.test.js test/adapters-exit.test.js test/web-pure.test.js test/install.test.js test/install-script.test.js test/mentions.test.js test/clone.test.js",
24
24
  "test:real": "node test/real-clis.mjs",
25
25
  "prepublishOnly": "node --check bin/wisp.js && node --check bin/mac-app.js && node -e \"import('./server/index.js').then(() => import('./server/mcp-wisp.js'))\" && node --check electron/main.js"
26
26
  },
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
+ }
@@ -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
@@ -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: 'Retry the automatic merge-conflict fix after it gave up: 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 on its own (defaults.fixConflicts: 'off' turns it off)" },
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
  },
@@ -34,7 +34,7 @@ export function register(r, app) {
34
34
 
35
35
  r.route('GET', '/openapi.json', (c) => c.send(200, openapi({ version: VERSION, port })), { doc: false });
36
36
 
37
- r.route('GET', '/agents', async (c) => c.send(200, await app.auto.detect({ fresh: c.query.has('fresh') })));
37
+ r.route('GET', '/agents', async (c) => c.send(200, await (c.query.has('fresh') ? app.auto.detect({ fresh: true }) : app.auto.listed())));
38
38
  // Re-check: detect harnesses and models again, here and on every online machine.
39
39
  r.route('POST', '/agents/recheck', async (c) => {
40
40
  const [agents] = await Promise.all([app.auto.detect({ fresh: true }), machines.recheckAgents()]);
@@ -9,16 +9,27 @@ import { normalizePreset } from '../subagents.js';
9
9
  import { envDisabled, feedbackAvailable } from '../telemetry.js';
10
10
  import { TERMINALS } from '../terminal.js';
11
11
  import { normalizeWebhooks } from '../services/webhooks.js';
12
+ import { cloneRepo, gh, git, parseRepo } from '../git.js';
12
13
  import { expand } from '../util.js';
13
14
 
14
15
  // Where people keep their repos, for one-click "add project".
15
16
  const REPO_ROOTS = ['code', 'Code', 'dev', 'Developer', 'projects', 'Projects', 'src', 'repos', 'work', 'git', 'GitHub', 'Documents', 'Downloads', 'Desktop'];
16
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
+
17
28
  export function register(r, app) {
18
29
  const { store, telemetry, prefs, retro, remote, machines } = app;
19
30
 
20
31
  r.route('GET', '/config', (c) =>
21
- c.send(200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
32
+ c.send(200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), cloneDir: cloneRoot(store.config), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
22
33
  );
23
34
  r.route('PATCH', '/config', async (c) => {
24
35
  const body = await c.body();
@@ -91,18 +102,54 @@ export function register(r, app) {
91
102
  r.route('POST', '/projects', async (c) => {
92
103
  const body = await c.body();
93
104
  if (body.machine) {
94
- const p = await machines.addProject(String(body.machine), String(body.path || '').trim(), body.name);
105
+ const p = await machines.addProject(String(body.machine), String(body.repo || body.path || '').trim(), body.name);
95
106
  app.broadcast('config', {});
96
107
  return c.send(201, p);
97
108
  }
98
- const dir = expand(body.path || '');
109
+ // A repo URL (in `repo`, or pasted into `path`): clone it, then add the clone.
110
+ const typed = String(body.repo || body.path || '').trim();
111
+ const local = !body.repo && (/^[~./\\]|^[a-z]:/i.test(typed) || fs.existsSync(expand(typed)));
112
+ const repo = !local && parseRepo(typed, { shorthand: true });
113
+ if (body.repo && !repo) return c.send(400, { error: `Not a repo URL: ${typed}` });
114
+ let dir = expand(typed);
115
+ let cloned = null;
116
+ if (repo) {
117
+ const out = await cloneRepo(repo, cloneRoot(store.config));
118
+ dir = out.dir;
119
+ cloned = { url: repo.url, existed: out.existed };
120
+ }
99
121
  if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return c.send(400, { error: `Not a folder: ${dir}` });
100
122
  const p = store.addProject(dir, { name: body.name });
101
123
  app.broadcast('config', {});
102
- c.send(201, p);
124
+ c.send(201, cloned ? { ...p, cloned } : p);
103
125
  });
104
126
  // Find git repos in the usual places so adding projects is one click.
105
127
  r.route('GET', '/projects/discover', (c) => c.send(200, discoverRepos(new Set(store.config.projects.map((p) => p.path)))), { doc: false });
128
+ // The user's GitHub repos (gh's login), newest first, minus ones already added: one click clones.
129
+ // Empty when gh isn't installed or signed in.
130
+ r.route(
131
+ 'GET',
132
+ '/projects/github',
133
+ async (c) => {
134
+ const list = await gh(os.homedir(), ['repo', 'list', '--limit', '40', '--json', 'nameWithOwner,description,isPrivate,isFork,pushedAt,url'], { timeout: 20_000 })
135
+ .then((out) => JSON.parse(out || '[]'))
136
+ .catch(() => null);
137
+ if (!list) return c.send(200, []);
138
+ const origins = await Promise.all(
139
+ store.config.projects.filter((p) => !p.machine).map((p) => git(p.path, ['remote', 'get-url', 'origin'], { timeout: 5000 }).catch(() => '')),
140
+ );
141
+ const have = new Set(origins.map((o) => parseRepo(o)?.gh?.toLowerCase()).filter(Boolean));
142
+ c.send(
143
+ 200,
144
+ list
145
+ .filter((x) => !have.has(x.nameWithOwner.toLowerCase()))
146
+ .sort((a, b) => String(b.pushedAt).localeCompare(String(a.pushedAt)))
147
+ .slice(0, 24)
148
+ .map((x) => ({ repo: x.nameWithOwner, url: x.url, description: x.description || '', private: !!x.isPrivate, fork: !!x.isFork, pushedAt: x.pushedAt })),
149
+ );
150
+ },
151
+ { doc: false },
152
+ );
106
153
  const project = (c) => store.config.projects.find((x) => x.id === c.params.id);
107
154
  r.route('PATCH', '/projects/:id', async (c) => {
108
155
  const p = project(c);
@@ -27,11 +27,20 @@ export function register(r, app) {
27
27
  one(async (c, s) => {
28
28
  if (!s.worktree) return c.send(400, { error: NO_TREE });
29
29
  if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
30
+ if (s.shipping) return c.send(409, { error: 'Already shipping: wait for it to land.' });
30
31
  const body = await c.body();
31
32
  const draft = app.ship.draft(s);
32
- s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr });
33
+ // The card follows along: s.shipping names the stage running now, sent with each summary.
34
+ s.shipping = { step: 'start', update: s.pr?.state === 'OPEN', startedAt: Date.now() };
35
+ app.sessionChanged(s);
36
+ const onStep = (step) => ((s.shipping = { ...s.shipping, step }), app.sessionChanged(s));
37
+ try {
38
+ s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr, onStep });
39
+ } finally {
40
+ delete s.shipping;
41
+ app.sessionChanged(s, { now: true });
42
+ }
33
43
  telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
34
- app.sessionChanged(s, { now: true });
35
44
  app.ship.recheck(s);
36
45
  c.send(200, s.pr);
37
46
  }),
@@ -44,6 +53,19 @@ export function register(r, app) {
44
53
  c.send(200, await app.ship.refreshPr(s));
45
54
  }),
46
55
  );
56
+ // Try the automatic conflict fix again after it gave up (see services/ship.js).
57
+ r.route(
58
+ 'POST',
59
+ '/sessions/:id/fix-conflicts',
60
+ one(async (c, s) => {
61
+ if (s.pr?.state !== 'OPEN' || !s.worktree) return c.send(400, { error: 'No open pull request.' });
62
+ if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
63
+ s.conflictFix = { tries: 0 };
64
+ await app.ship.refreshPr(s);
65
+ if (s.conflictFix?.step !== 'fixing') return c.send(409, { error: s.pr.conflict ? 'It merges cleanly with the latest base here: GitHub may not have caught up yet.' : 'The PR has no merge conflicts now.' });
66
+ c.send(200, { conflictFix: s.conflictFix });
67
+ }),
68
+ );
47
69
  // Squash-merge the reviewed head, sync the user's base branch, clean up the worktree.
48
70
  r.route(
49
71
  'POST',
package/server/runner.js CHANGED
@@ -81,7 +81,8 @@ export function summarize(s, store) {
81
81
  title: s.title,
82
82
  agent: s.agent,
83
83
  cwd: s.cwd,
84
- project: s.agent === 'wisp' ? 'tour' : project?.name || path.basename(s.cwd || ''),
84
+ scratch: store?.isScratch?.(s.cwd) || undefined,
85
+ project: s.agent === 'wisp' ? 'tour' : (store?.isScratch?.(s.cwd) ? 'General' : project?.name) || path.basename(s.cwd || ''),
85
86
  projectId: project?.id || null,
86
87
  color: project?.color || 'slate',
87
88
  mode: s.mode,
@@ -102,7 +103,10 @@ export function summarize(s, store) {
102
103
  approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
103
104
  branch: s.worktree?.branch || '',
104
105
  toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
105
- pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks } : null,
106
+ shipping: s.shipping || null, // { step: 'start' | 'commit' | 'push' | 'pr', update } while Ship it runs
107
+ pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks, conflict: !!s.pr.conflict } : null,
108
+ // wisp fixing the PR's merge conflicts on its own: { step: 'fixing' | 'pushing' | 'done' | 'failed', why }
109
+ conflictFix: s.conflictFix ? { step: s.conflictFix.step, why: s.conflictFix.why || '' } : null,
106
110
  groupId: s.groupId || null,
107
111
  origin: s.origin || null, // { kind: 'recipe' | 'trigger' | 'telegram', id, name }
108
112
  // The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
@@ -19,6 +19,17 @@ export function createAuto(app) {
19
19
  };
20
20
  // This computer's harnesses: the last ones detected, else detected now.
21
21
  const agents = async () => (agentsHere.length ? agentsHere : await detect());
22
+ // What the page asks for on every load: the last detection right away, checked again in the
23
+ // background (a cold probe takes seconds: opencode lists its models), and an 'agents' event
24
+ // if that changed anything, so the launcher never waits on it.
25
+ const listed = async () => {
26
+ if (!agentsHere.length) return detect();
27
+ const before = JSON.stringify(agentsHere);
28
+ detect()
29
+ .then((now) => JSON.stringify(now) !== before && app.broadcast('agents', {}))
30
+ .catch(() => {});
31
+ return agentsHere;
32
+ };
22
33
 
23
34
  const opts = () => {
24
35
  const r = store.config.defaults.router || {};
@@ -63,5 +74,5 @@ export function createAuto(app) {
63
74
  // What the Auto view shows: goals, settings, and what the router learned.
64
75
  const view = async () => ({ goals: GOALS, goal: goalOf(), settings: { explore: opts().explore, allowed: opts().allowed }, ...router.report(await agents()) });
65
76
 
66
- return { detect, agents, opts, goalOf, signals, resolve, view };
77
+ return { detect, agents, listed, opts, goalOf, signals, resolve, view };
67
78
  }
@@ -15,11 +15,13 @@ export const MAX_COMPARE = 4;
15
15
  export function createLaunch(app) {
16
16
  const { store, runner, machines } = app;
17
17
 
18
- // body: { prompt, cwd, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
18
+ // body: { prompt, cwd, scratch: true = no folder, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
19
19
  // subagents: [{ name, agent, model, effort, use }] | preset: teamId }
20
20
  async function launch(body, origin = null) {
21
21
  if (!String(body.prompt || '').trim()) throw bad('prompt required');
22
- const cwd = expand(body.cwd || process.cwd());
22
+ // scratch: no project folder (general questions); the task gets an empty directory of its own.
23
+ const scratch = body.scratch === true;
24
+ const cwd = scratch ? store.scratchDir(newId()) : expand(body.cwd || process.cwd());
23
25
  if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
24
26
  const d = store.config.defaults;
25
27
  const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
@@ -37,7 +39,7 @@ export function createLaunch(app) {
37
39
  // Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
38
40
  // runs need one each, or the harnesses would edit the same files.
39
41
  const comparing = runners.length > 1;
40
- const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
42
+ const wantTree = !scratch && (body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off')));
41
43
  // groupId: one launch sent to several machines (or here and there) shows as one task.
42
44
  const groupId = /^[a-f0-9]{12}$/.test(body.groupId || '') ? body.groupId : comparing ? newId() : null;
43
45
  const attachments = app.uploads.resolve(body.attachments);
@@ -92,10 +94,12 @@ export function createLaunch(app) {
92
94
  if (!sendBody.compare?.length && !body.compare) delete sendBody.compare;
93
95
  const out = await machines.launch(mid, { ...sendBody, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
94
96
  (out.group || [out]).forEach((x, i) => routes[i] && app.router.record(qualify(x.id, mid), routes[i]));
95
- const known = store.projectFor(out.cwd, mid);
96
- (known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
97
- store.saveConfig();
98
- if (!known) app.broadcast('config', {});
97
+ const known = out.scratch || store.projectFor(out.cwd, mid);
98
+ if (!out.scratch) {
99
+ (known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
100
+ store.saveConfig();
101
+ if (!known) app.broadcast('config', {});
102
+ }
99
103
  return (out.group || [out]).map((x) => machines.summary(mid, x));
100
104
  }
101
105