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 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.22.0...HEAD
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.22.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
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.22.0 bash`).
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.22.0 # run a recipe; values fill its {params} (or version=0.22.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** (best result per dollar), **Cheap** or **Best**. The goal also sets how hard the agent thinks: Cheap runs at low (or minimal), Balanced at the harness's default or medium, Best at high or x-high. A harness or model without levels runs at its default.
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 (Opus, not Sonnet or Haiku); it falls back to the strongest you have only when no frontier model is installed. Until Auto has 20 of your own outcomes, Balanced picks only everyday and frontier models. The shipped estimates come from a small pilot, so they count for at most 4 tasks each.
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.22.0` fills the fields in order, `wisp run release version=0.22.0` by name.
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.22.0",
4
- "description": "Launch coding agents, skip the transcript, read the presentation.",
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, detectVersion, exitText, flatten, rel, spawnJsonl } from './common.js';
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 BIN = process.env.WISP_CLAUDE_BIN || 'claude';
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
- detect() {
50
- return detectVersion(BIN);
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".
@@ -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, detectVersion, exitText, fileTracker, flatten, rel, spawnJsonl, withProtocol } from './common.js';
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 BIN = process.env.WISP_CODEX_BIN || 'codex';
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
- detect() {
33
- return detectVersion(BIN);
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;
@@ -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;
@@ -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, detectVersion, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
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
- let BIN = process.env.WISP_OPENCODE_BIN || 'opencode';
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
- let found = await detectVersion(BIN);
40
- if (!found.available && !process.env.WISP_OPENCODE_BIN) {
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. Within the band the levels are learned like models are, and a model's
6
- // record at its other levels counts half. For each kind of task (bug, ui, docs, …) wisp
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: 'Best result per dollar' },
31
- best: { label: 'Best', value: 50, hint: 'Frontier models only; cost only breaks ties' },
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
- const family = (k) => k.replace(/[-.]?\d+([.-]\d+)*/g, '');
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, hand-overs step up a tier, and until Auto has your own outcomes Balanced stays on tier 2+.
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. needDelegate: the lead
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 = (GOALS[goal] || GOALS.balanced).value;
275
- const out = new Set(exclude.map(baseOf));
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) => !out.has(c.key) && (!frontierOnly || tierOf(c.key) >= 3))
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
- // Cold start: until there are enough of your own outcomes, lean to quality (the shipped priors
287
- // are thin). Cheap is left alone: that goal asked for cheap.
288
- const cold = this.learned() < COLD_START;
289
- const floorTier = Math.max(minTier, goal === 'best' ? 3 : cold && goal !== 'cheap' ? 2 : 0);
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: GOALS[goal] ? goal : 'balanced',
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 */
@@ -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:not(:disabled), .pk-m:hover, .pk-m:focus-visible { background: var(--bg-2); }
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.45; cursor: not-allowed; }
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); }
@@ -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 */
@@ -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 runs frontier models only (Opus, not Sonnet or Haiku). Now and then it tries an alternative so the estimates stay honest.${r.coldStart && r.coldStart.have < r.coldStart.needed ? ` Until it has ${r.coldStart.needed} of your own outcomes (${r.coldStart.have} so far), Balanced uses everyday and frontier models only.` : ''}</p>
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) => {
@@ -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 want = show ? list : [];
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.slice(0, 60), S.showDone);
120
+ place($('#secDone'), done, S.showDone);
87
121
  $('#secDone').classList.toggle('collapsed', !S.showDone);
88
- place($('#secArchived'), archived.slice(0, 60), S.showArchived);
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 places?.length > 1 || places?.[0] ? 'Not installed there' : 'Not installed';
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 ? '' : 'disabled'}>
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(live, d)?.dataset.id);
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: 'Best result per dollar · default or medium thinking' },
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: 'Best result; cost only breaks ties · high thinking' },
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…' : canInstall ? 'Install it here: free models, no account needed' : `Install the ${a.name} CLI and sign in, then re-check`;
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
- return row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, esc(desc), `${install}<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
+ 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
  };
@@ -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 {