wisp-agents 0.9.0 → 0.11.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.11.0] - 2026-10-18
9
+
10
+ ### Added
11
+
12
+ - 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>"`.
13
+
14
+ ### Changed
15
+
16
+ - 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.
17
+ - 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.
18
+ - 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.
19
+
20
+ ### Fixed
21
+
22
+ - 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.
23
+
24
+ ## [0.10.0] - 2026-10-18
25
+
26
+ ### Added
27
+
28
+ - Machines finds hosts for you. **Found on this computer** lists the hosts this computer already reaches: `Host` entries in `~/.ssh/config`, your Tailscale peers (`tailscale status --json`, with an online dot; phones and tablets are skipped) and hosts in `~/.ssh/known_hosts` (code hosts such as github.com, hashed entries and non-default ports are skipped). Hosts you've already added aren't listed. Click one to fill in the form, then press Connect. The SSH host field suggests the same hosts. API: `GET /api/machines/discover`.
29
+
30
+ ### Changed
31
+
32
+ - Ship it only shows when there is something to ship. It used to sit on every finished worktree task, even one that changed nothing or whose work was already on its PR. Now it appears (on the card bar, next to the composer, in the right-click menu, in ⌘K and on `S`) only if the worktree has uncommitted changes or commits its PR doesn't have yet. **Push to PR #N** shows only when there are new changes to push. wisp checks the worktree when a turn ends, after Ship it, a revert, a rollback or an action command, and at startup. Summaries carry it as `toShip: { dirty, ahead, unpushed }`.
33
+
34
+ ### Fixed
35
+
36
+ - Updating from the top-bar Update button now restarts wisp on the port it was running on. Before, a wisp started with `--port` came back on 4477 and the open tab said "wisp did not come back".
37
+
8
38
  ## [0.9.0] - 2026-10-18
9
39
 
10
40
  ### Added
@@ -176,7 +206,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
176
206
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
177
207
  - Older turns are folded, with a floating collapse-all button and back-to-top.
178
208
 
179
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.9.0...HEAD
209
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.11.0...HEAD
210
+ [0.11.0]: https://github.com/M0xoo/wisp/compare/v0.10.0...v0.11.0
211
+ [0.10.0]: https://github.com/M0xoo/wisp/compare/v0.9.0...v0.10.0
180
212
  [0.9.0]: https://github.com/M0xoo/wisp/compare/v0.8.0...v0.9.0
181
213
  [0.8.0]: https://github.com/M0xoo/wisp/compare/v0.7.0...v0.8.0
182
214
  [0.7.0]: https://github.com/M0xoo/wisp/compare/v0.6.0...v0.7.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.9.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.11.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.9.0 # run a recipe; values fill its {params} (or version=0.9.0)
128
+ wisp run release 0.11.0 # run a recipe; values fill its {params} (or version=0.11.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.9.0` fills the fields in order, `wisp run release version=0.9.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.11.0` fills the fields in order, `wisp run release version=0.11.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.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Launch coding agents, skip the transcript, read the presentation.",
5
5
  "type": "module",
