wisp-agents 0.8.0 → 0.9.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 +20 -1
- package/README.md +28 -12
- package/bin/wisp.js +1 -1
- package/package.json +2 -2
- package/server/adapters/claude.js +3 -1
- package/server/adapters/grok.js +251 -0
- package/server/adapters/index.js +2 -1
- package/server/adapters/opencode.js +3 -1
- package/server/index.js +169 -18
- package/server/machines.js +672 -0
- package/server/mcp-ask.js +1 -1
- package/server/mcp-wisp.js +2 -2
- package/server/openapi.js +17 -2
- package/server/remote.js +8 -4
- package/server/runner.js +2 -0
- package/server/store.js +11 -6
- package/server/subagents.js +2 -1
- package/skills/wisp/SKILL.md +2 -2
- package/web/css/app.css +166 -39
- package/web/index.html +5 -0
- package/web/js/app.js +603 -80
- package/web/logos/grok.svg +1 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
// Machines: run agents on your other computers over SSH. wisp starts itself on the machine (or
|
|
2
|
+
// uses the one already running there), reaches it through an SSH tunnel and shows its sessions
|
|
3
|
+
// here, so worktrees, diffs, rollback, ask_user and Ship it work over there unchanged.
|
|
4
|
+
//
|
|
5
|
+
// connect one `ssh -L <free port>:127.0.0.1:<port> <host> sh -s` per machine. The script on
|
|
6
|
+
// stdin finds Node, starts `npx -y wisp-agents@<this version>` detached if nothing
|
|
7
|
+
// answers on <port>, reports each step as an `@@wisp …` line, then holds the session.
|
|
8
|
+
// requests go to the forwarded local port with the Host header the remote server insists on.
|
|
9
|
+
// sessions are <id>@<machine> here. Summaries, sessions and events are rewritten on the way.
|
|
10
|
+
//
|
|
11
|
+
// Nothing new listens on the network: the remote wisp stays on its own loopback, and it keeps
|
|
12
|
+
// running (agents too) when this computer sleeps. Auth is whatever your SSH setup does.
|
|
13
|
+
|
|
14
|
+
import { EventEmitter } from 'node:events';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import http from 'node:http';
|
|
17
|
+
import net from 'node:net';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import readline from 'node:readline';
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import { spawn, killTree } from './proc.js';
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_PORT = 4477;
|
|
25
|
+
const READY_TIMEOUT_MS = 6 * 60_000; // the first `npx` download can take a while
|
|
26
|
+
const RETRY_MS = [2_000, 5_000, 15_000, 30_000, 60_000];
|
|
27
|
+
const LIST_TIMEOUT_MS = 5_000;
|
|
28
|
+
const MAX_LOG = 80;
|
|
29
|
+
export const STEPS = [
|
|
30
|
+
['ssh', 'Connect over SSH'],
|
|
31
|
+
['node', 'Find Node.js 20+'],
|
|
32
|
+
['wisp', 'Start wisp there'],
|
|
33
|
+
['tunnel', 'Open the tunnel'],
|
|
34
|
+
['agents', 'Find harnesses'],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const sshBin = () => process.env.WISP_SSH || 'ssh';
|
|
38
|
+
export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
39
|
+
const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
|
40
|
+
|
|
41
|
+
// Session ids: <remote id>@<machine id> here. Local ids never contain '@'.
|
|
42
|
+
export const qualify = (id, mid) => (id ? `${id}@${mid}` : id);
|
|
43
|
+
export function splitId(id) {
|
|
44
|
+
const i = String(id || '').lastIndexOf('@');
|
|
45
|
+
return i > 0 ? { id: id.slice(0, i), machine: id.slice(i + 1) } : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// What runs on the machine. Sent on stdin, so the login shell there (bash, zsh, fish…) only ever
|
|
49
|
+
// sees `sh -s`. Steps come back as `@@wisp step <id> <run|done> <detail>`, `@@wisp fail <id>
|
|
50
|
+
// <message>`, and finally `@@wisp ready <version> <pid>`; then `cat` holds the session (and the
|
|
51
|
+
// tunnel) open until this side lets go.
|
|
52
|
+
export function bootstrapScript({ port, command, apiVersion }) {
|
|
53
|
+
return String.raw`say() { printf '@@wisp %s\n' "$*"; }
|
|
54
|
+
PORT=__PORT__
|
|
55
|
+
API=__API__
|
|
56
|
+
CMD=__CMD__
|
|
57
|
+
cd "$HOME" 2>/dev/null # npx would resolve wisp-agents from a package.json in the current folder
|
|
58
|
+
say step ssh done "$(uname -sn 2>/dev/null)"
|
|
59
|
+
# A non-interactive SSH shell skips the profile that puts node (Homebrew, nvm, Volta…) on PATH.
|
|
60
|
+
SH=$SHELL; [ -n "$SH" ] || SH=/bin/sh
|
|
61
|
+
LP=$("$SH" -lc 'printf "%s" "$PATH"' </dev/null 2>/dev/null) && [ -n "$LP" ] && PATH="$LP:$PATH"
|
|
62
|
+
NVM_BIN=
|
|
63
|
+
for d in "$HOME"/.nvm/versions/node/*/bin; do [ -x "$d/node" ] && NVM_BIN=$d; done
|
|
64
|
+
[ -n "$NVM_BIN" ] && PATH="$NVM_BIN:$PATH"
|
|
65
|
+
PATH="$PATH:$HOME/.local/bin:$HOME/.volta/bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin"
|
|
66
|
+
export PATH
|
|
67
|
+
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
|
|
68
|
+
NV=$(node -v 2>/dev/null)
|
|
69
|
+
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
|
|
70
|
+
say step node done "$NV"
|
|
71
|
+
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; }
|
|
72
|
+
H=$(health)
|
|
73
|
+
if [ -n "$H" ]; then
|
|
74
|
+
set -- $H
|
|
75
|
+
if [ "$1" != wisp ]; then say fail wisp "Port $PORT is used by another program there. Pick another port under Advanced."; exit 0; fi
|
|
76
|
+
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
|
|
77
|
+
say step wisp done "already running · $2"
|
|
78
|
+
else
|
|
79
|
+
LOG=$WISP_HOME; [ -n "$LOG" ] || LOG=$HOME/.wisp
|
|
80
|
+
mkdir -p "$LOG"; LOG=$LOG/machine.log
|
|
81
|
+
say step wisp run "starting $CMD"
|
|
82
|
+
npm_config_omit=optional nohup sh -c "exec $CMD --no-open --port $PORT" >"$LOG" 2>&1 </dev/null &
|
|
83
|
+
PID=$!
|
|
84
|
+
i=0
|
|
85
|
+
until H=$(health) && [ -n "$H" ]; do
|
|
86
|
+
if ! kill -0 "$PID" 2>/dev/null; then say fail wisp "wisp stopped while starting: $(tail -n 4 "$LOG" | tr '\n' ' ')"; exit 0; fi
|
|
87
|
+
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
|
|
88
|
+
sleep 1
|
|
89
|
+
done
|
|
90
|
+
set -- $H
|
|
91
|
+
say step wisp done "started · $2"
|
|
92
|
+
fi
|
|
93
|
+
say ready "$2" "$4"
|
|
94
|
+
exec cat >/dev/null
|
|
95
|
+
`
|
|
96
|
+
.replace('__PORT__', String(port))
|
|
97
|
+
.replace('__API__', String(apiVersion))
|
|
98
|
+
.replace('__CMD__', shq(command));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// What a failed `ssh` most likely needs, from its stderr.
|
|
102
|
+
export function sshHint(stderr, target) {
|
|
103
|
+
const e = String(stderr || '');
|
|
104
|
+
if (/Permission denied|Too many authentication failures|publickey/i.test(e))
|
|
105
|
+
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}` };
|
|
106
|
+
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}` };
|
|
107
|
+
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.' };
|
|
108
|
+
if (/Connection refused/i.test(e)) return { text: 'Nothing accepts SSH there. Turn on its SSH server (Remote Login on macOS, sshd on Linux).' };
|
|
109
|
+
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.' };
|
|
110
|
+
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.' };
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Hosts from ~/.ssh/config (and the files it Includes), for the "SSH host" suggestions.
|
|
115
|
+
export function sshHosts(file = path.join(os.homedir(), '.ssh', 'config'), seen = new Set()) {
|
|
116
|
+
if (seen.has(file) || seen.size > 20) return [];
|
|
117
|
+
seen.add(file);
|
|
118
|
+
let text = '';
|
|
119
|
+
try {
|
|
120
|
+
text = fs.readFileSync(file, 'utf8');
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
const hosts = [];
|
|
125
|
+
let current = []; // one Host line may name several hosts; what follows applies to each
|
|
126
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
127
|
+
const line = raw.trim();
|
|
128
|
+
const [key, ...rest] = line.split(/\s+/);
|
|
129
|
+
const value = rest.join(' ');
|
|
130
|
+
if (/^include$/i.test(key)) {
|
|
131
|
+
for (const pat of value.split(/\s+/)) {
|
|
132
|
+
const abs = path.resolve(path.join(os.homedir(), '.ssh'), pat.replace(/^~(?=\/)/, os.homedir()));
|
|
133
|
+
const dir = path.dirname(abs);
|
|
134
|
+
const re = new RegExp(`^${path.basename(abs).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
|
|
135
|
+
let names = [];
|
|
136
|
+
try {
|
|
137
|
+
names = fs.readdirSync(dir).filter((n) => re.test(n));
|
|
138
|
+
} catch {}
|
|
139
|
+
for (const n of names) hosts.push(...sshHosts(path.join(dir, n), seen));
|
|
140
|
+
}
|
|
141
|
+
} else if (/^host$/i.test(key)) {
|
|
142
|
+
current = value
|
|
143
|
+
.split(/\s+/)
|
|
144
|
+
.filter((h) => h && !/[*?!]/.test(h))
|
|
145
|
+
.map((host) => ({ host, hostname: '', user: '' }));
|
|
146
|
+
hosts.push(...current);
|
|
147
|
+
} else if (/^hostname$/i.test(key)) for (const h of current) h.hostname = value;
|
|
148
|
+
else if (/^user$/i.test(key)) for (const h of current) h.user = value;
|
|
149
|
+
}
|
|
150
|
+
const uniq = new Map(hosts.map((h) => [h.host, h]));
|
|
151
|
+
return [...uniq.values()];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const freePort = () =>
|
|
155
|
+
new Promise((resolve, reject) => {
|
|
156
|
+
const s = net.createServer().once('error', reject);
|
|
157
|
+
s.listen(0, '127.0.0.1', () => {
|
|
158
|
+
const { port } = s.address();
|
|
159
|
+
s.close(() => resolve(port));
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const readAll = (res) =>
|
|
164
|
+
new Promise((resolve, reject) => {
|
|
165
|
+
const chunks = [];
|
|
166
|
+
res.on('data', (c) => chunks.push(c));
|
|
167
|
+
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
168
|
+
res.on('error', reject);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// user@host, host, host:port-less alias, [v6]. Never an option: `-oProxyCommand=…` would run code.
|
|
172
|
+
const TARGET = /^(?!-)[\w.%+-]+(@[\w.%:[\]-]+)?$/;
|
|
173
|
+
|
|
174
|
+
export class Machines extends EventEmitter {
|
|
175
|
+
// onEvent(event, data): a session event from a machine, ids already rewritten.
|
|
176
|
+
constructor({ store, version, apiVersion, onEvent = () => {}, onChange = () => {} }) {
|
|
177
|
+
super();
|
|
178
|
+
Object.assign(this, { store, version, apiVersion, onEvent, onChange });
|
|
179
|
+
this.conns = new Map(); // machine id -> connection state
|
|
180
|
+
this.cache = new Map(); // machine id -> Map(remote id -> remote summary): shown while it's offline
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
get list() {
|
|
184
|
+
return (this.store.config.machines ||= []);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
get defaultCommand() {
|
|
188
|
+
return `npx -y wisp-agents@${this.version}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
get(id) {
|
|
192
|
+
const m = this.list.find((x) => x.id === id);
|
|
193
|
+
if (!m) throw bad('No such machine.', 404);
|
|
194
|
+
return m;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
isOnline(id) {
|
|
198
|
+
return this.conns.get(id)?.state === 'online';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
start() {
|
|
202
|
+
for (const m of this.list) if (m.autoConnect !== false) this.connect(m.id).catch(() => {});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
stop() {
|
|
206
|
+
for (const c of this.conns.values()) this.#hangUp(c);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- config ------------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
#clean(body, prev = {}) {
|
|
212
|
+
const v = (k, d) => (body[k] !== undefined ? body[k] : prev[k] !== undefined ? prev[k] : d);
|
|
213
|
+
const target = String(v('target', '')).trim();
|
|
214
|
+
if (!TARGET.test(target)) throw bad('SSH host: user@host, a host name, or a Host from ~/.ssh/config.');
|
|
215
|
+
const port = Number(v('port', DEFAULT_PORT));
|
|
216
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw bad('Port: a number from 1 to 65535.');
|
|
217
|
+
const command = String(v('command', '') || '').trim();
|
|
218
|
+
const name = String(v('name', '') || '').trim().slice(0, 40) || target.replace(/^.*@/, '');
|
|
219
|
+
return { name, target, port, command, autoConnect: v('autoConnect', true) !== false };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
add(body = {}) {
|
|
223
|
+
const m = { id: randomBytes(3).toString('hex'), ...this.#clean(body), addedAt: Date.now() };
|
|
224
|
+
if (this.list.some((x) => x.target === m.target && x.port === m.port)) throw bad(`${m.target} is already added.`, 409);
|
|
225
|
+
this.list.push(m);
|
|
226
|
+
this.store.saveConfig();
|
|
227
|
+
this.onChange();
|
|
228
|
+
return m;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async update(id, body = {}) {
|
|
232
|
+
const m = this.get(id);
|
|
233
|
+
const next = this.#clean(body, m);
|
|
234
|
+
const reconnect = ['target', 'port', 'command'].some((k) => next[k] !== m[k]) && this.conns.get(id)?.state !== 'off';
|
|
235
|
+
Object.assign(m, next);
|
|
236
|
+
this.store.saveConfig();
|
|
237
|
+
if (reconnect && this.conns.has(id)) {
|
|
238
|
+
this.disconnect(id);
|
|
239
|
+
this.connect(id).catch(() => {});
|
|
240
|
+
}
|
|
241
|
+
this.onChange();
|
|
242
|
+
return m;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
remove(id) {
|
|
246
|
+
this.get(id);
|
|
247
|
+
this.disconnect(id);
|
|
248
|
+
this.conns.delete(id);
|
|
249
|
+
this.cache.delete(id);
|
|
250
|
+
this.store.config.machines = this.list.filter((x) => x.id !== id);
|
|
251
|
+
this.store.config.projects = this.store.config.projects.filter((p) => p.machine !== id);
|
|
252
|
+
this.store.saveConfig();
|
|
253
|
+
this.onChange();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
view() {
|
|
257
|
+
return this.list.map((m) => {
|
|
258
|
+
const c = this.conns.get(m.id);
|
|
259
|
+
const cached = [...(this.cache.get(m.id)?.values() || [])];
|
|
260
|
+
return {
|
|
261
|
+
...m,
|
|
262
|
+
defaultCommand: this.defaultCommand,
|
|
263
|
+
state: c?.state || 'off',
|
|
264
|
+
steps: c?.steps || [],
|
|
265
|
+
error: c?.error || '',
|
|
266
|
+
hint: c?.hint || null,
|
|
267
|
+
stale: !!c?.stalePid,
|
|
268
|
+
attempt: c?.attempt || 0,
|
|
269
|
+
since: c?.since || null,
|
|
270
|
+
remote: c?.remote || null,
|
|
271
|
+
log: c?.log.slice(-30) || [],
|
|
272
|
+
projects: this.store.config.projects.filter((p) => p.machine === m.id).length,
|
|
273
|
+
working: cached.filter((s) => s.state === 'running').length,
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- the connection ----------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
#step(c, id, status, detail) {
|
|
281
|
+
const i = c.steps.findIndex((s) => s.id === id);
|
|
282
|
+
if (i < 0) return;
|
|
283
|
+
Object.assign(c.steps[i], { status, ...(detail !== undefined && { detail }) });
|
|
284
|
+
if (status === 'done' && c.steps[i + 1]?.status === 'pending') c.steps[i + 1].status = 'run';
|
|
285
|
+
this.onChange();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#log(c, line) {
|
|
289
|
+
c.log.push(line);
|
|
290
|
+
if (c.log.length > MAX_LOG) c.log.splice(0, c.log.length - MAX_LOG);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async connect(id, { retry = false } = {}) {
|
|
294
|
+
const m = this.get(id);
|
|
295
|
+
const prev = this.conns.get(id);
|
|
296
|
+
if (prev?.child || prev?.state === 'online') return this.view().find((x) => x.id === id);
|
|
297
|
+
clearTimeout(prev?.retryTimer);
|
|
298
|
+
const c = {
|
|
299
|
+
id,
|
|
300
|
+
state: retry ? 'reconnecting' : 'connecting',
|
|
301
|
+
steps: STEPS.map(([sid, label], i) => ({ id: sid, label, status: i ? 'pending' : 'run', detail: '' })),
|
|
302
|
+
error: '',
|
|
303
|
+
hint: null,
|
|
304
|
+
log: [],
|
|
305
|
+
stderr: '',
|
|
306
|
+
attempt: retry ? prev?.attempt || 0 : 0,
|
|
307
|
+
wasOnline: retry ? prev?.wasOnline : false,
|
|
308
|
+
remote: prev?.remote || null,
|
|
309
|
+
};
|
|
310
|
+
this.conns.set(id, c);
|
|
311
|
+
this.onChange();
|
|
312
|
+
c.localPort = await freePort();
|
|
313
|
+
const args = [
|
|
314
|
+
...['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=15'],
|
|
315
|
+
...['-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3', '-o', 'ExitOnForwardFailure=yes'],
|
|
316
|
+
...['-L', `${c.localPort}:127.0.0.1:${m.port}`, m.target, 'sh -s'],
|
|
317
|
+
];
|
|
318
|
+
const child = spawn(sshBin(), args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
319
|
+
c.child = child;
|
|
320
|
+
child.stdin.on('error', () => {});
|
|
321
|
+
child.stdin.write(bootstrapScript({ port: m.port, command: m.command || this.defaultCommand, apiVersion: this.apiVersion }));
|
|
322
|
+
child.stderr.on('data', (d) => {
|
|
323
|
+
c.stderr = (c.stderr + d).slice(-4000);
|
|
324
|
+
for (const l of String(d).split(/\r?\n/)) if (l.trim()) this.#log(c, l);
|
|
325
|
+
});
|
|
326
|
+
readline.createInterface({ input: child.stdout }).on('line', (line) => this.#line(m, c, line));
|
|
327
|
+
c.readyTimer = setTimeout(() => this.#fail(c, 'It took too long to start wisp there.'), READY_TIMEOUT_MS);
|
|
328
|
+
child.on('error', (err) => this.#fail(c, `Could not run ssh: ${err.message}`, { text: 'wisp needs the OpenSSH client (`ssh`) on this computer.' }));
|
|
329
|
+
child.on('close', (code) => this.#closed(m, c, code));
|
|
330
|
+
return this.view().find((x) => x.id === id);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#line(m, c, line) {
|
|
334
|
+
const hit = /^@@wisp (\w+)\s*(.*)$/.exec(line);
|
|
335
|
+
if (!hit) return line.trim() && this.#log(c, line);
|
|
336
|
+
const [, kind, rest] = hit;
|
|
337
|
+
if (kind === 'step') {
|
|
338
|
+
const [, sid, status, detail = ''] = /^(\w+) (\w+)\s*(.*)$/.exec(rest) || [];
|
|
339
|
+
this.#log(c, `${sid}: ${detail || status}`);
|
|
340
|
+
return this.#step(c, sid, status, detail);
|
|
341
|
+
}
|
|
342
|
+
if (kind === 'stale') return (c.stalePid = Number(rest) || null);
|
|
343
|
+
if (kind === 'fail') {
|
|
344
|
+
const [, sid, msg = ''] = /^(\w+)\s*(.*)$/.exec(rest) || [];
|
|
345
|
+
this.#step(c, sid, 'fail', msg);
|
|
346
|
+
const hint = sid === 'node' ? { text: 'Install Node.js 20 or newer there (nodejs.org, nvm, or the package manager), then connect again.' } : null;
|
|
347
|
+
c.giveUp = true; // retrying won't change the answer
|
|
348
|
+
return this.#fail(c, msg, hint, { keep: true });
|
|
349
|
+
}
|
|
350
|
+
if (kind === 'ready') {
|
|
351
|
+
const [version, pid] = rest.split(' ');
|
|
352
|
+
c.remote = { ...c.remote, version, pid: Number(pid) || null };
|
|
353
|
+
this.#ready(m, c).catch((err) => this.#fail(c, err.message));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The script says wisp answers over there; check it through the tunnel, then go live.
|
|
358
|
+
async #ready(m, c) {
|
|
359
|
+
this.#step(c, 'tunnel', 'run');
|
|
360
|
+
let health;
|
|
361
|
+
for (let i = 0; i < 40 && !health; i++) {
|
|
362
|
+
health = await this.#raw(m, c, 'GET', '/health').catch(() => null);
|
|
363
|
+
if (!health) await new Promise((r) => setTimeout(r, 250));
|
|
364
|
+
}
|
|
365
|
+
if (!health) throw new Error('The tunnel opened, but wisp doesn’t answer through it.');
|
|
366
|
+
if (this.conns.get(m.id) !== c) return;
|
|
367
|
+
this.#step(c, 'tunnel', 'done', `localhost:${c.localPort} → ${m.port}`);
|
|
368
|
+
const [agents, config] = await Promise.all([this.#raw(m, c, 'GET', '/agents').catch(() => []), this.#raw(m, c, 'GET', '/config').catch(() => ({}))]);
|
|
369
|
+
const ready = agents.filter((a) => a.available);
|
|
370
|
+
c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(({ id, name, available, version }) => ({ id, name, available, version })) };
|
|
371
|
+
this.#step(c, 'agents', ready.length ? 'done' : 'warn', ready.length ? ready.map((a) => a.name).join(', ') : 'None found. Install a harness CLI there (claude, codex, opencode…) and sign in.');
|
|
372
|
+
clearTimeout(c.readyTimer);
|
|
373
|
+
Object.assign(c, { state: 'online', error: '', hint: null, attempt: 0, wasOnline: true, since: Date.now() });
|
|
374
|
+
this.#listen(m, c);
|
|
375
|
+
await this.sessions({}, { only: m.id }).catch(() => {});
|
|
376
|
+
this.onChange();
|
|
377
|
+
this.emit('online', m);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#fail(c, msg, hint, { keep = false } = {}) {
|
|
381
|
+
if (this.conns.get(c.id) !== c) return;
|
|
382
|
+
if (c.error && !keep) return;
|
|
383
|
+
c.error = msg;
|
|
384
|
+
c.hint = hint || c.hint;
|
|
385
|
+
const running = c.steps.find((s) => s.status === 'run');
|
|
386
|
+
if (running) Object.assign(running, { status: 'fail', detail: running.detail || msg });
|
|
387
|
+
this.onChange();
|
|
388
|
+
this.#hangUp(c);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#closed(m, c, code) {
|
|
392
|
+
clearTimeout(c.readyTimer);
|
|
393
|
+
c.child = null;
|
|
394
|
+
c.events?.destroy();
|
|
395
|
+
if (this.conns.get(m.id) !== c) return;
|
|
396
|
+
if (c.stopped) {
|
|
397
|
+
c.state = 'off';
|
|
398
|
+
return this.onChange();
|
|
399
|
+
}
|
|
400
|
+
const lost = c.state === 'online' || c.wasOnline;
|
|
401
|
+
if (!c.error) {
|
|
402
|
+
c.error = lost ? 'Connection lost.' : String(c.stderr).trim().split('\n').filter(Boolean).at(-1) || `ssh exited (${code}).`;
|
|
403
|
+
c.hint = sshHint(c.stderr, m.target);
|
|
404
|
+
const running = c.steps.find((s) => s.status === 'run');
|
|
405
|
+
if (running) Object.assign(running, { status: 'fail', detail: c.error });
|
|
406
|
+
}
|
|
407
|
+
// Once it has worked, keep trying (laptop lid, Wi-Fi, VPN). A first connect that fails stops.
|
|
408
|
+
if (lost && !c.giveUp) {
|
|
409
|
+
c.state = 'reconnecting';
|
|
410
|
+
const wait = RETRY_MS[Math.min(c.attempt, RETRY_MS.length - 1)];
|
|
411
|
+
c.attempt += 1;
|
|
412
|
+
c.retryTimer = setTimeout(() => this.connect(m.id, { retry: true }).catch(() => {}), wait);
|
|
413
|
+
c.retryTimer.unref?.();
|
|
414
|
+
} else c.state = 'error';
|
|
415
|
+
this.onChange();
|
|
416
|
+
this.emit('offline', m);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#hangUp(c) {
|
|
420
|
+
clearTimeout(c.retryTimer);
|
|
421
|
+
clearTimeout(c.readyTimer);
|
|
422
|
+
c.events?.destroy();
|
|
423
|
+
if (c.child) {
|
|
424
|
+
c.child.stdin.end();
|
|
425
|
+
killTree(c.child);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
disconnect(id) {
|
|
430
|
+
const c = this.conns.get(id);
|
|
431
|
+
if (!c) return;
|
|
432
|
+
c.stopped = true;
|
|
433
|
+
c.giveUp = true;
|
|
434
|
+
this.#hangUp(c);
|
|
435
|
+
if (!c.child) c.state = 'off';
|
|
436
|
+
this.onChange();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Run one command on the machine (stop / restart its wisp). Resolves { code, out }.
|
|
440
|
+
#ssh(m, command) {
|
|
441
|
+
return new Promise((resolve) => {
|
|
442
|
+
const child = spawn(sshBin(), ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', m.target, command], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
443
|
+
let out = '';
|
|
444
|
+
child.stdout.on('data', (d) => (out += d));
|
|
445
|
+
child.stderr.on('data', (d) => (out += d));
|
|
446
|
+
child.on('error', (err) => resolve({ code: -1, out: err.message }));
|
|
447
|
+
child.on('close', (code) => resolve({ code, out }));
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Stop wisp on the machine: its agents stop too. restart: connect again right after, which
|
|
452
|
+
// starts this version there.
|
|
453
|
+
async stopRemote(id, { restart = false } = {}) {
|
|
454
|
+
const m = this.get(id);
|
|
455
|
+
const c = this.conns.get(id);
|
|
456
|
+
const pid = c?.remote?.pid || c?.stalePid;
|
|
457
|
+
if (!pid) throw bad('wisp isn’t known to be running there.', 409);
|
|
458
|
+
this.disconnect(id);
|
|
459
|
+
const r = await this.#ssh(m, `kill ${Number(pid)} 2>&1 || true`);
|
|
460
|
+
if (r.code) throw bad(`Couldn’t stop it: ${r.out.trim() || `ssh exited ${r.code}`}`, 502);
|
|
461
|
+
this.cache.delete(id);
|
|
462
|
+
if (c) Object.assign(c, { remote: null, stalePid: null });
|
|
463
|
+
if (restart) {
|
|
464
|
+
await new Promise((res) => setTimeout(res, 1500));
|
|
465
|
+
await this.connect(id);
|
|
466
|
+
}
|
|
467
|
+
this.onChange();
|
|
468
|
+
return { ok: true };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// --- requests through the tunnel ---------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
#online(id) {
|
|
474
|
+
const m = this.get(id);
|
|
475
|
+
const c = this.conns.get(id);
|
|
476
|
+
if (c?.state !== 'online') throw bad(`${m.name} is offline. Connect it from Machines.`, 503);
|
|
477
|
+
return { m, c };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#request(m, c, method, p, { body, headers = {}, timeout = 120_000 } = {}) {
|
|
481
|
+
return new Promise((resolve, reject) => {
|
|
482
|
+
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);
|
|
483
|
+
req.on('timeout', () => req.destroy(Object.assign(new Error(`${m.name} didn’t answer in time.`), { status: 504 })));
|
|
484
|
+
req.on('error', (err) => reject(Object.assign(err, { status: err.status || 502 })));
|
|
485
|
+
if (body && typeof body.pipe === 'function') body.pipe(req);
|
|
486
|
+
else req.end(body);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async #raw(m, c, method, p, body, opts = {}) {
|
|
491
|
+
const json = method !== 'GET';
|
|
492
|
+
const res = await this.#request(m, c, method, p, { body: json ? JSON.stringify(body ?? {}) : undefined, headers: json ? { 'content-type': 'application/json' } : {}, ...opts });
|
|
493
|
+
const text = await readAll(res);
|
|
494
|
+
let j;
|
|
495
|
+
try {
|
|
496
|
+
j = text ? JSON.parse(text) : {};
|
|
497
|
+
} catch {
|
|
498
|
+
j = { error: text.slice(0, 200) };
|
|
499
|
+
}
|
|
500
|
+
if (res.statusCode >= 400) throw bad(j.error || `${m.name}: HTTP ${res.statusCode}`, res.statusCode);
|
|
501
|
+
return j;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// JSON call to the machine's API: p is the path under /api.
|
|
505
|
+
json(id, method, p, body, opts) {
|
|
506
|
+
const { m, c } = this.#online(id);
|
|
507
|
+
return this.#raw(m, c, method, p, body, opts);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// A raw request (files, uploads): resolves with the http.IncomingMessage.
|
|
511
|
+
stream(id, method, p, opts) {
|
|
512
|
+
const { m, c } = this.#online(id);
|
|
513
|
+
return this.#request(m, c, method, p, opts);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// --- the machine's event stream, relayed with ids rewritten ------------------------------
|
|
517
|
+
|
|
518
|
+
#listen(m, c) {
|
|
519
|
+
const req = http.get({ host: '127.0.0.1', port: c.localPort, path: '/api/events', headers: { host: `127.0.0.1:${m.port}`, accept: 'text/event-stream' } }, (res) => {
|
|
520
|
+
res.setEncoding('utf8');
|
|
521
|
+
let buf = '';
|
|
522
|
+
res.on('data', (d) => {
|
|
523
|
+
buf += d;
|
|
524
|
+
let i;
|
|
525
|
+
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
526
|
+
const frame = buf.slice(0, i);
|
|
527
|
+
buf = buf.slice(i + 2);
|
|
528
|
+
const event = /^event: (.*)$/m.exec(frame)?.[1];
|
|
529
|
+
const data = /^data: (.*)$/m.exec(frame)?.[1];
|
|
530
|
+
if (!event || !data) continue;
|
|
531
|
+
try {
|
|
532
|
+
this.#relay(m, event, JSON.parse(data));
|
|
533
|
+
} catch {}
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
res.on('end', () => this.#streamLost(m, c));
|
|
537
|
+
});
|
|
538
|
+
req.on('error', () => this.#streamLost(m, c));
|
|
539
|
+
c.events = req;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// The stream only ends when wisp over there stopped or the tunnel broke: start over.
|
|
543
|
+
#streamLost(m, c) {
|
|
544
|
+
if (this.conns.get(m.id) !== c || c.state !== 'online' || c.stopped) return;
|
|
545
|
+
c.error = 'wisp stopped answering there.';
|
|
546
|
+
this.#hangUp(c);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
#relay(m, event, d) {
|
|
550
|
+
const q = (id) => qualify(id, m.id);
|
|
551
|
+
const cache = this.#cacheOf(m.id);
|
|
552
|
+
if (event === 'summary') {
|
|
553
|
+
if (d.agent === 'wisp') return; // its own welcome tour
|
|
554
|
+
cache.set(d.id, d);
|
|
555
|
+
return this.onEvent('summary', this.summary(m.id, d));
|
|
556
|
+
}
|
|
557
|
+
if (event === 'activity' || event === 'turn') return this.onEvent(event, { ...d, sessionId: q(d.sessionId) });
|
|
558
|
+
if (event === 'finished') {
|
|
559
|
+
if (d.session?.agent === 'wisp') return;
|
|
560
|
+
cache.set(d.session.id, d.session);
|
|
561
|
+
return this.onEvent('finished', { ...d, session: this.summary(m.id, d.session) });
|
|
562
|
+
}
|
|
563
|
+
if (event === 'removed') {
|
|
564
|
+
cache.delete(d.id);
|
|
565
|
+
return this.onEvent('removed', { id: q(d.id) });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#cacheOf(mid) {
|
|
570
|
+
if (!this.cache.has(mid)) this.cache.set(mid, new Map());
|
|
571
|
+
return this.cache.get(mid);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --- rewriting: remote shapes → local ones -----------------------------------------------
|
|
575
|
+
|
|
576
|
+
#tag(m) {
|
|
577
|
+
return { id: m.id, name: m.name, target: m.target };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
summary(mid, s) {
|
|
581
|
+
const m = this.get(mid);
|
|
582
|
+
const q = (id) => qualify(id, mid);
|
|
583
|
+
const p = this.store.projectFor(s.cwd || '', mid);
|
|
584
|
+
return {
|
|
585
|
+
...s,
|
|
586
|
+
id: q(s.id),
|
|
587
|
+
groupId: s.groupId || null, // random, and shared across machines when one launch fans out
|
|
588
|
+
...(s.parentId && { parentId: q(s.parentId) }),
|
|
589
|
+
...(s.group && { group: s.group.map((x) => this.summary(mid, x)) }),
|
|
590
|
+
projectId: p?.id || null,
|
|
591
|
+
project: p?.name || s.project,
|
|
592
|
+
color: p?.color || s.color,
|
|
593
|
+
machine: this.#tag(m),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
session(mid, s) {
|
|
598
|
+
const m = this.get(mid);
|
|
599
|
+
const q = (id) => qualify(id, mid);
|
|
600
|
+
return {
|
|
601
|
+
...s,
|
|
602
|
+
id: q(s.id),
|
|
603
|
+
groupId: s.groupId || null, // random, and shared across machines when one launch fans out
|
|
604
|
+
...(s.parentId && { parentId: q(s.parentId) }),
|
|
605
|
+
...(s.children && { children: Object.fromEntries(Object.entries(s.children).map(([k, v]) => [k, q(v)])) }),
|
|
606
|
+
...(s.childSessions && { childSessions: Object.fromEntries(Object.entries(s.childSessions).map(([k, v]) => [q(k), this.session(mid, v)])) }),
|
|
607
|
+
turns: (s.turns || []).map((t) => ({
|
|
608
|
+
...t,
|
|
609
|
+
...(t.delegations && { delegations: t.delegations.map((d) => ({ ...d, sessionId: q(d.sessionId) })) }),
|
|
610
|
+
...(t.attachments && { attachments: t.attachments.map((a) => ({ ...a, id: q(a.id) })) }),
|
|
611
|
+
})),
|
|
612
|
+
machine: this.#tag(m),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Every machine's sessions (fresh when online, the last seen ones when not), for GET /sessions.
|
|
617
|
+
async sessions(q = {}, { only } = {}) {
|
|
618
|
+
const qs = new URLSearchParams(Object.entries(q).filter(([k]) => k === 'children' || k === 'archived')).toString();
|
|
619
|
+
const lists = await Promise.all(
|
|
620
|
+
this.list
|
|
621
|
+
.filter((m) => !only || m.id === only)
|
|
622
|
+
.map(async (m) => {
|
|
623
|
+
const cache = this.#cacheOf(m.id);
|
|
624
|
+
if (this.isOnline(m.id)) {
|
|
625
|
+
try {
|
|
626
|
+
const list = await this.json(m.id, 'GET', `/sessions${qs ? `?${qs}` : ''}`, undefined, { timeout: LIST_TIMEOUT_MS });
|
|
627
|
+
cache.clear();
|
|
628
|
+
for (const s of list) if (s.agent !== 'wisp') cache.set(s.id, s);
|
|
629
|
+
} catch {}
|
|
630
|
+
}
|
|
631
|
+
return [...cache.values()].map((s) => this.summary(m.id, s));
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
return lists.flat();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// --- launching and projects ----------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
// Resolves with the machine's own summary (ids not rewritten yet): the caller may add the project first.
|
|
640
|
+
async launch(mid, body) {
|
|
641
|
+
const { machine, ...rest } = body;
|
|
642
|
+
const out = await this.json(mid, 'POST', '/sessions', rest);
|
|
643
|
+
for (const x of out.group || [out]) this.#cacheOf(mid).set(x.id, x);
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Copy one of this computer's uploads to the machine; resolves with its upload record there.
|
|
648
|
+
async upload(mid, u) {
|
|
649
|
+
const res = await this.stream(mid, 'POST', `/uploads?name=${encodeURIComponent(u.name)}&type=${encodeURIComponent(u.mime)}`, {
|
|
650
|
+
body: fs.createReadStream(u.path),
|
|
651
|
+
headers: { 'content-type': 'application/octet-stream', 'content-length': fs.statSync(u.path).size },
|
|
652
|
+
timeout: 10 * 60_000,
|
|
653
|
+
});
|
|
654
|
+
const text = await readAll(res);
|
|
655
|
+
const j = JSON.parse(text || '{}');
|
|
656
|
+
if (res.statusCode >= 400) throw bad(j.error || `Upload failed (${res.statusCode})`, res.statusCode);
|
|
657
|
+
return j;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async addProject(mid, dir, name) {
|
|
661
|
+
const p = await this.json(mid, 'POST', '/projects', { path: dir });
|
|
662
|
+
const local = this.store.addProject(p.path, { name: name || p.name, machine: mid });
|
|
663
|
+
return local;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// A terminal on the machine running `command` (Take over), in the user's own interactive login
|
|
667
|
+
// shell so its PATH has the harness. Wrapped in sh, so a fish or nu login shell parses it too.
|
|
668
|
+
terminalArgv(mid, command) {
|
|
669
|
+
const m = this.get(mid);
|
|
670
|
+
return [sshBin(), '-t', m.target, `sh -c ${shq('exec "${SHELL:-/bin/sh}" -lic "$0"')} ${shq(command)}`];
|
|
671
|
+
}
|
|
672
|
+
}
|
package/server/mcp-ask.js
CHANGED
|
@@ -38,7 +38,7 @@ const TEAM_TOOLS = [
|
|
|
38
38
|
'Hand tasks to your subagents (separate coding agents the user picked, each on its own harness and model). ' +
|
|
39
39
|
'Tasks in one call run in parallel in your working folder. By default the call waits until every task finishes and ' +
|
|
40
40
|
'returns each report plus the files it changed; wait=false returns delegation ids at once. Delegating to a subagent ' +
|
|
41
|
-
'again continues its conversation.\n\nThe team:\n' + roster,
|
|
41
|
+
'again continues its conversation. Use this instead of any built-in subagent or task tool.\n\nThe team:\n' + roster,
|
|
42
42
|
inputSchema: {
|
|
43
43
|
type: 'object',
|
|
44
44
|
properties: {
|