wisp-agents 0.23.0 → 0.24.1

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,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.24.1] - 2026-10-20
9
+
10
+ ### Fixed
11
+
12
+ - **Update now updates, however you installed wisp.** **Update to X** installs the new version and restarts wisp for every install route: `npx`, `npm install -g`, the `install.sh` script, and now Homebrew. On a Homebrew install it used to run `npm install -g` and then restart the old keg. Now it runs `brew upgrade m0xoo/tap/wisp-agents` and restarts from the new keg. If the install leaves wisp on the same version (the tap doesn't have the release yet, or npm installed into a different folder), the button says so instead of restarting into the old version and then reporting that wisp didn't come back. A git clone of the repo no longer shows the button or tells you to run `git pull`. `GET /update` no longer returns `auto`, and the method can now be `brew`.
13
+
14
+ ## [0.24.0] - 2026-10-20
15
+
16
+ ### Added
17
+
18
+ - **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)`.
19
+ - **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.
20
+
21
+ ### Changed
22
+
23
+ - **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.
24
+
25
+ ### Fixed
26
+
27
+ - **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.
28
+ - **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.
29
+
8
30
  ## [0.23.0] - 2026-10-20
9
31
 
10
32
  ### Changed
@@ -420,7 +442,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
420
442
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
421
443
  - Older turns are folded, with a floating collapse-all button and back-to-top.
422
444
 
423
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.23.0...HEAD
445
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.24.1...HEAD
446
+ [0.24.1]: https://github.com/M0xoo/wisp/compare/v0.24.0...v0.24.1
447
+ [0.24.0]: https://github.com/M0xoo/wisp/compare/v0.23.0...v0.24.0
424
448
  [0.23.0]: https://github.com/M0xoo/wisp/compare/v0.22.0...v0.23.0
425
449
  [0.22.0]: https://github.com/M0xoo/wisp/compare/v0.21.0...v0.22.0
426
450
  [0.21.0]: https://github.com/M0xoo/wisp/compare/v0.20.0...v0.21.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.23.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.1`), 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.23.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.1 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.23.0 # run a recipe; values fill its {params} (or version=0.23.0)
138
+ wisp run release 0.24.1 # run a recipe; values fill its {params} (or version=0.24.1)
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
@@ -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.23.0` fills the fields in order, `wisp run release version=0.23.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.1` fills the fields in order, `wisp run release version=0.24.1` 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,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
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": {
@@ -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
@@ -25,7 +25,7 @@ export function openapi({ version, port }) {
25
25
  paths: {
26
26
  '/health': { get: { summary: 'Liveness, version and pid', responses: { 200: json(ref('Health')) } } },
27
27
  '/update': {
28
- get: { summary: 'Current and latest published version, and how this copy updates (global | npx | git). ?check forces a registry check', responses: { 200: json({ type: 'object' }) } },
28
+ get: { summary: 'Current and latest published version, and how this copy updates (global | npx | brew | git; a git clone never offers one). ?check forces a registry check', responses: { 200: json({ type: 'object' }) } },
29
29
  post: { summary: 'Install the latest version and relaunch. 409 while agents run unless { force: true }', responses: { 202: json({ type: 'object' }), 409: json({ type: 'object' }) } },
30
30
  },
31
31
  '/preferences': {
@@ -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/update.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // Update check: polls the npm registry for a newer wisp-agents and, when asked, installs it and
2
2
  // relaunches wisp. How depends on how this copy was installed:
3
- // global npm install -g wisp-agents@latest, then rerun bin/wisp.js from the same folder
3
+ // global npm install -g wisp-agents@latest (also what install.sh does), then rerun bin/wisp.js
4
4
  // npx nothing to install: relaunch through `npx -y wisp-agents@latest`
5
- // git a clone: we only tell you to pull
5
+ // brew brew upgrade m0xoo/tap/wisp-agents, then rerun it from the keg's opt/ link
6
+ // git a clone of the repo, i.e. working on wisp itself: never offers an update
6
7
  // Opt out with WISP_UPDATE_CHECK=0.
7
8
 
8
9
  import { spawn } from 'node:child_process';
@@ -16,6 +17,9 @@ const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
16
17
  const REGISTRY = `https://registry.npmjs.org/${PKG.name}/latest`;