6
6
  "bin": {
package/server/git.js CHANGED
@@ -93,12 +93,15 @@ export async function ensureWorktree(wt) {
93
93
  await git(wt.root, ['worktree', 'add', '--quiet', wt.path, wt.branch]);
94
94
  }
95
95
 
96
- // { dirty: changed paths, ahead: commits past the base, exists }
96
+ // { dirty: changed paths, ahead: commits past the base, unpushed: commits not on the remote branch, exists }
97
97
  export async function worktreeStatus(wt) {
98
- if (!wt || !fs.existsSync(wt.path)) return { exists: false, dirty: 0, ahead: 0 };
98
+ if (!wt || !fs.existsSync(wt.path)) return { exists: false, dirty: 0, ahead: 0, unpushed: 0 };
99
99
  const porcelain = await quiet(git(wt.path, ['status', '--porcelain']));
100
100
  const ahead = Number(await quiet(git(wt.path, ['rev-list', '--count', `${wt.baseRef}..HEAD`]))) || 0;
101
- return { exists: true, dirty: porcelain ? porcelain.split('\n').length : 0, ahead };
101
+ const pushed = wt.remote && `refs/remotes/${wt.remote}/${wt.branch}`;
102
+ const tracked = pushed && (await quiet(git(wt.path, ['rev-parse', '--verify', '--quiet', pushed])));
103
+ const unpushed = tracked ? Number(await quiet(git(wt.path, ['rev-list', '--count', `${pushed}..HEAD`]))) || 0 : ahead;
104
+ return { exists: true, dirty: porcelain ? porcelain.split('\n').length : 0, ahead, unpushed };
102
105
  }
103
106
 
104
107
  // force: drop uncommitted work too. The branch goes only when nothing would be lost with it.
package/server/index.js CHANGED
@@ -22,7 +22,7 @@ import { createWorktree, worktreeSource, ensureWorktree, removeWorktree, worktre
22
22
  import { normalizeRecipe, findRecipe, recipeView, fill, positional } from './recipes.js';
23
23
  import { Triggers, normalizeTrigger, triggerView } from './triggers.js';
24
24
  import { Remote } from './remote.js';
25
- import { Machines, splitId, qualify, sshHosts } from './machines.js';
25
+ import { Machines, splitId, qualify, sshHosts, discoverHosts } from './machines.js';
26
26
  import { usage } from './usage.js';
27
27
  import { normalizeTeam, normalizePreset, delegationReport } from './subagents.js';
28
28
 
@@ -54,6 +54,7 @@ export const API_VERSION = 1;
54
54
  const settled = (s) => s.state === 'waiting' || (s.state !== 'running' && !s.queue?.length);
55
55
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
56
56
  const PR_POLL_MS = 60_000;
57
+ const SHIP_RECHECK_MS = 5_000;
57
58
  const MAX_COMPARE = 4;
58
59
  const MODES = new Set(['plan', 'edits', 'auto']);
59
60
  const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
@@ -304,6 +305,38 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
304
305
  return s && { s: machines.session(at.machine, s), answer: (a) => call('POST', '/answer', { text: a }), reply: (t) => call('POST', '/reply', { text: t }), done: async () => !live && !!(await call('PATCH', '', { state: 'done' })) };
305
306
  }
306
307
 
308
+ // Ship it shows only when the worktree has something to ship: uncommitted changes, or commits
309
+ // not yet pushed. Rechecked whenever wisp might have changed the worktree.
310
+ const checkShip = async (s) => {
311
+ if (!s?.worktree || s.parentId) return;
312
+ const { dirty, ahead, unpushed } = await worktreeStatus(s.worktree);
313
+ const next = { dirty, ahead, unpushed };
314
+ if (JSON.stringify(next) === JSON.stringify(s.toShip)) return;
315
+ s.toShip = next;
316
+ store.save(s);
317
+ broadcast('summary', summarize(s, store));
318
+ };
319
+ // One check at a time per session (one asked for meanwhile runs right after); `lazy` (a card
320
+ // fetching its session) skips running sessions and runs at most every few seconds.
321
+ const shipChecks = new Map(); // id → true while checking, 'again' if another check is due
322
+ const shipCheckedAt = new Map();
323
+ const recheckShip = (s, { lazy = false } = {}) => {
324
+ if (!s?.worktree || s.parentId) return;
325
+ if (lazy && (runner.live.has(s.id) || shipChecks.has(s.id) || Date.now() - (shipCheckedAt.get(s.id) || 0) < SHIP_RECHECK_MS)) return;
326
+ if (shipChecks.has(s.id)) return void shipChecks.set(s.id, 'again');
327
+ shipChecks.set(s.id, true);
328
+ shipCheckedAt.set(s.id, Date.now());
329
+ checkShip(s)
330
+ .catch(() => {})
331
+ .finally(() => {
332
+ const again = shipChecks.get(s.id) === 'again';
333
+ shipChecks.delete(s.id);
334
+ if (again) recheckShip(s);
335
+ });
336
+ };
337
+ runner.on('ended', ({ session }) => recheckShip(store.get(session.id)));
338
+ for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheckShip(s);
339
+
307
340
  // Ship it: follow open PRs (checks, reviews, merges) so their cards stay current.
308
341
  const refreshPr = async (s) => {
309
342
  if (!s.pr || !s.worktree) return s.pr;
@@ -717,6 +750,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
717
750
 
718
751
  if (!action) {
719
752
  // A lead comes with its subagents' sessions, so its cards can show their reports and diffs.
753
+ if (m === 'GET') recheckShip(s, { lazy: true }); // edits made outside wisp (in an editor) show up too
720
754
  if (m === 'GET') return send(res, 200, s.children ? { ...s, childSessions: Object.fromEntries(Object.values(s.children).map((cid) => [cid, store.get(cid)]).filter(([, c]) => c)) } : s);
721
755
  if (m === 'PATCH') {
722
756
  const body = await readJson(req);
@@ -829,6 +863,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
829
863
  broadcast('summary', summarize(s, store));
830
864
  broadcast('turn', { sessionId: s.id, turnId: turn.id });
831
865
  telemetry.track('revert_file', { agent: s.agent, restore: !!restore });
866
+ recheckShip(s);
832
867
  return send(res, 200, { ok: true, turnId: turn.id, ...result });
833
868
  }
834
869
 
@@ -847,6 +882,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
847
882
  broadcast('summary', summarize(s, store));
848
883
  broadcast('turn', { sessionId: s.id, turnId: turn.id });
849
884
  telemetry.track('rollback_turn', { agent: s.agent, restore: !!restore });
885
+ recheckShip(s);
850
886
  return send(res, 200, { ok: true, turnId: turn.id, ...result });
851
887
  }
852
888
 
@@ -869,6 +905,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
869
905
  broadcast('turn', { sessionId: s.id, turnId: turn.id });
870
906
  }
871
907
  telemetry.track('exec_action', { agent: s.agent, exitCode: resExec.exitCode });
908
+ recheckShip(s);
872
909
  return send(res, 200, resExec);
873
910
  }
