wisp-agents 0.15.0 → 0.17.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 (122) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +29 -13
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +7 -3
  6. package/server/adapters/claude.js +79 -55
  7. package/server/adapters/codex.js +5 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +4 -2
  10. package/server/adapters/grok.js +17 -2
  11. package/server/adapters/index.js +25 -2
  12. package/server/adapters/opencode.js +78 -12
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +6 -4
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +71 -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-ask.js +113 -8
  25. package/server/mcp-wisp.js +17 -5
  26. package/server/mentions.js +39 -0
  27. package/server/openapi.js +36 -4
  28. package/server/paths.js +32 -0
  29. package/server/preferences.js +2 -6
  30. package/server/proc.js +54 -0
  31. package/server/protocol.js +8 -1
  32. package/server/recipes.js +2 -4
  33. package/server/remote.js +14 -14
  34. package/server/render.js +3 -1
  35. package/server/router.js +7 -3
  36. package/server/routes/auto.js +29 -0
  37. package/server/routes/automations.js +92 -0
  38. package/server/routes/internal.js +176 -0
  39. package/server/routes/machines.js +43 -0
  40. package/server/routes/meta.js +76 -0
  41. package/server/routes/proxy.js +47 -0
  42. package/server/routes/sessions.js +351 -0
  43. package/server/routes/settings.js +144 -0
  44. package/server/routes/ship.js +61 -0
  45. package/server/routes/uploads.js +19 -0
  46. package/server/runner.js +216 -49
  47. package/server/services/auto.js +67 -0
  48. package/server/services/feedback.js +53 -0
  49. package/server/services/install.js +69 -0
  50. package/server/services/launch.js +127 -0
  51. package/server/services/phone.js +65 -0
  52. package/server/services/ship.js +82 -0
  53. package/server/services/telemetry.js +34 -0
  54. package/server/services/uploads.js +59 -0
  55. package/server/services/webhooks.js +28 -0
  56. package/server/store.js +13 -5
  57. package/server/subagents.js +5 -4
  58. package/server/telemetry.js +1 -1
  59. package/server/terminal.js +2 -1
  60. package/server/triggers.js +5 -7
  61. package/server/update.js +4 -3
  62. package/server/usage.js +94 -0
  63. package/server/util.js +42 -0
  64. package/server/welcome.js +3 -3
  65. package/web/css/auto.css +29 -0
  66. package/web/css/automations.css +71 -0
  67. package/web/css/base.css +83 -0
  68. package/web/css/blocks.css +461 -0
  69. package/web/css/composer.css +84 -0
  70. package/web/css/feed.css +235 -0
  71. package/web/css/launcher.css +204 -0
  72. package/web/css/layout.css +61 -0
  73. package/web/css/machines.css +125 -0
  74. package/web/css/modals.css +176 -0
  75. package/web/css/palette.css +79 -0
  76. package/web/css/responsive.css +35 -0
  77. package/web/css/ship.css +95 -0
  78. package/web/css/subagents.css +130 -0
  79. package/web/css/tokens.css +115 -0
  80. package/web/index.html +17 -1
  81. package/web/js/app.js +28 -5237
  82. package/web/js/blocks.js +46 -0
  83. package/web/js/core/api.js +25 -0
  84. package/web/js/core/bus.js +28 -0
  85. package/web/js/core/dom.js +7 -0
  86. package/web/js/core/format.js +27 -0
  87. package/web/js/core/state.js +43 -0
  88. package/web/js/features/actions.js +344 -0
  89. package/web/js/features/attachments.js +115 -0
  90. package/web/js/features/auto.js +54 -0
  91. package/web/js/features/automations.js +305 -0
  92. package/web/js/features/commands.js +123 -0
  93. package/web/js/features/compare.js +105 -0
  94. package/web/js/features/composer.js +315 -0
  95. package/web/js/features/feed.js +400 -0
  96. package/web/js/features/feedback.js +127 -0
  97. package/web/js/features/keys.js +83 -0
  98. package/web/js/features/launcher.js +561 -0
  99. package/web/js/features/live.js +91 -0
  100. package/web/js/features/machines.js +313 -0
  101. package/web/js/features/memory.js +154 -0
  102. package/web/js/features/mentions.js +125 -0
  103. package/web/js/features/notify.js +36 -0
  104. package/web/js/features/phone.js +124 -0
  105. package/web/js/features/places.js +38 -0
  106. package/web/js/features/projects.js +284 -0
  107. package/web/js/features/runner-picker.js +233 -0
  108. package/web/js/features/runners.js +163 -0
  109. package/web/js/features/scroll.js +75 -0
  110. package/web/js/features/settings.js +390 -0
  111. package/web/js/features/setup.js +94 -0
  112. package/web/js/features/ship.js +122 -0
  113. package/web/js/features/team.js +386 -0
  114. package/web/js/features/turns.js +432 -0
  115. package/web/js/features/update.js +48 -0
  116. package/web/js/features/usage.js +75 -0
  117. package/web/js/mention-text.js +59 -0
  118. package/web/js/ui/kbd.js +46 -0
  119. package/web/js/ui/overlay.js +38 -0
  120. package/web/js/ui/popover.js +52 -0
  121. package/web/js/ui/toast.js +23 -0
  122. 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);
