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.
- package/CHANGELOG.md +55 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +7 -3
- package/server/adapters/claude.js +79 -55
- package/server/adapters/codex.js +5 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +4 -2
- package/server/adapters/grok.js +17 -2
- package/server/adapters/index.js +25 -2
- package/server/adapters/opencode.js +78 -12
- package/server/checkpoint.js +12 -68
- package/server/client.js +6 -4
- package/server/git.js +9 -4
- package/server/handoff.js +71 -0
- package/server/http/respond.js +59 -0
- package/server/http/router.js +64 -0
- package/server/http/static.js +36 -0
- package/server/index.js +138 -1216
- package/server/machines/bootstrap.js +56 -0
- package/server/machines/hosts.js +127 -0
- package/server/machines.js +7 -179
- package/server/mcp-ask.js +113 -8
- package/server/mcp-wisp.js +17 -5
- package/server/mentions.js +39 -0
- package/server/openapi.js +36 -4
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/protocol.js +8 -1
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/render.js +3 -1
- package/server/router.js +7 -3
- package/server/routes/auto.js +29 -0
- package/server/routes/automations.js +92 -0
- package/server/routes/internal.js +176 -0
- package/server/routes/machines.js +43 -0
- package/server/routes/meta.js +76 -0
- package/server/routes/proxy.js +47 -0
- package/server/routes/sessions.js +351 -0
- package/server/routes/settings.js +144 -0
- package/server/routes/ship.js +61 -0
- package/server/routes/uploads.js +19 -0
- package/server/runner.js +216 -49
- package/server/services/auto.js +67 -0
- package/server/services/feedback.js +53 -0
- package/server/services/install.js +69 -0
- package/server/services/launch.js +127 -0
- package/server/services/phone.js +65 -0
- package/server/services/ship.js +82 -0
- package/server/services/telemetry.js +34 -0
- package/server/services/uploads.js +59 -0
- package/server/services/webhooks.js +28 -0
- package/server/store.js +13 -5
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +5 -7
- package/server/update.js +4 -3
- package/server/usage.js +94 -0
- package/server/util.js +42 -0
- package/server/welcome.js +3 -3
- package/web/css/auto.css +29 -0
- package/web/css/automations.css +71 -0
- package/web/css/base.css +83 -0
- package/web/css/blocks.css +461 -0
- package/web/css/composer.css +84 -0
- package/web/css/feed.css +235 -0
- package/web/css/launcher.css +204 -0
- package/web/css/layout.css +61 -0
- package/web/css/machines.css +125 -0
- package/web/css/modals.css +176 -0
- package/web/css/palette.css +79 -0
- package/web/css/responsive.css +35 -0
- package/web/css/ship.css +95 -0
- package/web/css/subagents.css +130 -0
- package/web/css/tokens.css +115 -0
- package/web/index.html +17 -1
- package/web/js/app.js +28 -5237
- package/web/js/blocks.js +46 -0
- package/web/js/core/api.js +25 -0
- package/web/js/core/bus.js +28 -0
- package/web/js/core/dom.js +7 -0
- package/web/js/core/format.js +27 -0
- package/web/js/core/state.js +43 -0
- package/web/js/features/actions.js +344 -0
- package/web/js/features/attachments.js +115 -0
- package/web/js/features/auto.js +54 -0
- package/web/js/features/automations.js +305 -0
- package/web/js/features/commands.js +123 -0
- package/web/js/features/compare.js +105 -0
- package/web/js/features/composer.js +315 -0
- package/web/js/features/feed.js +400 -0
- package/web/js/features/feedback.js +127 -0
- package/web/js/features/keys.js +83 -0
- package/web/js/features/launcher.js +561 -0
- package/web/js/features/live.js +91 -0
- package/web/js/features/machines.js +313 -0
- package/web/js/features/memory.js +154 -0
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +36 -0
- package/web/js/features/phone.js +124 -0
- package/web/js/features/places.js +38 -0
- package/web/js/features/projects.js +284 -0
- package/web/js/features/runner-picker.js +233 -0
- package/web/js/features/runners.js +163 -0
- package/web/js/features/scroll.js +75 -0
- package/web/js/features/settings.js +390 -0
- package/web/js/features/setup.js +94 -0
- package/web/js/features/ship.js +122 -0
- package/web/js/features/team.js +386 -0
- package/web/js/features/turns.js +432 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/mention-text.js +59 -0
- package/web/js/ui/kbd.js +46 -0
- package/web/js/ui/overlay.js +38 -0
- package/web/js/ui/popover.js +52 -0
- package/web/js/ui/toast.js +23 -0
- 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
|
+
}
|
package/server/machines.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
});
|
package/server/mcp-wisp.js
CHANGED
|
@@ -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
|
-
|
|
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':
|