wisp-agents 0.9.0 → 0.10.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,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.10.0] - 2026-10-18
9
+
10
+ ### Added
11
+
12
+ - 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`.
13
+
14
+ ### Changed
15
+
16
+ - 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 }`.
17
+
18
+ ### Fixed
19
+
20
+ - 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".
21
+
8
22
  ## [0.9.0] - 2026-10-18
9
23
 
10
24
  ### Added
@@ -176,7 +190,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
176
190
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
177
191
  - Older turns are folded, with a floating collapse-all button and back-to-top.
178
192
 
179
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.9.0...HEAD
193
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.10.0...HEAD
194
+ [0.10.0]: https://github.com/M0xoo/wisp/compare/v0.9.0...v0.10.0
180
195
  [0.9.0]: https://github.com/M0xoo/wisp/compare/v0.8.0...v0.9.0
181
196
  [0.8.0]: https://github.com/M0xoo/wisp/compare/v0.7.0...v0.8.0
182
197
  [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.10.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.10.0 # run a recipe; values fill its {params} (or version=0.10.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.10.0` fills the fields in order, `wisp run release version=0.10.0` by name.
309
309
 
310
310
  **Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
311
311
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.9.0",
3
+ "version": "0.10.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,7 @@ 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
23
 
24
24
  export const DEFAULT_PORT = 4477;
25
25
  const READY_TIMEOUT_MS = 6 * 60_000; // the first `npx` download can take a while
@@ -151,6 +151,70 @@ export function sshHosts(file = path.join(os.homedir(), '.ssh', 'config'), seen
151
151
  return [...uniq.values()];
152
152
  }
153
153
 
154
+ // Code hosts you push to, not computers to run agents on.
155
+ const FORGE = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org|ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com|heroku\.com|codeberg\.org|sr\.ht)$/i;
156
+
157
+ // Hosts you've already SSHed to, from ~/.ssh/known_hosts. Hashed entries and ones on a
158
+ // non-default port ([host]:2222, which a bare target can't express) are skipped.
159
+ export function knownHosts(file = path.join(os.homedir(), '.ssh', 'known_hosts')) {
160
+ let text = '';
161
+ try {
162
+ text = fs.readFileSync(file, 'utf8');
163
+ } catch {
164
+ return [];
165
+ }
166
+ const out = new Map();
167
+ for (const raw of text.split(/\r?\n/)) {
168
+ const line = raw.trim();
169
+ if (!line || line.startsWith('#') || line.startsWith('@') || line.startsWith('|')) continue;
170
+ const names = line.split(/\s+/)[0].split(',').filter((n) => n && !n.startsWith('[') && !/[*?!]/.test(n));
171
+ // "devbox,10.0.0.5": the name, with the address as a detail.
172
+ const host = names.find((n) => !/^[\d.]+$|:/.test(n)) || names[0];
173
+ if (!host || FORGE.test(host) || /^(localhost|127\.|::1$)/.test(host) || !TARGET.test(host)) continue;
174
+ if (!out.has(host)) out.set(host, { host, hostname: names.find((n) => n !== host) || '' });
175
+ }
176
+ return [...out.values()];
177
+ }
178
+
179
+ const TAILSCALE_BINS = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale'];
180
+
181
+ // Peers on your tailnet, from `tailscale status --json`. Phones and tablets don't run agents.
182
+ export function tailscalePeers({ bin = process.env.WISP_TAILSCALE, timeout = 3_000 } = {}) {
183
+ const bins = bin ? [bin] : TAILSCALE_BINS;
184
+ const run = (i) =>
185
+ new Promise((resolve) => {
186
+ if (i >= bins.length) return resolve([]);
187
+ execFile(bins[i], ['status', '--json'], { timeout, maxBuffer: 8 << 20 }, (err, stdout) => {
188
+ if (err) return resolve(err.code === 'ENOENT' ? run(i + 1) : []);
189
+ try {
190
+ const peers = Object.values(JSON.parse(stdout).Peer || {});
191
+ resolve(
192
+ peers
193
+ .filter((p) => !/^(ios|android|tvos)$/i.test(p.OS || ''))
194
+ .map((p) => ({ host: (p.DNSName || '').replace(/\.$/, '') || p.TailscaleIPs?.[0] || '', name: p.HostName || '', os: p.OS || '', online: !!p.Online }))
195
+ .filter((p) => p.host && TARGET.test(p.host)),
196
+ );
197
+ } catch {
198
+ resolve([]);
199
+ }
200
+ });
201
+ });
202
+ return run(0);
203
+ }
204
+
205
+ // Every host this computer already knows how to reach, for "Found on this computer" in Machines.
206
+ // One entry per target: ~/.ssh/config first (your own names), then the tailnet, then known_hosts.
207
+ export async function discoverHosts({ sshDir = path.join(os.homedir(), '.ssh'), tailscale } = {}) {
208
+ const out = new Map();
209
+ const put = (x) => {
210
+ if (x.target && !out.has(x.target)) out.set(x.target, x);
211
+ };
212
+ 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('@') });
213
+ 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 });
214
+ 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 });
215
+ return [...out.values()].sort((a, b) => (b.online === false ? 0 : 1) - (a.online === false ? 0 : 1));
216
+ }
217
+
154
218
  const freePort = () =>