package/server/mcp-ask.js CHANGED
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env node
2
- // Minimal stdio MCP server exposing `ask_user`, injected into every agent wisp launches.
2
+ // Minimal stdio MCP server exposing `ask_user`, `wisp_stats`, `wisp_sessions` and `wisp_session`, injected into every agent wisp launches.
3
3
  // The call is relayed to the wisp server, which surfaces it in the queue and blocks
4
4
  // (by polling) until the user answers — so the agent keeps its full context.
5
5
  // With a team (WISP_TEAM), it also offers `delegate` and `subagent_results`: tasks for the
6
6
  // subagents the user picked, which wisp runs as child sessions while this call waits.
7
+ // With WISP_APPROVE, it offers `approve`: the harness's permission prompt (Claude's
8
+ // --permission-prompt-tool), answered by the user in wisp.
9
+ // `wisp_stats` is read-only: tool counts, spend and success rate across the user's wisp sessions.
10
+ // `wisp_sessions` and `wisp_session` are read-only too: list the user's other tasks and read one's
11
+ // reports, e.g. a task the user mentioned in the prompt.
7
12
 
8
13
  import readline from 'node:readline';
9
14
 
10
- const { WISP_URL, WISP_TOKEN, WISP_SESSION } = process.env;
15
+ const { WISP_URL, WISP_TOKEN, WISP_SESSION, WISP_APPROVE } = process.env;
11
16
  let TEAM = [];
12
17
  try {
13
18
  TEAM = JSON.parse(process.env.WISP_TEAM || '[]');
@@ -30,6 +35,83 @@ const TOOL = {
30
35
  },
31
36
  };
32
37
 