874
911
 
@@ -902,7 +939,10 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
902
939
  // commit, push, open or update the PR.
903
940
  if (action === 'ship') {
904
941
  if (!s.worktree) return send(res, 400, { error: 'Ship it works on worktree sessions: launch with a worktree to get one.' });
905
- if (m === 'GET') return send(res, 200, { branch: s.worktree.branch, base: s.worktree.base, remote: s.worktree.remote, ...(await worktreeStatus(s.worktree)), pr: s.pr || null, ...shipDraft(s) });
942
+ if (m === 'GET') {
943
+ recheckShip(s);
944
+ return send(res, 200, { branch: s.worktree.branch, base: s.worktree.base, remote: s.worktree.remote, ...(await worktreeStatus(s.worktree)), pr: s.pr || null, ...shipDraft(s) });
945
+ }
906
946
  if (m === 'POST') {
907
947
  if (runner.live.has(s.id)) return send(res, 409, { error: 'Still working: wait for it to finish, or stop it.' });
908
948
  const body = await readJson(req);
@@ -911,6 +951,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
911
951
  telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
912
952
  store.save(s, { now: true });
913
953
  broadcast('summary', summarize(s, store));
954
+ recheckShip(s);
914
955
  return send(res, 200, s.pr);
915
956
  }
916
957
  }
@@ -963,6 +1004,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
963
1004
  return send(res, 201, one(x.id));
964
1005
  }
965
1006
  if (id === 'ssh-hosts' && m === 'GET') return send(res, 200, sshHosts());
1007
+ if (id === 'discover' && m === 'GET') return send(res, 200, await discoverHosts());
966
1008
  machines.get(id); // 404s
967
1009
  if (!action && m === 'PATCH') return send(res, 200, (await machines.update(id, await readJson(req)), one(id)));
