wisp-agents 0.10.0 → 0.12.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,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.12.0] - 2026-10-18
9
+
10
+ ### Added
11
+
12
+ - **Settings → Agents → Re-check** looks for harnesses and models again, on this computer and on every online machine. Install a CLI, sign in or add a provider (say a new opencode provider), come back to wisp and click it: the list, the model counts and the launcher's harness picker update without restarting wisp, and a toast says what changed ("opencode +12 models", "Codex found"). Each harness now shows how many models it has, and one that isn't installed says to install its CLI and sign in. Before, wisp kept the first detection until it restarted. API: `POST /api/agents/recheck`, or `GET /api/agents?fresh=1` for this computer only.
13
+
14
+ ### Changed
15
+
16
+ - The calm feed (nothing needs you) looks tidier. **Nothing needs you** gets a green dot and lines up with the **Done** and **Archived** headings, and folded groups now sit close together instead of a full section gap apart. Folded headings highlight on hover so they read as buttons.
17
+
18
+ ### Fixed
19
+
20
+ - In **Settings → Machines**, the add/edit machine form lays out properly again: labels sit left above their fields, and the **SSH host** and **Name** inputs are full height instead of squashed and centred. The Settings row style was leaking into it.
21
+
22
+ ## [0.11.0] - 2026-10-18
23
+
24
+ ### Added
25
+
26
+ - Machines shows **Update to X** next to the version when a connected machine runs an older wisp than this computer. One click restarts wisp there as this version (you're asked first if agents are working on it). Before, reconnecting kept the old wisp running, and you had to find **Restart wisp there** in the ⋯ menu. Machines with a custom start command don't show it, because that command decides the version. Machines carry it as `outdated: "<version>"`.
27
+
28
+ ### Changed
29
+
30
+ - The app responds right away instead of waiting for the server. A new task clears the prompt box and shows up in **Working** as a "Starting up" card while wisp makes its worktree. A follow-up or an answer clears the composer and moves the card to **Working** with your message as the live turn. **Mark done**, **Reopen**, **Archive**, **Delete**, **Stop**, **Rerun**, recipe runs and **Keep this** in Compare also act immediately. If the server refuses, the change is undone, your text and attachments come back, and an error toast says why.
31
+ - The web app uses the same icon as the site and the desktop app (the glowing orb on a dark tile) for its browser tab, home-screen shortcut and notifications.
32
+ - The launcher's **Run on** button sits right after the attach button, so the bar reads attachments, where it runs, then mode, harness, subagents and worktree.
33
+
34
+ ### Fixed
35
+
36
+ - The launcher's harness picker now lists the harnesses installed on the places picked under **Run on**, not the ones on this computer. Any picked place that has a harness makes it pickable (a union), with its models taken from a place that has it; a harness missing on some of them says **Not on <machine>**, and launching names the place it's missing on. Before, running only on a machine still offered Claude when only this computer had it, and hid a harness only the machine had. Changing the places (or a machine coming online) moves the pick to an installed harness if the current one isn't there. Machines now report each harness's models, thinking levels and whether it can lead subagents.
37
+
8
38
  ## [0.10.0] - 2026-10-18
9
39
 
10
40
  ### Added
@@ -190,7 +220,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
190
220
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
191
221
  - Older turns are folded, with a floating collapse-all button and back-to-top.
192
222
 
193
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.10.0...HEAD
223
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.12.0...HEAD
224
+ [0.12.0]: https://github.com/M0xoo/wisp/compare/v0.11.0...v0.12.0
225
+ [0.11.0]: https://github.com/M0xoo/wisp/compare/v0.10.0...v0.11.0
194
226
  [0.10.0]: https://github.com/M0xoo/wisp/compare/v0.9.0...v0.10.0
195
227
  [0.9.0]: https://github.com/M0xoo/wisp/compare/v0.8.0...v0.9.0
196
228
  [0.8.0]: https://github.com/M0xoo/wisp/compare/v0.7.0...v0.8.0
package/README.md CHANGED
@@ -53,7 +53,7 @@ From there you can **follow up**, **answer its questions**, **take it over** in
53
53
  | 🤝 **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). |
54
54
  | ⏪ **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. |
55
55
  | ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
56
- | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.10.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
56
+ | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.12.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
57
57
  | 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
58
58
  | 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there. |
59
59
  | 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
@@ -125,7 +125,7 @@ wisp reply <id> "also do X" # follow up (answers a pending question)
125
125
  wisp stop <id>
126
126
  wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
127
127
  wisp merge <id> # squash-merge its PR, fast-forward your local main
128
- wisp run release 0.10.0 # run a recipe; values fill its {params} (or version=0.10.0)
128
+ wisp run release 0.12.0 # run a recipe; values fill its {params} (or version=0.12.0)
129
129
  wisp recipes # list recipes
130
130
  wisp usage [--days 7] # spend and outcomes
131
131
  wisp mcp # MCP server for other agents and editors
@@ -305,7 +305,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
305
305
  Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
306
306
  ```
307
307
 
308
- 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.10.0` fills the fields in order, `wisp run release version=0.10.0` by name.
308
+ 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.12.0` fills the fields in order, `wisp run release version=0.12.0` by name.
309
309
 
310
310
  **Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
311
311
 
@@ -340,7 +340,7 @@ They receive titles, summaries and questions (with their context), never code or
340
340
 
341
341
  Then **Pick projects on it**: the machine's git repos are listed, and folders autocomplete. In the launcher, **Run on** (next to the harness) picks where the prompt goes: this computer, any machine, or several at once. Each place shows its own project chips; Enter launches on all of them in parallel, and the runs land as one task with a row per machine. Each run executes on its machine, with worktrees, diffs, rollback, `ask_user`, subagents, compare and Ship it all working on that machine. Its sessions join your feed with a badge, answer from your phone like any other, open in VS Code, Cursor or Windsurf through Remote - SSH, and **Take over** opens `ssh -t <host>` resuming the agent in your terminal.
342
342
 
343
- Nothing new listens on the network: wisp on the machine stays on its own `127.0.0.1`, and it keeps running (agents too) when this computer sleeps or disconnects; wisp reconnects on its own. **Stop wisp there** (the machine's ⋯ menu) stops it. The machine needs Node.js 20+ and the harness CLIs, signed in. Sessions there are `<id>@<machine>` in the API, CLI, SDK and MCP; recipes and schedules work with machine projects, while CI and issue triggers, usage and memory stay per machine.
343
+ Nothing new listens on the network: wisp on the machine stays on its own `127.0.0.1`, and it keeps running (agents too) when this computer sleeps or disconnects; wisp reconnects on its own. **Stop wisp there** (the machine's ⋯ menu) stops it. When the machine runs an older wisp than this computer, **Update to X** next to its version restarts it there as this version. The machine needs Node.js 20+ and the harness CLIs, signed in. Sessions there are `<id>@<machine>` in the API, CLI, SDK and MCP; recipes and schedules work with machine projects, while CI and issue triggers, usage and memory stay per machine.
344
344
 
345
345
  ## Learning
346
346
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Launch coding agents, skip the transcript, read the presentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,7 +35,10 @@ export const adapters = Object.fromEntries([claude, codex, opencode, cursor, ant
35
35
 
36
36
  const detected = new Map();
37
37
 
38
- export async function listAgents() {
38
+ // fresh: detect again even where a hit is cached (Settings → Agents → Re-check), so a newly
39
+ // signed-in provider's models or an upgraded CLI show up without restarting wisp.
40
+ export async function listAgents({ fresh = false } = {}) {
41
+ if (fresh) detected.clear();
39
42
  return Promise.all(
40
43
  Object.values(adapters).map(async (a) => {
41
44
  if (!detected.has(a.id)) detected.set(a.id, a.detect());
package/server/index.js CHANGED
@@ -429,7 +429,12 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
429
429
  }
430
430
  if (root === 'openapi.json' && m === 'GET') return send(res, 200, openapi({ version: VERSION, port }));
431
431
 
432
- if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents());
432
+ if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents({ fresh: url.searchParams.has('fresh') }));
433
+ // Re-check: detect harnesses and models again, here and on every online machine.
434
+ if (root === 'agents' && id === 'recheck' && m === 'POST') {
435
+ const [agents] = await Promise.all([listAgents({ fresh: true }), machines.recheckAgents()]);
436
+ return send(res, 200, agents);
437
+ }
433
438
 
434
439
  // Bridge for the ask_user MCP server running inside an agent process.
435
440
  if (root === 'internal') {
@@ -20,6 +20,7 @@ import path from 'node:path';
20
20
  import readline from 'node:readline';
21
21
  import { randomBytes } from 'node:crypto';
22
22
  import { spawn, execFile, killTree } from './proc.js';
23
+ import { newer } from './update.js';
23
24
 
24
25
  export const DEFAULT_PORT = 4477;
25
26
  const READY_TIMEOUT_MS = 6 * 60_000; // the first `npx` download can take a while
@@ -45,6 +46,10 @@ export function splitId(id) {
45
46
  return i > 0 ? { id: id.slice(0, i), machine: id.slice(i + 1) } : null;
46
47
  }
47
48
 
49
+ // The version an older wisp on the machine would become on Restart wisp there: this one, unless a
50
+ // custom start command decides what runs there.
51
+ export const updateTo = (local, remote, command) => (!command && remote && newer(local, remote) ? local : null);
52
+
48
53
  // What runs on the machine. Sent on stdin, so the login shell there (bash, zsh, fish…) only ever
49
54
  // sees `sh -s`. Steps come back as `@@wisp step <id> <run|done> <detail>`, `@@wisp fail <id>
