wisp-agents 0.15.0 → 0.16.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +28 -12
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +2 -2
  6. package/server/adapters/claude.js +31 -50
  7. package/server/adapters/codex.js +2 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +2 -2
  10. package/server/adapters/grok.js +2 -2
  11. package/server/adapters/index.js +16 -1
  12. package/server/adapters/opencode.js +47 -6
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +5 -3
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +70 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-wisp.js +17 -5
  25. package/server/openapi.js +33 -2
  26. package/server/paths.js +32 -0
  27. package/server/preferences.js +2 -6
  28. package/server/proc.js +54 -0
  29. package/server/recipes.js +2 -4
  30. package/server/remote.js +14 -14
  31. package/server/router.js +7 -3
  32. package/server/routes/auto.js +29 -0
  33. package/server/routes/automations.js +92 -0
  34. package/server/routes/internal.js +56 -0
  35. package/server/routes/machines.js +43 -0
  36. package/server/routes/meta.js +76 -0
  37. package/server/routes/proxy.js +47 -0
  38. package/server/routes/sessions.js +339 -0
  39. package/server/routes/settings.js +144 -0
  40. package/server/routes/ship.js +61 -0
  41. package/server/routes/uploads.js +19 -0
  42. package/server/runner.js +87 -36
  43. package/server/services/auto.js +67 -0
  44. package/server/services/feedback.js +53 -0
  45. package/server/services/install.js +69 -0
  46. package/server/services/launch.js +127 -0
  47. package/server/services/phone.js +65 -0
  48. package/server/services/ship.js +82 -0
  49. package/server/services/telemetry.js +34 -0
  50. package/server/services/uploads.js +59 -0
  51. package/server/services/webhooks.js +28 -0
  52. package/server/store.js +5 -3
  53. package/server/subagents.js +5 -4
  54. package/server/telemetry.js +1 -1
  55. package/server/terminal.js +2 -1
  56. package/server/triggers.js +4 -6
  57. package/server/update.js +4 -3
  58. package/server/util.js +42 -0
  59. package/server/welcome.js +1 -1
  60. package/web/css/auto.css +29 -0
  61. package/web/css/automations.css +71 -0
  62. package/web/css/base.css +83 -0
  63. package/web/css/blocks.css +461 -0
  64. package/web/css/composer.css +62 -0
  65. package/web/css/feed.css +208 -0
  66. package/web/css/launcher.css +204 -0
  67. package/web/css/layout.css +61 -0
  68. package/web/css/machines.css +125 -0
  69. package/web/css/modals.css +176 -0
  70. package/web/css/palette.css +79 -0
  71. package/web/css/responsive.css +35 -0
  72. package/web/css/ship.css +95 -0
  73. package/web/css/subagents.css +130 -0
  74. package/web/css/tokens.css +115 -0
  75. package/web/index.html +16 -1
  76. package/web/js/app.js +28 -5237
  77. package/web/js/core/api.js +25 -0
  78. package/web/js/core/bus.js +28 -0
  79. package/web/js/core/dom.js +7 -0
  80. package/web/js/core/format.js +27 -0
  81. package/web/js/core/state.js +43 -0
  82. package/web/js/features/actions.js +339 -0
  83. package/web/js/features/attachments.js +115 -0
  84. package/web/js/features/auto.js +54 -0
  85. package/web/js/features/automations.js +305 -0
  86. package/web/js/features/commands.js +123 -0
  87. package/web/js/features/compare.js +104 -0
  88. package/web/js/features/composer.js +311 -0
  89. package/web/js/features/feed.js +400 -0
  90. package/web/js/features/feedback.js +127 -0
  91. package/web/js/features/keys.js +83 -0
  92. package/web/js/features/launcher.js +559 -0
  93. package/web/js/features/live.js +91 -0
  94. package/web/js/features/machines.js +313 -0
  95. package/web/js/features/memory.js +154 -0
  96. package/web/js/features/notify.js +36 -0
  97. package/web/js/features/phone.js +124 -0
  98. package/web/js/features/places.js +38 -0
  99. package/web/js/features/projects.js +284 -0
  100. package/web/js/features/runner-picker.js +233 -0
  101. package/web/js/features/runners.js +161 -0
  102. package/web/js/features/scroll.js +75 -0
  103. package/web/js/features/settings.js +390 -0
  104. package/web/js/features/setup.js +94 -0
  105. package/web/js/features/ship.js +122 -0
  106. package/web/js/features/team.js +386 -0
  107. package/web/js/features/turns.js +401 -0
  108. package/web/js/features/update.js +48 -0
  109. package/web/js/features/usage.js +75 -0
  110. package/web/js/ui/kbd.js +46 -0
  111. package/web/js/ui/overlay.js +38 -0
  112. package/web/js/ui/popover.js +52 -0
  113. package/web/js/ui/toast.js +23 -0
  114. package/web/css/app.css +0 -1910