968
1010
  if (!action && m === 'DELETE') {
@@ -19,7 +19,8 @@ import os from 'node:os';
19
19
  import path from 'node:path';
20
20
  import readline from 'node:readline';
21
21
  import { randomBytes } from 'node:crypto';
22
- import { spawn, killTree } from './proc.js';
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,70 @@ export function sshHosts(file = path.join(os.homedir(), '.ssh', 'config'), seen
151
156
  return [...uniq.values()];
152
157
  }
153
158
 
159
+ // Code hosts you push to, not computers to run agents on.
160
+ const FORGE = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org|ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com|heroku\.com|codeberg\.org|sr\.ht)$/i;
161
+
162
+ // Hosts you've already SSHed to, from ~/.ssh/known_hosts. Hashed entries and ones on a
163
+ // non-default port ([host]:2222, which a bare target can't express) are skipped.
164
+ export function knownHosts(file = path.join(os.homedir(), '.ssh', 'known_hosts')) {
165
+ let text = '';
166
+ try {
167
+ text = fs.readFileSync(file, 'utf8');
168
+ } catch {
169
+ return [];
170
+ }
171
+ const out = new Map();
172
+ for (const raw of text.split(/\r?\n/)) {
173
+ const line = raw.trim();
174
+ if (!line || line.startsWith('#') || line.startsWith('@') || line.startsWith('|')) continue;
175
+ const names = line.split(/\s+/)[0].split(',').filter((n) => n && !n.startsWith('[') && !/[*?!]/.test(n));
176
+ // "devbox,10.0.0.5": the name, with the address as a detail.
177
+ const host = names.find((n) => !/^[\d.]+$|:/.test(n)) || names[0];
178
+ if (!host || FORGE.test(host) || /^(localhost|127\.|::1$)/.test(host) || !TARGET.test(host)) continue;
179
+ if (!out.has(host)) out.set(host, { host, hostname: names.find((n) => n !== host) || '' });
180
+ }
181
+ return [...out.values()];
182
+ }
183
+
184
+ const TAILSCALE_BINS = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale'];
185
+
186
+ // Peers on your tailnet, from `tailscale status --json`. Phones and tablets don't run agents.
187
+ export function tailscalePeers({ bin = process.env.WISP_TAILSCALE, timeout = 3_000 } = {}) {
188
+ const bins = bin ? [bin] : TAILSCALE_BINS;
189
+ const run = (i) =>
190
+ new Promise((resolve) => {
191
+ if (i >= bins.length) return resolve([]);
192
+ execFile(bins[i], ['status', '--json'], { timeout, maxBuffer: 8 << 20 }, (err, stdout) => {
193
+ if (err) return resolve(err.code === 'ENOENT' ? run(i + 1) : []);
194
+ try {
195
+ const peers = Object.values(JSON.parse(stdout).Peer || {});
196
+ resolve(
197
+ peers
198
+ .filter((p) => !/^(ios|android|tvos)$/i.test(p.OS || ''))
199
+ .map((p) => ({ host: (p.DNSName || '').replace(/\.$/, '') || p.TailscaleIPs?.[0] || '', name: p.HostName || '', os: p.OS || '', online: !!p.Online }))
200
+ .filter((p) => p.host && TARGET.test(p.host)),
201
+ );
202
+ } catch {
203
+ resolve([]);
204
+ }
205
+ });
206
+ });
207
+ return run(0);
208
+ }
209
+
210
+ // Every host this computer already knows how to reach, for "Found on this computer" in Machines.
211
+ // One entry per target: ~/.ssh/config first (your own names), then the tailnet, then known_hosts.
212
+ export async function discoverHosts({ sshDir = path.join(os.homedir(), '.ssh'), tailscale } = {}) {
213
+ const out = new Map();
214
+ const put = (x) => {
215
+ if (x.target && !out.has(x.target)) out.set(x.target, x);
216
+ };
217
+ for (const h of sshHosts(path.join(sshDir, 'config'))) put({ target: h.host, name: h.host, source: 'ssh-config', detail: [h.user, h.hostname].filter(Boolean).join('@') });
218
+ for (const p of await tailscalePeers(tailscale)) put({ target: p.host, name: p.name || p.host.split('.')[0], source: 'tailscale', detail: [p.os, p.online ? 'online' : 'offline'].filter(Boolean).join(' · '), online: p.online });
219
+ for (const h of knownHosts(path.join(sshDir, 'known_hosts'))) put({ target: h.host, name: /^[\d.]+$/.test(h.host) ? h.host : h.host.split('.')[0], source: 'known-hosts', detail: h.hostname });
220
+ return [...out.values()].sort((a, b) => (b.online === false ? 0 : 1) - (a.online === false ? 0 : 1));
221
+ }
222
+
154
223
  const freePort = () =>
