wisp-agents 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +2 -2
- package/server/adapters/claude.js +31 -50
- package/server/adapters/codex.js +2 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +2 -2
- package/server/adapters/grok.js +2 -2
- package/server/adapters/index.js +16 -1
- package/server/adapters/opencode.js +47 -6
- package/server/checkpoint.js +12 -68
- package/server/client.js +5 -3
- package/server/git.js +9 -4
- package/server/handoff.js +70 -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-wisp.js +17 -5
- package/server/openapi.js +33 -2
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- 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 +56 -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 +339 -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 +87 -36
- 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 +5 -3
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +4 -6
- package/server/update.js +4 -3
- package/server/util.js +42 -0
- package/server/welcome.js +1 -1
- 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 +62 -0
- package/web/css/feed.css +208 -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 +16 -1
- package/web/js/app.js +28 -5232
- 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 +339 -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 +104 -0
- package/web/js/features/composer.js +311 -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 +559 -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/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 +161 -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 +401 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -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 -1901
|
@@ -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-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':
|
package/server/openapi.js
CHANGED
|
@@ -50,6 +50,13 @@ export function openapi({ version, port }) {
|
|
|
50
50
|
patch: { summary: 'Auto settings', requestBody: body({ type: 'object', properties: { goal: { enum: ['cheap', 'balanced', 'best'] }, explore: { type: 'boolean', description: 'Let Auto try new models on a few tasks' }, allowed: { type: 'array', items: { type: 'string' }, description: 'Harness ids Auto may pick; empty or null = all' } } }), responses: { 200: json({ type: 'object' }) } },
|
|
51
51
|
},
|
|
52
52
|
'/router/reset': { post: { summary: 'Forget what Auto learned here (the shipped starting point stays)', responses: { 200: json({ type: 'object' }) } } },
|
|
53
|
+
'/agents/install': {
|
|
54
|
+
post: {
|
|
55
|
+
summary: 'Install a harness into ~/.wisp/tools with npm (only opencode: its free models need no account), then detect again',
|
|
56
|
+
requestBody: body({ type: 'object', required: ['id'], properties: { id: { type: 'string', enum: ['opencode'] } } }),
|
|
57
|
+
responses: { 200: json({ type: 'array', items: ref('Agent') }), 400: err[400] },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
53
60
|
'/agents/recheck': { post: { summary: 'Detect harnesses and models again, here and on every online machine', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
|
|
54
61
|
'/feedback': {
|
|
55
62
|
post: {
|
|
@@ -129,6 +136,16 @@ export function openapi({ version, port }) {
|
|
|
129
136
|
parameters: [id],
|
|
130
137
|
post: { summary: 'Mark the PR ready if draft, squash-merge it with --match-head-commit, fast-forward the local base branch, remove the worktree', responses: { 200: json({ type: 'object', properties: { pr: ref('PullRequest'), sync: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' } } } } }), 400: err[400] } },
|
|
131
138
|
},
|
|
139
|
+
'/teams': {
|
|
140
|
+
get: { summary: 'Saved subagent presets: a team the lead can delegate to', responses: { 200: json({ type: 'array', items: ref('Team') }) } },
|
|
141
|
+
post: { summary: 'Save a subagent preset', requestBody: body(ref('Team')), responses: { 201: json(ref('Team')), 400: err[400] } },
|
|
142
|
+
},
|
|
143
|
+
'/teams/{tid}': {
|
|
144
|
+
parameters: [{ name: 'tid', in: 'path', required: true, schema: { type: 'string' } }],
|
|
145
|
+
get: { summary: 'One subagent preset', responses: { 200: json(ref('Team')), 404: err[404] } },
|
|
146
|
+
patch: { summary: 'Edit a subagent preset', requestBody: body(ref('Team')), responses: { 200: json(ref('Team')), 400: err[400], 404: err[404] } },
|
|
147
|
+
delete: { summary: 'Delete a subagent preset', responses: { 200: ok, 404: err[404] } },
|
|
148
|
+
},
|
|
132
149
|
'/recipes': {
|
|
133
150
|
get: { summary: 'Saved recipes, with the {params} found in each prompt', responses: { 200: json({ type: 'array', items: ref('Recipe') }) } },
|
|
134
151
|
post: { summary: 'Save a recipe', requestBody: body(ref('Recipe')), responses: { 201: json(ref('Recipe')), 400: err[400] } },
|
|
@@ -205,7 +222,7 @@ export function openapi({ version, port }) {
|
|
|
205
222
|
parameters: [id],
|
|
206
223
|
post: {
|
|
207
224
|
summary: 'Follow up (answers a pending ask_user question if there is one)',
|
|
208
|
-
requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' }, mode: { enum: ['plan', 'edits', 'auto'], description: 'Switch the session to this mode from this follow-up on' } } }),
|
|
225
|
+
requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' }, mode: { enum: ['plan', 'edits', 'auto'], description: 'Switch the session to this mode from this follow-up on' }, agent: { type: 'string', description: 'Switch to this harness from this follow-up on; it is handed the turns it has not seen' }, model: { type: 'string', description: 'Switch model (empty: the harness default)' }, effort: { type: 'string', description: 'Switch thinking level (empty: the default)' } } }),
|
|
209
226
|
responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
|
|
210
227
|
},
|
|
211
228
|
},
|
|
@@ -249,9 +266,13 @@ export function openapi({ version, port }) {
|
|
|
249
266
|
responses: { 201: json({ type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, size: { type: 'integer' }, mime: { type: 'string' } } }) },
|
|
250
267
|
},
|
|
251
268
|
},
|
|
269
|
+
'/uploads/{uid}': {
|
|
270
|
+
parameters: [{ name: 'uid', in: 'path', required: true, schema: { type: 'string' }, description: 'Upload id (<id>@<machine> for one on a machine)' }],
|
|
271
|
+
get: { summary: 'The uploaded file, with its MIME type', responses: { 200: { description: 'The file' }, 404: err[404] } },
|
|
272
|
+
},
|
|
252
273
|
'/events': {
|
|
253
274
|
get: {
|
|
254
|
-
summary: 'Server-sent events: summary, activity, turn, finished, removed, config, preferences, ping',
|
|
275
|
+
summary: 'Server-sent events: summary, activity, turn, finished, removed, config, preferences, agents, ping',
|
|
255
276
|
responses: { 200: { description: 'text/event-stream', content: { 'text/event-stream': { schema: { type: 'string' } } } } },
|
|
256
277
|
},
|
|
257
278
|
},
|
|
@@ -294,6 +315,16 @@ export function openapi({ version, port }) {
|
|
|
294
315
|
base: { type: 'string' },
|
|
295
316
|
},
|
|
296
317
|
},
|
|
318
|
+
Team: {
|
|
319
|
+
type: 'object',
|
|
320
|
+
required: ['name', 'members'],
|
|
321
|
+
properties: {
|
|
322
|
+
id: { type: 'string', readOnly: true },
|
|
323
|
+
name: { type: 'string' },
|
|
324
|
+
members: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, agent: { type: 'string', description: 'Harness id, or auto' }, model: { type: 'string' }, effort: { type: 'string' }, use: { type: 'string', description: 'What the lead should use it for' }, count: { type: 'integer', minimum: 1 } } } },
|
|
325
|
+
updatedAt: { type: 'integer', readOnly: true },
|
|
326
|
+
},
|
|
327
|
+
},
|
|
297
328
|
Recipe: {
|
|
298
329
|
type: 'object',
|
|
299
330
|
required: ['name', 'prompt'],
|
package/server/paths.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Project-relative paths and the size cap for the file contents wisp keeps (diffs, checkpoints).
|
|
2
|
+
// Shared by the adapters (what a harness changed) and checkpoint.js (reverting it).
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
// Larger files are tracked as changed, but their contents aren't kept.
|
|
8
|
+
export const MAX_SNAPSHOT = 256 * 1024;
|
|
9
|
+
|
|
10
|
+
const realDirs = new Map();
|
|
11
|
+
function realDir(dir) {
|
|
12
|
+
if (!realDirs.has(dir)) {
|
|
13
|
+
try {
|
|
14
|
+
realDirs.set(dir, fs.realpathSync(dir));
|
|
15
|
+
} catch {
|
|
16
|
+
realDirs.set(dir, dir);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return realDirs.get(dir);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Path relative to cwd, or the path as given if it lies outside. Harnesses may report paths
|
|
23
|
+
// through the resolved cwd (/private/tmp vs /tmp on macOS), so both spellings count.
|
|
24
|
+
export function rel(file, cwd) {
|
|
25
|
+
if (!file) return '';
|
|
26
|
+
const abs = path.resolve(cwd, file);
|
|
27
|
+
for (const base of [cwd, realDir(cwd)]) {
|
|
28
|
+
const r = path.relative(base, abs);
|
|
29
|
+
if (r && !r.startsWith('..') && !path.isAbsolute(r)) return r;
|
|
30
|
+
}
|
|
31
|
+
return file;
|
|
32
|
+
}
|
package/server/preferences.js
CHANGED
|
@@ -14,6 +14,7 @@ import fs from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { adapters } from './adapters/index.js';
|
|
16
16
|
import { heroOf } from './protocol.js';
|
|
17
|
+
import { clip, writeJsonAtomic } from './util.js';
|
|
17
18
|
|
|
18
19
|
export const KINDS = ['preferences', 'lessons'];
|
|
19
20
|
export const MAX_ITEMS = 10; // per list
|
|
@@ -23,10 +24,6 @@ const MAX_HISTORY = 30; // retro runs kept for Settings, newest last
|
|
|
23
24
|
const RETRO_TIMEOUT_MS = 3 * 60_000;
|
|
24
25
|
const IDLE_MS = Number(process.env.WISP_RETRO_IDLE_MS ?? 90_000); // quiet time before a retro runs
|
|
25
26
|
|
|
26
|
-
const clip = (s, n) => {
|
|
27
|
-
s = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
28
|
-
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
29
|
-
};
|
|
30
27
|
const emptyScope = () => ({ preferences: [], lessons: [], updatedAt: 0 });
|
|
31
28
|
|
|
32
29
|
// Trim, dedupe (case-insensitively), cap length and count. Newest items win the cap.
|
|
@@ -66,8 +63,7 @@ export class Preferences {
|
|
|
66
63
|
}
|
|
67
64
|
|
|
68
65
|
#save() {
|
|
69
|
-
|
|
70
|
-
fs.renameSync(`${this.file}.tmp`, this.file);
|
|
66
|
+
writeJsonAtomic(this.file, this.data, 2);
|
|
71
67
|
}
|
|
72
68
|
|
|
73
69
|
// scope: 'global' or a project path; kind: 'preferences' | 'lessons'.
|