155
219
  new Promise((resolve, reject) => {
156
220
  const s = net.createServer().once('error', reject);
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) {
package/web/css/app.css CHANGED
@@ -1455,6 +1455,13 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1455
1455
  .mc-add { margin-top: 12px; }
1456
1456
  .mc-add h4 { margin-top: 18px; }
1457
1457
  .mc-new { height: 32px; }
1458
+ .mc-found { margin-bottom: 14px; }
1459
+ .mc-found h4 { margin: 0 0 8px; }
1460
+ .mc-found-list { display: flex; flex-wrap: wrap; gap: 6px; }
1461
+ .mc-found .lp-chip .i { width: 14px; height: 14px; color: var(--muted); }
1462
+ .mc-found .lp-chip.off { color: var(--muted); }
1463
+ .mc-fdot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); }
1464
+ .lp-chip.off .mc-fdot { background: var(--faint); }
1458
1465
  .mc-form { display: flex; flex-direction: column; gap: 10px; }
1459
1466
  .mc-row { display: flex; gap: 10px; }
1460
1467
  .mc-field { display: flex; flex-direction: column; gap: 5px; width: 150px; min-width: 0; }
package/web/js/app.js CHANGED
@@ -1633,7 +1633,6 @@ function renderBody(id, { fresh }) {
1633
1633
  ${s.origin ? `<span class="m" title="Launched by a ${esc(s.origin.kind)}">${icon(s.origin.kind === 'trigger' ? 'replay' : 'spark')}${esc(s.origin.name)}</span>` : ''}`;
1634
1634
  const live = s.state === 'running' || s.state === 'waiting';
1635
1635
  // Ship sits in the top bar and again next to the composer, so it's there after reading the result.
1636
- $$('[data-a="ship"]', body).forEach((b) => (b.hidden = !s.worktree || live || s.agent === 'wisp' || s.pr?.state === 'MERGED'));
1637
1636
  renderPrStrip(id);
1638
1637
  $$('[data-a="done"]', body).forEach((b) => {
1639
1638
  b.classList.toggle('on', s.state === 'done');
@@ -2438,7 +2437,7 @@ function taskMenu(id, e) {
2438
2437
  ${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
2439
2438
  <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
2440
2439
  <button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
2441
- ${s.branch && !busy && s.pr?.state !== 'MERGED' ? `<button data-a="ship">${icon('send')}${s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…'}${keyHint('S')}</button>` : ''}
2440
+ ${shippable(s) ? `<button data-a="ship">${icon('send')}${shipLabel(s)}${keyHint('S')}</button>` : ''}
2442
2441
  ${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
2443
2442
  ${s.groupId ? `<button data-a="compare"><b class="vs-mark">vs</b>Compare runs</button>` : ''}
2444
2443
  <button data-a="recipe">${icon('spark')}Save as recipe…</button>
@@ -2837,6 +2836,15 @@ function prLine(pr) {
2837
2836
  const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
2838
2837
  return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
2839
2838
  }
2839
+ // Ship it only makes sense on a finished worktree task with something to ship: uncommitted
2840
+ // changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
2841
+ function shippable(s) {
2842
+ if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED') return false;
2843
+ const t = s.toShip;
2844
+ return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
2845
+ }
2846
+ const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
2847
+
2840
2848
  const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
2841
2849
 
2842
2850
  // The PR row under a card's bar; reads the live summary, so polled check results show up.
@@ -2846,7 +2854,10 @@ function renderPrStrip(id) {
2846
2854
  const s = S.sessions.get(id);
2847
2855
  if (!node || !s) return;
2848
2856
  const pr = s.pr;
2849
- $$('[data-a="ship"] span', n.body).forEach((b) => (b.textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it'));
2857
+ $$('[data-a="ship"]', n.body).forEach((b) => {
2858
+ b.hidden = !shippable(s);
2859
+ $('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
2860
+ });
2850
2861
  node.hidden = !pr;
2851
2862
  if (!pr) return;
2852
2863
  const tone = prTone(pr);
@@ -3539,14 +3550,31 @@ function openMachines({ host, focusAdd = false } = {}) {
3539
3550
  const close = present(node, host);
3540
3551
  const body = $('.mc-body', node);
3541
3552
  let editing = null; // machine id whose form is open, '' = a new one, null = closed
3542
- let hosts = [];
3543
- api('/machines/ssh-hosts')
3553
+ let hosts = []; // found on this computer: ~/.ssh/config, Tailscale peers, ~/.ssh/known_hosts
3554
+ api('/machines/discover')
3544
3555
  .then((h) => {
3545
3556
  hosts = h;
3557
+ const slot = $('.mc-found-slot', node);
3558
+ if (slot) slot.innerHTML = found();
3546
3559
  const dl = $('#sshHosts', node);
3547
- if (dl) dl.innerHTML = hosts.map((x) => `<option value="${esc(x.host)}">${esc([x.user, x.hostname].filter(Boolean).join('@'))}</option>`).join('');
3560
+ if (dl) dl.innerHTML = datalist();
3548
3561
  })
3549
3562
  .catch(() => {});
3563
+ const datalist = () => hosts.map((x) => `<option value="${esc(x.target)}">${esc(x.detail || '')}</option>`).join('');
3564
+ const SOURCE = { 'ssh-config': '~/.ssh/config', tailscale: 'Tailscale', 'known-hosts': 'You’ve SSHed here before' };
3565
+ // Hosts not added yet, one click from a filled-in form.
3566
+ const found = () => {
3567
+ const added = new Set(S.machines.map((m) => m.target));
3568
+ const list = hosts.filter((x) => !added.has(x.target));
3569
+ if (!list.length) return '';
3570
+ return `<div class="mc-found"><h4>Found on this computer</h4><div class="mc-found-list">${list
3571
+ .slice(0, 12)
3572
+ .map(
3573
+ (x) =>
3574
+ `<button type="button" class="lp-chip${x.online === false ? ' off' : ''}" data-a="pick" data-target="${esc(x.target)}" data-name="${esc(x.name)}" title="${esc([SOURCE[x.source], x.target, x.detail].filter(Boolean).join(' · '))}">${icon('server')}${esc(x.name)}${x.online !== undefined ? `<i class="mc-fdot"></i>` : ''}</button>`,
3575
+ )
3576
+ .join('')}</div></div>`;
3577
+ };
3550
3578
 
3551
3579
  // Projects on a machine open here, with a way back.
3552
3580
  const sub = (view) => {
@@ -3630,8 +3658,10 @@ function openMachines({ host, focusAdd = false } = {}) {
3630
3658
  const add = $('.mc-add', node);
3631
3659
  // The form for a new machine: open when there are none yet, else behind a button.
3632
3660
  if (!S.machines.length || editing === '') {
3633
- if (!$('form[data-id=""]', add)) add.innerHTML = `<h4>${S.machines.length ? 'Add another machine' : 'Add your first machine'}</h4>${form(null)}<datalist id="sshHosts">${hosts.map((x) => `<option value="${esc(x.host)}">${esc([x.user, x.hostname].filter(Boolean).join('@'))}</option>`).join('')}</datalist>`;
3634
- } else add.innerHTML = `<button class="lp-chip add mc-new" data-a="new">${icon('plus')}Add a machine</button><datalist id="sshHosts">${hosts.map((x) => `<option value="${esc(x.host)}"></option>`).join('')}</datalist>`;
3661
+ // Keep a half-filled form; only the suggestions above it change.
3662
+ if ($('form[data-id=""]', add)) $('.mc-found-slot', add).innerHTML = found();
3663
+ else add.innerHTML = `<div class="mc-found-slot">${found()}</div><h4>${S.machines.length ? 'Add another machine' : 'Add your first machine'}</h4>${form(null)}<datalist id="sshHosts">${datalist()}</datalist>`;
3664
+ } else add.innerHTML = `<div class="mc-found-slot">${found()}</div><button class="lp-chip add mc-new" data-a="new">${icon('plus')}Add a machine</button><datalist id="sshHosts">${datalist()}</datalist>`;
3635
3665
  };
3636
3666
 
3637
3667
  const menu = (m, anchor) => {
@@ -3703,6 +3733,16 @@ function openMachines({ host, focusAdd = false } = {}) {
3703
3733
  $('.mc-add input[name="target"]', node)?.focus();
3704
3734
  return;
3705
3735
  }
3736
+ if (a === 'pick') {
3737
+ // Fill in the form; connecting still waits for the button.
3738
+ editing = '';
3739
+ paint();
3740
+ const f = $('.mc-add form[data-id=""]', node);
3741
+ f.target.value = b.dataset.target;
3742
+ f.name.value = b.dataset.name;
3743
+ $('button[type="submit"]', f).focus();
3744
+ return;
3745
+ }
3706
3746
  if (a === 'cancel') {
3707
3747
  editing = null;
3708
3748
  paint();
@@ -3718,8 +3758,8 @@ function openMachines({ host, focusAdd = false } = {}) {
3718
3758
  node.addEventListener('input', (e) => {
3719
3759
  if (e.target.name !== 'target') return;
3720
3760
  const name = $('input[name="name"]', e.target.form);
3721
- const h = hosts.find((x) => x.host === e.target.value.trim());
3722
- name.placeholder = (h?.host || e.target.value.trim().replace(/^.*@/, '')).slice(0, 40) || 'devbox';
3761
+ const h = hosts.find((x) => x.target === e.target.value.trim());
3762
+ name.placeholder = (h?.name || e.target.value.trim().replace(/^.*@/, '')).slice(0, 40) || 'devbox';
3723
3763
  });
3724
3764
  node.addEventListener('submit', async (e) => {
3725
3765
  e.preventDefault();
@@ -4201,7 +4241,7 @@ function paletteItems() {
4201
4241
  if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
4202
4242
  items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
4203
4243
  items.push({ group: g, label: 'Rerun…', sub, icon: 'rerun', kbd: '⇧R', keywords: 'retry again restart model harness', run: () => rerun(id) });
4204
- if (cur.branch && !live && cur.pr?.state !== 'MERGED') items.push({ group: g, label: cur.pr?.state === 'OPEN' ? `Push to PR #${cur.pr.number}` : 'Ship it…', sub, icon: 'send', kbd: 'S', keywords: 'commit push pull request pr github', run: () => shipDialog(id) });
4244
+ if (shippable(cur)) items.push({ group: g, label: shipLabel(cur), sub, icon: 'send', kbd: 'S', keywords: 'commit push pull request pr github', run: () => shipDialog(id) });
4205
4245
  if (cur.pr?.state === 'OPEN') items.push({ group: g, label: `Merge PR #${cur.pr.number}…`, sub, icon: 'check', keywords: 'squash github', run: () => mergePr(id) });
4206
4246
  if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
4207
4247
  items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
@@ -4324,7 +4364,7 @@ function connect() {
4324
4364
  renderFeed();
4325
4365
  });
4326
4366
  if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
4327
- if (JSON.stringify(prev?.pr) !== JSON.stringify(s.pr)) renderPrStrip(s.id);
4367
+ if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state]) !== JSON.stringify([s.pr, s.toShip, s.state])) renderPrStrip(s.id);
4328
4368
  });
4329
4369
  es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
4330
4370
  es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
@@ -4486,7 +4526,7 @@ document.addEventListener('keydown', (e) => {
4486
4526
  deleteSession(id);
4487
4527
  } else if (k === 't' && id) takeOver(id);
4488
4528
  else if (k === 'r' && e.shiftKey && id) rerun(id);
4489
- else if (k === 's' && id && S.sessions.get(id)?.branch) shipDialog(id);
4529
+ else if (k === 's' && id && shippable(S.sessions.get(id))) shipDialog(id);
4490
4530
  else if (k === 'r' && id) replay(id);
4491
4531
  else if (k === '/' && id) {
4492
4532
  e.preventDefault();