50
55
  // <message>`, and finally `@@wisp ready <version> <pid>`; then `cat` holds the session (and the
@@ -151,6 +156,9 @@ export function sshHosts(file = path.join(os.homedir(), '.ssh', 'config'), seen
151
156
  return [...uniq.values()];
152
157
  }
153
158
 
159
+ // What the UI needs of a machine's harness list.
160
+ const remoteAgent = ({ id, name, available, version, models, efforts, delegates }) => ({ id, name, available, version, models, efforts, delegates });
161
+
154
162
  // Code hosts you push to, not computers to run agents on.
155
163
  const FORGE = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org|ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com|heroku\.com|codeberg\.org|sr\.ht)$/i;
156
164
 
@@ -332,6 +340,7 @@ export class Machines extends EventEmitter {
332
340
  attempt: c?.attempt || 0,
333
341
  since: c?.since || null,
334
342
  remote: c?.remote || null,
343
+ outdated: updateTo(this.version, c?.remote?.version, m.command),
335
344
  log: c?.log.slice(-30) || [],
336
345
  projects: this.store.config.projects.filter((p) => p.machine === m.id).length,
337
346
  working: cached.filter((s) => s.state === 'running').length,
@@ -431,7 +440,7 @@ export class Machines extends EventEmitter {
431
440
  this.#step(c, 'tunnel', 'done', `localhost:${c.localPort} → ${m.port}`);
432
441
  const [agents, config] = await Promise.all([this.#raw(m, c, 'GET', '/agents').catch(() => []), this.#raw(m, c, 'GET', '/config').catch(() => ({}))]);
433
442
  const ready = agents.filter((a) => a.available);
434
- c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(({ id, name, available, version }) => ({ id, name, available, version })) };
443
+ c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(remoteAgent) };
435
444
  this.#step(c, 'agents', ready.length ? 'done' : 'warn', ready.length ? ready.map((a) => a.name).join(', ') : 'None found. Install a harness CLI there (claude, codex, opencode…) and sign in.');
436
445
  clearTimeout(c.readyTimer);
437
446
  Object.assign(c, { state: 'online', error: '', hint: null, attempt: 0, wasOnline: true, since: Date.now() });
@@ -441,6 +450,20 @@ export class Machines extends EventEmitter {
441
450
  this.emit('online', m);
442
451
  }
443
452
 
453
+ // Detect harnesses and models again on every online machine (after installing or signing in
454
+ // to something there). A machine that doesn't answer keeps what it had.
455
+ async recheckAgents() {
456
+ const online = this.list.map((m) => [m, this.conns.get(m.id)]).filter(([, c]) => c?.state === 'online');
457
+ await Promise.all(
458
+ online.map(async ([m, c]) => {
459
+ const agents = await this.#raw(m, c, 'GET', '/agents?fresh=1').catch(() => null);
460
+ if (!Array.isArray(agents) || this.conns.get(m.id) !== c) return;
461
+ c.remote = { ...c.remote, agents: agents.map(remoteAgent) };
462
+ }),
463
+ );
464
+ if (online.length) this.onChange();
465
+ }
466
+
444
467
  #fail(c, msg, hint, { keep = false } = {}) {
445
468
  if (this.conns.get(c.id) !== c) return;
446
469
  if (c.error && !keep) return;
package/server/openapi.js CHANGED
@@ -44,7 +44,8 @@ export function openapi({ version, port }) {
44
44
  responses: { 200: json({ type: 'object' }), 400: err[400] },
45
45
  },
46
46
  },
47
- '/agents': { get: { summary: 'Installed harnesses with their models and thinking levels', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
47
+ '/agents': { get: { summary: 'Installed harnesses with their models and thinking levels (?fresh=1 detects again)', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
48
+ '/agents/recheck': { post: { summary: 'Detect harnesses and models again, here and on every online machine', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
48
49
  '/config': {
49
50
  get: { summary: 'Launcher defaults, projects, webhooks', responses: { 200: json({ type: 'object' }) } },
50
51
  patch: {
Binary file
package/web/css/app.css CHANGED
@@ -408,6 +408,7 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
408
408
  .pk-text b { font-weight: 500; font-size: 13.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
409
409
  .pk-text small { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
410
410
  .pk-text small:empty { display: none; }
411
+ .pk-text small.warn { color: var(--warn); }
411
412
  .pk-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
412
413
  .pk-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
413
414
  .pk-search { flex: none; width: auto; margin: 8px 8px 2px; color: var(--faint); }
@@ -433,13 +434,13 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
433
434
  }
434
435
 
435
436
  /* ---------- feed ---------- */
436
- .feed-head { display: flex; align-items: center; gap: 14px; min-height: 22px; margin: 26px 2px 10px; font-size: 13px; }
437
- .fh-status { display: flex; gap: 14px; }
437
+ .feed-head { display: flex; align-items: center; gap: 14px; min-height: 22px; margin: 26px 0 10px; padding: 0 4px; font-size: 13px; }
438
438
  .feed-head[hidden] + .feed { margin-top: 26px; }
439
439
  .hs { display: inline-flex; align-items: center; gap: 7px; color: var(--ink-2); }
440
440
  .hs i { width: 7px; height: 7px; border-radius: 50%; }
441
441
  .hs.working i { background: var(--accent); animation: pulse 1.6s infinite; }
442
442
  .hs.needs i { background: var(--warn); }
443
+ .hs.calm i { background: var(--good); }
443
444
 
444
445
  .f-group { margin-bottom: 22px; }
445
446
  .f-group h4 {
@@ -461,6 +462,11 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
461
462
  .toggle-done:hover { color: var(--ink-2); }
462
463
  .toggle-done .chev { width: 12px; height: 12px; transition: transform 0.2s; }
463
464
  .f-group.done:not(.collapsed) .chev { transform: rotate(90deg); }
465
+ /* Folded groups are just a row of headings: stack them tight instead of leaving a section gap each. */
466
+ .f-group.collapsed { margin-bottom: 2px; }
467
+ .f-group.collapsed h4 { margin: 0; }
468
+ .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; }
469
+ .f-group h4.toggle-done:hover { background: var(--bg-2); }
464
470
  .f-list { display: flex; flex-direction: column; gap: 6px; }
465
471
  .feed-empty { padding: 48px 0; text-align: center; color: var(--faint); font-size: 13.5px; }
466
472
 
@@ -473,6 +479,10 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
473
479
  .item.focused { border-color: var(--line-2); box-shadow: 0 0 0 1px var(--line-2); }
474
480
  .item.open { border-color: var(--line-2); box-shadow: var(--shadow-2); }
475
481
  .item.st-done:not(.open) { background: transparent; }
482
+ /* a launch the server hasn't confirmed yet: shown at once, can't be opened */
483
+ .item.pending { opacity: 0.75; }
484
+ .item.pending .it-head { cursor: default; }
485
+ .item.pending .chev { visibility: hidden; }
476
486
  .item.arrived { animation: arrive 1.4s var(--ease); }
477
487
  @keyframes arrive { from { box-shadow: 0 0 0 3px var(--accent-soft); border-color: var(--accent); } }
478
488
 
@@ -1195,6 +1205,8 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1195
1205
  .sx-ctl .input { width: 300px; }
1196
1206
  .sx-state { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
1197
1207
  .sx-state.bad { color: var(--bad); }
1208
+ #recheck.busy { cursor: progress; }
1209
+ #recheck.busy .i { animation: spin 0.8s linear infinite reverse; }
1198
1210
  .sx-usage .it-loading { grid-column: 1 / -1; }
1199
1211
  /* a view (Memory, Automations, Usage, Phone) opened inside Settings: no dialog chrome of its own */
1200
1212
  .modal.embedded { width: auto; max-height: none; overflow: visible; padding: 0; border: 0; border-radius: 0; box-shadow: none; background: none; transform: none; }
@@ -1423,6 +1435,7 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1423
1435
  .mc-meta, .mc-steps, .mc-hint, .mc-projects, .mc-edit { margin-left: 42px; }
1424
1436
  .mc-meta { display: flex; align-items: center; gap: 12px; margin-top: 8px; font-size: 12px; color: var(--muted); }
1425
1437
  .mc-meta .bad { color: var(--bad); }
1438
+ .mc-meta .mc-update { margin-left: -6px; color: var(--accent); }
1426
1439
  .mc-agents { display: inline-flex; gap: 7px; }
1427
1440
  .mc-steps { list-style: none; display: flex; flex-direction: column; gap: 5px; margin-top: 10px; margin-bottom: 0; padding: 0; }
1428
1441
  .mc-steps li { display: grid; grid-template-columns: 16px auto 1fr; align-items: center; column-gap: 8px; font-size: 12.5px; color: var(--faint); }
@@ -1470,6 +1483,12 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1470
1483
  .mc-adv summary { cursor: pointer; width: max-content; font-size: 12.5px; color: var(--muted); }
1471
1484
  .mc-adv[open] summary { margin-bottom: 8px; }
1472
1485
  .mc-check { margin-top: 8px; }
1486
+ /* Hosted inside Settings: undo its row styling for labels (centred, padded, bordered, input flexed to 0 tall). */
1487
+ .mc-form label { padding: 0; border-bottom: 0; }
1488
+ .mc-form .mc-field { align-items: stretch; justify-content: flex-start; gap: 5px; }
1489
+ .mc-form .mc-field > span { text-align: left; }
1490
+ .mc-form .mc-field > .input { flex: none; max-width: none; }
1491
+ .mc-form .mc-check { display: inline-flex; justify-content: flex-start; gap: 8px; width: max-content; }
1473
1492
  .mc-foot { display: flex; align-items: center; gap: 10px; }
1474
1493
  .mc-foot .hint { flex: 1; margin: 0 !important; font-size: 12px !important; }
1475
1494
  .mc-foot .primary { flex: none; }
package/web/favicon.svg CHANGED
@@ -1,5 +1,13 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
- <circle cx="28" cy="30" r="20" fill="#171717"/>
3
- <circle cx="47" cy="45" r="11" fill="#ffffff"/>
4
- <circle cx="47" cy="45" r="8" fill="#2f5bea"/>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1d1c22"/><stop offset="1" stop-color="#0e0e10"/></linearGradient>
4
+ <radialGradient id="glow" cx="50%" cy="50%" r="50%"><stop offset="0" stop-color="#6a5cff" stop-opacity=".55"/><stop offset="1" stop-color="#6a5cff" stop-opacity="0"/></radialGradient>
5
+ <radialGradient id="orb" cx="36%" cy="32%" r="68%"><stop offset="0" stop-color="#fff"/><stop offset=".16" stop-color="#d9fff6"/><stop offset=".38" stop-color="#2fd0b4"/><stop offset=".78" stop-color="#6a5cff"/><stop offset="1" stop-color="#4b3fe0"/></radialGradient>
6
+ </defs>
7
+ <rect x="100" y="100" width="824" height="824" rx="190" fill="url(#bg)"/>
8
+ <circle cx="512" cy="512" r="330" fill="url(#glow)"/>
9
+ <circle cx="512" cy="512" r="210" fill="url(#orb)"/>
10
+ <circle cx="742" cy="360" r="16" fill="#fff" opacity=".9"/>
11
+ <circle cx="300" cy="650" r="10" fill="#bff" opacity=".7"/>
12
+ <circle cx="690" cy="720" r="7" fill="#fff" opacity=".6"/>
5
13
  </svg>
package/web/index.html CHANGED
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>wisp</title>
7
7
  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
8
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
8
9
  <link rel="stylesheet" href="/css/app.css">
9
10
  </head>
10
11
  <body>
package/web/js/app.js CHANGED
@@ -64,6 +64,28 @@ const MODES = [
64
64
  const KIND_ICON = { read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask' };
65
65
  const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
66
66
  const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'wisp' ? 'wisp' : id);
67
+ // Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
68
+ const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
69
+ // A harness's models and levels: this computer's if it's installed here, else the first machine's that has it.
70
+ function agentById(id) {
71
+ const local = S.agents.find((a) => a.id === id);
72
+ if (local?.available) return local;
73
+ return S.machines.map((m) => agentsAt(m.id)?.find((a) => a.id === id && a.available)).find(Boolean) || local;
74
+ }
75
+ // The harnesses you can pick for the places under "Run on": installed on any of them counts (a union).
76
+ // `missing` names the picked places that don't have it. Machines not online yet don't count.
77
+ function harnessesAt(places) {
78
+ const known = places.map((k) => [k, agentsAt(k)]).filter(([, l]) => l);
79
+ if (!known.length) known.push(['', S.agents]);
80
+ const ids = [...new Set([...S.agents, ...known.flatMap(([, l]) => l)].map((a) => a.id))];
81
+ return ids
82
+ .map((id) => {
83
+ const hits = known.filter(([, l]) => l.some((a) => a.id === id && a.available));
84
+ const base = hits.length ? hits[0][1].find((a) => a.id === id) : S.agents.find((a) => a.id === id) || known.flatMap(([, l]) => l).find((a) => a.id === id);
85
+ return { ...base, available: hits.length > 0, missing: hits.length ? known.filter((x) => !hits.includes(x)).map(([k]) => k) : [] };
86
+ })
87
+ .filter((a) => a.available || S.agents.some((x) => x.id === a.id));
88
+ }
67
89
 
68
90
  // Harness logos for the request bubble, vendored from @lobehub/icons into web/logos/.
69
91
  // `mono` marks are drawn in the text colour so they work in both themes.
@@ -83,14 +105,14 @@ function harnessLogo(agent) {
83
105
  // Reasoning levels the harness accepts for a model: the model's own list (opencode variants),
84
106
  // else the harness-wide one. Empty means the harness has no knob for it.
85
107
  function effortsFor(agent, model) {
86
- const a = S.agents.find((x) => x.id === agent);
108
+ const a = agentById(agent);
87
109
  return a?.models?.find((m) => m.id === model)?.efforts ?? a?.efforts ?? [];
88
110
  }
89
111
  const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
90
112
 
91
113
  function modelBadge(agent, model, effort) {
92
114
  if (agent === 'wisp') return '';
93
- const label = (model && S.agents.find((a) => a.id === agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
115
+ const label = (model && agentById(agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
94
116
  const tip = `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
95
117
  const logo = harnessLogo(agent);
96
118
  return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
@@ -247,7 +269,7 @@ function renderFeedHead(needs, working) {
247
269
  if (working.length) parts.push(`<span class="hs working"><i></i>${working.length} working</span>`);
248
270
  if (needs.length) parts.push(`<span class="hs needs"><i></i>${needs.length} need${needs.length === 1 ? 's' : ''} you</span>`);
249
271
  // Group headings already carry the counts; only say something when the feed is calm.
250
- $('#feedHead').innerHTML = parts.length ? '' : '<span class="fh-status"><span class="muted">Nothing needs you.</span></span>';
272
+ $('#feedHead').innerHTML = parts.length ? '' : '<span class="hs calm"><i></i>Nothing needs you</span>';
251
273
  $('#feedHead').hidden = !!parts.length;
252
274
  placeCollapseAll();
253
275
  }
@@ -294,14 +316,17 @@ function item(s) {
294
316
  n = { card, head: $('.it-head', card), body: null, turnNodes: new Map(), tray: null, showAll: false, unfolded: new Set(), latestTurn: null };
295
317
  S.nodes.set(s.id, n);
296
318
  n.head.addEventListener('click', (e) => {
297
- if (n.head.querySelector('[contenteditable="true"]')) return;
319
+ if (s.pending || n.head.querySelector('[contenteditable="true"]')) return;
298
320
  if (e.target.closest('[data-a="quick-done"]')) return toggleDone(s.id);
299
321
  setFocus(s.id);
300
322
  toggle(s.id);
301
323
  });
302
- card.addEventListener('mousedown', () => setFocus(s.id, { scroll: false }));
324
+ card.addEventListener('mousedown', () => {
325
+ if (!s.pending) setFocus(s.id, { scroll: false });
326
+ });
303
327
  n.head.addEventListener('contextmenu', (e) => {
304
328
  e.preventDefault();
329
+ if (s.pending) return;
305
330
  setFocus(s.id, { scroll: false });
306
331
  taskMenu(s.id, e);
307
332
  });
@@ -322,7 +347,7 @@ function renderHead(n, s) {
322
347
  ? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
323
348
  : '';
324
349
  if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
325
- n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
350
+ n.card.className = `item ${s.pending ? 'pending' : ''} c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
326
351
  n.head.innerHTML = `
327
352
  <span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
328
353
  <span class="it-main">
@@ -528,6 +553,17 @@ function attachTray() {
528
553
  items.length = 0;
529
554
  render();
530
555
  },
556
+ // Empty the tray for an optimistic send, and put the files back if the send fails.
557
+ take() {
558
+ const list = items.splice(0);
559
+ render();
560
+ return list;
561
+ },
562
+ restore(list) {
563
+ if (!list?.length) return;
564
+ items.unshift(...list);
565
+ render();
566
+ },
531
567
  get busy() {
532
568
  return items.some((i) => i.status === 'uploading');
533
569
  },
@@ -606,7 +642,7 @@ function syncWhere() {
606
642
  function savedModel(id) {
607
643
  const d = S.config.defaults;
608
644
  const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
609
- const models = S.agents.find((a) => a.id === id)?.models || [];
645
+ const models = agentById(id)?.models || [];
610
646
  return models.some((m) => m.id === want) ? want : '';
611
647
  }
612
648
  // Thinking level too, and only if the chosen model still accepts it.
@@ -619,21 +655,21 @@ function savedEffort(id, model) {
619
655
  function renderLauncher() {
620
656
  const box = $('#launcher');
621
657
  const mode = S.config.defaults.mode || 'edits';
622
- const ready = S.agents.filter((a) => a.available);
623
- const chosen = S.agents.find((a) => a.id === (S.config.defaults.agent || 'claude'));
624
- let agent = chosen?.available ? chosen : ready[0] || chosen || S.agents[0];
658
+ const here = harnessesAt(launchWhere);
659
+ const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'claude'));
660
+ let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
625
661
  box.innerHTML = `
626
662
  <div class="l-projects"></div>
627
663
  <textarea id="prompt" rows="2" spellcheck="false"></textarea>
628
664
  <div class="l-bar">
629
665
  <button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
666
+ <button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
630
667
  <button class="mode-btn" id="mode" aria-haspopup="menu"></button>
631
668
  <div class="runners">
632
669
  <button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
633
670
  <button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
634
671
  <button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
635
672
  </div>
636
- <button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
637
673
  <button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
638
674
  <span class="grow"></span>
639
675
  <button class="tog-btn" id="wtBtn"></button>
@@ -719,7 +755,7 @@ function renderLauncher() {
719
755
  const paintTeam = () => paintTeamBtn(teamBtn, team, teamPreset, agent?.id);
720
756
  paintTeam();
721
757
  teamBtn.onclick = () =>
722
- teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset }, (members, preset) => {
758
+ teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset, places: launchWhere }, (members, preset) => {
723
759
  if (JSON.stringify([members, preset]) === JSON.stringify([team, teamPreset])) return;
724
760
  team = members;
725
761
  teamPreset = preset;
@@ -743,19 +779,19 @@ function renderLauncher() {
743
779
  vsBtn.onclick = () => {
744
780
  if (compare) compare = null;
745
781
  else {
746
- const other = S.agents.find((a) => a.available && a.id !== agent?.id) || agent;
782
+ const other = harnessesAt(launchWhere).find((a) => a.available && a.id !== agent?.id) || agent;
747
783
  compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
748
784
  }
749
785
  paintCompare();
750
786
  };
751
787
  runner2.onclick = () =>
752
- runnerPicker(runner2, { ...compare, savedModel, savedEffort }, (id, model, effort) => {
788
+ runnerPicker(runner2, { ...compare, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
753
789
  compare = { agent: id, model, effort };
754
790
  paintCompare();
755
791
  });
756
792
  runnerBtn.onclick = () =>
757
- runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
758
- agent = S.agents.find((a) => a.id === id);
793
+ runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
794
+ agent = harnessesAt(launchWhere).find((a) => a.id === id);
759
795
  curModel = model;
760
796
  curEffort = effort;
761
797
  paintRunner();
@@ -779,21 +815,39 @@ function renderLauncher() {
779
815
  if (off) return offlineToast(off.k);
780
816
  const wanted = [agent?.id, ...(compare ? [compare.agent] : [])];
781
817
  for (const t of targets) {
782
- const missing = t.m?.remote?.agents && wanted.find((id) => !t.m.remote.agents.some((x) => x.id === id && x.available));
783
- if (missing) return toast(`${esc(agentName(missing))} isn’t installed on <b>${esc(t.m.name)}</b>`, { tone: 'bad' });
818
+ const list = agentsAt(t.k);
819
+ const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
820
+ if (missing) return toast(`${esc(agentName(missing))} isn’t installed on <b>${esc(placeName(t.k))}</b>`, { tone: 'bad' });
784
821
  }
785
822
  const btn = $('#launch', box);
786
- btn.disabled = true;
787
- btn.classList.add('busy'); // a fetch and fresh worktrees can take a second or two
823
+ // Optimistic: wait only for uploads, then empty the box and show the task in Working at
824
+ // once. Fetching and making worktrees can take a few seconds; the real card replaces the
825
+ // placeholder when the server has it. On failure the prompt and files come back.
826
+ const attachments = await tray.ids();
827
+ const files = tray.take();
828
+ const cmp = compare;
829
+ const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
830
+ const pending = targets.map((t) => addPending(prompt, t, runner));
831
+ if (compare) (compare = null), paintCompare();
832
+ ta.value = '';
833
+ S.drafts.delete('home');
834
+ ta.dispatchEvent(new Event('input'));
835
+ if (S.filter !== 'all' && targets.some((t) => t.project.id !== S.filter)) {
836
+ S.filter = 'all';
837
+ save('wisp.filter', S.filter);
838
+ }
839
+ renderFeed();
840
+ const ghost = S.nodes.get(pending[0])?.card;
841
+ showLaunched(btn, ghost);
842
+ ta.focus();
788
843
  try {
789
- const attachments = await tray.ids();
790
844
  // Several places: one shared groupId, so the runs land as one task with a row each.
791
845
  const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
792
846
  const results = await Promise.allSettled(
793
847
  targets.map((t) =>
794
848
  api('/sessions', {
795
849
  method: 'POST',
796
- body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments, worktree: S.config.defaults.worktree === 'off' && !compare ? false : undefined, compare: compare ? [compare] : undefined, subagents: team.length && canLead(agent?.id) ? team : undefined },
850
+ body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp ? false : undefined, compare: cmp ? [cmp] : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
797
851
  }),
798
852
  ),
799
853
  );
@@ -801,31 +855,36 @@ function renderLauncher() {
801
855
  if (r.status === 'rejected') toast(`${esc(placeName(targets[i].k))}: ${esc(r.reason.message)}`, { tone: 'bad' });
802
856
  });
803
857
  const started = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
804
- if (!started.length) return;
858
+ for (const id of pending) S.sessions.delete(id);
805
859
  for (const x of started.flatMap((y) => y.group || [y])) S.sessions.set(x.id, x);
806
- const s = started[0];
807
- if (compare) (compare = null), paintCompare();
808
- ta.value = '';
809
- S.drafts.delete('home');
810
- tray.clear();
811
- ta.dispatchEvent(new Event('input'));
812
- if (S.filter !== 'all' && (started.length > 1 || S.filter !== s.projectId)) {
813
- S.filter = 'all';
814
- save('wisp.filter', S.filter);
860
+ keepInPlace(feedAnchor(), renderFeed);
861
+ if (!started.length) {
862
+ if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
863
+ tray.restore(files);
864
+ if (cmp && !compare) (compare = cmp), paintCompare();
865
+ return;
815
866
  }
816
- renderFeed();
817
- const card = S.nodes.get(s.id)?.card;
818
- showLaunched(btn, card?.closest('.task-item') || card);
819
867
  askNotify();
820
868
  } catch (err) {
869
+ for (const id of pending) S.sessions.delete(id);
870
+ renderFeed();
821
871
  toast(esc(err.message), { tone: 'bad' });
822
- } finally {
823
- btn.disabled = false;
824
- btn.classList.remove('busy');
825
- ta.focus();
826
872
  }
827
873
  };
828
874
  $('#launch', box).onclick = launch;
875
+ // Picking other places (or a machine coming online) changes which harnesses exist: keep the
876
+ // pick if one of them still has it, else move to one that's installed there.
877
+ box.placesChanged = () => {
878
+ const here = harnessesAt(launchWhere);
879
+ if (!agent || here.find((a) => a.id === agent.id)?.available) return paintRunner();
880
+ const next = here.find((a) => a.id === S.config.defaults.agent && a.available) || here.find((a) => a.available);
881
+ if (!next) return paintRunner();
882
+ agent = next;
883
+ curModel = savedModel(agent.id);
884
+ curEffort = savedEffort(agent.id, curModel);
885
+ paintRunner();
886
+ paintTeam();
887
+ };
829
888
  ta.addEventListener('keydown', (e) => {
830
889
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
831
890
  e.preventDefault();
@@ -840,7 +899,7 @@ function renderLauncher() {
840
899
 
841
900
  // The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
842
901
  function paintRunnerBtn(btn, agentId, model, effort) {
843
- const a = S.agents.find((x) => x.id === agentId);
902
+ const a = agentById(agentId);
844
903
  const m = (a?.models || []).find((x) => x.id === model);
845
904
  btn.innerHTML = agentId
846
905
  ? `<span class="rb-logo">${harnessLogo(agentId)}</span><span class="rb-h">${esc(a?.name || agentName(agentId))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`
@@ -856,9 +915,9 @@ const MAX_COPIES = 5; // parallel copies of one row
856
915
  const MAX_INSTANCES = 12; // copies across the team
857
916
  const copiesOf = (m) => Math.max(1, m.count || 1);
858
917
  const teamSize = (team) => team.reduce((n, m) => n + copiesOf(m), 0);
859
- const canLead = (id) => S.agents.find((a) => a.id === id)?.delegates !== false;
918
+ const canLead = (id) => agentById(id)?.delegates !== false;
860
919
  const memberLabel = (m) => {
861
- const a = S.agents.find((x) => x.id === m.agent);
920
+ const a = agentById(m.agent);
862
921
  const model = (m.model && a?.models?.find((x) => x.id === m.model)?.label) || m.model || 'Default model';
863
922
  return `${a?.name || agentName(m.agent)} · ${model}${m.effort ? ` · ${effortLabel(m.effort)}` : ''}`;
864
923
  };
@@ -905,7 +964,7 @@ function paintTeamBtn(btn, team, presetId, lead) {
905
964
  : `${agentName(lead)} can't delegate (wisp can't give it tools), so this launch runs without subagents`;
906
965
  }
907
966
 
908
- function teamEditor(anchor, { lead, team, presetId }, onChange) {
967
+ function teamEditor(anchor, { lead, team, presetId, places }, onChange) {
909
968
  const open = $('.team-pop');
910
969
  if (open) return open.close();
911
970
  let members = team.map((m) => ({ ...m }));
@@ -969,7 +1028,8 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
969
1028
  const add = () => {
970
1029
  if (members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES) return;
971
1030
  const used = new Set([lead, ...members.map((m) => m.agent)]);
972
- const a = S.agents.find((x) => x.available && !used.has(x.id)) || S.agents.find((x) => x.available && x.id !== lead) || S.agents.find((x) => x.available);
1031
+ const pool = places ? harnessesAt(places) : S.agents;
1032
+ const a = pool.find((x) => x.available && !used.has(x.id)) || pool.find((x) => x.available && x.id !== lead) || pool.find((x) => x.available);
973
1033
  if (!a) return toast('No harness is installed.', { tone: 'bad' });
974
1034
  const model = savedModel(a.id);
975
1035
  members.push({ name: '', agent: a.id, model, effort: savedEffort(a.id, model), use: '', count: 1 });
@@ -1072,7 +1132,7 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
1072
1132
  const rb = e.target.closest('.tp-runner');
1073
1133
  if (rb) {
1074
1134
  const m = members[i];
1075
- runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort }, (agent, model, effort) => {
1135
+ runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort, places }, (agent, model, effort) => {
1076
1136
  Object.assign(m, { agent, model, effort });
1077
1137
  paintList();
1078
1138
  commit();
@@ -1349,6 +1409,7 @@ function whereMenu(anchor) {
1349
1409
  // A machine picked while disconnected starts connecting; you asked to run there.
1350
1410
  for (const k of launchWhere) if (k && machineById(k)?.state === 'off') connectMachine(k);
1351
1411
  renderLaunchChips();
1412
+ $('#launcher')?.placesChanged?.();
1352
1413
  paint();
1353
1414
  saveWhere();
1354
1415
  };
@@ -1467,6 +1528,65 @@ function launchedFx(from, to) {
1467
1528
  ).onfinish = () => w.remove();
1468
1529
  }
1469
1530
 
1531
+ // A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
1532
+ // "Starting up" card. It can't be opened; the real card takes its place.
1533
+ let pendingSeq = 0;
1534
+ function addPending(prompt, { project: p, m }, { agent, model, effort }) {
1535
+ const id = `pending-${++pendingSeq}`;
1536
+ const line = prompt.split('\n')[0].replace(/\s+/g, ' ');
1537
+ const now = Date.now();
1538
+ S.sessions.set(id, {
1539
+ id,
1540
+ pending: true,
1541
+ title: line.length > 64 ? `${line.slice(0, 63)}…` : line,
1542
+ agent,
1543
+ model,
1544
+ effort,
1545
+ cwd: p.path,
1546
+ project: p.name,
1547
+ projectId: p.id,
1548
+ color: p.color || 'slate',
1549
+ state: 'running',
1550
+ live: 'Starting up',
1551
+ createdAt: now,
1552
+ updatedAt: now,
1553
+ machine: m ? { id: m.id, name: m.name, target: m.target } : null,
1554
+ });
1555
+ return id;
1556
+ }
1557
+
1558
+ // A launch from a dialog (rerun, recipes): the dialog closes and a placeholder shows in Working
1559
+ // at once; the real session replaces it, or it disappears and the error is thrown on.
1560
+ async function launchBehind({ prompt, cwd, runner }, request) {
1561
+ const sum = S.config.projects.find((p) => p.path === cwd);
1562
+ const project = sum || { path: cwd, name: cwd.split('/').filter(Boolean).pop() || cwd, id: null, color: 'slate' };
1563
+ const ghost = addPending(prompt, { project, m: project.machine ? machineById(project.machine) : null }, runner);
1564
+ if (S.filter !== 'all' && S.filter !== project.id) {
1565
+ S.filter = 'all';
1566
+ save('wisp.filter', S.filter);
1567
+ }
1568
+ renderFeed();
1569
+ const card = S.nodes.get(ghost)?.card;
1570
+ if (card) (card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), card.classList.add('arrived'), setTimeout(() => card.classList.remove('arrived'), 1600));
1571
+ try {
1572
+ const s = await request();
1573
+ S.sessions.delete(ghost);
1574
+ for (const x of s.group || [s]) S.sessions.set(x.id, x);
1575
+ keepInPlace(feedAnchor(), renderFeed);
1576
+ return s;
1577
+ } catch (err) {
1578
+ S.sessions.delete(ghost);
1579
+ renderFeed();
1580
+ throw err;
1581
+ }
1582
+ }
1583
+
1584
+ // The real session showed up (live events can beat the launch response): drop its placeholder.
1585
+ function dropPending(s) {
1586
+ if (s.pending || s.parentId) return;
1587
+ for (const x of S.sessions.values()) if (x.pending && x.projectId === s.projectId) return S.sessions.delete(x.id);
1588
+ }
1589
+
1470
1590
  // A new card lands in Working, below everything in "Needs you", so it's often off-screen. Scroll
1471
1591
  // it into view (just enough) and flash it; the flying wisp only plays when the card is already
1472
1592
  // on screen, since the launcher it starts from may scroll away or fold into the dock.
@@ -1807,8 +1927,18 @@ function renderWorking(s, t) {
1807
1927
  .filter((a) => a.kind !== 'text')
1808
1928
  .slice(-5)
1809
1929
  .forEach((a) => ticker.prepend(actItem(a)));
1810
- $('.stop', node).onclick = async () => {
1811
- await api(`/sessions/${s.id}/stop`, { method: 'POST', body: {} }).catch((e) => toast(esc(e.message), { tone: 'bad' }));
1930
+ $('.stop', node).onclick = async (e) => {
1931
+ // Shows at once; the run winds down and the live update replaces this panel.
1932
+ const btn = e.currentTarget;
1933
+ btn.disabled = true;
1934
+ btn.lastChild.textContent = 'Stopping…';
1935
+ $('.w-status b', node).textContent = `Stopping ${agentName(s.agent)}…`;
1936
+ await api(`/sessions/${s.id}/stop`, { method: 'POST', body: {} }).catch((err) => {
1937
+ btn.disabled = false;
1938
+ btn.lastChild.textContent = 'Stop';
1939
+ $('.w-status b', node).textContent = `${agentName(s.agent)} is working`;
1940
+ toast(esc(err.message), { tone: 'bad' });
1941
+ });
1812
1942
  };
1813
1943
  $('.peek', node).onclick = (e) => {
1814
1944
  const log = $('.log', node);
@@ -1836,7 +1966,15 @@ function renderAskCard(s) {
1836
1966
  const answer = async (text) => {
1837
1967
  if (!text.trim()) return;
1838
1968
  node.classList.add('sent');
1839
- await api(`/sessions/${s.id}/answer`, { method: 'POST', body: { text } }).catch((e) => toast(esc(e.message), { tone: 'bad' }));
1969
+ const undo = startOptimistic(s.id, text, null, { answering: true });
1970
+ try {
1971
+ await api(`/sessions/${s.id}/answer`, { method: 'POST', body: { text } });
1972
+ if (undo) scheduleRefetch(s.id);
1973
+ } catch (e) {
1974
+ node.classList.remove('sent');
1975
+ undo?.();
1976
+ toast(esc(e.message), { tone: 'bad' });
1977
+ }
1840
1978
  };
1841
1979
  node.addEventListener('click', (e) => {
1842
1980
  const b = e.target.closest('.opt');
@@ -2063,21 +2201,58 @@ async function sendReply(id, text) {
2063
2201
  if (!s || !text) return;
2064
2202
  if (n?.tray?.busy) toast('Waiting for uploads…');
2065
2203
  const attachments = n?.tray ? await n.tray.ids() : [];
2204
+ const answering = !!s.ask && !attachments.length;
2205
+ // Optimistic: the composer empties and the card goes to Working before the server answers
2206
+ // (it may first have to bring back an archived worktree, or reach another machine).
2207
+ const ta = n && $('.reply', n.card);
2208
+ if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
2209
+ S.drafts.delete(id);
2210
+ const files = n?.tray?.take();
2211
+ const undo = startOptimistic(id, text, files, { answering });
2066
2212
  try {
2067
- const r = s.ask && !attachments.length
2213
+ const r = answering
2068
2214
  ? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
2069
2215
  : await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing() } });
2070
- const ta = n && $('.reply', n.card);
2071
- if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
2072
- S.drafts.delete(id);
2073
- n?.tray?.clear();
2216
+ if (undo) scheduleRefetch(id); // the summary that follows says "running" too, so it won't refetch by itself
2074
2217
  if (r.queued)
2075
2218
  toast(r.ahead ? `Queued behind ${r.ahead} earlier follow-up${r.ahead > 1 ? 's' : ''} — they go out first, in order.` : 'Queued — it goes out as soon as the agent finishes.');
2076
2219
  } catch (err) {
2220
+ undo?.();
2221
+ // Give the message back, unless the user has already started typing another one.
2222
+ if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
2223
+ n?.tray?.restore(files);
2077
2224
  toast(esc(err.message), { tone: 'bad' });
2078
2225
  }
2079
2226
  }
2080
2227
 
2228
+ // Show a follow-up as started right away: the card moves to Working with the prompt as a live
2229
+ // turn. Only when it will start now, not when it queues or interrupts a run already working.
2230
+ // Returns an undo for a failed send, or null when nothing changed.
2231
+ function startOptimistic(id, text, files, { answering }) {
2232
+ const full = S.full.get(id);
2233
+ const sum = S.sessions.get(id);
2234
+ if (!full || !sum) return null;
2235
+ const live = full.state === 'running' || full.state === 'waiting';
2236
+ if (live && !answering) return null;
2237
+ const now = Date.now();
2238
+ const turn = { id: `pending-${now}`, prompt: text, status: 'running', startedAt: now, activity: [], attachments: (files || []).filter((f) => f.id).map((f) => ({ id: f.id, name: f.name, mime: f.mime })) };
2239
+ const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn] };
2240
+ const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : 'Starting up', updatedAt: now };
2241
+ const apply = (f, sm) => {
2242
+ S.full.set(id, f);
2243
+ S.sessions.set(id, sm);
2244
+ const card = S.nodes.get(id)?.card;
2245
+ keepInPlace(watching(id) ? card : feedAnchor(id), renderFeed);
2246
+ renderBody(id, { fresh: false });
2247
+ };
2248
+ apply(nextFull, nextSum);
2249
+ return () => {
2250
+ // A live update may already have replaced the guess; only roll back what is still ours.
2251
+ if (S.full.get(id) !== nextFull || S.sessions.get(id) !== nextSum) return scheduleRefetch(id);
2252
+ apply(full, sum);
2253
+ };
2254
+ }
2255
+
2081
2256
  // --- item actions ------------------------------------------------------------------
2082
2257
 
2083
2258
  async function toggleDone(id) {
@@ -2085,23 +2260,48 @@ async function toggleDone(id) {
2085
2260
  if (!s) return;
2086
2261
  if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
2087
2262
  const next = s.state === 'done' ? 'review' : 'done';
2088
- const leave = next === 'done' && handOff(id); // measured before the live update moves the card
2089
- await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
2090
- if (leave) {
2091
- leave(() => S.sessions.set(id, { ...S.sessions.get(id), state: 'done' }));
2092
- toast(`Marked done`, { action: { label: 'Undo', run: () => api(`/sessions/${id}`, { method: 'PATCH', body: { state: 'review' } }) } });
2263
+ // Optimistic: the card moves now; a failed save puts it back.
2264
+ move(id, { ...s, state: next }, next === 'done');
2265
+ if (next === 'done') toast(`Marked done`, { action: { label: 'Undo', run: () => toggleDone(id) } });
2266
+ try {
2267
+ await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
2268
+ } catch (err) {
2269
+ revertSummary(id, s);
2270
+ toast(esc(err.message), { tone: 'bad' });
2093
2271
  }
2094
2272
  }
2095
2273
 
2274
+ // Apply a summary change locally before the server confirms it. Leaving the list (done, archived)
2275
+ // hands focus to the next card; coming back just re-renders around the reader.
2276
+ function move(id, next, leaving) {
2277
+ if (leaving) return handOff(id)(() => S.sessions.set(id, next));
2278
+ S.sessions.set(id, next);
2279
+ keepInPlace(feedAnchor(id), renderFeed);
2280
+ }
2281
+
2282
+ // Put back a summary changed optimistically, unless a live update has replaced it since.
2283
+ function revertSummary(id, prev) {
2284
+ const cur = S.sessions.get(id);
2285
+ if (cur && cur.updatedAt > prev.updatedAt) return;
2286
+ S.sessions.set(id, prev);
2287
+ keepInPlace(feedAnchor(id), renderFeed);
2288
+ }
2289
+
2096
2290
  // Every finished run of a compare task at once, with one toast to undo them all.
2097
2291
  async function taskDone(ids) {
2098
2292
  if (!ids.length) return;
2099
2293
  const card = S.nodes.get(ids[0])?.card.closest('.task-item');
2100
2294
  const next = card?.nextElementSibling?.querySelector('.item')?.dataset.id || card?.nextElementSibling?.dataset.id;
2101
- const set = (state) => Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } })));
2102
- await set('done').catch((err) => toast(esc(err.message), { tone: 'bad' }));
2103
- for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state: 'done' });
2104
- renderFeed();
2295
+ const set = (state) => {
2296
+ const prev = ids.map((id) => S.sessions.get(id));
2297
+ for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state });
2298
+ renderFeed();
2299
+ Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } }))).catch((err) => {
2300
+ prev.forEach((p, i) => p && revertSummary(ids[i], p));
2301
+ toast(esc(err.message), { tone: 'bad' });
2302
+ });
2303
+ };
2304
+ set('done');
2105
2305
  if (next && S.nodes.has(next)) setFocus(next);
2106
2306
  toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
2107
2307
  }
@@ -2182,10 +2382,17 @@ function rename(id) {
2182
2382
  // One popover for all three choices: harnesses on the left, the focused harness's models on the
2183
2383
  // right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
2184
2384
  // models; picking a model commits harness + model, picking a thinking level commits it in place.
2185
- function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort }, pick) {
2385
+ // places: the "Run on" picks, so the list is what's installed there rather than only on this computer.
2386
+ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, places }, pick) {
2186
2387
  const open = $('.picker');
2187
2388
  if (open) return open.close();
2188
- let focusAgent = S.agents.find((a) => a.id === agent && a.available) ? agent : S.agents.find((a) => a.available)?.id;
2389
+ const agents = places ? harnessesAt(places) : S.agents;
2390
+ const sub = (a) => {
2391
+ if (!a.available) return places?.length > 1 || places?.[0] ? 'Not installed there' : 'Not installed';
2392
+ const models = (a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model';
2393
+ return a.missing?.length ? `Not on ${a.missing.map(placeName).join(', ')}` : models;
2394
+ };
2395
+ let focusAgent = agents.find((a) => a.id === agent && a.available) ? agent : agents.find((a) => a.available)?.id;
2189
2396
 
2190
2397
  const r = anchor.getBoundingClientRect();
2191
2398
  const width = Math.min(580, innerWidth - 24);
@@ -2198,11 +2405,11 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
2198
2405
  <div class="picker" role="dialog" aria-label="Harness, model and thinking level" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
2199
2406
  <div class="pk-side" role="listbox" aria-label="Harness">
2200
2407
  <div class="pk-head">Harness</div>
2201
- ${S.agents
2408
+ ${agents
2202
2409
  .map(
2203
2410
  (a) => `<button class="pk-h${a.available ? '' : ' off'}" role="option" data-id="${esc(a.id)}" ${a.available ? '' : 'disabled'}>
2204
2411
  <span class="pk-logo">${harnessLogo(a.id)}</span>
2205
- <span class="pk-text"><b>${esc(a.name)}</b><small>${esc(a.available ? `${(a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model'}` : 'Not installed')}</small></span>
2412
+ <span class="pk-text"><b>${esc(a.name)}</b><small${a.missing?.length ? ' class="warn"' : ''}>${esc(sub(a))}</small></span>
2206
2413
  ${a.id === agent ? '<span class="pk-dot" title="In use"></span>' : ''}
2207
2414
  </button>`,
2208
2415
  )
@@ -2246,7 +2453,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
2246
2453
  };
2247
2454
 
2248
2455
  const paintModels = () => {
2249
- const a = S.agents.find((x) => x.id === focusAgent);
2456
+ const a = agents.find((x) => x.id === focusAgent);
2250
2457
  sideRows.forEach((b) => b.classList.toggle('on', b.dataset.id === focusAgent));
2251
2458
  const models = a?.models || [{ id: '', label: 'Default' }];
2252
2459
  const current = focusAgent === agent ? model : savedModel(focusAgent);
@@ -2476,9 +2683,12 @@ const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
2476
2683
 
2477
2684
  async function deleteSession(id) {
2478
2685
  if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
2479
- const leave = handOff(id);
2480
- const ok = await api(`/sessions/${id}`, { method: 'DELETE' }).then(() => true, (err) => toast(esc(err.message), { tone: 'bad' }));
2481
- if (ok) leave(() => S.sessions.delete(id));
2686
+ const prev = S.sessions.get(id);
2687
+ handOff(id)(() => S.sessions.delete(id));
2688
+ await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => {
2689
+ if (prev && !S.sessions.has(id)) (S.sessions.set(id, prev), keepInPlace(feedAnchor(id), renderFeed));
2690
+ toast(esc(err.message), { tone: 'bad' });
2691
+ });
2482
2692
  }
2483
2693
 
2484
2694
  // Launch the task again as a new session in the same folder: same prompt and attachments,
@@ -2537,22 +2747,13 @@ async function rerun(id) {
2537
2747
  const go = async () => {
2538
2748
  const prompt = ta.value.trim();
2539
2749
  if (!prompt) return ta.focus();
2540
- const btn = $('[data-a="go"]', node);
2541
- btn.disabled = true;
2750
+ close();
2542
2751
  try {
2543
- const next = await api('/sessions', { method: 'POST', body: { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) } });
2544
- S.sessions.set(next.id, next);
2545
- close();
2546
- if (S.filter !== 'all' && S.filter !== next.projectId) {
2547
- S.filter = 'all';
2548
- save('wisp.filter', S.filter);
2549
- }
2550
- renderFeed();
2752
+ const body = { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) };
2753
+ const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
2551
2754
  setFocus(next.id);
2552
- toast(`Rerunning on ${esc(agentName(agent))}`);
2553
2755
  } catch (err) {
2554
- btn.disabled = false;
2555
- toast(esc(err.message), { tone: 'bad' });
2756
+ toast(`Rerun failed: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Edit again', run: () => rerun(id) } });
2556
2757
  }
2557
2758
  };
2558
2759
  $('[data-a="go"]', node).onclick = go;
@@ -2568,15 +2769,16 @@ async function rerun(id) {
2568
2769
  }
2569
2770
 
2570
2771
  async function setArchived(id, archived) {
2571
- const leave = archived && handOff(id);
2772
+ const prev = S.sessions.get(id);
2773
+ if (!prev) return;
2774
+ // Optimistic: the card leaves (or comes back) now; a failed save puts it back.
2775
+ move(id, { ...prev, archived }, archived);
2776
+ if (archived) toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
2572
2777
  try {
2573
2778
  await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
2574
2779
  } catch (err) {
2575
- return toast(esc(err.message), { tone: 'bad' });
2576
- }
2577
- if (leave) {
2578
- leave(() => S.sessions.set(id, { ...S.sessions.get(id), archived: true }));
2579
- toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
2780
+ revertSummary(id, prev);
2781
+ toast(esc(err.message), { tone: 'bad' });
2580
2782
  }
2581
2783
  }
2582
2784
 
@@ -3008,16 +3210,21 @@ async function compareView(id) {
3008
3210
  if (a === 'open') return close(), reveal(keep);
3009
3211
  const others = group.filter((g) => g.id !== keep);
3010
3212
  if (!confirm(`Keep ${agentName(S.sessions.get(keep)?.agent)}'s result?\n\nThe other ${others.length === 1 ? 'run is' : `${others.length} runs are`} archived, and ${others.length === 1 ? 'its worktree is' : 'their worktrees are'} deleted with their changes.`)) return;
3213
+ // Optimistic: the others leave the feed now; stopping and cleaning them up goes on behind.
3214
+ for (const o of others) S.sessions.set(o.id, { ...S.sessions.get(o.id), archived: true });
3215
+ close();
3216
+ renderFeed();
3217
+ reveal(keep);
3218
+ toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
3219
+ let failed = false;
3011
3220
  for (const o of others) {
3012
3221
  if (o.state === 'running' || o.state === 'waiting') {
3013
3222
  await api(`/sessions/${o.id}/stop`, { method: 'POST', body: {} }).catch(() => {});
3014
3223
  await until(() => S.sessions.get(o.id)?.state !== 'running', 5000);
3015
3224
  }
3016
- await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
3225
+ await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => ((failed = true), toast(esc(err.message), { tone: 'bad' })));
3017
3226
  }
3018
- close();
3019
- reveal(keep);
3020
- toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
3227
+ if (failed) loadSessions(); // show what really got archived
3021
3228
  };
3022
3229
  }
3023
3230
 
@@ -3282,18 +3489,17 @@ function runRecipe(r) {
3282
3489
  const params = Object.fromEntries($$('input[name^="p:"]', node).map((i) => [i.name.slice(2), i.value]));
3283
3490
  const d = S.config.defaults;
3284
3491
  const agent = r.agent || d.agent;
3492
+ const cwd = needsProject ? $('[name="cwd"]', node).value : r.cwd;
3493
+ const model = r.agent ? r.model : savedModel(agent);
3494
+ const effort = r.agent ? r.effort : savedEffort(agent, model);
3495
+ const body = { params, cwd: needsProject ? cwd : undefined, agent, model: r.agent ? undefined : model, effort: r.agent ? undefined : effort, mode: r.mode || d.mode };
3496
+ close();
3285
3497
  try {
3286
- const s = await api(`/recipes/${r.id}/run`, {
3287
- method: 'POST',
3288
- body: { params, cwd: needsProject ? $('[name="cwd"]', node).value : undefined, agent, model: r.agent ? undefined : savedModel(agent), effort: r.agent ? undefined : savedEffort(agent, savedModel(agent)), mode: r.mode || d.mode },
3289
- });
3290
- S.sessions.set(s.id, s);
3291
- close();
3292
- renderFeed();
3498
+ const prompt = r.prompt.replace(/\{([a-zA-Z][\w-]{0,30})\}/g, (_, k) => params[k] || ''); // as the server fills it
3499
+ const s = await launchBehind({ prompt, cwd, runner: { agent, model, effort } }, () => api(`/recipes/${r.id}/run`, { method: 'POST', body }));
3293
3500
  setFocus(s.id);
3294
- toast(`Running “${esc(r.name)}”`);
3295
3501
  } catch (err) {
3296
- toast(esc(err.message), { tone: 'bad' });
3502
+ toast(`“${esc(r.name)}” didn’t start: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Try again', run: () => runRecipe(r) } });
3297
3503
  }
3298
3504
  };
3299
3505
  // Always wait for Run, even with nothing to fill in, so a click on a recipe never launches it outright.
@@ -3500,6 +3706,7 @@ function setMachines(list) {
3500
3706
  S.machines = list;
3501
3707
  paintMachinesBtn();
3502
3708
  renderLaunchChips();
3709
+ $('#launcher')?.placesChanged?.();
3503
3710
  renderProjects();
3504
3711
  $('.machines-modal')?.refresh();
3505
3712
  $('.projects-modal')?.refresh?.();
@@ -3624,7 +3831,7 @@ function openMachines({ host, focusAdd = false } = {}) {
3624
3831
  </div>
3625
3832
  ${
3626
3833
  st === 'online'
3627
- ? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
3834
+ ? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${m.outdated ? `<button class="ghost xs mc-update" data-a="update" title="Restart wisp on ${esc(m.name)} as ${esc(m.outdated)}">${icon('rerun')}Update to ${esc(m.outdated)}</button>` : ''}${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
3628
3835
  : ''
3629
3836
  }
3630
3837
  ${busy || st === 'error' ? steps(m) : ''}
@@ -3697,8 +3904,9 @@ function openMachines({ host, focusAdd = false } = {}) {
3697
3904
  paint();
3698
3905
  $(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
3699
3906
  }
3700
- if (a === 'restart') {
3701
- if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. Restarting wisp there stops ${m.working === 1 ? 'it' : 'them'}. Restart?`)) return;
3907
+ if (a === 'restart' || a === 'update') {
3908
+ const verb = a === 'update' ? `Updating wisp there to ${m.outdated}` : 'Restarting wisp there';
3909
+ if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. ${verb} stops ${m.working === 1 ? 'it' : 'them'}. ${a === 'update' ? 'Update' : 'Restart'}?`)) return;
3702
3910
  await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
3703
3911
  }
3704
3912
  if (a === 'stop') {
@@ -3812,6 +4020,22 @@ const SETTINGS_PANES = [
3812
4020
  ];
3813
4021
  let settingsPane = 'general';
3814
4022
 
4023
+ // What a re-check turned up: harnesses found or gone, and model lists that grew or shrank.
4024
+ function recheckSummary(before, after) {
4025
+ const count = (a) => (a?.models || []).filter((m) => m.id).length;
4026
+ const notes = [];
4027
+ for (const a of after) {
4028
+ const was = before.get(a.id);
4029
+ if (a.available && !was?.available) notes.push(`<b>${esc(a.name)}</b> found`);
4030
+ else if (!a.available && was?.available) notes.push(`<b>${esc(a.name)}</b> not found`);
4031
+ else if (a.available && count(a) !== count(was)) {
4032
+ const d = count(a) - count(was);
4033
+ notes.push(`<b>${esc(a.name)}</b> ${d > 0 ? '+' : '−'}${Math.abs(d)} model${Math.abs(d) === 1 ? '' : 's'}`);
4034
+ } else if (a.available && a.version !== was?.version) notes.push(`<b>${esc(a.name)}</b> updated`);
4035
+ }
4036
+ return notes.length ? notes.join(' · ') : 'Up to date: nothing new';
4037
+ }
4038
+
3815
4039
  function openSettings(opts) {
3816
4040
  if (SETTINGS_PANES.some((p) => p.id === opts?.pane)) settingsPane = opts.pane;
3817
4041
  const d = S.config.defaults;
@@ -3848,9 +4072,10 @@ function openSettings(opts) {
3848
4072
  </div>
3849
4073
  </section>
3850
4074
  <section class="sx-pane" data-pane="agents">
3851
- <div class="sx-group">${S.agents
3852
- .map((a) => row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, esc(a.available ? a.version : a.error || 'not found'), `<span class="sx-state ${a.available ? 'ok' : 'bad'}"><i class="dot ${a.available ? 'ok' : 'bad'}"></i>${a.available ? 'Ready' : 'Not found'}</span>`))
3853
- .join('')}</div>
4075
+ <div class="sx-group sx-agents"></div>
4076
+ <div class="sx-group">
4077
+ ${row('Installed something new?', '<span class="recheck-hint"></span>', `<button class="ghost sm" id="recheck">${icon('replay')}Re-check</button>`)}
4078
+ </div>
3854
4079
  </section>
3855
4080
  <section class="sx-pane" data-pane="machines">
3856
4081
  <div class="sx-group">
@@ -3884,6 +4109,41 @@ function openSettings(opts) {
3884
4109
  </div>`);
3885
4110
  const close = overlay(node);
3886
4111
  $('.close', node).onclick = close;
4112
+ const paintAgents = () => {
4113
+ $('.sx-agents', node).innerHTML = S.agents.length
4114
+ ? S.agents.map((a) => {
4115
+ const n = (a.models || []).filter((m) => m.id).length;
4116
+ const desc = a.available ? [a.version, n && `${n} model${n === 1 ? '' : 's'}`].filter(Boolean).join(' · ') : `Install the ${a.name} CLI and sign in, then re-check`;
4117
+ return row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, esc(desc), `<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>`);
4118
+ }).join('')
4119
+ : '<div class="sx-row"><div class="it-loading"><span class="orb tiny"></span>Looking for harnesses…</div></div>';
4120
+ const missing = S.agents.filter((a) => !a.available).length;
4121
+ const dot = $('.sx-nav [data-pane="agents"] .dot', node);
4122
+ if (dot && !missing) dot.remove();
4123
+ if (!dot && missing) $('.sx-nav [data-pane="agents"]', node).insertAdjacentHTML('beforeend', `<i class="dot bad" title="${missing} not found"></i>`);
4124
+ const online = S.machines.filter((m) => m.state === 'online').length;
4125
+ $('.recheck-hint', node).textContent = `After installing a CLI, signing in or adding a provider, look for harnesses and models again${online ? `, here and on ${online} machine${online === 1 ? '' : 's'}` : ''}`;
4126
+ };
4127
+ paintAgents();
4128
+ node.paintAgents = paintAgents;
4129
+ $('#recheck', node).onclick = async (e) => {
4130
+ const btn = e.currentTarget;
4131
+ btn.disabled = true;
4132
+ btn.classList.add('busy');
4133
+ const before = new Map(S.agents.map((a) => [a.id, a]));
4134
+ try {
4135
+ S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
4136
+ if (!node.isConnected) return;
4137
+ paintAgents();
4138
+ $('#launcher')?.placesChanged?.();
4139
+ toast(recheckSummary(before, S.agents));
4140
+ } catch (err) {
4141
+ toast(esc(err.message), { tone: 'bad' });
4142
+ } finally {
4143
+ btn.disabled = false;
4144
+ btn.classList.remove('busy');
4145
+ }
4146
+ };
3887
4147
  const pick = (id, focus = false) => {
3888
4148
  settingsPane = id;
3889
4149
  const p = SETTINGS_PANES.find((x) => x.id === id);
@@ -4250,7 +4510,7 @@ function paletteItems() {
4250
4510
  items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
4251
4511
  }
4252
4512
 
4253
- const sessions = [...S.sessions.values()].sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
4513
+ const sessions = [...S.sessions.values()].filter((s) => !s.pending).sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
4254
4514
  for (const s of sessions) {
4255
4515
  const state = s.archived ? 'Archived' : s.state === 'review' ? STATUS_LABEL[s.status] || 'Review' : STATE_LABEL[s.state] || s.state;
4256
4516
  items.push({
@@ -4336,7 +4596,7 @@ function notifyFinished(s) {
4336
4596
  }
4337
4597
  toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
4338
4598
  if (notifyOn() && !document.hasFocus()) {
4339
- const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/favicon.svg', tag: s.id });
4599
+ const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/apple-touch-icon.png', tag: s.id });
4340
4600
  note.onclick = () => (window.focus(), reveal(s.id));
4341
4601
  }
4342
4602
  }
@@ -4349,6 +4609,7 @@ function connect() {
4349
4609
  const s = JSON.parse(e.data);
4350
4610
  if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
4351
4611
  const prev = S.sessions.get(s.id);
4612
+ if (!prev) dropPending(s);
4352
4613
  const was = prev && sectionOf(prev); // before the update: a compare task's section depends on all its runs
4353
4614
  S.sessions.set(s.id, s);
4354
4615
  // A section change moves the card (a follow-up sends it from "Needs you" to "Working").
@@ -4448,7 +4709,7 @@ async function installUpdate() {
4448
4709
 
4449
4710
  // --- keyboard ---------------------------------------------------------------------------
4450
4711
 
4451
- const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
4712
+ const feedIds = () => $$('#feed .item:not(.pending)').map((x) => x.dataset.id);
4452
4713
  // The user takes a card out of its list (done, archive, delete). The next card — the one below,
4453
4714
  // or the one above at the end of the list — gets focus and lands where the old one sat, so
4454
4715
  // triage continues on the same spot. Call before the request, while the card is still laid
@@ -4611,6 +4872,7 @@ window.addEventListener('focus', () => {
4611
4872
  (async () => {
4612
4873
  [S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions(), loadMachines()]);
4613
4874
  renderLauncher();
4875
+ $('.settings-modal')?.paintAgents?.();
4614
4876
  connect();
4615
4877
  route();
4616
4878
  $('#prompt')?.focus();