38
+ // Claude's permission-prompt contract: { tool_name, input, tool_use_id } in, a JSON
39
+ // { behavior: 'allow', updatedInput } or { behavior: 'deny', message } back as text.
40
+ const APPROVE = {
41
+ name: 'approve',
42
+ description: 'Internal: wisp asks the user to approve a tool call your permissions do not allow on their own. Never call this yourself.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } },
46
+ required: ['tool_name'],
47
+ },
48
+ };
49
+
50
+ const STATS = {
51
+ name: 'wisp_stats',
52
+ description:
53
+ 'Read-only stats about the user\'s past wisp tasks: how many tool calls each kind of action made (run = shell, read, edit, ' +
54
+ 'search, web, agent, tool = other MCP tools, ask), broken down by harness, project and day, plus turns, cost, tokens and ' +
55
+ 'success rate. Use when the user asks about their wisp usage, which tools or harnesses their tasks use, or what tasks cost.',
56
+ inputSchema: {
57
+ type: 'object',
58
+ properties: {
59
+ days: { type: 'number', description: 'How many days back, 1–365. Default 30.' },
60
+ agent: { type: 'string', description: 'Only this harness, e.g. claude, codex, opencode.' },
61
+ project: { type: 'string', description: 'Only this project (name or id); "current" = the project of this task.' },
62
+ },
63
+ },
64
+ };
65
+
66
+ const SESSIONS = {
67
+ name: 'wisp_sessions',
68
+ description:
69
+ 'List the user\'s other wisp tasks, newest first: id, title, state (running | waiting | review | done), harness, project, ' +
70
+ 'last update and a one-line summary. Read-only. Use it to find a task the user refers to; read one with wisp_session.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {
74
+ query: { type: 'string', description: 'Only tasks whose title or summary has all these words.' },
75
+ state: { type: 'string', description: 'Comma-separated, e.g. "running,waiting".' },
76
+ project: { type: 'string', description: 'Only this project (name or id); "current" = the project of this task.' },
77
+ archived: { type: 'boolean', description: 'Include archived tasks. Default false.' },
78
+ limit: { type: 'number', description: 'Default 20, at most 100.' },
79
+ },
80
+ },
81
+ };
82
+
83
+ const SESSION = {
84
+ name: 'wisp_session',
85
+ description:
86
+ 'Read another wisp task by id: its title, state, harness, folder and branch, every request the user made in it, and a ' +
87
+ 'turn\'s report as markdown (summary, diffs, tests, next steps). Read-only. Use it when the prompt mentions a wisp task ' +
88
+ '("@Title (wisp task <id>)") or the user asks what another task did.',
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: {
92
+ id: { type: 'string', description: 'The task id.' },
93
+ turn: { type: 'number', description: 'Which turn\'s report, 1 = first. Default: the latest.' },
94
+ },
95
+ required: ['id'],
96
+ },
97
+ };
98
+
99
+ async function sessions(args = {}) {
100
+ const q = new URLSearchParams(Object.entries(args).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => [k, String(v)]));
101
+ return (await call(`/api/internal/sessions/${WISP_SESSION}?${q}`)).text;
102
+ }
103
+
104
+ async function session({ id, turn } = {}) {
105
+ if (!id) throw new Error('id is required');
106
+ const q = new URLSearchParams({ of: String(id), ...(turn !== undefined && { turn: String(turn) }) });
107
+ return (await call(`/api/internal/session/${WISP_SESSION}?${q}`)).text;
108
+ }
109
+
110
+ async function stats({ days, agent, project } = {}) {
111
+ const q = new URLSearchParams(Object.entries({ days, agent, project }).filter(([, v]) => v !== undefined && v !== ''));
112
+ return (await call(`/api/internal/stats/${WISP_SESSION}?${q}`)).text;
113
+ }
114
+
33
115
  const roster = TEAM.map((m) => `- ${m.name}${m.count > 1 ? ` ×${m.count} (parallel copies ${m.copies.join(', ')})` : ''}: ${[m.agent, m.model || 'default model', m.effort && `${m.effort} thinking`].filter(Boolean).join(' · ')}${m.use ? ` — use for: ${m.use}` : ''}`).join('\n');
