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 +16 -1
- package/README.md +3 -3
- package/package.json +1 -1
- package/server/git.js +6 -3
- package/server/index.js +44 -2
- package/server/machines.js +65 -1
- package/server/openapi.js +1 -0
- package/server/runner.js +1 -0
- package/server/update.js +8 -4
- package/web/css/app.css +7 -0
- package/web/js/app.js +53 -13
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
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
|
-
|
|
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')
|
|
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') {
|
package/server/machines.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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"]
|
|
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/
|
|
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 =
|
|
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
|
-
|
|
3634
|
-
|
|
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.
|
|
3722
|
-
name.placeholder = (h?.
|
|
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
|
|
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)
|
|
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();
|