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 +33 -1
- package/README.md +4 -4
- package/package.json +1 -1
- package/server/git.js +6 -3
- package/server/index.js +44 -2
- package/server/machines.js +72 -2
- package/server/openapi.js +1 -0
- package/server/runner.js +1 -0
- package/server/update.js +8 -4
- package/web/apple-touch-icon.png +0 -0
- package/web/css/app.css +13 -0
- package/web/favicon.svg +12 -4
- package/web/index.html +1 -0
- package/web/js/app.js +369 -120
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
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,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
|
-
|
|
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
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
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>
|