155
224
  new Promise((resolve, reject) => {
156
225
  const s = net.createServer().once('error', reject);
@@ -268,6 +337,7 @@ export class Machines extends EventEmitter {
268
337
  attempt: c?.attempt || 0,
269
338
  since: c?.since || null,
270
339
  remote: c?.remote || null,
340
+ outdated: updateTo(this.version, c?.remote?.version, m.command),
271
341
  log: c?.log.slice(-30) || [],
272
342
  projects: this.store.config.projects.filter((p) => p.machine === m.id).length,
273
343
  working: cached.filter((s) => s.state === 'running').length,
@@ -367,7 +437,7 @@ export class Machines extends EventEmitter {
367
437
  this.#step(c, 'tunnel', 'done', `localhost:${c.localPort} → ${m.port}`);
368
438
  const [agents, config] = await Promise.all([this.#raw(m, c, 'GET', '/agents').catch(() => []), this.#raw(m, c, 'GET', '/config').catch(() => ({}))]);
369
439
  const ready = agents.filter((a) => a.available);
370
- c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(({ id, name, available, version }) => ({ id, name, available, version })) };
440
+ c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(({ id, name, available, version, models, efforts, delegates }) => ({ id, name, available, version, models, efforts, delegates })) };
371
441
  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.');
372
442
  clearTimeout(c.readyTimer);
373
443
  Object.assign(c, { state: 'online', error: '', hint: null, attempt: 0, wasOnline: true, since: Date.now() });
package/server/openapi.js CHANGED
@@ -151,6 +151,7 @@ export function openapi({ version, port }) {
151
151
  responses: { 201: json({ type: 'object' }), 400: err[400] },
152
152
  },
153
153
  },
154
+ '/machines/discover': { get: { summary: 'Hosts this computer can reach: ~/.ssh/config, Tailscale peers, ~/.ssh/known_hosts', responses: { 200: json({ type: 'array', items: { type: 'object' } }) } } },
154
155
  '/machines/ssh-hosts': { get: { summary: 'Hosts from ~/.ssh/config, for suggestions', responses: { 200: json({ type: 'array', items: { type: 'object' } }) } } },
155
156
  '/machines/{mid}': { parameters: [{ name: 'mid', in: 'path', required: true, schema: { type: 'string' } }], patch: { summary: 'Edit a machine (host, port or command changes reconnect)', responses: { 200: json({ type: 'object' }) } }, delete: { summary: 'Remove a machine and its projects here (nothing on the machine is touched)', responses: { 200: json({ type: 'object' }) } } },
156
157
  '/machines/{mid}/connect': { parameters: [{ name: 'mid', in: 'path', required: true, schema: { type: 'string' } }], post: { summary: 'Connect: SSH in, start wisp there if needed, open the tunnel', responses: { 200: json({ type: 'object' }) } } },
package/server/runner.js CHANGED
@@ -67,6 +67,7 @@ export function summarize(s, store) {
67
67
  asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
68
68
  question: s.ask?.question || '',
69
69
  branch: s.worktree?.branch || '',
70
+ toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
70
71
  pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks } : null,
71
72
  groupId: s.groupId || null,
72
73
  origin: s.origin || null, // { kind: 'recipe' | 'trigger', id, name }
package/server/update.js CHANGED
@@ -88,12 +88,16 @@ export class Updater {
88
88
  this.set({ status: 'idle', error: err.message });
89
89
  throw err;
90
90
  }
91
- const args = process.versions.electron ? '' : ' --browser --no-open'; // desktop: reopen the app; browser: keep the tab
92
- const node = JSON.stringify(process.versions.electron ? 'node' : process.execPath);
93
- const relaunch = this.method === 'npx' ? `npx -y ${PKG.name}@latest${args}` : `${node} ${JSON.stringify(path.join(ROOT, 'bin', 'wisp.js'))}${args}`;
94
- handOff(this.port, relaunch);
91
+ handOff(this.port, this.relaunchCommand());
95
92
  this.set({ status: 'restarting' });
96
93
  }
94
+
95
+ // Same port as now (wisp may have been started with --port), so the open tab finds it again.
96
+ relaunchCommand(electron = !!process.versions.electron) {
97
+ const args = `${electron ? '' : ' --browser --no-open'} --port ${this.port}`; // desktop: reopen the app; browser: keep the tab
98
+ const node = JSON.stringify(electron ? 'node' : process.execPath);
99
+ return this.method === 'npx' ? `npx -y ${PKG.name}@latest${args}` : `${node} ${JSON.stringify(path.join(ROOT, 'bin', 'wisp.js'))}${args}`;
100
+ }
97
101
  }
98
102
 
99
103
  function run(cmd) {
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); }
@@ -473,6 +474,10 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
473
474
  .item.focused { border-color: var(--line-2); box-shadow: 0 0 0 1px var(--line-2); }
