wisp-agents 0.22.0 → 0.24.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 +26 -1
- package/README.md +7 -7
- package/package.json +12 -2
- package/server/adapters/antigravity.js +5 -0
- package/server/adapters/claude.js +51 -4
- package/server/adapters/codex.js +38 -4
- package/server/adapters/common.js +13 -0
- package/server/adapters/cursor.js +9 -0
- package/server/adapters/grok.js +5 -0
- package/server/adapters/index.js +3 -1
- package/server/adapters/opencode.js +10 -13
- package/server/openapi.js +1 -1
- package/server/router.js +35 -20
- package/web/css/feed.css +3 -0
- package/web/css/launcher.css +18 -2
- package/web/css/modals.css +3 -0
- package/web/js/features/auto.js +1 -1
- package/web/js/features/feed.js +44 -3
- package/web/js/features/runner-picker.js +56 -6
- package/web/js/features/runners.js +2 -2
- package/web/js/features/settings.js +8 -4
- package/web/js/features/setup.js +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.24.0] - 2026-10-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **How to get a missing harness, where you look for it.** Settings → Agents now shows, for each harness wisp didn't find, what to do on that computer's OS: a one-line tip (e.g. that the ChatGPT app is enough for Codex on macOS and Linux but not on Windows, or that the Antigravity and Cursor editors don't include their CLIs), the install command to copy with one click, and a **Docs** link. In the harness picker, hovering a greyed-out harness shows the same tip, and clicking it (or arrowing onto it) shows the steps in place of its models, with **Re-check** (and **Install** for opencode). It still can't be picked. API: unavailable harnesses in `GET /agents` carry `setup: { tip, command?, url }`; adapters provide it through an optional `setup(platform)`.
|
|
13
|
+
- **Codex and Claude Code from their desktop apps, no CLI install needed.** If `codex` isn't on PATH, wisp uses the Codex CLI the ChatGPT desktop app ships: `ChatGPT.app/Contents/Resources/codex-cli/bin/codex` on macOS (or `Codex.app/Contents/Resources/codex` from the older Codex app), in `/Applications` or `~/Applications`, and `/usr/lib/chatgpt/resources/codex` from the Linux `.deb`. It shares `~/.codex`, so the app's sign-in carries over. The Windows app is a Store package whose files can't be run from outside it, so on Windows Codex still needs the CLI. If `claude` isn't on PATH, wisp uses the newest copy the Claude desktop app downloads for its Code tab, on macOS and Windows (`<app data>/Claude/claude-code/<version>/…`, including the Store installs' folders). That copy is signed in only while the app runs it, so run it once and `/login`. Both adapters also look in the CLIs' own install folders (`~/.codex/packages/standalone/current/bin`, `%LOCALAPPDATA%\Programs\OpenAI\Codex\bin`, `~/.local/bin`, `~/.claude/local`), which an app started from the Dock or Start menu may not have on PATH. A `WISP_CODEX_BIN` or `WISP_CLAUDE_BIN` override still wins.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **The website shows moving a task to another computer or agent.** A new landing-page section, *Start on your laptop. Finish on the dev box.*, has a working copy of a task card's follow-up box: pick a computer under **Runs on** and an agent, press ↵, and it packs the work, hands the history over and carries on there, the way the app does. It plays once by itself when it comes into view; click anything to try it. A new guide, [Move a task to another machine or agent, mid-way](https://trywisp.dev/guides/move-a-task-to-another-machine-or-agent.html), covers doing it by hand with `git bundle`, what wisp moves, what each agent is told when you switch harness, model or computer, and the CLI and API. The landing FAQ answers it too, and *Machines* is in the landing nav. In *Also in the box*, the Machines tile became *Several folders*, since machines now have their own section. The landing nav no longer wraps onto two lines between 960 and 1200px wide; it drops Control and CLI there.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **The site builds the same on Windows.** With a CRLF checkout, the integrations page kept its markdown title as a second `<h1>` and the changelog page repeated each release heading, because `scripts/site.mjs` matched `\n` line ends only. It now normalizes line ends when reading `docs/integrations.md` and `CHANGELOG.md`, and the site test passes on Windows CI.
|
|
22
|
+
- **Done and Archived count every task and load more as you scroll.** Their headings always said 60 once you had more than that, and the older tasks never showed. The headings now count them all, the lists show 60 cards at first and add the next 60 as you scroll near the end (or click **Show 60 more · N left**), and opening an older task from a notification or ⌘K shows the cards down to it.
|
|
23
|
+
|
|
24
|
+
## [0.23.0] - 2026-10-20
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **Auto's Balanced goal runs frontier models.** When you have a frontier model installed and allowed (Opus, Codex's default, Gemini Argon, Grok 4.x), Balanced now picks it, at the harness's default or medium thinking, instead of Sonnet or Haiku. Best stays on frontier models at high or x-high, so the thinking level is what sets the two apart. Balanced steps down to an everyday model (Sonnet, never Haiku) only while every frontier model is at its limit, so the task still runs, or when you have none; small models' wins no longer pull Balanced to them after 20 outcomes. Cheap is unchanged. For models with the level in their name (Antigravity's `gemini-4-argon-low` / `-medium` / `-high`), the goal now picks the variant: Cheap low, Balanced medium, Best high, and the other variants count as the same model.
|
|
29
|
+
- **Guides on the website for what people search for.** Six new pages under [trywisp.dev/guides](https://trywisp.dev/guides/): [run Claude Code and Codex in parallel](https://trywisp.dev/guides/run-claude-code-and-codex-in-parallel.html), [run multiple Claude Code agents in parallel](https://trywisp.dev/guides/run-multiple-claude-code-agents-in-parallel.html), [run coding agents on multiple machines](https://trywisp.dev/guides/run-coding-agents-on-multiple-machines.html), [multi-agent coding](https://trywisp.dev/guides/multi-agent-coding.html), [Claude Code vs Codex on your own repo](https://trywisp.dev/guides/claude-code-vs-codex.html) and [run coding agents in the background](https://trywisp.dev/guides/run-coding-agents-in-the-background.html). Each answers its question in the first paragraph, shows the manual way with `git worktree` and `ssh`, then how wisp does it, and ends with FAQs (with `FAQPage` structured data). `scripts/guides.mjs` holds them; `scripts/site.mjs` renders them into the sitemap. The landing page's title, description and structured data now say *in parallel* and *on your other machines*, its FAQ answers both questions, and Guides is in the nav and footer of every page. The npm description and keywords match.
|
|
30
|
+
|
|
8
31
|
## [0.22.0] - 2026-10-20
|
|
9
32
|
|
|
10
33
|
### Added
|
|
@@ -413,7 +436,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
413
436
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
414
437
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
415
438
|
|
|
416
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
439
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.24.0...HEAD
|
|
440
|
+
[0.24.0]: https://github.com/M0xoo/wisp/compare/v0.23.0...v0.24.0
|
|
441
|
+
[0.23.0]: https://github.com/M0xoo/wisp/compare/v0.22.0...v0.23.0
|
|
417
442
|
[0.22.0]: https://github.com/M0xoo/wisp/compare/v0.21.0...v0.22.0
|
|
418
443
|
[0.21.0]: https://github.com/M0xoo/wisp/compare/v0.20.0...v0.21.0
|
|
419
444
|
[0.20.0]: https://github.com/M0xoo/wisp/compare/v0.19.0...v0.20.0
|
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
56
56
|
| 🤝 **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). |
|
|
57
57
|
| ⏪ **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. |
|
|
58
58
|
| ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
|
|
59
|
-
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.
|
|
59
|
+
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.24.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
|
|
60
60
|
| 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
|
|
61
61
|
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
|
|
62
62
|
| 🔐 **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. |
|
|
@@ -65,7 +65,7 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
65
65
|
|
|
66
66
|
## Quick start
|
|
67
67
|
|
|
68
|
-
> **Requirements:** macOS, Linux, or Windows; Node.js ≥ 20; and at least one harness installed and signed in: [Claude Code](https://claude.com/claude-code) (`claude`), [Codex](https://github.com/openai/codex) (`codex`), [opencode](https://opencode.ai) (`opencode`), [Cursor CLI](https://cursor.com/cli) (`cursor-agent`), [Antigravity CLI](https://antigravity.google/docs/cli/headless/) (`agy`), or [Grok Build](https://x.ai/cli) (`grok`). Override a binary path with `WISP_CLAUDE_BIN`, `WISP_CODEX_BIN`, `WISP_OPENCODE_BIN`, `WISP_CURSOR_BIN`, or `WISP_ANTIGRAVITY_BIN`.
|
|
68
|
+
> **Requirements:** macOS, Linux, or Windows; Node.js ≥ 20; and at least one harness installed and signed in: [Claude Code](https://claude.com/claude-code) (`claude`), [Codex](https://github.com/openai/codex) (`codex`), [opencode](https://opencode.ai) (`opencode`), [Cursor CLI](https://cursor.com/cli) (`cursor-agent`), [Antigravity CLI](https://antigravity.google/docs/cli/headless/) (`agy`), or [Grok Build](https://x.ai/cli) (`grok`). No CLI? The [ChatGPT desktop app](https://chatgpt.com/download) is enough for Codex on macOS and Linux, and the [Claude desktop app](https://claude.com/download) for Claude Code on macOS and Windows once you've opened its Code tab and signed that CLI in once: wisp uses the CLI each app ships when the command isn't on PATH. Override a binary path with `WISP_CLAUDE_BIN`, `WISP_CODEX_BIN`, `WISP_OPENCODE_BIN`, `WISP_CURSOR_BIN`, or `WISP_ANTIGRAVITY_BIN`.
|
|
69
69
|
|
|
70
70
|
```sh
|
|
71
71
|
npx wisp-agents # open the wisp desktop app
|
|
@@ -85,7 +85,7 @@ brew install m0xoo/tap/wisp-agents # Homebrew: CLI and browser mode,
|
|
|
85
85
|
wisp "fix the flaky auth test"
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
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://trywisp.dev/install.sh | WISP_VERSION=0.
|
|
88
|
+
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://trywisp.dev/install.sh | WISP_VERSION=0.24.0 bash`).
|
|
89
89
|
|
|
90
90
|
From source:
|
|
91
91
|
|
|
@@ -135,7 +135,7 @@ wisp btw <id> "did you do X?" # ask on the side without interrupting the run
|
|
|
135
135
|
wisp stop <id>
|
|
136
136
|
wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
|
|
137
137
|
wisp merge <id> # squash-merge its PR, fast-forward your local main
|
|
138
|
-
wisp run release 0.
|
|
138
|
+
wisp run release 0.24.0 # run a recipe; values fill its {params} (or version=0.24.0)
|
|
139
139
|
wisp recipes # list recipes
|
|
140
140
|
wisp usage [--days 7] # spend and outcomes
|
|
141
141
|
wisp mcp # MCP server for other agents and editors
|
|
@@ -304,7 +304,7 @@ If the harness has no system-prompt flag, prepend `systemPrompt` to the first pr
|
|
|
304
304
|
|
|
305
305
|
## Auto
|
|
306
306
|
|
|
307
|
-
**Auto** is the default harness: wisp picks the harness, model and thinking level for each task. Its "model" is a goal: **Balanced** (
|
|
307
|
+
**Auto** is the default harness: wisp picks the harness, model and thinking level for each task. Its "model" is a goal: **Balanced** (the default), **Cheap** or **Best**. Balanced and Best both run frontier models (Opus, not Sonnet or Haiku) when you have one installed and allowed; what sets them apart is how hard the agent thinks: Balanced runs at the harness's default or medium, Best at high or x-high. Cheap runs at low (or minimal) on whichever model does the job for least. A harness or model without levels runs at its default; for models with the level in their name (Antigravity's `gemini-4-argon-high`) the goal picks the variant the same way.
|
|
308
308
|
|
|
309
309
|
How it picks: every harness · model · thinking level you have installed is an option (`claude:opus:high`). For the kind of task (bug, feature, UI, tests, refactor, docs, release, question) Auto keeps an estimate of how often each option does it well. The estimate starts from a small wisp-bench pilot that ships with wisp (`server/router-priors.json`), then counts your own outcomes: merged PR (best), closed PR, 👍 / 👎, rollback, then how the run ended, less a bit for each follow-up you needed. It draws from each estimate (Thompson sampling), so options with little data now and then get a turn, and takes the best value for the goal after expected cost and time. A launch with `important: true` (API, SDK) gets the best-known option, with no trying. Options that keep failing a kind of task are left out of it. Thinking harder starts out a little more likely to work and costs more, and then your outcomes decide. A model's record at one level counts half at its other levels.
|
|
310
310
|
|
|
@@ -319,7 +319,7 @@ After the run, Auto checks the work before it lands in your queue:
|
|
|
319
319
|
3. **Repair**: new failures go back to the same agent (1 round by default, up to 2).
|
|
320
320
|
4. **Hand over**: still failing, or the run errored, so the task runs again on a stronger tier (small → everyday → frontier) in its own worktree, next to the first run.
|
|
321
321
|
|
|
322
|
-
A harness that says it's at its limit (session or usage limit, rate limit, no credit) isn't failing the task. Auto rests it until the limit resets, doesn't count the run against it, and moves the task to the best pick that can run now. Best always runs frontier models only
|
|
322
|
+
A harness that says it's at its limit (session or usage limit, rate limit, no credit) isn't failing the task. Auto rests it until the limit resets, doesn't count the run against it, and moves the task to the best pick that can run now. Best always runs frontier models only; it falls back to the strongest you have only when no frontier model is installed. Balanced runs on the strongest tier that can run now, whatever the record of the weaker models: frontier, or everyday (Sonnet, never Haiku) while every frontier model is at its limit or when you have none. The shipped estimates come from a small pilot, so they count for at most 4 tasks each.
|
|
323
323
|
5. **Best of two** (goal Best, off by default): two picks work side by side; tests decide, then a cheap model reads both diffs, and the better run is marked **Pick**.
|
|
324
324
|
|
|
325
325
|
Each step's outcome feeds the router: a test pass counts more than the agent's "done", needing a repair counts less, and losing a best-of-2 a little less again. Your own follow-up stops whatever Auto was about to do next. A launch on a machine is checked on that machine, where the files are. Settings → Auto turns each step on or off and sets the test command per project.
|
|
@@ -354,7 +354,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
|
|
|
354
354
|
Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
|
|
355
355
|
```
|
|
356
356
|
|
|
357
|
-
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.
|
|
357
|
+
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.24.0` fills the fields in order, `wisp run release version=0.24.0` by name.
|
|
358
358
|
|
|
359
359
|
**Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
|
|
360
360
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wisp-agents",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.24.0",
|
|
4
|
+
"description": "Run Claude Code, Codex, Cursor and other coding agents in parallel, in the background, on any of your machines. Skip the transcript, read the report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"wisp": "bin/wisp.js",
|
|
@@ -31,7 +31,17 @@
|
|
|
31
31
|
"keywords": [
|
|
32
32
|
"claude",
|
|
33
33
|
"claude-code",
|
|
34
|
+
"codex",
|
|
35
|
+
"openai-codex",
|
|
36
|
+
"cursor",
|
|
37
|
+
"opencode",
|
|
34
38
|
"coding-agent",
|
|
39
|
+
"ai-agents",
|
|
40
|
+
"multi-agent",
|
|
41
|
+
"parallel",
|
|
42
|
+
"git-worktree",
|
|
43
|
+
"ssh",
|
|
44
|
+
"orchestration",
|
|
35
45
|
"ai",
|
|
36
46
|
"agents",
|
|
37
47
|
"cli",
|
|
@@ -37,6 +37,11 @@ export default {
|
|
|
37
37
|
return [BIN, ...(resumeId ? ['--conversation', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : [])];
|
|
38
38
|
},
|
|
39
39
|
|
|
40
|
+
// How to get it, shown in Settings → Agents and the picker while it isn't found (this OS's steps).
|
|
41
|
+
setup() {
|
|
42
|
+
return { tip: 'Install the Antigravity CLI (agy) and sign in. The Antigravity app doesn’t include it', url: 'https://antigravity.google/docs/cli/headless/' };
|
|
43
|
+
},
|
|
44
|
+
|
|
40
45
|
async detect() {
|
|
41
46
|
const found = await detectVersion(BIN);
|
|
42
47
|
if (!found.available) return found;
|
|
@@ -3,10 +3,46 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { ASK_TIMEOUT_MS, MAX_SNAPSHOT, attachmentNote, clip,
|
|
6
|
+
import { ASK_TIMEOUT_MS, MAX_SNAPSHOT, attachmentNote, clip, detectFirst, exitText, flatten, rel, spawnJsonl } from './common.js';
|
|
7
7
|
import { describeCommand } from './commands.js';
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const DEFAULT_BIN = process.env.WISP_CLAUDE_BIN || 'claude';
|
|
10
|
+
let BIN = DEFAULT_BIN;
|
|
11
|
+
// Where claude lives when it isn't on PATH: the native installer's folders (an app started from
|
|
12
|
+
// the Dock never reads the shell profile that adds them), then the copies the Claude desktop app
|
|
13
|
+
// downloads for its Code tab into <userData>/claude-code/<version>/<sha12>/ (older builds: straight
|
|
14
|
+
// into <version>/; macOS builds may wrap it in claude.app), newest version first. That copy is
|
|
15
|
+
// signed in only while the app runs it, so on its own it needs one sign-in.
|
|
16
|
+
const newestFirst = (dir) => {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readdirSync(dir).sort((a, b) => b.localeCompare(a, 'en', { numeric: true }));
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export const fallbackBins = () => {
|
|
24
|
+
const home = os.homedir();
|
|
25
|
+
const win = process.platform === 'win32';
|
|
26
|
+
const exe = win ? 'claude.exe' : 'claude';
|
|
27
|
+
const local = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
28
|
+
// The app's userData: a direct install, then the two Store (MSIX) packages, whose AppData writes
|
|
29
|
+
// land in their own LocalCache.
|
|
30
|
+
const userData = {
|
|
31
|
+
darwin: [path.join(home, 'Library', 'Application Support', 'Claude')],
|
|
32
|
+
win32: [
|
|
33
|
+
path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Claude'),
|
|
34
|
+
...['Claude_pzs8sxrjxfjjc', 'AnthropicPBC.Claude_fnn82j28hfe8t'].map((p) => path.join(local, 'Packages', p, 'LocalCache', 'Roaming', 'Claude')),
|
|
35
|
+
],
|
|
36
|
+
}[process.platform] || [];
|
|
37
|
+
const desktop = userData.flatMap((d) => {
|
|
38
|
+
const root = path.join(d, 'claude-code');
|
|
39
|
+
return newestFirst(root).flatMap((v) => [
|
|
40
|
+
...newestFirst(path.join(root, v)).flatMap((h) => [path.join(root, v, h, exe), path.join(root, v, h, 'claude.app', 'Contents', 'MacOS', 'claude')]),
|
|
41
|
+
path.join(root, v, exe),
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
return [path.join(home, '.local', 'bin', exe), ...(win ? [] : [path.join(home, '.claude', 'local', 'claude')]), ...desktop];
|
|
45
|
+
};
|
|
10
46
|
|
|
11
47
|
// Claude in Chrome turns itself on only in interactive sessions; `-p` needs --chrome. Follow the
|
|
12
48
|
// user's own "enabled by default" choice (/chrome in Claude Code) so a wisp run gets the same tools.
|
|
@@ -46,8 +82,19 @@ export default {
|
|
|
46
82
|
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : []), ...dirs.flatMap((d) => ['--add-dir', d])];
|
|
47
83
|
},
|
|
48
84
|
|
|
49
|
-
|
|
50
|
-
|
|
85
|
+
// How to get it, shown in Settings → Agents and the picker while it isn't found (this OS's steps).
|
|
86
|
+
setup(platform = process.platform) {
|
|
87
|
+
return {
|
|
88
|
+
tip: platform === 'linux' ? 'Install Claude Code, then run claude once to sign in' : 'Install Claude Code and run claude once to sign in. Or open the Claude app’s Code tab once: wisp uses the copy it downloads',
|
|
89
|
+
command: platform === 'win32' ? 'irm https://claude.ai/install.ps1 | iex' : 'curl -fsSL https://claude.ai/install.sh | bash',
|
|
90
|
+
url: 'https://claude.com/claude-code',
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async detect() {
|
|
95
|
+
const { bin, ...found } = await detectFirst(DEFAULT_BIN, fallbackBins, !!process.env.WISP_CLAUDE_BIN);
|
|
96
|
+
BIN = bin;
|
|
97
|
+
return found;
|
|
51
98
|
},
|
|
52
99
|
|
|
53
100
|
// What a permission prompt is about, for the approval card and for matching "always allow".
|
package/server/adapters/codex.js
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
// Codex CLI adapter — drives `codex exec --json` (JSONL thread/turn/item events).
|
|
2
2
|
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
|
-
import { ASK_TIMEOUT_MS, attachmentNote, clip,
|
|
5
|
+
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectFirst, exitText, fileTracker, flatten, rel, spawnJsonl, withProtocol } from './common.js';
|
|
5
6
|
import { describeCommand } from './commands.js';
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const DEFAULT_BIN = process.env.WISP_CODEX_BIN || 'codex';
|
|
9
|
+
let BIN = DEFAULT_BIN;
|
|
10
|
+
// Where codex lives when it isn't on PATH: the standalone installer's own copy (its ~/.local/bin
|
|
11
|
+
// link may not be on an app's PATH; on Windows it also keeps one in Programs\OpenAI\Codex), then
|
|
12
|
+
// the CLI the ChatGPT desktop app ships: in its bundle on macOS (the older Codex.app kept it at
|
|
13
|
+
// Resources/codex) and in /usr/lib/chatgpt from the Linux .deb. They share ~/.codex, so the app's
|
|
14
|
+
// sign-in carries. The Windows app is a Store package whose files can't be run from outside it.
|
|
15
|
+
export const fallbackBins = () => {
|
|
16
|
+
const home = os.homedir();
|
|
17
|
+
if (process.platform === 'win32') {
|
|
18
|
+
const local = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
19
|
+
return [path.join(home, '.codex', 'packages', 'standalone', 'current', 'bin', 'codex.exe'), path.join(local, 'Programs', 'OpenAI', 'Codex', 'bin', 'codex.exe')];
|
|
20
|
+
}
|
|
21
|
+
const apps = process.platform === 'darwin' ? ['/Applications', path.join(home, 'Applications')] : [];
|
|
22
|
+
return [
|
|
23
|
+
path.join(home, '.codex', 'packages', 'standalone', 'current', 'bin', 'codex'),
|
|
24
|
+
...apps.flatMap((d) => [path.join(d, 'ChatGPT.app', 'Contents', 'Resources', 'codex-cli', 'bin', 'codex'), path.join(d, 'Codex.app', 'Contents', 'Resources', 'codex')]),
|
|
25
|
+
...(process.platform === 'linux' ? ['/usr/lib/chatgpt/resources/codex'] : []),
|
|
26
|
+
];
|
|
27
|
+
};
|
|
8
28
|
const IMAGE = /^image\/(png|jpe?g|gif|webp)$/;
|
|
9
29
|
|
|
10
30
|
// Codex has no "accept edits only" mode; workspace-write lets it edit the project and run
|
|
@@ -29,8 +49,22 @@ export default {
|
|
|
29
49
|
return [BIN, ...(resumeId ? ['resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['-c', `model_reasoning_effort=${toml(effort)}`] : [])];
|
|
30
50
|
},
|
|
31
51
|
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
// How to get it, shown in Settings → Agents and the picker while it isn't found (this OS's steps).
|
|
53
|
+
setup(platform = process.platform) {
|
|
54
|
+
return {
|
|
55
|
+
tip:
|
|
56
|
+
platform === 'win32'
|
|
57
|
+
? 'Install the Codex CLI and run codex once to sign in. On Windows the ChatGPT app’s built-in Codex can’t be run from outside the app'
|
|
58
|
+
: 'Install the Codex CLI and run codex once to sign in. Or install the ChatGPT desktop app and sign in there: wisp uses the Codex inside it',
|
|
59
|
+
command: 'npm i -g @openai/codex',
|
|
60
|
+
url: 'https://github.com/openai/codex',
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async detect() {
|
|
65
|
+
const { bin, ...found } = await detectFirst(DEFAULT_BIN, fallbackBins, !!process.env.WISP_CODEX_BIN);
|
|
66
|
+
BIN = bin;
|
|
67
|
+
return found;
|
|
34
68
|
},
|
|
35
69
|
|
|
36
70
|
// dirs: the task's other folders, writable like cwd.
|
|
@@ -18,6 +18,19 @@ export function detectVersion(bin, args = ['--version']) {
|
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Detect `bin`, or when it doesn't run, the first copy in fallbacks() that does: CLIs that live
|
|
22
|
+
// off PATH (an install wisp made, a desktop app's bundled CLI). Resolves detectVersion's answer
|
|
23
|
+
// plus the `bin` that answered. An explicit WISP_*_BIN override (`pinned`) is never second-guessed.
|
|
24
|
+
export async function detectFirst(bin, fallbacks, pinned = false) {
|
|
25
|
+
const found = await detectVersion(bin);
|
|
26
|
+
if (found.available || pinned) return { ...found, bin };
|
|
27
|
+
for (const b of fallbacks().filter((f) => fs.existsSync(f))) {
|
|
28
|
+
const there = await detectVersion(b);
|
|
29
|
+
if (there.available) return { ...there, bin: b };
|
|
30
|
+
}
|
|
31
|
+
return { ...found, bin };
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
// Run `bin args` and resolve with stdout, or '' on any failure. Used for model discovery.
|
|
22
35
|
export function quietOutput(bin, args, timeout = 15_000) {
|
|
23
36
|
return new Promise((resolve) => {
|
|
@@ -18,6 +18,15 @@ export default {
|
|
|
18
18
|
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : [])];
|
|
19
19
|
},
|
|
20
20
|
|
|
21
|
+
// How to get it, shown in Settings → Agents and the picker while it isn't found (this OS's steps).
|
|
22
|
+
setup(platform = process.platform) {
|
|
23
|
+
return {
|
|
24
|
+
tip: 'Install the Cursor CLI, then run cursor-agent login. The Cursor editor doesn’t include it',
|
|
25
|
+
...(platform === 'win32' ? {} : { command: 'curl https://cursor.com/install -fsS | bash' }),
|
|
26
|
+
url: 'https://cursor.com/cli',
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
|
|
21
30
|
async detect() {
|
|
22
31
|
const found = await detectVersion(BIN);
|
|
23
32
|
if (!found.available) return found;
|
package/server/adapters/grok.js
CHANGED
|
@@ -35,6 +35,11 @@ export default {
|
|
|
35
35
|
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--reasoning-effort', effort] : [])];
|
|
36
36
|
},
|
|
37
37
|
|
|
38
|
+
// How to get it, shown in Settings → Agents and the picker while it isn't found (this OS's steps).
|
|
39
|
+
setup() {
|
|
40
|
+
return { tip: 'Install Grok Build (grok) and sign in', url: 'https://x.ai/cli' };
|
|
41
|
+
},
|
|
42
|
+
|
|
38
43
|
async detect() {
|
|
39
44
|
const found = await detectVersion(BIN);
|
|
40
45
|
if (!found.available) return found;
|
package/server/adapters/index.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// that conversation, leaving it as it is (/btw)
|
|
14
14
|
// permission?(tool, input, cwd): { tool, action, detail, why? } what a live approval is about
|
|
15
15
|
// detect(): Promise<{ available, version?, error?, models? }> models overrides the static list
|
|
16
|
+
// setup?(platform): { tip, command?, url? } how to get it on that OS, sent while it isn't found
|
|
16
17
|
// resumeCommand({ resumeId, model, effort }): string[] argv to continue the session interactively
|
|
17
18
|
// run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, grants, onEvent }): { stop(), done: Promise }
|
|
18
19
|
// grants: `rule`s from its own blocked events the
|
|
@@ -52,6 +53,7 @@ function checked(a) {
|
|
|
52
53
|
a.askTool !== null && a.askTool !== undefined && typeof a.askTool !== 'string' && 'askTool',
|
|
53
54
|
a.approveTool !== undefined && (typeof a.approveTool !== 'string' || typeof a.permission !== 'function') && 'approveTool + permission()',
|
|
54
55
|
typeof a.detect !== 'function' && 'detect()',
|
|
56
|
+
a.setup !== undefined && typeof a.setup !== 'function' && 'setup()',
|
|
55
57
|
typeof a.run !== 'function' && 'run()',
|
|
56
58
|
a.resumeCommand !== undefined && typeof a.resumeCommand !== 'function' && 'resumeCommand()',
|
|
57
59
|
].filter(Boolean);
|
|
@@ -74,7 +76,7 @@ export async function listAgents({ fresh = false } = {}) {
|
|
|
74
76
|
// Only a hit is cached: a CLI installed while wisp is running shows up on the next call.
|
|
75
77
|
if (!found.available) detected.delete(a.id);
|
|
76
78
|
// delegates: can lead a team of subagents (needs wisp's MCP server injected).
|
|
77
|
-
return { id: a.id, name: a.name, models: a.models, efforts: a.efforts || [], delegates: a.askTool !== null, ...found };
|
|
79
|
+
return { id: a.id, name: a.name, models: a.models, efforts: a.efforts || [], delegates: a.askTool !== null, ...found, ...(!found.available && a.setup && { setup: a.setup() }) };
|
|
78
80
|
}),
|
|
79
81
|
);
|
|
80
82
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
// opencode adapter — drives `opencode run --format json` (one JSON event per line).
|
|
2
2
|
|
|
3
|
-
import fs from 'node:fs';
|
|
4
3
|
import os from 'node:os';
|
|
5
4
|
import path from 'node:path';
|
|
6
5
|
import { toolsDir } from '../util.js';
|
|
7
|
-
import { ASK_TIMEOUT_MS, attachmentNote, clip,
|
|
6
|
+
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectFirst, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
|
|
8
7
|
import { describeCommand } from './commands.js';
|
|
9
8
|
|
|
10
9
|
const exe = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
|
|
@@ -12,7 +11,8 @@ const exe = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
|
|
|
12
11
|
// and the official install script's folder (it edits your shell profile, which an app started
|
|
13
12
|
// from the Dock or a service never reads).
|
|
14
13
|
export const fallbackBins = () => [path.join(toolsDir(), 'node_modules', '.bin', exe), path.join(os.homedir(), '.opencode', 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode')];
|
|
15
|
-
|
|
14
|
+
const DEFAULT_BIN = process.env.WISP_OPENCODE_BIN || 'opencode';
|
|
15
|
+
let BIN = DEFAULT_BIN;
|
|
16
16
|
// Rate-limited (the free models, mostly), `opencode run` keeps retrying and prints nothing, so
|
|
17
17
|
// the task would sit at "Starting up" for good. Its error log says so: give it this long to get
|
|
18
18
|
// through, then stop and tell the user what to do.
|
|
@@ -35,17 +35,14 @@ export default {
|
|
|
35
35
|
return [BIN, ...(resumeId ? ['--session', resumeId] : []), ...(model ? ['--model', model] : [])];
|
|
36
36
|
},
|
|
37
37
|
|
|
38
|
+
// How to get it, shown in Settings → Agents and the picker while it isn't found (this OS's steps).
|
|
39
|
+
setup() {
|
|
40
|
+
return { tip: 'wisp can install it here: free models, no account needed', command: 'npm i -g opencode-ai', url: 'https://opencode.ai' };
|
|
41
|
+
},
|
|
42
|
+
|
|
38
43
|
async detect() {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
for (const bin of fallbackBins().filter((b) => fs.existsSync(b))) {
|
|
42
|
-
const there = await detectVersion(bin);
|
|
43
|
-
if (!there.available) continue;
|
|
44
|
-
BIN = bin;
|
|
45
|
-
found = there;
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
44
|
+
const { bin, ...found } = await detectFirst(DEFAULT_BIN, fallbackBins, !!process.env.WISP_OPENCODE_BIN);
|
|
45
|
+
BIN = bin;
|
|
49
46
|
if (!found.available) return found;
|
|
50
47
|
// `opencode models --verbose` prints each provider/model id followed by its JSON metadata;
|
|
51
48
|
// the metadata's `variants` are the reasoning efforts `run --variant` accepts for that model.
|
package/server/openapi.js
CHANGED
|
@@ -304,7 +304,7 @@ export function openapi({ version, port }) {
|
|
|
304
304
|
schemas: {
|
|
305
305
|
Error: { type: 'object', properties: { error: { type: 'string' } } },
|
|
306
306
|
Health: { type: 'object', properties: { ok: { type: 'boolean' }, name: { const: 'wisp' }, version: { type: 'string' }, apiVersion: { type: 'integer' }, pid: { type: 'integer' }, url: { type: 'string' } } },
|
|
307
|
-
Agent: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, available: { type: 'boolean' }, version: { type: 'string' }, models: { type: 'array', items: { type: 'object' } }, efforts: { type: 'array', items: { type: 'string' } } } },
|
|
307
|
+
Agent: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, available: { type: 'boolean' }, version: { type: 'string' }, models: { type: 'array', items: { type: 'object' } }, efforts: { type: 'array', items: { type: 'string' } }, setup: { type: 'object', description: 'Only while not found: how to get it on that OS', properties: { tip: { type: 'string' }, command: { type: 'string' }, url: { type: 'string' } } } } },
|
|
308
308
|
Project: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, path: { type: 'string' }, color: { type: 'string' } } },
|
|
309
309
|
Webhook: { type: 'object', required: ['url'], properties: { url: { type: 'string', format: 'uri' }, events: { type: 'array', items: { enum: ['session.finished', 'session.waiting'] } } } },
|
|
310
310
|
Launch: {
|
package/server/router.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Every harness × model × thinking level the user has is an "arm" (claude:opus:high; no level is
|
|
4
4
|
// the harness's default). The goal picks the band of levels: Cheap runs low, Balanced the default
|
|
5
|
-
// or medium, Best high.
|
|
6
|
-
//
|
|
5
|
+
// or medium, Best high. Balanced and Best both run frontier models when you have one, so the
|
|
6
|
+
// level is what tells them apart. Within the band the levels are learned like models are, and a
|
|
7
|
+
// model's record at its other levels counts half. For each kind of task (bug, ui, docs, …) wisp
|
|
7
8
|
// keeps a Beta(wins, losses) estimate per arm, stacked from three sources: the shipped priors
|
|
8
9
|
// (./router-priors.json, from wisp-bench), the user's history on every kind of task, and the
|
|
9
10
|
// history on this kind. A launch draws a chance of success from each estimate (Thompson
|
|
@@ -27,13 +28,12 @@ const PRIORS = JSON.parse(fs.readFileSync(new URL('./router-priors.json', import
|
|
|
27
28
|
|
|
28
29
|
export const GOALS = {
|
|
29
30
|
cheap: { label: 'Cheap', value: 1, hint: 'Lowest cost that still gets it done' },
|
|
30
|
-
balanced: { label: 'Balanced', value: 5, hint: '
|
|
31
|
-
best: { label: 'Best', value: 50, hint: 'Frontier models
|
|
31
|
+
balanced: { label: 'Balanced', value: 5, hint: 'Frontier models at default or medium thinking' },
|
|
32
|
+
best: { label: 'Best', value: 50, hint: 'Frontier models at high thinking; cost only breaks ties' },
|
|
32
33
|
};
|
|
33
34
|
export const TYPES = ['bug', 'feature', 'ui', 'test', 'refactor', 'docs', 'release', 'question'];
|
|
34
35
|
const MINUTE_USD = 0.05; // what a minute of waiting costs you, in the utility
|
|
35
36
|
const PRIOR_CAP = 4; // shipped priors count as at most this many tasks, so your own soon outweigh them
|
|
36
|
-
const COLD_START = 20; // until Auto has this many of your outcomes, Balanced and Best stay on stronger tiers
|
|
37
37
|
const ALL_WEIGHT = 0.5; // your history on other kinds of task counts half
|
|
38
38
|
const HALF_LIFE_MS = 30 * 864e5; // models change: older outcomes fade
|
|
39
39
|
const NEW_ARM_SHARE = 0.25; // how often a new arm on probation may take an exploring slot
|
|
@@ -80,6 +80,17 @@ export const levelsFor = (efforts = [], goal = 'balanced') => {
|
|
|
80
80
|
const have = (BANDS[goal] || BANDS.balanced).filter((e) => e === '' || efforts.includes(e));
|
|
81
81
|
return have.length ? have : [''];
|
|
82
82
|
};
|
|
83
|
+
// Some harnesses bake the level into the model id (Antigravity's gemini-4-argon-high, no separate
|
|
84
|
+
// selector): there the goal picks among a model's variants the same way.
|
|
85
|
+
const SUFFIX = /-(minimal|low|medium|high|xhigh|max)$/;
|
|
86
|
+
const NAMED = { cheap: ['minimal', 'low'], balanced: ['medium'], best: ['high', 'xhigh', 'max'] };
|
|
87
|
+
const stemOf = (k) => baseOf(k).replace(SUFFIX, '');
|
|
88
|
+
function byNamedLevel(cands, goal) {
|
|
89
|
+
const want = NAMED[goal] || NAMED.balanced;
|
|
90
|
+
const named = (c) => !c.efforts.length && c.model.match(SUFFIX)?.[1];
|
|
91
|
+
const fits = new Set(cands.filter((c) => want.includes(named(c))).map((c) => stemOf(c.key)));
|
|
92
|
+
return cands.filter((c) => !named(c) || want.includes(named(c)) || !fits.has(stemOf(c.key)));
|
|
93
|
+
}
|
|
83
94
|
const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
|
|
84
95
|
|
|
85
96
|
export const armKey = (agent, model = '', effort = '') => `${agent}:${model || ''}${effort ? `:${effort}` : ''}`;
|
|
@@ -99,10 +110,12 @@ export const baseOf = (k) => {
|
|
|
99
110
|
};
|
|
100
111
|
// Versions stripped: claude-sonnet-5-5 and claude-sonnet-5-6 are one family, and a new version
|
|
101
112
|
// starts from what the family already showed.
|
|
102
|
-
|
|
113
|
+
// A level in the id is the same model too: gemini-4-argon-high starts from what -medium showed.
|
|
114
|
+
const family = (k) => k.replace(SUFFIX, '').replace(/[-.]?\d+([.-]\d+)*/g, '');
|
|
103
115
|
|
|
104
116
|
// How strong a model is, roughly: 1 small/fast/free, 2 everyday, 3 frontier. Best runs on tier 3
|
|
105
|
-
// only,
|
|
117
|
+
// only, Balanced on the strongest tier that can run (frontier when you have one), and hand-overs
|
|
118
|
+
// step up a tier.
|
|
106
119
|
// The shipped priors come from a small pilot that flattered cheap models; tiers keep them honest.
|
|
107
120
|
const FRONTIER_DEFAULTS = new Set(['codex:']); // a harness whose default model is its frontier one
|
|
108
121
|
export function tierOf(key) {
|
|
@@ -259,34 +272,38 @@ export class Router {
|
|
|
259
272
|
// Pick an arm. agents: listAgents() of the place it runs. goal: cheap | balanced | best, which
|
|
260
273
|
// also sets the band of thinking levels (levelsFor).
|
|
261
274
|
// important: no exploring. exclude: arm keys already taken (compare, hand-overs): every level of
|
|
262
|
-
// that harness · model is out
|
|
263
|
-
// of a team must be a harness that can delegate. rng: for tests.
|
|
275
|
+
// that harness · model is out, and every variant of a model with its level in its id.
|
|
276
|
+
// needDelegate: the lead of a team must be a harness that can delegate. rng: for tests.
|
|
264
277
|
// scouted: { type, size } from the scout, which beats the keyword guess.
|
|
265
278
|
// minTier: only arms at least that strong (a hand-over steps up). Arms cooling down after a usage
|
|
266
279
|
// or rate limit are skipped while any other arm can run.
|
|
267
280
|
// Best runs frontier models only: if any is installed and allowed, Best never steps down a tier,
|
|
268
281
|
// even when they're all taken or at their limit (null then). With none installed, the strongest.
|
|
282
|
+
// Balanced runs frontier models too, whatever its record of the others (Sonnet or Haiku winning
|
|
283
|
+
// a few tasks doesn't make them the face of Auto), and steps down a tier only while every
|
|
284
|
+
// frontier model is taken or at its limit, so the task still runs.
|
|
269
285
|
choose({ prompt = '', attachments = 0, agents = [], goal = 'balanced', important = false, explore = true, exclude = [], needDelegate = false, allowed = null, scouted = null, minTier = 0, rng = Math.random } = {}) {
|
|
270
286
|
if (!this.synced) this.sync(agents);
|
|
287
|
+
if (!GOALS[goal]) goal = 'balanced';
|
|
271
288
|
const guess = classify(prompt, { attachments });
|
|
272
289
|
const type = scouted?.type || guess.type;
|
|
273
290
|
const size = scouted?.size || guess.size;
|
|
274
|
-
const V =
|
|
275
|
-
const out = new Set(exclude.map(
|
|
291
|
+
const V = GOALS[goal].value;
|
|
292
|
+
const out = new Set(exclude.map(stemOf));
|
|
276
293
|
const usable = candidates(agents, new Set(Object.keys(this.state.arms))).filter((c) => (!needDelegate || c.delegates) && (!allowed || allowed.includes(c.agent)));
|
|
277
294
|
const frontierOnly = goal === 'best' && usable.some((c) => tierOf(c.key) >= 3);
|
|
278
|
-
let arms = usable
|
|
279
|
-
.filter((c) => !
|
|
295
|
+
let arms = byNamedLevel(usable.filter((c) => !out.has(stemOf(c.key))), goal)
|
|
296
|
+
.filter((c) => !frontierOnly || tierOf(c.key) >= 3)
|
|
280
297
|
.flatMap((c) => levelsFor(c.efforts, goal).map((effort) => ({ ...c, key: armKey(c.agent, c.model, effort), effort, label: `${c.label}${effort ? ` · ${effortLabel(effort)}` : ''}`, base: c.key })))
|
|
281
298
|
.map((c) => ({ ...c, est: this.estimate(type, c.key), state: this.state.arms[c.base]?.state || 'new' }));
|
|
282
299
|
if (!arms.length) return null;
|
|
283
300
|
const free = arms.filter((x) => !this.cooling(x.key));
|
|
284
301
|
if (!free.length) return null; // everything allowed is at its limit: nothing to route to now
|
|
285
302
|
arms = free;
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
const
|
|
289
|
-
const floorTier = Math.max(minTier, goal === '
|
|
303
|
+
// Balanced and Best run on the strongest tier that can run now, whatever the record of the
|
|
304
|
+
// weaker ones. Cheap is left alone: that goal asked for cheap.
|
|
305
|
+
const top = Math.max(...arms.map((x) => tierOf(x.key)));
|
|
306
|
+
const floorTier = Math.max(minTier, goal === 'cheap' ? 0 : top);
|
|
290
307
|
for (let t = floorTier; t > 0; t--) {
|
|
291
308
|
const strong = arms.filter((x) => tierOf(x.key) >= t);
|
|
292
309
|
if (strong.length) {
|
|
@@ -323,7 +340,7 @@ export class Router {
|
|
|
323
340
|
key: pick.key,
|
|
324
341
|
label: pick.label,
|
|
325
342
|
route: {
|
|
326
|
-
goal
|
|
343
|
+
goal,
|
|
327
344
|
type,
|
|
328
345
|
size,
|
|
329
346
|
key: pick.key,
|
|
@@ -336,7 +353,6 @@ export class Router {
|
|
|
336
353
|
explored: byMean[0].key !== pick.key,
|
|
337
354
|
trying: pick.state === 'new',
|
|
338
355
|
tier: tierOf(pick.key),
|
|
339
|
-
...(cold && goal !== 'cheap' && goal !== 'best' && { coldStart: true }),
|
|
340
356
|
propensity: round(hits / 200),
|
|
341
357
|
alternatives: byMean.filter((x) => x.key !== pick.key).slice(0, 3).map((x) => ({ key: x.key, label: x.label, chance: round(x.est.mean), usd: round(x.est.usd) })),
|
|
342
358
|
at: this.now(),
|
|
@@ -453,7 +469,6 @@ export class Router {
|
|
|
453
469
|
return {
|
|
454
470
|
priors: { version: PRIORS.version, source: PRIORS.source },
|
|
455
471
|
learned: routes.filter((r) => r.r !== null).length,
|
|
456
|
-
coldStart: { needed: COLD_START, have: this.learned() },
|
|
457
472
|
routed: routes.length,
|
|
458
473
|
arms,
|
|
459
474
|
recent: routes.sort((p, q) => q.at - p.at).slice(0, 30).map((r) => ({ id: r.id, key: r.key, label: r.label, type: r.type, goal: r.goal, chance: r.chance, r: r.r, explored: r.explored, trying: r.trying, at: r.at, signals: r.signals })),
|
package/web/css/feed.css
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
.f-group h4.toggle-done { width: fit-content; min-height: 24px; margin-left: -4px; padding: 0 8px; border-radius: 6px; transition: background 0.15s, color 0.15s; }
|
|
35
35
|
.f-group h4.toggle-done:hover { background: var(--bg-2); }
|
|
36
36
|
.f-list { display: flex; flex-direction: column; gap: 6px; }
|
|
37
|
+
/* The end of a paged Done / Archived list: scrolling near it adds the next page, clicking does too. */
|
|
38
|
+
.f-more { display: block; margin: 10px auto 0; color: var(--faint); font-weight: 400; }
|
|
39
|
+
.f-more:hover { color: var(--ink-2); }
|
|
37
40
|
.feed-empty { padding: 48px 0; text-align: center; color: var(--faint); font-size: 13.5px; }
|
|
38
41
|
|
|
39
42
|
/* one feed item — a row that expands in place */
|
package/web/css/launcher.css
CHANGED
|
@@ -195,10 +195,11 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
195
195
|
.pk-side { display: flex; flex-direction: column; gap: 2px; padding: 6px; overflow-y: auto; background: var(--surface-2); border-right: 1px solid var(--line); }
|
|
196
196
|
.pk-head, .pk-ghead { padding: 8px 10px 5px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
|
|
197
197
|
.pk-h, .pk-m { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 9px; border-radius: 9px; text-align: left; outline: 0; color: var(--ink-2); transition: background 0.12s; }
|
|
198
|
-
.pk-h:hover
|
|
198
|
+
.pk-h:hover, .pk-m:hover, .pk-m:focus-visible { background: var(--bg-2); }
|
|
199
199
|
.pk-h:focus-visible { box-shadow: inset 0 0 0 1px var(--line-2); }
|
|
200
200
|
.pk-h.on { background: var(--surface); box-shadow: 0 0 0 1px var(--line-2), var(--shadow-1); }
|
|
201
|
-
.pk-h.off { opacity: 0.
|
|
201
|
+
.pk-h.off .pk-logo, .pk-h.off .pk-text { opacity: 0.5; }
|
|
202
|
+
.pk-h.off.on .pk-logo, .pk-h.off.on .pk-text { opacity: 0.8; }
|
|
202
203
|
.pk-logo { display: grid; place-items: center; flex: none; width: 28px; height: 28px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line); color: var(--ink); }
|
|
203
204
|
.pk-h.on .pk-logo { background: var(--bg-2); border-color: transparent; }
|
|
204
205
|
.pk-logo > * { display: block; width: 15px; height: 15px; }
|
|
@@ -224,6 +225,21 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
224
225
|
.pk-seg button:focus-visible { box-shadow: inset 0 0 0 1px var(--accent); }
|
|
225
226
|
.pk-seg button.on { background: var(--surface); color: var(--accent-ink); font-weight: 500; box-shadow: var(--shadow-1); }
|
|
226
227
|
.pk-empty { padding: 30px 10px; text-align: center; font-size: 13px; color: var(--muted); }
|
|
228
|
+
/* a harness that isn't installed: its setup steps instead of models */
|
|
229
|
+
.pk-setup { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px 8px; }
|
|
230
|
+
.pk-setup b { font-weight: 600; font-size: 13.5px; color: var(--ink); }
|
|
231
|
+
.pk-setup p { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--muted); }
|
|
232
|
+
.pk-setup-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
|
|
233
|
+
.pk-recheck.busy { cursor: progress; }
|
|
234
|
+
.pk-recheck.busy .i { animation: spin 0.8s linear infinite reverse; }
|
|
235
|
+
/* a copyable install command (picker and Settings → Agents) */
|
|
236
|
+
.setup-cmd { display: inline-flex; align-items: center; gap: 7px; max-width: 100%; height: 28px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-2); color: var(--ink-2); font: 12px/1 var(--mono); cursor: copy; transition: border-color 0.12s, color 0.12s; }
|
|
237
|
+
.setup-cmd:hover { border-color: var(--line-2); color: var(--ink); }
|
|
238
|
+
.setup-cmd .p { color: var(--faint); }
|
|
239
|
+
button.setup-cmd code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: inherit; background: none; padding: 0; }
|
|
240
|
+
.setup-cmd .i { flex: none; width: 13px; height: 13px; color: var(--muted); }
|
|
241
|
+
.setup-cmd.copied .i { color: var(--good); }
|
|
242
|
+
.sx-text .setup-cmd { display: flex; width: fit-content; margin-top: 6px; }
|
|
227
243
|
@media (max-width: 560px) {
|
|
228
244
|
.picker { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
|
229
245
|
.pk-side { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }
|
package/web/css/modals.css
CHANGED
|
@@ -132,6 +132,9 @@
|
|
|
132
132
|
.sx-main { padding: 16px; }
|
|
133
133
|
.sx-ctl .input { width: 180px; }
|
|
134
134
|
.sx-usage { grid-template-columns: repeat(2, 1fr); }
|
|
135
|
+
/* a harness that isn't found: its setup tip gets the full width, its buttons go underneath */
|
|
136
|
+
.sx-agents .sx-row:has(.sx-state.bad) { flex-wrap: wrap; row-gap: 6px; }
|
|
137
|
+
.sx-agents .sx-row:has(.sx-state.bad) .sx-text { flex-basis: 100%; }
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
/* memory: what the background retros learned */
|
package/web/js/features/auto.js
CHANGED
|
@@ -25,7 +25,7 @@ export async function openRouter({ host } = {}) {
|
|
|
25
25
|
if (!r || !node.isConnected) return;
|
|
26
26
|
const arms = r.arms.filter((a) => a.state !== 'gone' || a.n);
|
|
27
27
|
$('.ro-body', node).innerHTML = `
|
|
28
|
-
<p class="hint">For each task Auto estimates every harness · model · thinking level’s chance of doing it well, from wisp-bench (${esc(r.priors.version)}) and then more and more from your own tasks: merged PRs, 👍 / 👎, rollbacks, follow-ups and how the run ended. It picks the best value for your goal (<b>${esc(AUTO_GOALS.find((g) => g.id === r.goal)?.label || '')}</b>), which also sets how hard it thinks: Cheap low, Balanced default or medium, Best high. Best
|
|
28
|
+
<p class="hint">For each task Auto estimates every harness · model · thinking level’s chance of doing it well, from wisp-bench (${esc(r.priors.version)}) and then more and more from your own tasks: merged PRs, 👍 / 👎, rollbacks, follow-ups and how the run ended. It picks the best value for your goal (<b>${esc(AUTO_GOALS.find((g) => g.id === r.goal)?.label || '')}</b>), which also sets how hard it thinks: Cheap low, Balanced default or medium, Best high. Balanced and Best run frontier models (Opus, not Sonnet or Haiku) when you have one; Balanced steps down to an everyday model only while every frontier one is at its limit. Now and then it tries an alternative so the estimates stay honest.</p>
|
|
29
29
|
<h4>Harness · model · thinking</h4>
|
|
30
30
|
<table class="us-table ro-table"><thead><tr><th></th><th>Works</th><th>Your tasks</th><th>~Cost</th><th>~Time</th><th></th></tr></thead><tbody>${arms
|
|
31
31
|
.map((a) => {
|
package/web/js/features/feed.js
CHANGED
|
@@ -66,13 +66,47 @@ function groups() {
|
|
|
66
66
|
return { needs, working, done, archived };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Done and Archived can hold hundreds of tasks: they show a page of cards and add the next
|
|
70
|
+
// page as the user scrolls to the end of the list. The headings count every one.
|
|
71
|
+
const PAGE = 60;
|
|
72
|
+
const page = { secDone: PAGE, secArchived: PAGE };
|
|
73
|
+
const moreObserver = new IntersectionObserver(
|
|
74
|
+
(entries) => {
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
if (!e.isIntersecting || e.target.hidden) continue;
|
|
77
|
+
page[e.target.closest('.f-group').id] += PAGE;
|
|
78
|
+
renderFeed();
|
|
79
|
+
// Still in view after a short page: observing again reports it again, so the next page comes.
|
|
80
|
+
moreObserver.unobserve(e.target);
|
|
81
|
+
moreObserver.observe(e.target);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{ root: stage, rootMargin: '0px 0px 800px 0px' },
|
|
85
|
+
);
|
|
86
|
+
for (const id of Object.keys(page)) {
|
|
87
|
+
const more = el(`<button class="f-more link xs" hidden></button>`);
|
|
88
|
+
more.addEventListener('click', () => {
|
|
89
|
+
page[id] += PAGE;
|
|
90
|
+
renderFeed();
|
|
91
|
+
});
|
|
92
|
+
$(`#${id}`).append(more);
|
|
93
|
+
moreObserver.observe(more);
|
|
94
|
+
}
|
|
95
|
+
|
|
69
96
|
export function renderFeed() {
|
|
70
97
|
const { needs, working, done, archived } = groups();
|
|
71
98
|
const place = (sec, list, show = true) => {
|
|
72
99
|
const box = $('.f-list', sec);
|
|
73
100
|
sec.hidden = !list.length;
|
|
74
101
|
$('h4 .n', sec).textContent = list.length || '';
|
|
75
|
-
const
|
|
102
|
+
const cap = page[sec.id] ?? list.length;
|
|
103
|
+
const want = show ? list.slice(0, cap) : [];
|
|
104
|
+
const more = $('.f-more', sec);
|
|
105
|
+
if (more) {
|
|
106
|
+
const left = show ? list.length - want.length : 0;
|
|
107
|
+
more.hidden = left <= 0;
|
|
108
|
+
more.textContent = `Show ${Math.min(left, PAGE)} more · ${left} left`;
|
|
109
|
+
}
|
|
76
110
|
want.forEach((s, i) => {
|
|
77
111
|
const { card } = s.runs ? taskItem(s) : item(s);
|
|
78
112
|
// Only touch the DOM when the order actually changed: moving a node
|
|
@@ -83,9 +117,9 @@ export function renderFeed() {
|
|
|
83
117
|
};
|
|
84
118
|
place($('#secNeeds'), needs);
|
|
85
119
|
place($('#secWorking'), working);
|
|
86
|
-
place($('#secDone'), done
|
|
120
|
+
place($('#secDone'), done, S.showDone);
|
|
87
121
|
$('#secDone').classList.toggle('collapsed', !S.showDone);
|
|
88
|
-
place($('#secArchived'), archived
|
|
122
|
+
place($('#secArchived'), archived, S.showArchived);
|
|
89
123
|
$('#secArchived').classList.toggle('collapsed', !S.showArchived);
|
|
90
124
|
|
|
91
125
|
// Drop nodes for sessions that are gone or filtered out of the feed entirely.
|
|
@@ -399,6 +433,13 @@ export function reveal(id) {
|
|
|
399
433
|
save('wisp.filter', S.filter);
|
|
400
434
|
renderLaunchChips();
|
|
401
435
|
}
|
|
436
|
+
// A card further down than the pages shown so far: show pages up to it.
|
|
437
|
+
const sec = { done: 'secDone', archived: 'secArchived' }[sectionOf(s)];
|
|
438
|
+
if (sec) {
|
|
439
|
+
const { done, archived } = groups();
|
|
440
|
+
const at = (sec === 'secDone' ? done : archived).findIndex((t) => (t.runs || [t]).some((r) => r.id === id));
|
|
441
|
+
if (at >= page[sec]) page[sec] = Math.ceil((at + 1) / PAGE) * PAGE;
|
|
442
|
+
}
|
|
402
443
|
renderFeed();
|
|
403
444
|
setFocus(id, { scroll: false });
|
|
404
445
|
if (S.open.has(id)) S.nodes.get(id)?.card.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { esc } from '../md.js';
|
|
2
|
+
import { api } from '../core/api.js';
|
|
3
|
+
import * as bus from '../core/bus.js';
|
|
2
4
|
import { $, $$, el, icon } from '../core/dom.js';
|
|
3
|
-
import { S } from '../core/state.js';
|
|
5
|
+
import { S, setAgents } from '../core/state.js';
|
|
4
6
|
import { popover } from '../ui/popover.js';
|
|
7
|
+
import { toast, toastError } from '../ui/toast.js';
|
|
5
8
|
import { placeName } from './places.js';
|
|
6
9
|
import { AUTO, AUTO_GOALS, effortLabel, effortsFor, harnessesAt, harnessLogo } from './runners.js';
|
|
7
10
|
import { openSettings } from './settings.js';
|
|
11
|
+
import { copySetupCommand, INSTALLABLE, installHarness, setupCommand, setupDocs, setupTip, setupTitle } from './setup.js';
|
|
8
12
|
|
|
9
13
|
// One popover for all three choices: harnesses on the left, the focused harness's models on the
|
|
10
14
|
// right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
|
|
@@ -14,9 +18,10 @@ export function runnerPicker(anchor, { agent, model, effort, savedModel, savedEf
|
|
|
14
18
|
const open = $('.picker');
|
|
15
19
|
if (open) return open.close();
|
|
16
20
|
const agents = places ? harnessesAt(places, { auto }) : auto ? [AUTO, ...S.agents] : S.agents;
|
|
21
|
+
const remote = places?.length > 1 || !!places?.[0];
|
|
17
22
|
const sub = (a) => {
|
|
18
23
|
if (a.id === 'auto') return 'Picks harness and model for you';
|
|
19
|
-
if (!a.available) return
|
|
24
|
+
if (!a.available) return remote ? 'Not installed there' : 'Not installed';
|
|
20
25
|
const models = (a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model';
|
|
21
26
|
return a.missing?.length ? `Not on ${a.missing.map(placeName).join(', ')}` : models;
|
|
22
27
|
};
|
|
@@ -35,7 +40,7 @@ export function runnerPicker(anchor, { agent, model, effort, savedModel, savedEf
|
|
|
35
40
|
<div class="pk-head">Harness</div>
|
|
36
41
|
${agents
|
|
37
42
|
.map(
|
|
38
|
-
(a) => `<button class="pk-h${a.available ? '' : ' off'}" role="option" data-id="${esc(a.id)}" ${a.available ? '' :
|
|
43
|
+
(a) => `<button class="pk-h${a.available ? '' : ' off'}" role="option" data-id="${esc(a.id)}" ${a.available ? '' : `aria-disabled="true" title="${esc(setupTitle(a))}"`}>
|
|
39
44
|
<span class="pk-logo">${harnessLogo(a.id)}</span>
|
|
40
45
|
<span class="pk-text"><b>${esc(a.name)}</b><small${a.missing?.length ? ' class="warn"' : ''}>${esc(sub(a))}</small></span>
|
|
41
46
|
${a.id === agent ? '<span class="pk-dot" title="In use"></span>' : ''}
|
|
@@ -86,6 +91,7 @@ export function runnerPicker(anchor, { agent, model, effort, savedModel, savedEf
|
|
|
86
91
|
const models = a?.models || [{ id: '', label: 'Default' }];
|
|
87
92
|
const current = focusAgent === agent ? model : savedModel(focusAgent);
|
|
88
93
|
input.placeholder = `Search ${a?.name || ''} models`;
|
|
94
|
+
if (a && !a.available) return paintSetup(a);
|
|
89
95
|
if (a?.id === 'auto') {
|
|
90
96
|
list.innerHTML = `<div class="pk-group"><div class="pk-ghead">Goal</div>${AUTO_GOALS.map((g) => `<button class="pk-m${g.id === current ? ' on' : ''}" role="option" aria-selected="${g.id === current}" data-id="${g.id}"><span class="pk-text"><b>${g.label}</b><small>${g.hint}</small></span>${icon('check', 'pk-check')}</button>`).join('')}</div><p class="pk-note">wisp picks the harness and model for each task from what worked before, and learns from how it went. ${'<a href="#" class="pk-auto-more">How Auto picks</a>'}</p><div class="pk-empty" hidden>No models match</div>`;
|
|
91
97
|
$('.pk-search', node).hidden = true;
|
|
@@ -120,6 +126,51 @@ export function runnerPicker(anchor, { agent, model, effort, savedModel, savedEf
|
|
|
120
126
|
filter();
|
|
121
127
|
paintEffort();
|
|
122
128
|
};
|
|
129
|
+
// A harness that isn't installed: how to get it, in place of its models.
|
|
130
|
+
const paintSetup = (a) => {
|
|
131
|
+
$('.pk-search', node).hidden = true;
|
|
132
|
+
seg.hidden = true;
|
|
133
|
+
const canInstall = !remote && INSTALLABLE.has(a.id);
|
|
134
|
+
list.innerHTML = `<div class="pk-setup">
|
|
135
|
+
<b>${esc(a.name)} isn’t installed${remote ? ' there' : ''}</b>
|
|
136
|
+
<p>${esc(setupTip(a))}</p>
|
|
137
|
+
${a.setup?.command && !canInstall ? setupCommand(a.setup.command) : ''}
|
|
138
|
+
<div class="pk-setup-actions">
|
|
139
|
+
${canInstall ? `<button class="primary sm pk-install">Install ${esc(a.name)}</button>` : ''}
|
|
140
|
+
${setupDocs(a)}
|
|
141
|
+
<button class="ghost sm pk-recheck" title="Look for harnesses again">${icon('replay')}<span>Re-check</span></button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>`;
|
|
144
|
+
const c = $('.setup-cmd', list);
|
|
145
|
+
if (c) c.onclick = () => {
|
|
146
|
+
copySetupCommand(c);
|
|
147
|
+
};
|
|
148
|
+
const inst = $('.pk-install', list);
|
|
149
|
+
if (inst) inst.onclick = () => {
|
|
150
|
+
close();
|
|
151
|
+
installHarness(a.id);
|
|
152
|
+
};
|
|
153
|
+
$('.pk-recheck', list).onclick = async (e) => {
|
|
154
|
+
const btn = e.currentTarget;
|
|
155
|
+
btn.disabled = true;
|
|
156
|
+
btn.classList.add('busy');
|
|
157
|
+
try {
|
|
158
|
+
setAgents(await api('/agents/recheck', { method: 'POST', body: {} }));
|
|
159
|
+
$('#launcher')?.placesChanged?.();
|
|
160
|
+
bus.emit('agents');
|
|
161
|
+
const found = S.agents.find((x) => x.id === a.id)?.available;
|
|
162
|
+
if (found) {
|
|
163
|
+
close();
|
|
164
|
+
toast(`<b>${esc(a.name)}</b> found`);
|
|
165
|
+
} else if (node.isConnected) toast(`Still no ${esc(a.name)}. Install it, sign in, then re-check.`);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
toastError(err);
|
|
168
|
+
} finally {
|
|
169
|
+
btn.disabled = false;
|
|
170
|
+
btn.classList.remove('busy');
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
};
|
|
123
174
|
const rows = () => $$('.pk-m', list).filter((b) => !b.hidden);
|
|
124
175
|
const filter = () => {
|
|
125
176
|
const q = input.value.trim().toLowerCase();
|
|
@@ -145,7 +196,7 @@ export function runnerPicker(anchor, { agent, model, effort, savedModel, savedEf
|
|
|
145
196
|
// Picking a harness applies it straight away with the model and level it shows as selected.
|
|
146
197
|
const useAgent = (id) => {
|
|
147
198
|
preview(id);
|
|
148
|
-
if (id === agent) return;
|
|
199
|
+
if (id === agent || !agents.find((x) => x.id === id)?.available) return;
|
|
149
200
|
markInUse(id);
|
|
150
201
|
agent = id;
|
|
151
202
|
model = savedModel(id);
|
|
@@ -171,7 +222,6 @@ export function runnerPicker(anchor, { agent, model, effort, savedModel, savedEf
|
|
|
171
222
|
};
|
|
172
223
|
const keys = (e) => {
|
|
173
224
|
const inSide = sideRows.includes(document.activeElement);
|
|
174
|
-
const live = sideRows.filter((b) => !b.disabled);
|
|
175
225
|
const segBtns = $$('button', seg);
|
|
176
226
|
const inSeg = segBtns.includes(document.activeElement);
|
|
177
227
|
// Tab hops from the model list to the thinking levels and back; anywhere else it leaves.
|
|
@@ -191,7 +241,7 @@ export function runnerPicker(anchor, { agent, model, effort, savedModel, savedEf
|
|
|
191
241
|
e.preventDefault();
|
|
192
242
|
e.stopPropagation();
|
|
193
243
|
const d = e.key === 'ArrowDown' ? 1 : -1;
|
|
194
|
-
if (inSide) preview(step(
|
|
244
|
+
if (inSide) preview(step(sideRows, d)?.dataset.id); // not-installed ones too: they show how to get them
|
|
195
245
|
else step(rows(), d);
|
|
196
246
|
} else if (e.key === 'ArrowLeft' && document.activeElement !== input) {
|
|
197
247
|
e.preventDefault();
|
|
@@ -21,9 +21,9 @@ export const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id
|
|
|
21
21
|
// level per task. Its "models" are the goal it optimises for, which also sets the level band, so
|
|
22
22
|
// there are no thinking levels to pick here.
|
|
23
23
|
export const AUTO_GOALS = [
|
|
24
|
-
{ id: 'balanced', label: 'Balanced', hint: '
|
|
24
|
+
{ id: 'balanced', label: 'Balanced', hint: 'Frontier models · default or medium thinking' },
|
|
25
25
|
{ id: 'cheap', label: 'Cheap', hint: 'Lowest cost that still gets it done · low thinking' },
|
|
26
|
-
{ id: 'best', label: 'Best', hint: '
|
|
26
|
+
{ id: 'best', label: 'Best', hint: 'Frontier models · high thinking; cost only breaks ties' },
|
|
27
27
|
];
|
|
28
28
|
export const AUTO = { id: 'auto', name: 'Auto', available: true, delegates: true, models: AUTO_GOALS, efforts: [], hint: 'wisp picks the harness, model and thinking level from what worked before' };
|
|
29
29
|
|
|
@@ -19,7 +19,7 @@ import { notifyOn } from './notify.js';
|
|
|
19
19
|
import { openPhone, phoneSummary } from './phone.js';
|
|
20
20
|
import { machinesSummary } from './places.js';
|
|
21
21
|
import { AUTO_GOALS, harnessLogo } from './runners.js';
|
|
22
|
-
import { INSTALLABLE, installHarness, isInstalling } from './setup.js';
|
|
22
|
+
import { copySetupCommand, INSTALLABLE, installHarness, isInstalling, setupCommand, setupDocs, setupTip } from './setup.js';
|
|
23
23
|
import { clearPicks } from './transcript.js';
|
|
24
24
|
import { repaintViews } from './turns.js';
|
|
25
25
|
import { openUsage } from './usage.js';
|
|
@@ -156,11 +156,13 @@ export function openSettings(opts) {
|
|
|
156
156
|
// opencode: wisp can install it (free models, no account), so its row offers that.
|
|
157
157
|
const canInstall = !a.available && INSTALLABLE.has(a.id);
|
|
158
158
|
const busy = isInstalling(a.id);
|
|
159
|
+
// Not found: this OS's setup tip, with the command to copy unless wisp installs it itself.
|
|
159
160
|
const desc = a.available
|
|
160
|
-
? [a.version, n && `${n} model${n === 1 ? '' : 's'}`].filter(Boolean).join(' · ')
|
|
161
|
-
: busy ? 'Installing into ~/.wisp/tools…' :
|
|
161
|
+
? esc([a.version, n && `${n} model${n === 1 ? '' : 's'}`].filter(Boolean).join(' · '))
|
|
162
|
+
: busy ? 'Installing into ~/.wisp/tools…' : `${esc(setupTip(a))}${a.setup?.command && !canInstall ? setupCommand(a.setup.command) : ''}`;
|
|
162
163
|
const install = canInstall ? `<button class="ghost sm" data-install="${esc(a.id)}" ${busy ? 'disabled' : ''}>${busy ? 'Installing…' : 'Install'}</button>` : '';
|
|
163
|
-
|
|
164
|
+
const docs = a.available ? '' : setupDocs(a);
|
|
165
|
+
return row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, desc, `${install}${docs}<span class="sx-state ${a.available ? 'ok' : 'bad'}" ${a.error ? `title="${esc(a.error)}"` : ''}><i class="dot ${a.available ? 'ok' : 'bad'}"></i>${a.available ? 'Ready' : 'Not found'}</span>`);
|
|
164
166
|
}).join('')
|
|
165
167
|
: '<div class="sx-row"><div class="it-loading"><span class="orb tiny"></span>Looking for harnesses…</div></div>';
|
|
166
168
|
const missing = S.agents.filter((a) => !a.available).length;
|
|
@@ -174,6 +176,8 @@ export function openSettings(opts) {
|
|
|
174
176
|
bus.on('agents', paintAgents, { node });
|
|
175
177
|
bus.on('install', paintAgents, { node });
|
|
176
178
|
$('.sx-agents', node).onclick = (e) => {
|
|
179
|
+
const c = e.target.closest('[data-copy]');
|
|
180
|
+
if (c) return copySetupCommand(c);
|
|
177
181
|
const b = e.target.closest('[data-install]');
|
|
178
182
|
if (b) installHarness(b.dataset.install);
|
|
179
183
|
};
|
package/web/js/features/setup.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Settings → Agents uses the same install.
|
|
4
4
|
|
|
5
5
|
import { esc } from '../md.js';
|
|
6
|
+
import { copy } from '../blocks.js';
|
|
6
7
|
import { api } from '../core/api.js';
|
|
7
8
|
import * as bus from '../core/bus.js';
|
|
8
9
|
import { $, icon } from '../core/dom.js';
|
|
@@ -13,6 +14,17 @@ import { harnessLogo } from './runners.js';
|
|
|
13
14
|
// What wisp can install itself (server/services/install.js).
|
|
14
15
|
export const INSTALLABLE = new Set(['opencode']);
|
|
15
16
|
|
|
17
|
+
// A harness that isn't found: how to get it, from the server for that computer's OS
|
|
18
|
+
// (adapter setup()). As a tooltip, as a copyable command, and as a docs link.
|
|
19
|
+
export const setupTip = (a) => a.setup?.tip || `Install the ${a.name} CLI and sign in, then re-check`;
|
|
20
|
+
export const setupTitle = (a) => [setupTip(a), a.setup?.command && `$ ${a.setup.command}`].filter(Boolean).join('\n');
|
|
21
|
+
export const setupCommand = (cmd) => `<button type="button" class="setup-cmd" data-copy="${esc(cmd)}" title="Copy"><span class="p">$</span><code>${esc(cmd)}</code>${icon('copy')}</button>`;
|
|
22
|
+
export const setupDocs = (a, cls = 'ghost sm') => (a.setup?.url ? `<a class="${cls}" href="${esc(a.setup.url)}" target="_blank" rel="noopener" title="${esc(a.name)} install guide">${icon('external')}<span>Docs</span></a>` : '');
|
|
23
|
+
export function copySetupCommand(btn) {
|
|
24
|
+
copy(btn.dataset.copy, btn);
|
|
25
|
+
toast('Copied. Run it in a terminal, sign in, then Re-check');
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
let installing = null; // the running install's id
|
|
17
29
|
let failed = ''; // the last install's error, shown in the strip until the next try
|
|
18
30
|
|
|
@@ -71,7 +83,7 @@ export function renderSetup() {
|
|
|
71
83
|
setAgents(await api('/agents/recheck', { method: 'POST', body: {} }));
|
|
72
84
|
$('#launcher')?.placesChanged?.();
|
|
73
85
|
bus.emit('agents');
|
|
74
|
-
if (needsSetup()) toast('Still no harness found. Install opencode here, or Claude Code, Codex, Cursor… yourself.');
|
|
86
|
+
if (needsSetup()) toast('Still no harness found. Install opencode here, or Claude Code, Codex, Cursor… yourself. The ChatGPT app (Codex) or the Claude app (its Code tab) works too.');
|
|
75
87
|
} catch (err) {
|
|
76
88
|
toastError(err);
|
|
77
89
|
} finally {
|