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 +22 -1
- package/README.md +5 -4
- package/bin/wisp.js +4 -2
- package/package.json +2 -2
- package/server/git.js +114 -2
- package/server/machines.js +2 -2
- package/server/openapi.js +18 -1
- package/server/routes/meta.js +1 -1
- package/server/routes/settings.js +51 -4
- package/server/routes/ship.js +24 -2
- package/server/runner.js +6 -2
- package/server/services/auto.js +12 -1
- package/server/services/launch.js +11 -7
- package/server/services/ship.js +83 -4
- package/server/store.js +15 -0
- package/web/css/launcher.css +17 -0
- package/web/css/layout.css +10 -2
- package/web/css/modals.css +8 -0
- package/web/css/palette.css +22 -0
- package/web/css/ship.css +40 -0
- package/web/index.html +15 -5
- package/web/js/app.js +14 -3
- package/web/js/core/state.js +2 -1
- package/web/js/features/clones.js +79 -0
- package/web/js/features/feed.js +3 -3
- package/web/js/features/launcher.js +139 -19
- package/web/js/features/live.js +3 -3
- package/web/js/features/project-list.js +99 -0
- package/web/js/features/projects.js +218 -34
- package/web/js/features/settings.js +16 -2
- package/web/js/features/setup.js +3 -3
- package/web/js/features/ship.js +100 -24
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
240
|
-
const
|
|
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.
|
|
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
|
-
|
|
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/machines.js
CHANGED
|
@@ -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: {
|
|
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
|
},
|
package/server/routes/meta.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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);
|
package/server/routes/ship.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/server/services/auto.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|