@yanzc/claude-pro 0.0.0-stage → 0.4.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/.env.example +41 -0
- package/LICENSE +21 -0
- package/README.md +314 -3
- package/bin/claude-pro.js +32 -0
- package/package.json +40 -6
- package/src/agent.js +752 -0
- package/src/agents.js +186 -0
- package/src/api.js +488 -0
- package/src/cache.js +150 -0
- package/src/checkpoints.js +184 -0
- package/src/claude-code.js +60 -0
- package/src/codex/client.js +300 -0
- package/src/codex/translate-request.js +239 -0
- package/src/codex/translate-stream.js +328 -0
- package/src/commands.js +803 -0
- package/src/config.js +453 -0
- package/src/context.js +109 -0
- package/src/filestate.js +57 -0
- package/src/gateway.js +110 -0
- package/src/hooks.js +81 -0
- package/src/main.js +193 -0
- package/src/mcp.js +290 -0
- package/src/modes.js +154 -0
- package/src/oauth.js +287 -0
- package/src/peers.js +156 -0
- package/src/print.js +106 -0
- package/src/prompts.js +179 -0
- package/src/ratelimit.js +244 -0
- package/src/restart.js +115 -0
- package/src/runtime.js +162 -0
- package/src/selfcheck.js +71 -0
- package/src/session.js +139 -0
- package/src/supervisor.js +110 -0
- package/src/tasks.js +76 -0
- package/src/tools/bash.js +227 -0
- package/src/tools/effort.js +41 -0
- package/src/tools/files.js +352 -0
- package/src/tools/index.js +52 -0
- package/src/tools/interaction.js +157 -0
- package/src/tools/notebook.js +91 -0
- package/src/tools/peers.js +45 -0
- package/src/tools/restart.js +50 -0
- package/src/tools/search.js +458 -0
- package/src/tools/skill.js +32 -0
- package/src/tools/task.js +139 -0
- package/src/tools/web.js +283 -0
- package/src/ui/ansi.js +228 -0
- package/src/ui/dialogs.js +122 -0
- package/src/ui/editor.js +389 -0
- package/src/ui/markdown.js +249 -0
- package/src/ui/render.js +155 -0
- package/src/ui/repl.js +1450 -0
- package/src/ui/screen.js +259 -0
- package/src/updater.js +93 -0
- package/src/utils/clipboard.js +197 -0
- package/src/utils/diff.js +95 -0
- package/src/utils/paths.js +37 -0
- package/src/utils/shell.js +251 -0
package/src/gateway.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The codex-gateway provider: model requests go to a shared gateway (https://gw.predix.icu by default) with an
|
|
2
|
+
// API key of the user's gateway account, which pays for them from its balance. /login gets that key the way
|
|
3
|
+
// Claude Code logs in: a local callback server, the gateway's login page in the browser, and a one-time code
|
|
4
|
+
// exchanged for a key of this machine's own (see handleCli in codex-gateway's server.mjs).
|
|
5
|
+
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { CONFIG_DIR, GATEWAY_DEFAULT_URL } from './config.js';
|
|
13
|
+
|
|
14
|
+
export { GATEWAY_DEFAULT_URL };
|
|
15
|
+
export const gatewayLoginFile = () => path.join(process.env.CLAUDE_PRO_CONFIG_DIR || CONFIG_DIR, 'gateway.json');
|
|
16
|
+
const LOGIN_TIMEOUT_MS = 10 * 60_000;
|
|
17
|
+
|
|
18
|
+
export const normalizeGatewayUrl = (url) => String(url || GATEWAY_DEFAULT_URL).trim().replace(/\/+$/, '');
|
|
19
|
+
|
|
20
|
+
// {url, apiKey, account, savedAt}, or null when there is none for this gateway.
|
|
21
|
+
export function readGatewayLogin(url) {
|
|
22
|
+
try {
|
|
23
|
+
const data = JSON.parse(fs.readFileSync(gatewayLoginFile(), 'utf8'));
|
|
24
|
+
if (!data?.apiKey || (url && normalizeGatewayUrl(data.url) !== normalizeGatewayUrl(url))) return null;
|
|
25
|
+
return data;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveGatewayLogin(login) {
|
|
32
|
+
const file = gatewayLoginFile();
|
|
33
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
34
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
35
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...login, url: normalizeGatewayUrl(login.url), savedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
|
36
|
+
fs.renameSync(tmp, file);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function clearGatewayLogin() {
|
|
40
|
+
try { fs.unlinkSync(gatewayLoginFile()); return true; } catch { return false; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function openBrowser(url) {
|
|
44
|
+
try {
|
|
45
|
+
const [cmd, args] = process.platform === 'win32'
|
|
46
|
+
? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
47
|
+
: process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
|
|
48
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
|
|
49
|
+
child.on('error', () => {});
|
|
50
|
+
child.unref();
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function gatewayJson(url, init = {}, fetchImpl = globalThis.fetch) {
|
|
58
|
+
const res = await fetchImpl(url, { ...init, headers: { 'content-type': 'application/json', ...(init.headers || {}) } });
|
|
59
|
+
const body = await res.json().catch(() => ({}));
|
|
60
|
+
if (!res.ok) throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), { status: res.status });
|
|
61
|
+
return body;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The account behind a key: balance, usage totals (GET /v1/account).
|
|
65
|
+
export async function fetchGatewayAccount({ url, apiKey }, { fetchImpl = globalThis.fetch, signal } = {}) {
|
|
66
|
+
const body = await gatewayJson(`${normalizeGatewayUrl(url)}/v1/account`, { headers: { authorization: `Bearer ${apiKey}` }, signal }, fetchImpl);
|
|
67
|
+
return body.account;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PAGE = (title, text) => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;line-height:1.6"><h2>${title}</h2><p>${text}</p></body>`;
|
|
71
|
+
|
|
72
|
+
// Log in through the browser. `onUrl(url)` is told the login page's address (to show it, for when the browser
|
|
73
|
+
// does not open by itself); `open` opens it. Resolves with the saved login once the key is in.
|
|
74
|
+
export async function loginWithBrowser({ url = GATEWAY_DEFAULT_URL, onUrl = () => {}, open = openBrowser, signal, fetchImpl = globalThis.fetch, timeoutMs = LOGIN_TIMEOUT_MS } = {}) {
|
|
75
|
+
const base = normalizeGatewayUrl(url);
|
|
76
|
+
const state = crypto.randomBytes(24).toString('base64url');
|
|
77
|
+
let settle;
|
|
78
|
+
const result = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
79
|
+
const server = http.createServer((req, res) => {
|
|
80
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
81
|
+
if (u.pathname !== '/callback') { res.writeHead(404).end(); return; }
|
|
82
|
+
const ok = u.searchParams.get('state') === state && u.searchParams.get('code');
|
|
83
|
+
res.writeHead(ok ? 200 : 400, { 'content-type': 'text/html; charset=utf-8' });
|
|
84
|
+
res.end(ok ? PAGE('登录成功', '可以关掉这个页面,回到终端继续使用 claude-pro。') : PAGE('登录失败', '链接无效或已过期。请回到终端重新执行 /login。'));
|
|
85
|
+
if (ok) settle.resolve(u.searchParams.get('code'));
|
|
86
|
+
});
|
|
87
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
88
|
+
const timer = setTimeout(() => settle.reject(new Error('Login timed out: the browser did not come back within 10 minutes.')), timeoutMs);
|
|
89
|
+
const onAbort = () => settle.reject(Object.assign(new Error('Login cancelled'), { name: 'AbortError' }));
|
|
90
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
91
|
+
try {
|
|
92
|
+
const page = `${base}/cli/login?${new URLSearchParams({ port: String(server.address().port), state, client: 'claude-pro', device: os.hostname().slice(0, 60) })}`;
|
|
93
|
+
onUrl(page);
|
|
94
|
+
Promise.resolve().then(() => open(page)).catch((e) => settle.reject(e));
|
|
95
|
+
const code = await result;
|
|
96
|
+
const token = await gatewayJson(`${base}/cli/token`, { method: 'POST', body: JSON.stringify({ code, state }) }, fetchImpl);
|
|
97
|
+
const login = { url: base, apiKey: token.api_key, account: { id: token.account?.id, name: token.account?.name, email: token.account?.email } };
|
|
98
|
+
saveGatewayLogin(login);
|
|
99
|
+
return { ...login, balance: token.account };
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
signal?.removeEventListener('abort', onAbort);
|
|
103
|
+
server.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const formatUsd = (value) => {
|
|
108
|
+
const n = Number(value);
|
|
109
|
+
return Number.isFinite(n) ? `$${n.toFixed(n !== 0 && Math.abs(n) < 0.01 ? 4 : 2)}` : '-';
|
|
110
|
+
};
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Hooks: user-defined shell commands that run around tool calls, in the same shape as
|
|
2
|
+
// Claude Code's settings.json:
|
|
3
|
+
// "hooks": { "PreToolUse": [ { "matcher": "Bash|Edit", "hooks": [ { "type": "command", "command": "..." } ] } ],
|
|
4
|
+
// "PostToolUse": [...], "UserPromptSubmit": [...], "Stop": [...] }
|
|
5
|
+
// The event payload is passed as JSON on stdin (like Claude Code) and in $CLAUDE_PRO_HOOK_INPUT; $CLAUDE_PROJECT_DIR is set too.
|
|
6
|
+
// Exit code 0: fine (stdout is kept as extra context). Exit code 2: block, stderr goes to the model.
|
|
7
|
+
// Any other non-zero exit code is reported as a warning and ignored.
|
|
8
|
+
|
|
9
|
+
import { spawnShell, killTree, findShell, watchShell } from './utils/shell.js';
|
|
10
|
+
import { stripAnsi } from './ui/ansi.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
function matches(matcher, name) {
|
|
15
|
+
if (!matcher || matcher === '*') return true;
|
|
16
|
+
try { return new RegExp(`^(?:${matcher})$`).test(name || ''); } catch { return matcher === name; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function runCommand(shell, command, { cwd, payload, timeout, signal }) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
let out = '', err = '', timedOut = false;
|
|
22
|
+
let child;
|
|
23
|
+
try {
|
|
24
|
+
const json = JSON.stringify(payload);
|
|
25
|
+
child = spawnShell(shell, command, {
|
|
26
|
+
cwd,
|
|
27
|
+
env: { ...process.env, CLAUDE_PRO_HOOK_INPUT: json, CLAUDE_PROJECT_DIR: cwd, CLAUDE_PRO: '1' },
|
|
28
|
+
stdin: json,
|
|
29
|
+
});
|
|
30
|
+
} catch (e) {
|
|
31
|
+
resolve({ code: 1, out: '', err: e.message });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeout);
|
|
35
|
+
const onAbort = () => killTree(child);
|
|
36
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
37
|
+
watchShell(child, {
|
|
38
|
+
onOutput: (text, stream) => { if (stream === 'stderr') err += text; else out += text; },
|
|
39
|
+
done: (code, error) => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
signal?.removeEventListener('abort', onAbort);
|
|
42
|
+
if (error) err += error.message;
|
|
43
|
+
resolve({ code: timedOut || error ? 1 : code, out: stripAnsi(out).trim(), err: stripAnsi(err + (timedOut ? `\nhook timed out after ${timeout}ms` : '')).trim() });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class HookRunner {
|
|
50
|
+
constructor(config) {
|
|
51
|
+
this.config = config;
|
|
52
|
+
this.shell = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
has(event) { return (this.config.settings.hooks?.[event] || []).length > 0; }
|
|
56
|
+
|
|
57
|
+
// Returns {blocked, message, context: string[], warnings: string[]}.
|
|
58
|
+
async run(event, payload, { cwd, signal } = {}) {
|
|
59
|
+
const result = { blocked: false, message: '', context: [], warnings: [] };
|
|
60
|
+
const entries = this.config.settings.hooks?.[event] || [];
|
|
61
|
+
if (!entries.length) return result;
|
|
62
|
+
this.shell ||= findShell(this.config);
|
|
63
|
+
const full = { hook_event_name: event, cwd, ...payload };
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!matches(entry.matcher, payload.tool_name)) continue;
|
|
66
|
+
for (const h of entry.hooks || []) {
|
|
67
|
+
if (h.type && h.type !== 'command') continue;
|
|
68
|
+
if (!h.command) continue;
|
|
69
|
+
const r = await runCommand(this.shell, h.command, { cwd, payload: full, timeout: h.timeout ? h.timeout * 1000 : DEFAULT_TIMEOUT_MS, signal });
|
|
70
|
+
if (r.code === 2) {
|
|
71
|
+
result.blocked = true;
|
|
72
|
+
result.message = r.err || r.out || `Blocked by ${event} hook: ${h.command}`;
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
if (r.code !== 0) result.warnings.push(`${event} hook "${h.command}" exited with ${r.code}${r.err ? `: ${r.err.split('\n')[0]}` : ''}`);
|
|
76
|
+
else if (r.out) result.context.push(r.out);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { VERSION, KNOWN_KEYS, loadConfig, settingsFiles, updateSettingsFile, applyRememberedChoices } from './config.js';
|
|
3
|
+
import { SessionStore } from './session.js';
|
|
4
|
+
import { takeRestartState } from './restart.js';
|
|
5
|
+
|
|
6
|
+
const HELP = `Usage: claude-pro [options] [prompt]
|
|
7
|
+
|
|
8
|
+
A Claude Code style coding agent for APIs or existing local Claude Code OAuth credentials.
|
|
9
|
+
Starts an interactive session by default; use -p for non-interactive output.
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
-p, --print Print the response and exit (useful for pipes)
|
|
13
|
+
-c, --continue Continue the most recent conversation in this directory
|
|
14
|
+
-r, --resume [sessionId] Resume a conversation (picker if no id given)
|
|
15
|
+
--provider <backend> api (default) | claude-code (direct HTTP with existing local login)
|
|
16
|
+
--model <model> Model for this session (name or alias: opus, sonnet, haiku)
|
|
17
|
+
--effort <level> Reasoning effort: none|minimal|low|medium|high|xhigh|max
|
|
18
|
+
--plan Start in read-only plan mode (research first, then ExitPlanMode)
|
|
19
|
+
--output-format <format> text | json | stream-json (with --print)
|
|
20
|
+
--verify <command> After the agent edits files, run this (e.g. "npm test") and send failures back to fix
|
|
21
|
+
--append-system-prompt <text> Append text to the system prompt
|
|
22
|
+
--system-prompt <text> Replace the system prompt
|
|
23
|
+
--verbose Show thinking and full tool output
|
|
24
|
+
--no-mcp Do not start MCP servers
|
|
25
|
+
-v, --version Print version
|
|
26
|
+
-h, --help Show help
|
|
27
|
+
|
|
28
|
+
There are no permission prompts: every tool runs immediately (full access). File edits are
|
|
29
|
+
checkpointed per prompt, and /rewind in a session undoes them. The old flags
|
|
30
|
+
--dangerously-skip-permissions, --permission-mode, --allowedTools and --disallowedTools
|
|
31
|
+
are accepted and ignored (--permission-mode plan is the same as --plan).
|
|
32
|
+
|
|
33
|
+
Commands:
|
|
34
|
+
claude-pro config list Show effective configuration and where each value comes from
|
|
35
|
+
claude-pro config set <KEY> <VALUE> [--project|--local] Save a setting (default: ~/.claude-pro/settings.json)
|
|
36
|
+
claude-pro config unset <KEY> [--project|--local]
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
function parseArgs(argv) {
|
|
40
|
+
const flags = {};
|
|
41
|
+
const positional = [];
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i];
|
|
44
|
+
const next = () => argv[++i];
|
|
45
|
+
// Old permission flags take a list of rules (Tool, Tool(content), "A B(c)"); skip over them.
|
|
46
|
+
const RULES = /^\s*[\w-]+(\([^)]*\))?(\s*[,\s]\s*[\w-]+(\([^)]*\))?)*\s*$/;
|
|
47
|
+
const skipRules = () => {
|
|
48
|
+
while (i + 1 < argv.length && !argv[i + 1].startsWith('-') && RULES.test(argv[i + 1])) i++;
|
|
49
|
+
};
|
|
50
|
+
switch (a) {
|
|
51
|
+
case '-p': case '--print': flags.print = true; break;
|
|
52
|
+
case '-c': case '--continue': flags.continue = true; break;
|
|
53
|
+
case '-r': case '--resume':
|
|
54
|
+
flags.resume = true;
|
|
55
|
+
if (argv[i + 1] && !argv[i + 1].startsWith('-') && /^[0-9a-f-]{8,}$/i.test(argv[i + 1])) flags.resumeId = next();
|
|
56
|
+
break;
|
|
57
|
+
case '--model': flags.model = next(); break;
|
|
58
|
+
case '--provider': flags.provider = next(); break;
|
|
59
|
+
case '--effort': flags.effort = next(); break;
|
|
60
|
+
case '--plan': flags.plan = true; break;
|
|
61
|
+
case '--permission-mode': if (next() === 'plan') flags.plan = true; break;
|
|
62
|
+
case '--dangerously-skip-permissions': break; // nothing to skip: there are no prompts
|
|
63
|
+
case '--allowedTools': case '--allowed-tools': case '--disallowedTools': case '--disallowed-tools': skipRules(); break;
|
|
64
|
+
case '--output-format': flags.outputFormat = next(); break;
|
|
65
|
+
case '--verify': flags.verify = next(); break;
|
|
66
|
+
case '--append-system-prompt': flags.appendSystemPrompt = next(); break;
|
|
67
|
+
case '--system-prompt': flags.systemPrompt = next(); break;
|
|
68
|
+
case '--verbose': flags.verbose = true; break;
|
|
69
|
+
case '--no-mcp': flags.noMcp = true; break;
|
|
70
|
+
case '-v': case '--version': flags.version = true; break;
|
|
71
|
+
case '-h': case '--help': flags.help = true; break;
|
|
72
|
+
default:
|
|
73
|
+
if (a.startsWith('--') && a.includes('=')) { argv.splice(i + 1, 0, a.slice(a.indexOf('=') + 1)); argv[i] = a.slice(0, a.indexOf('=')); i--; break; }
|
|
74
|
+
if (a.startsWith('-') && a !== '-') { process.stderr.write(`Unknown option: ${a}\n`); process.exit(2); }
|
|
75
|
+
positional.push(a);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { flags, positional };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function configCommand(args, cwd) {
|
|
82
|
+
const [sub, key, ...rest] = args;
|
|
83
|
+
const scopeFlag = rest.find((r) => r === '--project' || r === '--local') || (key === '--project' || key === '--local' ? key : null);
|
|
84
|
+
const files = settingsFiles(cwd);
|
|
85
|
+
const file = scopeFlag === '--project' ? files.project : scopeFlag === '--local' ? files.local : files.user;
|
|
86
|
+
if (!sub || sub === 'list' || sub === 'get') {
|
|
87
|
+
const cfg = loadConfig({ cwd });
|
|
88
|
+
const remembered = applyRememberedChoices(cfg);
|
|
89
|
+
const keys = sub === 'get' && key ? [key] : Object.keys(KNOWN_KEYS);
|
|
90
|
+
for (const k of keys) {
|
|
91
|
+
const v = cfg.display(k);
|
|
92
|
+
if (v === undefined && sub !== 'get') continue;
|
|
93
|
+
console.log(`${k}=${v ?? ''}${cfg.sources[k] ? ` # ${cfg.sources[k]}` : ''}`);
|
|
94
|
+
}
|
|
95
|
+
if (sub !== 'get') {
|
|
96
|
+
console.log(`\nprovider: ${cfg.provider} (${cfg.endpointLabel})`);
|
|
97
|
+
console.log(`main model: ${cfg.mainModel.label} (context ${cfg.mainModel.contextWindow.toLocaleString()})${cfg.effort ? `, effort ${cfg.effort}` : ''}`);
|
|
98
|
+
if (remembered.model || remembered.effort) {
|
|
99
|
+
const what = Object.entries(remembered).map(([k, v]) => `${k} ${v}`).join(', ');
|
|
100
|
+
console.log(`chosen with /model, /effort: ${what} (kept in ${files.user}; wins over the settings above, --model/--effort win over it)`);
|
|
101
|
+
}
|
|
102
|
+
console.log(`sub-agent model: ${cfg.subagentModel.label}; small model: ${cfg.smallModel.label}`);
|
|
103
|
+
console.log(`auto-compact at ${cfg.autoCompactThreshold().toLocaleString()} tokens (window ${cfg.compactWindow().toLocaleString()})`);
|
|
104
|
+
}
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if (sub === 'set') {
|
|
108
|
+
const value = rest.filter((r) => r !== '--project' && r !== '--local').join(' ');
|
|
109
|
+
if (!key || !value) { console.error('Usage: claude-pro config set <KEY> <VALUE> [--project|--local]'); return 2; }
|
|
110
|
+
if (!KNOWN_KEYS[key]) console.error(`note: ${key} is not a known key; saving anyway`);
|
|
111
|
+
updateSettingsFile(file, (json) => { json.env = { ...(json.env || {}), [key]: value }; });
|
|
112
|
+
console.log(`Set ${key} in ${file}`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (sub === 'unset') {
|
|
116
|
+
updateSettingsFile(file, (json) => { if (json.env) delete json.env[key]; });
|
|
117
|
+
console.log(`Removed ${key} from ${file}`);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
console.error(`Unknown config command: ${sub}`);
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Read piped stdin. When a prompt was given on the command line, stdin is optional: if
|
|
125
|
+
// nothing arrives quickly (e.g. an inherited pipe that never closes) we carry on without it.
|
|
126
|
+
function readStdin({ optional }) {
|
|
127
|
+
if (process.stdin.isTTY) return Promise.resolve('');
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
const chunks = [];
|
|
130
|
+
let timer = null;
|
|
131
|
+
const done = () => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
process.stdin.removeAllListeners('data');
|
|
134
|
+
process.stdin.removeAllListeners('end');
|
|
135
|
+
process.stdin.pause();
|
|
136
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
137
|
+
};
|
|
138
|
+
if (optional) timer = setTimeout(done, 300);
|
|
139
|
+
process.stdin.on('data', (d) => { clearTimeout(timer); chunks.push(d); });
|
|
140
|
+
process.stdin.on('end', done);
|
|
141
|
+
process.stdin.on('error', done);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function main(argv) {
|
|
146
|
+
const cwd = process.cwd();
|
|
147
|
+
if (argv[0] === 'config') return configCommand(argv.slice(1), cwd);
|
|
148
|
+
if (argv[0] === '--self-check') return (await import('./selfcheck.js')).selfCheck(); // see restart.js
|
|
149
|
+
const { flags, positional } = parseArgs(argv);
|
|
150
|
+
if (flags.version) { console.log(`${VERSION} (claude-pro)`); return 0; }
|
|
151
|
+
if (flags.help) { process.stdout.write(HELP); return 0; }
|
|
152
|
+
|
|
153
|
+
let prompt = positional.join(' ');
|
|
154
|
+
|
|
155
|
+
// Started by a restart of a running session: that session is picked up again, whatever the command line
|
|
156
|
+
// that started the first one said (-c, --resume, a first prompt).
|
|
157
|
+
const interactive = !(flags.print || !process.stdout.isTTY || !process.stdin.isTTY);
|
|
158
|
+
const restart = interactive ? takeRestartState() : null;
|
|
159
|
+
if (restart) {
|
|
160
|
+
flags.restart = restart;
|
|
161
|
+
flags.continue = false;
|
|
162
|
+
flags.resume = false;
|
|
163
|
+
flags.resumeId = undefined;
|
|
164
|
+
prompt = '';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (flags.continue || (flags.resume && flags.resumeId)) {
|
|
168
|
+
const sessions = SessionStore.list(cwd);
|
|
169
|
+
const found = flags.resumeId ? sessions.find((s) => s.id.startsWith(flags.resumeId)) : sessions[0];
|
|
170
|
+
if (!found) {
|
|
171
|
+
process.stderr.write(flags.resumeId ? `No conversation found with id ${flags.resumeId}\n` : 'No conversation found to continue\n');
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
flags.resumeFile = found.file;
|
|
175
|
+
flags.resumeId = found.id;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// mintty (Git Bash's default window) connects programs through pipes, not a console.
|
|
179
|
+
if (!flags.print && !process.stdin.isTTY && process.env.TERM_PROGRAM === 'mintty') {
|
|
180
|
+
process.stderr.write('Interactive mode needs a real console. In mintty/Git Bash run `winpty claude-pro` (or `winpty node bin/claude-pro.js`), or use Windows Terminal. For scripts use `claude-pro -p`.\n');
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
if (flags.print || !process.stdout.isTTY || !process.stdin.isTTY) {
|
|
184
|
+
const stdin = await readStdin({ optional: !!prompt.trim() });
|
|
185
|
+
if (stdin.trim()) prompt = prompt ? `${stdin.trimEnd()}\n\n${prompt}` : stdin;
|
|
186
|
+
if (!prompt.trim()) { process.stderr.write('Error: no prompt given. Usage: claude-pro -p "your prompt"\n'); return 2; }
|
|
187
|
+
const { runPrint } = await import('./print.js');
|
|
188
|
+
return await runPrint({ prompt, flags, cwd });
|
|
189
|
+
}
|
|
190
|
+
if (!fs.existsSync(cwd)) return 1;
|
|
191
|
+
const { runRepl } = await import('./ui/repl.js');
|
|
192
|
+
return await runRepl({ flags, cwd, initialPrompt: prompt });
|
|
193
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Minimal Model Context Protocol client. Supports stdio servers and streamable HTTP
|
|
2
|
+
// servers configured like Claude Code (.mcp.json or "mcpServers" in settings.json):
|
|
3
|
+
// { "mcpServers": { "name": { "command": "npx", "args": [...], "env": {...} },
|
|
4
|
+
// "remote": { "type": "http", "url": "https://...", "headers": {...} } } }
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { VERSION } from './config.js';
|
|
8
|
+
import { killTree } from './utils/shell.js';
|
|
9
|
+
|
|
10
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
11
|
+
const isWin = process.platform === 'win32';
|
|
12
|
+
|
|
13
|
+
function quoteWinArg(a) {
|
|
14
|
+
if (a === '') return '""';
|
|
15
|
+
if (!/[\s"&|<>^()%!]/.test(a)) return a;
|
|
16
|
+
return '"' + a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1') + '"';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function expandEnv(value) {
|
|
20
|
+
return String(value).replace(/\$\{(\w+)(?::-([^}]*))?\}/g, (m, name, def) => process.env[name] ?? def ?? '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class StdioTransport {
|
|
24
|
+
constructor(name, cfg, cwd) {
|
|
25
|
+
this.name = name;
|
|
26
|
+
this.cfg = cfg;
|
|
27
|
+
this.cwd = cwd;
|
|
28
|
+
this.pending = new Map();
|
|
29
|
+
this.nextId = 1;
|
|
30
|
+
this.stderr = '';
|
|
31
|
+
this.closed = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async connect() {
|
|
35
|
+
const env = { ...process.env };
|
|
36
|
+
for (const [k, v] of Object.entries(this.cfg.env || {})) env[k] = expandEnv(v);
|
|
37
|
+
const command = expandEnv(this.cfg.command);
|
|
38
|
+
const args = (this.cfg.args || []).map(expandEnv);
|
|
39
|
+
if (isWin) {
|
|
40
|
+
const line = [command, ...args].map(quoteWinArg).join(' ');
|
|
41
|
+
this.child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${line}"`], { env, cwd: this.cwd, windowsHide: true, windowsVerbatimArguments: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
42
|
+
} else {
|
|
43
|
+
this.child = spawn(command, args, { env, cwd: this.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
44
|
+
}
|
|
45
|
+
let buf = '';
|
|
46
|
+
// Decode as a stream: a multi-byte character (CJK text) can be split between two chunks.
|
|
47
|
+
this.child.stdout.setEncoding('utf8');
|
|
48
|
+
this.child.stderr.setEncoding('utf8');
|
|
49
|
+
this.child.stdout.on('data', (d) => {
|
|
50
|
+
buf += d;
|
|
51
|
+
let i;
|
|
52
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
53
|
+
const line = buf.slice(0, i).trim();
|
|
54
|
+
buf = buf.slice(i + 1);
|
|
55
|
+
if (!line) continue;
|
|
56
|
+
let msg;
|
|
57
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
58
|
+
this.onMessage(msg);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
this.child.stderr.on('data', (d) => { this.stderr = (this.stderr + d).slice(-4000); });
|
|
62
|
+
const failAll = (err) => {
|
|
63
|
+
this.closed = true;
|
|
64
|
+
for (const p of this.pending.values()) p.reject(err);
|
|
65
|
+
this.pending.clear();
|
|
66
|
+
};
|
|
67
|
+
this.child.on('error', (e) => failAll(e));
|
|
68
|
+
this.child.on('close', (code) => failAll(new Error(`MCP server "${this.name}" exited (code ${code}). ${this.stderr.trim().split('\n').slice(-3).join(' ')}`)));
|
|
69
|
+
this.child.stdin.on('error', () => {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onMessage(msg) {
|
|
73
|
+
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
74
|
+
const p = this.pending.get(msg.id);
|
|
75
|
+
if (!p) return;
|
|
76
|
+
this.pending.delete(msg.id);
|
|
77
|
+
if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
78
|
+
else p.resolve(msg.result);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Server -> client requests we don't support get a polite error; pings get an answer.
|
|
82
|
+
if (msg.id !== undefined && msg.method) {
|
|
83
|
+
const reply = msg.method === 'ping'
|
|
84
|
+
? { jsonrpc: '2.0', id: msg.id, result: {} }
|
|
85
|
+
: { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Method not supported by client' } };
|
|
86
|
+
this.send(reply);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
send(obj) {
|
|
91
|
+
if (!this.closed) this.child.stdin.write(JSON.stringify(obj) + '\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
request(method, params, { timeout = 60_000, signal } = {}) {
|
|
95
|
+
if (this.closed) return Promise.reject(new Error(`MCP server "${this.name}" is not running`));
|
|
96
|
+
const aborted = () => Object.assign(new Error('aborted'), { name: 'AbortError' });
|
|
97
|
+
if (signal?.aborted) return Promise.reject(aborted());
|
|
98
|
+
const id = this.nextId++;
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
// The abort listener must go as soon as the request is over: left in place it would send a
|
|
101
|
+
// cancellation for a request that already finished when the signal is aborted later.
|
|
102
|
+
const onAbort = () => {
|
|
103
|
+
this.pending.delete(id);
|
|
104
|
+
cleanup();
|
|
105
|
+
this.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id, reason: 'User interrupted' } });
|
|
106
|
+
reject(aborted());
|
|
107
|
+
};
|
|
108
|
+
const timer = setTimeout(() => { this.pending.delete(id); cleanup(); reject(new Error(`MCP request ${method} timed out after ${timeout}ms`)); }, timeout);
|
|
109
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); };
|
|
110
|
+
this.pending.set(id, { resolve: (v) => { cleanup(); resolve(v); }, reject: (e) => { cleanup(); reject(e); } });
|
|
111
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
112
|
+
this.send({ jsonrpc: '2.0', id, method, params });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
notify(method, params) { this.send({ jsonrpc: '2.0', method, params }); }
|
|
117
|
+
close() { this.closed = true; killTree(this.child); }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class HttpTransport {
|
|
121
|
+
constructor(name, cfg) {
|
|
122
|
+
this.name = name;
|
|
123
|
+
this.url = expandEnv(cfg.url);
|
|
124
|
+
this.headers = Object.fromEntries(Object.entries(cfg.headers || {}).map(([k, v]) => [k, expandEnv(v)]));
|
|
125
|
+
this.nextId = 1;
|
|
126
|
+
this.sessionId = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async connect() {}
|
|
130
|
+
|
|
131
|
+
async post(body, signal, timeout) {
|
|
132
|
+
const ctrl = new AbortController();
|
|
133
|
+
// A timeout must not look like the user's interrupt: both abort the fetch, and an AbortError
|
|
134
|
+
// would end the whole agent turn as "interrupted by user".
|
|
135
|
+
let timedOut = false;
|
|
136
|
+
const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, timeout);
|
|
137
|
+
const onAbort = () => ctrl.abort();
|
|
138
|
+
if (signal?.aborted) ctrl.abort();
|
|
139
|
+
else signal?.addEventListener('abort', onAbort, { once: true });
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(this.url, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: {
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
Accept: 'application/json, text/event-stream',
|
|
146
|
+
'MCP-Protocol-Version': PROTOCOL_VERSION,
|
|
147
|
+
...(this.sessionId ? { 'Mcp-Session-Id': this.sessionId } : {}),
|
|
148
|
+
...this.headers,
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
signal: ctrl.signal,
|
|
152
|
+
});
|
|
153
|
+
this.sessionId = res.headers.get('mcp-session-id') || this.sessionId;
|
|
154
|
+
if (!res.ok && res.status !== 202) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
155
|
+
if (body.id === undefined) return null;
|
|
156
|
+
const type = res.headers.get('content-type') || '';
|
|
157
|
+
const text = await res.text();
|
|
158
|
+
if (type.includes('text/event-stream')) {
|
|
159
|
+
for (const event of text.split(/\r?\n\r?\n/)) {
|
|
160
|
+
const data = event.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
|
|
161
|
+
if (!data) continue;
|
|
162
|
+
try {
|
|
163
|
+
const msg = JSON.parse(data);
|
|
164
|
+
if (msg.id === body.id) return msg;
|
|
165
|
+
} catch { /* skip */ }
|
|
166
|
+
}
|
|
167
|
+
throw new Error('No response in event stream');
|
|
168
|
+
}
|
|
169
|
+
return JSON.parse(text);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (timedOut && !signal?.aborted) throw new Error(`MCP request ${body.method} timed out after ${timeout}ms`);
|
|
172
|
+
throw e;
|
|
173
|
+
} finally {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
signal?.removeEventListener('abort', onAbort);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async request(method, params, { timeout = 60_000, signal } = {}) {
|
|
180
|
+
const msg = await this.post({ jsonrpc: '2.0', id: this.nextId++, method, params }, signal, timeout);
|
|
181
|
+
if (msg.error) throw new Error(msg.error.message || JSON.stringify(msg.error));
|
|
182
|
+
return msg.result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Returns a promise that never rejects, so callers can wait until the server has taken the notification.
|
|
186
|
+
notify(method, params) { return this.post({ jsonrpc: '2.0', method, params }, null, 10_000).catch(() => {}); }
|
|
187
|
+
close() {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const sanitize = (s) => String(s).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
191
|
+
|
|
192
|
+
function convertContent(result) {
|
|
193
|
+
const blocks = [];
|
|
194
|
+
for (const c of result.content || []) {
|
|
195
|
+
if (c.type === 'text') blocks.push({ type: 'text', text: c.text });
|
|
196
|
+
else if (c.type === 'image') blocks.push({ type: 'image', source: { type: 'base64', media_type: c.mimeType, data: c.data } });
|
|
197
|
+
else if (c.type === 'resource') blocks.push({ type: 'text', text: c.resource?.text ?? `[resource ${c.resource?.uri}]` });
|
|
198
|
+
else if (c.type === 'resource_link') blocks.push({ type: 'text', text: `[resource ${c.name || ''} ${c.uri}]` });
|
|
199
|
+
else blocks.push({ type: 'text', text: JSON.stringify(c) });
|
|
200
|
+
}
|
|
201
|
+
if (!blocks.length && result.structuredContent) blocks.push({ type: 'text', text: JSON.stringify(result.structuredContent, null, 2) });
|
|
202
|
+
if (!blocks.length) blocks.push({ type: 'text', text: '(no content)' });
|
|
203
|
+
return blocks.every((b) => b.type === 'text') ? blocks.map((b) => b.text).join('\n') : blocks;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export class McpManager {
|
|
207
|
+
constructor(servers = {}, { cwd }) {
|
|
208
|
+
this.servers = servers;
|
|
209
|
+
this.cwd = cwd;
|
|
210
|
+
this.clients = new Map(); // name -> {transport, tools, status, error}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async connectOne(name, cfg) {
|
|
214
|
+
const entry = { status: 'connecting', tools: [], error: null, config: cfg };
|
|
215
|
+
this.clients.set(name, entry);
|
|
216
|
+
try {
|
|
217
|
+
const type = cfg.type || (cfg.url ? 'http' : 'stdio');
|
|
218
|
+
if (type === 'sse') throw new Error('legacy SSE transport is not supported; use "type": "http"');
|
|
219
|
+
const transport = type === 'http' ? new HttpTransport(name, cfg) : new StdioTransport(name, cfg, this.cwd);
|
|
220
|
+
entry.transport = transport;
|
|
221
|
+
await transport.connect();
|
|
222
|
+
const timeout = parseInt(process.env.MCP_TIMEOUT, 10) || 30_000;
|
|
223
|
+
const init = await transport.request('initialize', {
|
|
224
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
225
|
+
capabilities: {},
|
|
226
|
+
clientInfo: { name: 'claude-pro', version: VERSION },
|
|
227
|
+
}, { timeout });
|
|
228
|
+
entry.serverInfo = init?.serverInfo;
|
|
229
|
+
entry.instructions = init?.instructions;
|
|
230
|
+
// Over HTTP the notification is a request of its own: tools/list must not overtake it, or a
|
|
231
|
+
// server that insists on a finished handshake answers "not initialized".
|
|
232
|
+
await transport.notify('notifications/initialized', {});
|
|
233
|
+
const tools = [];
|
|
234
|
+
let cursor;
|
|
235
|
+
do {
|
|
236
|
+
const page = await transport.request('tools/list', cursor ? { cursor } : {}, { timeout });
|
|
237
|
+
tools.push(...(page?.tools || []));
|
|
238
|
+
cursor = page?.nextCursor;
|
|
239
|
+
} while (cursor);
|
|
240
|
+
entry.tools = tools;
|
|
241
|
+
entry.status = 'connected';
|
|
242
|
+
} catch (e) {
|
|
243
|
+
entry.status = 'failed';
|
|
244
|
+
entry.error = e.message;
|
|
245
|
+
entry.transport?.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async start(onWarn) {
|
|
250
|
+
const names = Object.keys(this.servers || {});
|
|
251
|
+
if (!names.length) return;
|
|
252
|
+
await Promise.all(names.map((n) => this.connectOne(n, this.servers[n])));
|
|
253
|
+
for (const [name, e] of this.clients) if (e.status === 'failed') onWarn?.(`MCP server "${name}" failed to connect: ${e.error}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
tools() {
|
|
257
|
+
const out = [];
|
|
258
|
+
const toolTimeout = parseInt(process.env.MCP_TOOL_TIMEOUT, 10) || 600_000;
|
|
259
|
+
for (const [server, entry] of this.clients) {
|
|
260
|
+
if (entry.status !== 'connected') continue;
|
|
261
|
+
for (const t of entry.tools) {
|
|
262
|
+
const name = `mcp__${sanitize(server)}__${sanitize(t.name)}`.slice(0, 64);
|
|
263
|
+
out.push({
|
|
264
|
+
name,
|
|
265
|
+
mcp: { server, tool: t.name },
|
|
266
|
+
description: (t.description || `${t.name} (MCP tool from ${server})`).slice(0, 4000),
|
|
267
|
+
input_schema: t.inputSchema && t.inputSchema.type === 'object' ? t.inputSchema : { type: 'object', properties: {} },
|
|
268
|
+
summarize(input) {
|
|
269
|
+
const s = JSON.stringify(input);
|
|
270
|
+
return s.length > 80 ? s.slice(0, 80) + '…' : s;
|
|
271
|
+
},
|
|
272
|
+
label() { return `${server} - ${t.title || t.name} (MCP)`; },
|
|
273
|
+
async call(input, ctx) {
|
|
274
|
+
const result = await entry.transport.request('tools/call', { name: t.name, arguments: input }, { timeout: toolTimeout, signal: ctx.signal });
|
|
275
|
+
return { content: convertContent(result || {}), isError: !!result?.isError };
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
status() {
|
|
284
|
+
return [...this.clients.entries()].map(([name, e]) => ({ name, status: e.status, tools: e.tools.length, error: e.error, serverInfo: e.serverInfo }));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
close() {
|
|
288
|
+
for (const e of this.clients.values()) e.transport?.close();
|
|
289
|
+
}
|
|
290
|
+
}
|