17
18
  const DAY = 24 * 3600_000;
18
19
  const TICK = 30 * 60_000; // timers stall while the machine sleeps, so poll the wall clock instead
20
+ const TAP = 'm0xoo/tap/wisp-agents';
21
+ // <prefix>/Cellar/wisp-agents/<version>/libexec/lib/node_modules/wisp-agents
22
+ const CELLAR = /^(.*)[\\/]Cellar[\\/]wisp-agents[\\/]/;
19
23
 
20
24
  export function newer(a, b) {
21
25
  const p = (v) => String(v).replace(/^v/, '').split(/[-+]/)[0].split('.').map(Number);
@@ -25,20 +29,23 @@ export function newer(a, b) {
25
29
  }
26
30
 
27
31
  export function installMethod(root = ROOT) {
32
+ if (CELLAR.test(root)) return 'brew';
28
33
  if (fs.existsSync(path.join(root, '.git'))) return 'git';
29
34
  if (/[\\/]_npx[\\/]/.test(root)) return 'npx';
30
35
  return 'global';
31
36
  }
32
37
 
33
38
  export class Updater {
34
- constructor({ port, onChange = () => {}, env = process.env, fetch = globalThis.fetch, now = Date.now } = {}) {
39
+ constructor({ port, onChange = () => {}, env = process.env, fetch = globalThis.fetch, now = Date.now, root = ROOT } = {}) {
35
40
  this.port = port;
36
41
  this.onChange = onChange;
37
42
  this.fetch = fetch;
38
43
  this.now = now;
39
44
  this.checkedAt = -Infinity;
40
- this.enabled = !/^(0|false|no|off)$/i.test(env.WISP_UPDATE_CHECK || '') && !env.CI;
41
- this.method = installMethod();
45
+ this.root = root;
46
+ this.method = installMethod(root);
47
+ this.brew = root.match(CELLAR)?.[1]; // Homebrew's prefix, e.g. /opt/homebrew
48
+ this.enabled = this.method !== 'git' && !/^(0|false|no|off)$/i.test(env.WISP_UPDATE_CHECK || '') && !env.CI;
42
49
  this.state = { current: PKG.version, latest: null, available: false, method: this.method, status: 'idle', error: null };
43
50
  }
44
51
 
@@ -60,9 +67,20 @@ export class Updater {
60
67
  }
61
68
 
62
69
  view() {
63
- const s = this.state;
64
- const command = { global: `npm install -g ${PKG.name}@latest`, npx: `npx -y ${PKG.name}@latest`, git: 'git pull' }[s.method];
65
- return { ...s, command, auto: s.method !== 'git' };
70
+ return { ...this.state, command: this.command() };
71
+ }
72
+
73
+ // What installs the latest version; shown to copy if it fails. A clone has none.
74
+ command() {
75
+ if (this.method === 'brew') return `${JSON.stringify(path.join(this.brew, 'bin', 'brew'))} upgrade ${TAP}`;
76
+ return { global: `npm install -g ${PKG.name}@latest`, npx: `npx -y ${PKG.name}@latest` }[this.method];
77
+ }
78
+
79
+ // Where the updated copy lives: the same folder, except Homebrew puts each version in its own
80
+ // keg and keeps opt/wisp-agents pointing at the current one.
81
+ home() {
82
+ if (this.method !== 'brew') return this.root;
83
+ return path.join(this.brew, 'opt', 'wisp-agents', 'libexec', 'lib', 'node_modules', PKG.name);
66
84
  }
67
85
 
68
86
  async check() {
@@ -71,20 +89,28 @@ export class Updater {
71
89
  const r = await this.fetch(REGISTRY, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(10_000) });
72
90
  if (!r.ok) return this.view();
73
91
  const { version } = await r.json();
74
- const available = newer(version, PKG.version);
92
+ const available = this.method !== 'git' && newer(version, PKG.version);
75
93
  if (version !== this.state.latest || available !== this.state.available) this.set({ latest: version, available });
76
94
  } catch {} // offline: try again next round
77
95
  return this.view();
78
96
  }
79
97
 
80
- // Installs (global) then hands off to a detached helper that waits for this server's port to
81
- // free up and starts the new version. The caller shuts this process down.
98
+ // Installs (npm or brew; npx fetches on relaunch) then hands off to a detached helper that waits
99
+ // for this server's port to free up and starts the new version. The caller shuts this process down.
82
100
  async install() {
83
- if (this.method === 'git') throw bad('This is a git checkout: run `git pull` to update.');
101
+ if (this.method === 'git') throw bad('This copy of wisp is a git clone, so it does not update itself.');
84
102
  if (this.state.status === 'installing') throw bad('Already updating.', 409);
85
103
  this.set({ status: 'installing', error: null });
86
104
  try {
87
- if (this.method === 'global') await run(`npm install -g ${PKG.name}@latest`);
105
+ if (this.method !== 'npx') {
106
+ // Old kegs stay until brew's own periodic cleanup: this process still runs from one.
107
+ await run(this.command(), this.method === 'brew' ? { HOMEBREW_NO_INSTALL_CLEANUP: '1' } : {});
108
+ // Relaunching a copy that didn't change would leave the UI waiting for a version that never comes.
109
+ const got = installedVersion(this.home());
110
+ if (!newer(got, PKG.version)) {
111
+ throw bad(this.method === 'brew' ? `Homebrew doesn't have wisp ${this.state.latest} yet. Try again later.` : `npm installed wisp somewhere else: this copy is still ${got || PKG.version}.`);
112
+ }
113
+ }
88
114
  } catch (err) {
89
115
  this.set({ status: 'idle', error: err.message });
90
116
  throw err;
@@ -97,13 +123,21 @@ export class Updater {
97
123
  relaunchCommand(electron = !!process.versions.electron) {
98
124
  const args = `${electron ? '' : ' --browser --no-open'} --port ${this.port}`; // desktop: reopen the app; browser: keep the tab
99
125
  const node = JSON.stringify(electron ? 'node' : process.execPath);
100
- return this.method === 'npx' ? `npx -y ${PKG.name}@latest${args}` : `${node} ${JSON.stringify(path.join(ROOT, 'bin', 'wisp.js'))}${args}`;
126
+ return this.method === 'npx' ? `npx -y ${PKG.name}@latest${args}` : `${node} ${JSON.stringify(path.join(this.home(), 'bin', 'wisp.js'))}${args}`;
127
+ }
128
+ }
129
+
130
+ function installedVersion(dir) {
131
+ try {
132
+ return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
133
+ } catch {
134
+ return null;
101
135
  }
102
136
  }
103
137
 
104
- function run(cmd) {
138
+ function run(cmd, env = {}) {
105
139
  return new Promise((resolve, reject) => {
106
- const child = spawn(cmd, { shell: true, stdio: ['ignore', 'ignore', 'pipe'], env: withoutElectronNode() });
140
+ const child = spawn(cmd, { shell: true, stdio: ['ignore', 'ignore', 'pipe'], env: { ...withoutElectronNode(), ...env } });
107
141
  let err = '';
108
142
  child.stderr.on('data', (d) => (err = (err + d).slice(-2000)));
109
143
  child.on('error', reject);
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 */
@@ -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();
@@ -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 {
@@ -18,12 +18,11 @@ export function renderUpdate(u) {
18
18
  b.disabled = busy;
19
19
  b.classList.toggle('busy', busy);
20
20
  b.querySelector('span').textContent = u.status === 'installing' ? 'Updating…' : u.status === 'restarting' ? 'Restarting…' : `Update to ${u.latest}`;
21
- b.title = u.auto ? `wisp ${u.current} → ${u.latest}: install and restart` : `wisp ${u.latest} is out. Run \`${u.command}\` to update`;
21
+ b.title = `wisp ${u.current} → ${u.latest}: install and restart`;
22
22
  }
23
23
 
24
24
  export async function installUpdate() {
25
25
  if (!update?.available) return;
26
- if (!update.auto) return toast(`wisp <b>${esc(update.latest)}</b> is out. Run <code>${esc(update.command)}</code>`, { action: { label: 'Copy', run: () => copy(update.command) }, timeout: 8000 });
27
26
  const post = (force) => api('/update', { method: 'POST', body: { force } });
28
27
  try {
29
28
  await post(false).catch(async (err) => {