474
475
  .item.open { border-color: var(--line-2); box-shadow: var(--shadow-2); }
475
476
  .item.st-done:not(.open) { background: transparent; }
477
+ /* a launch the server hasn't confirmed yet: shown at once, can't be opened */
478
+ .item.pending { opacity: 0.75; }
479
+ .item.pending .it-head { cursor: default; }
480
+ .item.pending .chev { visibility: hidden; }
476
481
  .item.arrived { animation: arrive 1.4s var(--ease); }
477
482
  @keyframes arrive { from { box-shadow: 0 0 0 3px var(--accent-soft); border-color: var(--accent); } }
478
483
 
@@ -1423,6 +1428,7 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1423
1428
  .mc-meta, .mc-steps, .mc-hint, .mc-projects, .mc-edit { margin-left: 42px; }
1424
1429
  .mc-meta { display: flex; align-items: center; gap: 12px; margin-top: 8px; font-size: 12px; color: var(--muted); }
1425
1430
  .mc-meta .bad { color: var(--bad); }
1431
+ .mc-meta .mc-update { margin-left: -6px; color: var(--accent); }
1426
1432
  .mc-agents { display: inline-flex; gap: 7px; }
1427
1433
  .mc-steps { list-style: none; display: flex; flex-direction: column; gap: 5px; margin-top: 10px; margin-bottom: 0; padding: 0; }
1428
1434
  .mc-steps li { display: grid; grid-template-columns: 16px auto 1fr; align-items: center; column-gap: 8px; font-size: 12.5px; color: var(--faint); }
@@ -1455,6 +1461,13 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1455
1461
  .mc-add { margin-top: 12px; }
1456
1462
  .mc-add h4 { margin-top: 18px; }
1457
1463
  .mc-new { height: 32px; }
1464
+ .mc-found { margin-bottom: 14px; }
1465
+ .mc-found h4 { margin: 0 0 8px; }
1466
+ .mc-found-list { display: flex; flex-wrap: wrap; gap: 6px; }
1467
+ .mc-found .lp-chip .i { width: 14px; height: 14px; color: var(--muted); }
1468
+ .mc-found .lp-chip.off { color: var(--muted); }
1469
+ .mc-fdot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); }
1470
+ .lp-chip.off .mc-fdot { background: var(--faint); }
1458
1471
  .mc-form { display: flex; flex-direction: column; gap: 10px; }
1459
1472
  .mc-row { display: flex; gap: 10px; }
1460
1473
  .mc-field { display: flex; flex-direction: column; gap: 5px; width: 150px; min-width: 0; }
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>