@@ -0,0 +1,56 @@
1
+ // The script that starts wisp on a machine over `ssh … sh -s` and reports each step back.
2
+
3
+ import { shq } from '../util.js';
4
+
5
+ // What runs on the machine. Sent on stdin, so the login shell there (bash, zsh, fish…) only ever
6
+ // sees `sh -s`. Steps come back as `@@wisp step <id> <run|done> <detail>`, `@@wisp fail <id>
7
+ // <message>`, and finally `@@wisp ready <version> <pid>`; then `cat` holds the session (and the
8
+ // tunnel) open until this side lets go.
9
+ export function bootstrapScript({ port, command, apiVersion }) {
10
+ return String.raw`say() { printf '@@wisp %s\n' "$*"; }
11
+ PORT=__PORT__
12
+ API=__API__
13
+ CMD=__CMD__
14
+ cd "$HOME" 2>/dev/null # npx would resolve wisp-agents from a package.json in the current folder
15
+ say step ssh done "$(uname -sn 2>/dev/null)"
16
+ # A non-interactive SSH shell skips the profile that puts node (Homebrew, nvm, Volta…) on PATH.
17
+ SH=$SHELL; [ -n "$SH" ] || SH=/bin/sh
18
+ LP=$("$SH" -lc 'printf "%s" "$PATH"' </dev/null 2>/dev/null) && [ -n "$LP" ] && PATH="$LP:$PATH"
19
+ NVM_BIN=
20
+ for d in "$HOME"/.nvm/versions/node/*/bin; do [ -x "$d/node" ] && NVM_BIN=$d; done
21
+ [ -n "$NVM_BIN" ] && PATH="$NVM_BIN:$PATH"
22
+ PATH="$PATH:$HOME/.local/bin:$HOME/.volta/bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin"
23
+ export PATH
24
+ if ! command -v node >/dev/null 2>&1; then say fail node "Node.js isn't installed there (or isn't on PATH)."; exit 0; fi
25
+ NV=$(node -v 2>/dev/null)
26
+ if ! node -e 'process.exit(+process.versions.node.split(".")[0] >= 20 ? 0 : 1)' 2>/dev/null; then say fail node "Node $NV is too old: wisp needs 20 or newer."; exit 0; fi
27
+ say step node done "$NV"
28
+ health() { node -e 'fetch("http://127.0.0.1:" + process.argv[1] + "/api/health", { signal: AbortSignal.timeout(2500) }).then((r) => r.json().catch(() => ({ name: "other" }))).then((j) => console.log([j.name, j.version, j.apiVersion, j.pid].join(" ")), () => process.exit(1))' "$PORT" 2>/dev/null; }
29
+ H=$(health)
30
+ if [ -n "$H" ]; then
31
+ set -- $H
32
+ if [ "$1" != wisp ]; then say fail wisp "Port $PORT is used by another program there. Pick another port under Advanced."; exit 0; fi
33
+ if [ "$3" != "$API" ]; then say stale "$4"; say fail wisp "wisp $2 is already running there and speaks an older API. Restart it to update."; exit 0; fi
34
+ say step wisp done "already running · $2"
35
+ else
36
+ LOG=$WISP_HOME; [ -n "$LOG" ] || LOG=$HOME/.wisp
37
+ mkdir -p "$LOG"; LOG=$LOG/machine.log
38
+ say step wisp run "starting $CMD"
39
+ npm_config_omit=optional nohup sh -c "exec $CMD --no-open --port $PORT" >"$LOG" 2>&1 </dev/null &
40
+ PID=$!
41
+ i=0
42
+ until H=$(health) && [ -n "$H" ]; do
43
+ if ! kill -0 "$PID" 2>/dev/null; then say fail wisp "wisp stopped while starting: $(tail -n 4 "$LOG" | tr '\n' ' ')"; exit 0; fi
44
+ i=$((i+1)); if [ "$i" -ge 330 ]; then say fail wisp "wisp didn't answer within 5 minutes: $(tail -n 4 "$LOG" | tr '\n' ' ')"; exit 0; fi
45
+ sleep 1
46
+ done
47
+ set -- $H
48
+ say step wisp done "started · $2"
49
+ fi
50
+ say ready "$2" "$4"
51
+ exec cat >/dev/null
52
+ `
53
+ .replace('__PORT__', String(port))
54
+ .replace('__API__', String(apiVersion))
55
+ .replace('__CMD__', shq(command));
56
+ }
@@ -0,0 +1,127 @@
1
+ // Hosts this computer already knows how to reach (~/.ssh/config, known_hosts, Tailscale), and
2
+ // what a failed `ssh` most likely needs. Pure reads: nothing here connects anywhere.
3
+
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { execFile } from '../proc.js';
8
+
9
+ // user@host, host, host:port-less alias, [v6]. Never an option: `-oProxyCommand=…` would run code.
10
+ export const TARGET = /^(?!-)[\w.%+-]+(@[\w.%:[\]-]+)?$/;
11
+
12
+ // What a failed `ssh` most likely needs, from its stderr.
13
+ export function sshHint(stderr, target) {
14
+ const e = String(stderr || '');
15
+ if (/Permission denied|Too many authentication failures|publickey/i.test(e))
16
+ return { text: 'SSH asked for a password or a key wisp can’t use. wisp signs in with keys only: copy yours over once, or load it into ssh-agent.', command: `ssh-copy-id ${target}` };
17
+ if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(e)) return { text: 'The machine’s host key changed since you last connected. Check it in a terminal before trusting it again.', command: `ssh ${target}` };
18
+ if (/Could not resolve hostname|Name or service not known|nodename nor servname/i.test(e)) return { text: 'That name doesn’t resolve. Use user@host, an IP address, or a Host from ~/.ssh/config.' };
19
+ if (/Connection refused/i.test(e)) return { text: 'Nothing accepts SSH there. Turn on its SSH server (Remote Login on macOS, sshd on Linux).' };
20
+ if (/timed out|No route to host|Network is unreachable|Connection closed|Broken pipe/i.test(e)) return { text: 'Can’t reach that machine right now. Check the network or VPN.' };
21
+ if (/Address already in use|forwarding failed/i.test(e)) return { text: 'The local end of the tunnel was taken. Connect again to pick another port.' };
22
+ return null;
23
+ }
24
+
25
+ // Hosts from ~/.ssh/config (and the files it Includes), for the "SSH host" suggestions.
26
+ export function sshHosts(file = path.join(os.homedir(), '.ssh', 'config'), seen = new Set()) {
27
+ if (seen.has(file) || seen.size > 20) return [];
28
+ seen.add(file);
29
+ let text = '';
30
+ try {
31
+ text = fs.readFileSync(file, 'utf8');
32
+ } catch {
33
+ return [];
34
+ }
35
+ const hosts = [];
36
+ let current = []; // one Host line may name several hosts; what follows applies to each
37
+ for (const raw of text.split(/\r?\n/)) {
38
+ const line = raw.trim();
39
+ const [key, ...rest] = line.split(/\s+/);
40
+ const value = rest.join(' ');
41
+ if (/^include$/i.test(key)) {
42
+ for (const pat of value.split(/\s+/)) {
43
+ const abs = path.resolve(path.join(os.homedir(), '.ssh'), pat.replace(/^~(?=\/)/, os.homedir()));
44
+ const dir = path.dirname(abs);
45
+ const re = new RegExp(`^${path.basename(abs).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
46
+ let names = [];
47
+ try {
48
+ names = fs.readdirSync(dir).filter((n) => re.test(n));
49
+ } catch {}
50
+ for (const n of names) hosts.push(...sshHosts(path.join(dir, n), seen));
51
+ }
52
+ } else if (/^host$/i.test(key)) {
53
+ current = value
54
+ .split(/\s+/)
55
+ .filter((h) => h && !/[*?!]/.test(h))
56
+ .map((host) => ({ host, hostname: '', user: '' }));
57
+ hosts.push(...current);
58
+ } else if (/^hostname$/i.test(key)) for (const h of current) h.hostname = value;
59
+ else if (/^user$/i.test(key)) for (const h of current) h.user = value;
60
+ }
61
+ const uniq = new Map(hosts.map((h) => [h.host, h]));
62
+ return [...uniq.values()];
63
+ }
64
+
65
+ // Code hosts you push to, not computers to run agents on.
66
+ const FORGE = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org|ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com|heroku\.com|codeberg\.org|sr\.ht)$/i;
67
+
68
+ // Hosts you've already SSHed to, from ~/.ssh/known_hosts. Hashed entries and ones on a
69
+ // non-default port ([host]:2222, which a bare target can't express) are skipped.
70
+ export function knownHosts(file = path.join(os.homedir(), '.ssh', 'known_hosts')) {
71
+ let text = '';
72
+ try {
73
+ text = fs.readFileSync(file, 'utf8');
74
+ } catch {
75
+ return [];
76
+ }
77
+ const out = new Map();
78
+ for (const raw of text.split(/\r?\n/)) {
79
+ const line = raw.trim();
80
+ if (!line || line.startsWith('#') || line.startsWith('@') || line.startsWith('|')) continue;
81
+ const names = line.split(/\s+/)[0].split(',').filter((n) => n && !n.startsWith('[') && !/[*?!]/.test(n));
82
+ // "devbox,10.0.0.5": the name, with the address as a detail.
83
+ const host = names.find((n) => !/^[\d.]+$|:/.test(n)) || names[0];
84
+ if (!host || FORGE.test(host) || /^(localhost|127\.|::1$)/.test(host) || !TARGET.test(host)) continue;
85
+ if (!out.has(host)) out.set(host, { host, hostname: names.find((n) => n !== host) || '' });
86
+ }
87
+ return [...out.values()];
88
+ }
89
+
90
+ const TAILSCALE_BINS = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale'];
91
+
92
+ // Peers on your tailnet, from `tailscale status --json`. Phones and tablets don't run agents.
93
+ export function tailscalePeers({ bin = process.env.WISP_TAILSCALE, timeout = 3_000 } = {}) {
94
+ const bins = bin ? [bin] : TAILSCALE_BINS;
95
+ const run = (i) =>
96
+ new Promise((resolve) => {
97
+ if (i >= bins.length) return resolve([]);
98
+ execFile(bins[i], ['status', '--json'], { timeout, maxBuffer: 8 << 20 }, (err, stdout) => {
99
+ if (err) return resolve(err.code === 'ENOENT' ? run(i + 1) : []);
100
+ try {
101
+ const peers = Object.values(JSON.parse(stdout).Peer || {});
102
+ resolve(
103
+ peers
104
+ .filter((p) => !/^(ios|android|tvos)$/i.test(p.OS || ''))
105
+ .map((p) => ({ host: (p.DNSName || '').replace(/\.$/, '') || p.TailscaleIPs?.[0] || '', name: p.HostName || '', os: p.OS || '', online: !!p.Online }))
106
+ .filter((p) => p.host && TARGET.test(p.host)),
107
+ );
108
+ } catch {
109
+ resolve([]);
110
+ }
111
+ });
112
+ });
113
+ return run(0);
114
+ }
115
+
116
+ // Every host this computer already knows how to reach, for "Found on this computer" in Machines.
117
+ // One entry per target: ~/.ssh/config first (your own names), then the tailnet, then known_hosts.
118
+ export async function discoverHosts({ sshDir = path.join(os.homedir(), '.ssh'), tailscale } = {}) {
119
+ const out = new Map();
120
+ const put = (x) => {
121
+ if (x.target && !out.has(x.target)) out.set(x.target, x);
122
+ };
123
+ 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('@') });
124
+ 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 });
125
+ 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 });
126
+ return [...out.values()].sort((a, b) => (b.online === false ? 0 : 1) - (a.online === false ? 0 : 1));
127
+ }
@@ -15,14 +15,15 @@ import { EventEmitter } from 'node:events';
15
15
  import fs from 'node:fs';
16
16
  import http from 'node:http';
17
17
  import net from 'node:net';
18
- import os from 'node:os';
19
- import path from 'node:path';
20
18
  import readline from 'node:readline';
21
19
  import { randomBytes } from 'node:crypto';
22
- import { spawn, execFile, killTree } from './proc.js';
20
+ import { spawn, killTree } from './proc.js';
23
21
  import { newer } from './update.js';
22
+ import { bootstrapScript } from './machines/bootstrap.js';
23
+ import { sshHint, sshHosts, knownHosts, tailscalePeers, discoverHosts, TARGET } from './machines/hosts.js';
24
+ import { bad, DEFAULT_PORT, shq } from './util.js';
24
25
 
25
- export const DEFAULT_PORT = 4477;
26
+ export { DEFAULT_PORT };
26
27
  const READY_TIMEOUT_MS = 6 * 60_000; // the first `npx` download can take a while
27
28
  const RETRY_MS = [2_000, 5_000, 15_000, 30_000, 60_000];
28
29
  const LIST_TIMEOUT_MS = 5_000;
@@ -36,8 +37,7 @@ export const STEPS = [
36
37
  ];
37
38
 
38
39
  const sshBin = () => process.env.WISP_SSH || 'ssh';
39
- export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
40
- const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
40
+ export { shq, bootstrapScript, sshHint, sshHosts, knownHosts, tailscalePeers, discoverHosts };
41
41
 
42
42
  // Session ids: <remote id>@<machine id> here. Local ids never contain '@'.
43
43
  export const qualify = (id, mid) => (id ? `${id}@${mid}` : id);
@@ -50,179 +50,9 @@ export function splitId(id) {
50
50
  // custom start command decides what runs there.
51
51
  export const updateTo = (local, remote, command) => (!command && remote && newer(local, remote) ? local : null);
52
52
 
53
- // What runs on the machine. Sent on stdin, so the login shell there (bash, zsh, fish…) only ever
54
- // sees `sh -s`. Steps come back as `@@wisp step <id> <run|done> <detail>`, `@@wisp fail <id>
55
- // <message>`, and finally `@@wisp ready <version> <pid>`; then `cat` holds the session (and the
56
- // tunnel) open until this side lets go.
57
- export function bootstrapScript({ port, command, apiVersion }) {
58
- return String.raw`say() { printf '@@wisp %s\n' "$*"; }
59
- PORT=__PORT__
60
- API=__API__
61
- CMD=__CMD__
62
- cd "$HOME" 2>/dev/null # npx would resolve wisp-agents from a package.json in the current folder
63
- say step ssh done "$(uname -sn 2>/dev/null)"
64
- # A non-interactive SSH shell skips the profile that puts node (Homebrew, nvm, Volta…) on PATH.
65
- SH=$SHELL; [ -n "$SH" ] || SH=/bin/sh
66
- LP=$("$SH" -lc 'printf "%s" "$PATH"' </dev/null 2>/dev/null) && [ -n "$LP" ] && PATH="$LP:$PATH"
67
- NVM_BIN=
68
- for d in "$HOME"/.nvm/versions/node/*/bin; do [ -x "$d/node" ] && NVM_BIN=$d; done
69
- [ -n "$NVM_BIN" ] && PATH="$NVM_BIN:$PATH"
70
- PATH="$PATH:$HOME/.local/bin:$HOME/.volta/bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin"
71
- export PATH
72
- if ! command -v node >/dev/null 2>&1; then say fail node "Node.js isn't installed there (or isn't on PATH)."; exit 0; fi
73
- NV=$(node -v 2>/dev/null)
74
- if ! node -e 'process.exit(+process.versions.node.split(".")[0] >= 20 ? 0 : 1)' 2>/dev/null; then say fail node "Node $NV is too old: wisp needs 20 or newer."; exit 0; fi
75
- say step node done "$NV"
76
- health() { node -e 'fetch("http://127.0.0.1:" + process.argv[1] + "/api/health", { signal: AbortSignal.timeout(2500) }).then((r) => r.json().catch(() => ({ name: "other" }))).then((j) => console.log([j.name, j.version, j.apiVersion, j.pid].join(" ")), () => process.exit(1))' "$PORT" 2>/dev/null; }
77
- H=$(health)
78
- if [ -n "$H" ]; then
79
- set -- $H
80
- if [ "$1" != wisp ]; then say fail wisp "Port $PORT is used by another program there. Pick another port under Advanced."; exit 0; fi
81
- if [ "$3" != "$API" ]; then say stale "$4"; say fail wisp "wisp $2 is already running there and speaks an older API. Restart it to update."; exit 0; fi
82
- say step wisp done "already running · $2"
83
- else
84
- LOG=$WISP_HOME; [ -n "$LOG" ] || LOG=$HOME/.wisp
85
- mkdir -p "$LOG"; LOG=$LOG/machine.log
86
- say step wisp run "starting $CMD"
87
- npm_config_omit=optional nohup sh -c "exec $CMD --no-open --port $PORT" >"$LOG" 2>&1 </dev/null &
88
- PID=$!
89
- i=0
90
- until H=$(health) && [ -n "$H" ]; do
91
- if ! kill -0 "$PID" 2>/dev/null; then say fail wisp "wisp stopped while starting: $(tail -n 4 "$LOG" | tr '\n' ' ')"; exit 0; fi
92
- i=$((i+1)); if [ "$i" -ge 330 ]; then say fail wisp "wisp didn't answer within 5 minutes: $(tail -n 4 "$LOG" | tr '\n' ' ')"; exit 0; fi
93
- sleep 1
94
- done
95
- set -- $H
96
- say step wisp done "started · $2"
97
- fi
98
- say ready "$2" "$4"
99
- exec cat >/dev/null
100
- `
101
- .replace('__PORT__', String(port))
102
- .replace('__API__', String(apiVersion))
103
- .replace('__CMD__', shq(command));
104
- }
105
-
106
- // What a failed `ssh` most likely needs, from its stderr.
107
- export function sshHint(stderr, target) {
108
- const e = String(stderr || '');
109
- if (/Permission denied|Too many authentication failures|publickey/i.test(e))
110
- return { text: 'SSH asked for a password or a key wisp can’t use. wisp signs in with keys only: copy yours over once, or load it into ssh-agent.', command: `ssh-copy-id ${target}` };
111
- if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(e)) return { text: 'The machine’s host key changed since you last connected. Check it in a terminal before trusting it again.', command: `ssh ${target}` };
112
- if (/Could not resolve hostname|Name or service not known|nodename nor servname/i.test(e)) return { text: 'That name doesn’t resolve. Use user@host, an IP address, or a Host from ~/.ssh/config.' };
113
- if (/Connection refused/i.test(e)) return { text: 'Nothing accepts SSH there. Turn on its SSH server (Remote Login on macOS, sshd on Linux).' };
114
- if (/timed out|No route to host|Network is unreachable|Connection closed|Broken pipe/i.test(e)) return { text: 'Can’t reach that machine right now. Check the network or VPN.' };
115
- if (/Address already in use|forwarding failed/i.test(e)) return { text: 'The local end of the tunnel was taken. Connect again to pick another port.' };
116
- return null;
117
- }
118
-
119
- // Hosts from ~/.ssh/config (and the files it Includes), for the "SSH host" suggestions.
120
- export function sshHosts(file = path.join(os.homedir(), '.ssh', 'config'), seen = new Set()) {
121
- if (seen.has(file) || seen.size > 20) return [];
122
- seen.add(file);
123
- let text = '';
124
- try {
125
- text = fs.readFileSync(file, 'utf8');
126
- } catch {
127
- return [];
128
- }
129
- const hosts = [];
130
- let current = []; // one Host line may name several hosts; what follows applies to each
131
- for (const raw of text.split(/\r?\n/)) {
132
- const line = raw.trim();
133
- const [key, ...rest] = line.split(/\s+/);
134
- const value = rest.join(' ');
135
- if (/^include$/i.test(key)) {
136
- for (const pat of value.split(/\s+/)) {
137
- const abs = path.resolve(path.join(os.homedir(), '.ssh'), pat.replace(/^~(?=\/)/, os.homedir()));
138
- const dir = path.dirname(abs);
139
- const re = new RegExp(`^${path.basename(abs).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
140
- let names = [];
141
- try {
142
- names = fs.readdirSync(dir).filter((n) => re.test(n));
143
- } catch {}
144
- for (const n of names) hosts.push(...sshHosts(path.join(dir, n), seen));
145
- }
146
- } else if (/^host$/i.test(key)) {
147
- current = value
148
- .split(/\s+/)
149
- .filter((h) => h && !/[*?!]/.test(h))
150
- .map((host) => ({ host, hostname: '', user: '' }));
151
- hosts.push(...current);
152
- } else if (/^hostname$/i.test(key)) for (const h of current) h.hostname = value;
153
- else if (/^user$/i.test(key)) for (const h of current) h.user = value;
154
- }
155
- const uniq = new Map(hosts.map((h) => [h.host, h]));
156
- return [...uniq.values()];
157
- }
158
-
159
53
  // What the UI needs of a machine's harness list.
160
54
  const remoteAgent = ({ id, name, available, version, models, efforts, delegates }) => ({ id, name, available, version, models, efforts, delegates });
161
55
 
162
- // Code hosts you push to, not computers to run agents on.
163
- const FORGE = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org|ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com|heroku\.com|codeberg\.org|sr\.ht)$/i;
164
-
165
- // Hosts you've already SSHed to, from ~/.ssh/known_hosts. Hashed entries and ones on a
166
- // non-default port ([host]:2222, which a bare target can't express) are skipped.
167
- export function knownHosts(file = path.join(os.homedir(), '.ssh', 'known_hosts')) {
168
- let text = '';
169
- try {
170
- text = fs.readFileSync(file, 'utf8');
171
- } catch {
172
- return [];
173
- }
174
- const out = new Map();
175
- for (const raw of text.split(/\r?\n/)) {
176
- const line = raw.trim();
177
- if (!line || line.startsWith('#') || line.startsWith('@') || line.startsWith('|')) continue;
178
- const names = line.split(/\s+/)[0].split(',').filter((n) => n && !n.startsWith('[') && !/[*?!]/.test(n));
179
- // "devbox,10.0.0.5": the name, with the address as a detail.
180
- const host = names.find((n) => !/^[\d.]+$|:/.test(n)) || names[0];
181
- if (!host || FORGE.test(host) || /^(localhost|127\.|::1$)/.test(host) || !TARGET.test(host)) continue;
182
- if (!out.has(host)) out.set(host, { host, hostname: names.find((n) => n !== host) || '' });
183
- }
184
- return [...out.values()];
185
- }
186
-
187
- const TAILSCALE_BINS = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale'];
188
-
189
- // Peers on your tailnet, from `tailscale status --json`. Phones and tablets don't run agents.
190
- export function tailscalePeers({ bin = process.env.WISP_TAILSCALE, timeout = 3_000 } = {}) {
191
- const bins = bin ? [bin] : TAILSCALE_BINS;
192
- const run = (i) =>
193
- new Promise((resolve) => {
194
- if (i >= bins.length) return resolve([]);
195
- execFile(bins[i], ['status', '--json'], { timeout, maxBuffer: 8 << 20 }, (err, stdout) => {
196
- if (err) return resolve(err.code === 'ENOENT' ? run(i + 1) : []);
197
- try {
198
- const peers = Object.values(JSON.parse(stdout).Peer || {});
199
- resolve(
200
- peers
201
- .filter((p) => !/^(ios|android|tvos)$/i.test(p.OS || ''))
202
- .map((p) => ({ host: (p.DNSName || '').replace(/\.$/, '') || p.TailscaleIPs?.[0] || '', name: p.HostName || '', os: p.OS || '', online: !!p.Online }))
203
- .filter((p) => p.host && TARGET.test(p.host)),
204
- );
205
- } catch {
206
- resolve([]);
207
- }
208
- });
209
- });
210
- return run(0);
211
- }
212
-
213
- // Every host this computer already knows how to reach, for "Found on this computer" in Machines.
214
- // One entry per target: ~/.ssh/config first (your own names), then the tailnet, then known_hosts.
215
- export async function discoverHosts({ sshDir = path.join(os.homedir(), '.ssh'), tailscale } = {}) {
216
- const out = new Map();
217
- const put = (x) => {
218
- if (x.target && !out.has(x.target)) out.set(x.target, x);
219
- };
220
- 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('@') });
221
- 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 });
222
- 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 });
223
- return [...out.values()].sort((a, b) => (b.online === false ? 0 : 1) - (a.online === false ? 0 : 1));
224
- }
225
-
226
56
  const freePort = () =>
227
57
  new Promise((resolve, reject) => {
228
58
  const s = net.createServer().once('error', reject);
@@ -240,8 +70,6 @@ const readAll = (res) =>
240
70
  res.on('error', reject);
241
71
  });
242
72
 
243
- // user@host, host, host:port-less alias, [v6]. Never an option: `-oProxyCommand=…` would run code.
244
- const TARGET = /^(?!-)[\w.%+-]+(@[\w.%:[\]-]+)?$/;
245
73
 
246
74
  export class Machines extends EventEmitter {
247
75
  // onEvent(event, data): a session event from a machine, ids already rewritten.
@@ -572,7 +400,7 @@ export class Machines extends EventEmitter {
572
400
  #request(m, c, method, p, { body, headers = {}, timeout = 120_000 } = {}) {
573
401
  return new Promise((resolve, reject) => {
574
402
  const req = http.request({ host: '127.0.0.1', port: c.localPort, path: `/api${p}`, method, headers: { ...headers, host: `127.0.0.1:${m.port}` }, timeout }, resolve);
575
- req.on('timeout', () => req.destroy(Object.assign(new Error(`${m.name} didn’t answer in time.`), { status: 504 })));
403
+ req.on('timeout', () => req.destroy(bad(`${m.name} didn’t answer in time.`, 504)));
576
404
  req.on('error', (err) => reject(Object.assign(err, { status: err.status || 502 })));
577
405
  if (body && typeof body.pipe === 'function') body.pipe(req);
578
406
  else req.end(body);
@@ -5,10 +5,9 @@
5
5
  // claude mcp add wisp -- npx -y wisp-agents mcp
6
6
 
7
7
  import readline from 'node:readline';
8
- import fs from 'node:fs';
9
8
  import { WispClient } from './client.js';
9
+ import { VERSION } from './util.js';
10
10
 
11
- const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
11
  const AGENTS = ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'];
13
12
  const DEFAULT_WAIT_S = 240; // stay under typical MCP client tool-call timeouts; call wisp_wait again for more
14
13
 
@@ -66,8 +65,21 @@ const TOOLS = [
66
65
  name: 'wisp_reply',
67
66
  description:
68
67
  'Send a follow-up to a session; it resumes the same agent conversation. If the agent is waiting on a question, this answers it. ' +
69
- 'A follow-up sent while it runs interrupts it (queue=true waits for the run to finish instead). mode switches the session’s mode from this follow-up on.',
70
- inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' }, queue: { type: 'boolean' }, mode: { type: 'string', enum: ['plan', 'edits', 'auto'] } }, required: ['id', 'text'] },
68
+ 'A follow-up sent while it runs interrupts it (queue=true waits for the run to finish instead). mode switches the session’s mode from this follow-up on. ' +
69
+ 'agent / model / effort switch the harness, model or thinking level from this follow-up on (see wisp_agents); a new harness is handed the conversation so far.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ id: { type: 'string' },
74
+ text: { type: 'string' },
75
+ queue: { type: 'boolean' },
76
+ mode: { type: 'string', enum: ['plan', 'edits', 'auto'] },
77
+ agent: { type: 'string', description: 'Harness id, e.g. claude, codex, opencode' },
78
+ model: { type: 'string', description: 'Model id for that harness; empty for its default' },
79
+ effort: { type: 'string', description: 'Thinking level; empty for the default' },
80
+ },
81
+ required: ['id', 'text'],
82
+ },
71
83
  },
72
84
  {
73
85
  name: 'wisp_stop',
@@ -118,7 +130,7 @@ async function callTool(name, a = {}) {
118
130
  return waitFor(a.id);
119
131
  case 'wisp_reply': {
120
132
  if (!a.text?.trim()) throw new Error('text is required');
121
- const r = await c.reply(a.id, a.text.trim(), { queue: a.queue, mode: a.mode });
133
+ const r = await c.reply(a.id, a.text.trim(), { queue: a.queue, mode: a.mode, agent: a.agent, model: a.model, effort: a.effort });
122
134
  return r.answered ? 'Answered the agent’s question; it is continuing.' : r.interrupted ? 'Interrupted the run; it resumes with your message.' : r.queued ? 'Queued; it goes out when the current run finishes.' : 'Sent; the agent is working on it.';
123
135
  }
124
136
  case 'wisp_stop':
package/server/openapi.js CHANGED
@@ -50,6 +50,13 @@ export function openapi({ version, port }) {
50
50
  patch: { summary: 'Auto settings', requestBody: body({ type: 'object', properties: { goal: { enum: ['cheap', 'balanced', 'best'] }, explore: { type: 'boolean', description: 'Let Auto try new models on a few tasks' }, allowed: { type: 'array', items: { type: 'string' }, description: 'Harness ids Auto may pick; empty or null = all' } } }), responses: { 200: json({ type: 'object' }) } },
51
51
  },
52
52
  '/router/reset': { post: { summary: 'Forget what Auto learned here (the shipped starting point stays)', responses: { 200: json({ type: 'object' }) } } },
53
+ '/agents/install': {
54
+ post: {
55
+ summary: 'Install a harness into ~/.wisp/tools with npm (only opencode: its free models need no account), then detect again',
56
+ requestBody: body({ type: 'object', required: ['id'], properties: { id: { type: 'string', enum: ['opencode'] } } }),
57
+ responses: { 200: json({ type: 'array', items: ref('Agent') }), 400: err[400] },
58
+ },
59
+ },
53
60
  '/agents/recheck': { post: { summary: 'Detect harnesses and models again, here and on every online machine', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
54
61
  '/feedback': {
55
62
  post: {
@@ -129,6 +136,16 @@ export function openapi({ version, port }) {
129
136
  parameters: [id],
130
137
  post: { summary: 'Mark the PR ready if draft, squash-merge it with --match-head-commit, fast-forward the local base branch, remove the worktree', responses: { 200: json({ type: 'object', properties: { pr: ref('PullRequest'), sync: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' } } } } }), 400: err[400] } },
131
138
  },
139
+ '/teams': {
140
+ get: { summary: 'Saved subagent presets: a team the lead can delegate to', responses: { 200: json({ type: 'array', items: ref('Team') }) } },
141
+ post: { summary: 'Save a subagent preset', requestBody: body(ref('Team')), responses: { 201: json(ref('Team')), 400: err[400] } },
142
+ },
143
+ '/teams/{tid}': {
144
+ parameters: [{ name: 'tid', in: 'path', required: true, schema: { type: 'string' } }],
145
+ get: { summary: 'One subagent preset', responses: { 200: json(ref('Team')), 404: err[404] } },
146
+ patch: { summary: 'Edit a subagent preset', requestBody: body(ref('Team')), responses: { 200: json(ref('Team')), 400: err[400], 404: err[404] } },
147
+ delete: { summary: 'Delete a subagent preset', responses: { 200: ok, 404: err[404] } },
148
+ },
132
149
  '/recipes': {
133
150
  get: { summary: 'Saved recipes, with the {params} found in each prompt', responses: { 200: json({ type: 'array', items: ref('Recipe') }) } },
134
151
  post: { summary: 'Save a recipe', requestBody: body(ref('Recipe')), responses: { 201: json(ref('Recipe')), 400: err[400] } },
@@ -205,7 +222,7 @@ export function openapi({ version, port }) {
205
222
  parameters: [id],
206
223
  post: {
207
224
  summary: 'Follow up (answers a pending ask_user question if there is one)',
208
- requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' }, mode: { enum: ['plan', 'edits', 'auto'], description: 'Switch the session to this mode from this follow-up on' } } }),
225
+ requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' }, mode: { enum: ['plan', 'edits', 'auto'], description: 'Switch the session to this mode from this follow-up on' }, agent: { type: 'string', description: 'Switch to this harness from this follow-up on; it is handed the turns it has not seen' }, model: { type: 'string', description: 'Switch model (empty: the harness default)' }, effort: { type: 'string', description: 'Switch thinking level (empty: the default)' } } }),
209
226
  responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
210
227
  },
211
228
  },
@@ -249,9 +266,13 @@ export function openapi({ version, port }) {
249
266
  responses: { 201: json({ type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, size: { type: 'integer' }, mime: { type: 'string' } } }) },
250
267
  },
251
268
  },
269
+ '/uploads/{uid}': {
270
+ parameters: [{ name: 'uid', in: 'path', required: true, schema: { type: 'string' }, description: 'Upload id (<id>@<machine> for one on a machine)' }],
271
+ get: { summary: 'The uploaded file, with its MIME type', responses: { 200: { description: 'The file' }, 404: err[404] } },
272
+ },
252
273
  '/events': {
253
274
  get: {
254
- summary: 'Server-sent events: summary, activity, turn, finished, removed, config, preferences, ping',
275
+ summary: 'Server-sent events: summary, activity, turn, finished, removed, config, preferences, agents, ping',
255
276
  responses: { 200: { description: 'text/event-stream', content: { 'text/event-stream': { schema: { type: 'string' } } } } },
256
277
  },
257
278
  },
@@ -294,6 +315,16 @@ export function openapi({ version, port }) {
294
315
  base: { type: 'string' },
295
316
  },
296
317
  },
318
+ Team: {
319
+ type: 'object',
320
+ required: ['name', 'members'],
321
+ properties: {
322
+ id: { type: 'string', readOnly: true },
323
+ name: { type: 'string' },
324
+ members: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, agent: { type: 'string', description: 'Harness id, or auto' }, model: { type: 'string' }, effort: { type: 'string' }, use: { type: 'string', description: 'What the lead should use it for' }, count: { type: 'integer', minimum: 1 } } } },
325
+ updatedAt: { type: 'integer', readOnly: true },
326
+ },
327
+ },
297
328
  Recipe: {
298
329
  type: 'object',
299
330
  required: ['name', 'prompt'],
@@ -0,0 +1,32 @@
1
+ // Project-relative paths and the size cap for the file contents wisp keeps (diffs, checkpoints).
2
+ // Shared by the adapters (what a harness changed) and checkpoint.js (reverting it).
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ // Larger files are tracked as changed, but their contents aren't kept.
8
+ export const MAX_SNAPSHOT = 256 * 1024;
9
+
10
+ const realDirs = new Map();
11
+ function realDir(dir) {
12
+ if (!realDirs.has(dir)) {
13
+ try {
14
+ realDirs.set(dir, fs.realpathSync(dir));
15
+ } catch {
16
+ realDirs.set(dir, dir);
17
+ }
18
+ }
19
+ return realDirs.get(dir);
20
+ }
21
+
22
+ // Path relative to cwd, or the path as given if it lies outside. Harnesses may report paths
23
+ // through the resolved cwd (/private/tmp vs /tmp on macOS), so both spellings count.
24
+ export function rel(file, cwd) {
25
+ if (!file) return '';
26
+ const abs = path.resolve(cwd, file);
27
+ for (const base of [cwd, realDir(cwd)]) {
28
+ const r = path.relative(base, abs);
29
+ if (r && !r.startsWith('..') && !path.isAbsolute(r)) return r;
30
+ }
31
+ return file;
32
+ }
@@ -14,6 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { adapters } from './adapters/index.js';
16
16
  import { heroOf } from './protocol.js';
17
+ import { clip, writeJsonAtomic } from './util.js';
17
18
 
18
19
  export const KINDS = ['preferences', 'lessons'];
19
20
  export const MAX_ITEMS = 10; // per list
@@ -23,10 +24,6 @@ const MAX_HISTORY = 30; // retro runs kept for Settings, newest last
23
24
  const RETRO_TIMEOUT_MS = 3 * 60_000;
24
25
  const IDLE_MS = Number(process.env.WISP_RETRO_IDLE_MS ?? 90_000); // quiet time before a retro runs
25
26
 
26
- const clip = (s, n) => {
27
- s = String(s ?? '').replace(/\s+/g, ' ').trim();
28
- return s.length > n ? `${s.slice(0, n - 1)}…` : s;
29
- };
30
27
  const emptyScope = () => ({ preferences: [], lessons: [], updatedAt: 0 });
31
28
 
32
29
  // Trim, dedupe (case-insensitively), cap length and count. Newest items win the cap.
@@ -66,8 +63,7 @@ export class Preferences {
66
63
  }
67
64
 
68
65
  #save() {
69
- fs.writeFileSync(`${this.file}.tmp`, JSON.stringify(this.data, null, 2));
70
- fs.renameSync(`${this.file}.tmp`, this.file);
66
+ writeJsonAtomic(this.file, this.data, 2);
71
67
  }
72
68
 
73
69
  // scope: 'global' or a project path; kind: 'preferences' | 'lessons'.