34
116
  const TEAM_TOOLS = [
35
117
  {
@@ -104,8 +186,8 @@ async function call(path, body) {
104
186
  return res.json();
105
187
  }
106
188
 
107
- async function askUser(args) {
108
- const { id } = await call(`/api/internal/ask`, { session: WISP_SESSION, ...args });
189
+ // Poll a question until it's answered ({ answer, … }) or cancelled (null).
190
+ async function waitFor(id) {
109
191
  for (;;) {
110
192
  await sleep(1500);
111
193
  let r;
@@ -114,11 +196,24 @@ async function askUser(args) {
114
196
  } catch {
115
197
  continue; // wisp restarting — keep waiting
116
198
  }
117
- if (r.answer !== undefined) return r.answer;
199
+ if (r.answer !== undefined) return r;
118
200
  if (r.cancelled) return null;
119
201
  }
120
202
  }
121
203
 
204
+ async function askUser(args) {
205
+ const { id } = await call(`/api/internal/ask`, { session: WISP_SESSION, ...args });
206
+ return (await waitFor(id))?.answer ?? null;
207
+ }
208
+
209
+ async function approve({ tool_name: tool, input = {} }) {
210
+ let r = await call(`/api/internal/approve`, { session: WISP_SESSION, tool, input });
211
+ if (r.id) r = await waitFor(r.id);
212
+ if (r?.decision === 'allow') return { behavior: 'allow', updatedInput: input };
213
+ if (!r) return { behavior: 'deny', message: 'The user did not answer (the request was cancelled). Do not retry it; carry on without it and say what was skipped.' };
214
+ return { behavior: 'deny', message: r.message ? `The user denied this and said: ${r.message}` : 'The user denied this. Do not retry it; carry on without it or ask how to proceed.' };
215
+ }
216
+
122
217
  readline.createInterface({ input: process.stdin }).on('line', async (line) => {
123
218
  let msg;
124
219
  try {
@@ -133,11 +228,19 @@ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
133
228
  if (method === 'initialize') {
134
229
  send({ id, result: { protocolVersion: params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'wisp', version: '0.1.0' } } });
135
230
  } else if (method === 'tools/list') {
136
- send({ id, result: { tools: TEAM.length ? [TOOL, ...TEAM_TOOLS] : [TOOL] } });
231
+ send({ id, result: { tools: [TOOL, STATS, SESSIONS, SESSION, ...(WISP_APPROVE ? [APPROVE] : []), ...(TEAM.length ? TEAM_TOOLS : [])] } });
137
232
  } else if (method === 'tools/call' && params?.name === 'ask_user') {
138
233
  const answer = await askUser(params.arguments || {});
139
234
  const text = answer === null ? 'The user did not answer (the question was cancelled). Use your best judgment and note the assumption.' : `The user answered: ${answer}`;
140
235
  send({ id, result: { content: [{ type: 'text', text }] } });
236
+ } else if (method === 'tools/call' && WISP_APPROVE && params?.name === 'approve') {
237
+ send({ id, result: { content: [{ type: 'text', text: JSON.stringify(await approve(params.arguments || {})) }] } });
238
+ } else if (method === 'tools/call' && params?.name === 'wisp_stats') {
239
+ send({ id, result: { content: [{ type: 'text', text: await stats(params.arguments || {}) }] } });
240
+ } else if (method === 'tools/call' && params?.name === 'wisp_sessions') {
241
+ send({ id, result: { content: [{ type: 'text', text: await sessions(params.arguments || {}) }] } });
242
+ } else if (method === 'tools/call' && params?.name === 'wisp_session') {
243
+ send({ id, result: { content: [{ type: 'text', text: await session(params.arguments || {}) }] } });
141
244
  } else if (method === 'tools/call' && TEAM.length && params?.name === 'delegate') {
142
245
  send({ id, result: { content: [{ type: 'text', text: await delegate(params.arguments || {}) }] } });
143
246
  } else if (method === 'tools/call' && TEAM.length && params?.name === 'subagent_results') {
@@ -149,7 +252,9 @@ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
149
252
  send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
150
253
  }
151
254
  } catch (err) {
152
- const what = params?.name === 'ask_user' ? 'wisp could not reach the user' : 'wisp';
153
- send({ id, result: { isError: true, content: [{ type: 'text', text: `${what}: ${err.message}` }] } });
255
+ const what = params?.name === 'ask_user' || params?.name === 'approve' ? 'wisp could not reach the user' : 'wisp';
256
+ // A permission prompt that errors would leave the harness guessing: deny, and say why.
257
+ if (params?.name === 'approve') send({ id, result: { content: [{ type: 'text', text: JSON.stringify({ behavior: 'deny', message: `${what}: ${err.message}` }) }] } });
258
+ else send({ id, result: { isError: true, content: [{ type: 'text', text: `${what}: ${err.message}` }] } });
154
259
  }
155
260
  });
@@ -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':