clixad 0.0.1-beta.47 → 0.0.1-beta.49
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/README.md +7 -3
- package/dist/{chunk-TKGMAY4C.mjs → chunk-E7SAKAVQ.mjs} +571 -100
- package/dist/clixad.mjs +2 -2
- package/dist/{tui-US36BPP3.mjs → tui-2H2XRALC.mjs} +106 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,11 @@ before it changes anything. `shift+tab` cycles through the permission modes. Bey
|
|
|
63
63
|
- **Keeps a task list** on multi-step work, and **asks you** when guessing wrong would waste real
|
|
64
64
|
work.
|
|
65
65
|
- **Uses MCP servers** you configure in `.mcp.json` or `~/.clixad/mcp.json`; `/mcp` shows their
|
|
66
|
-
status. Every tool a server offers is put to you before it runs.
|
|
66
|
+
status. Every tool a server offers is put to you before it runs. A server from a project's
|
|
67
|
+
`.mcp.json` or `.clixad/mcp.json` starts only after you approve it (asked once per project, and
|
|
68
|
+
again if its entry changes; `/mcp approve` starts one later). A server gets `PATH`, `HOME` and the
|
|
69
|
+
locale from your environment and nothing else, so pass what it needs in its `env`, for example
|
|
70
|
+
`"env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }`.
|
|
67
71
|
|
|
68
72
|
Type `/help` for the commands, `@` to attach a file, and `!` to run a shell command yourself.
|
|
69
73
|
|
|
@@ -95,8 +99,8 @@ github.com/clixad/cli is an automatic one-way copy of the CLI's part of it, so a
|
|
|
95
99
|
nowhere to be merged and is closed automatically. If you have a fix in mind, describe it in an
|
|
96
100
|
issue.
|
|
97
101
|
|
|
98
|
-
Please report security problems
|
|
99
|
-
|
|
102
|
+
Please report security problems by email to [info@clixad.io](mailto:info@clixad.io), not in a
|
|
103
|
+
public GitHub issue. See [SECURITY.md](https://github.com/clixad/cli/blob/main/SECURITY.md).
|
|
100
104
|
|
|
101
105
|
## Status
|
|
102
106
|
|
|
@@ -468,6 +468,13 @@ var MaintenanceError = class extends Error {
|
|
|
468
468
|
}
|
|
469
469
|
};
|
|
470
470
|
var MAINTENANCE = "maintenance";
|
|
471
|
+
var PromptTooLongError = class extends Error {
|
|
472
|
+
constructor(message) {
|
|
473
|
+
super(message);
|
|
474
|
+
this.name = "PromptTooLongError";
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
var PROMPT_TOO_LONG = "prompt_too_long";
|
|
471
478
|
async function refusal503(res, what) {
|
|
472
479
|
const text = await res.text().catch(() => "");
|
|
473
480
|
try {
|
|
@@ -537,7 +544,7 @@ async function refusal400(res, what) {
|
|
|
537
544
|
const text = await res.text().catch(() => "");
|
|
538
545
|
try {
|
|
539
546
|
const body = JSON.parse(text);
|
|
540
|
-
if (body.error ===
|
|
547
|
+
if (body.error === PROMPT_TOO_LONG && typeof body.message === "string") return new PromptTooLongError(body.message);
|
|
541
548
|
} catch {
|
|
542
549
|
}
|
|
543
550
|
const detail = safeDetail(text);
|
|
@@ -643,16 +650,16 @@ async function httpFailure(res, what) {
|
|
|
643
650
|
return detail ? `${what} failed: ${res.status} \u2014 ${detail}` : `${what} failed: ${res.status}`;
|
|
644
651
|
}
|
|
645
652
|
function sleep(ms, signal) {
|
|
646
|
-
return new Promise((
|
|
653
|
+
return new Promise((resolve6) => {
|
|
647
654
|
if (!signal) {
|
|
648
|
-
setTimeout(
|
|
655
|
+
setTimeout(resolve6, ms);
|
|
649
656
|
return;
|
|
650
657
|
}
|
|
651
|
-
if (signal.aborted) return
|
|
658
|
+
if (signal.aborted) return resolve6();
|
|
652
659
|
const done = () => {
|
|
653
660
|
clearTimeout(timer);
|
|
654
661
|
signal.removeEventListener("abort", done);
|
|
655
|
-
|
|
662
|
+
resolve6();
|
|
656
663
|
};
|
|
657
664
|
const timer = setTimeout(done, ms);
|
|
658
665
|
signal.addEventListener("abort", done, { once: true });
|
|
@@ -1482,6 +1489,7 @@ var GatewayClient = class {
|
|
|
1482
1489
|
}
|
|
1483
1490
|
if (premiumRefusal) throw premiumRefusal;
|
|
1484
1491
|
if (streamError && streamErrorType === MAINTENANCE) throw new MaintenanceError(streamError);
|
|
1492
|
+
if (streamError && streamErrorType === PROMPT_TOO_LONG) throw new PromptTooLongError(streamError);
|
|
1485
1493
|
if (streamError && streamErrorType && LIMIT_REFUSALS.has(streamErrorType)) {
|
|
1486
1494
|
throw new LimitRefusalError(streamError, streamErrorType, spendable ? { figures: spendable, balance } : void 0);
|
|
1487
1495
|
}
|
|
@@ -2032,16 +2040,16 @@ async function devLogin(client, config, email, invite) {
|
|
|
2032
2040
|
};
|
|
2033
2041
|
}
|
|
2034
2042
|
function sleep2(ms, signal) {
|
|
2035
|
-
return new Promise((
|
|
2043
|
+
return new Promise((resolve6) => {
|
|
2036
2044
|
if (!signal) {
|
|
2037
|
-
setTimeout(
|
|
2045
|
+
setTimeout(resolve6, ms);
|
|
2038
2046
|
return;
|
|
2039
2047
|
}
|
|
2040
|
-
if (signal.aborted) return
|
|
2048
|
+
if (signal.aborted) return resolve6();
|
|
2041
2049
|
const done = () => {
|
|
2042
2050
|
clearTimeout(timer);
|
|
2043
2051
|
signal.removeEventListener("abort", done);
|
|
2044
|
-
|
|
2052
|
+
resolve6();
|
|
2045
2053
|
};
|
|
2046
2054
|
const timer = setTimeout(done, ms);
|
|
2047
2055
|
signal.addEventListener("abort", done, { once: true });
|
|
@@ -2336,8 +2344,18 @@ ${renderTodos(items)}${warning}`;
|
|
|
2336
2344
|
|
|
2337
2345
|
// src/tools.ts
|
|
2338
2346
|
import { spawn as spawn4 } from "node:child_process";
|
|
2339
|
-
import {
|
|
2340
|
-
|
|
2347
|
+
import {
|
|
2348
|
+
existsSync as existsSync2,
|
|
2349
|
+
lstatSync,
|
|
2350
|
+
mkdirSync as mkdirSync3,
|
|
2351
|
+
readdirSync,
|
|
2352
|
+
readFileSync as readFileSync3,
|
|
2353
|
+
readlinkSync,
|
|
2354
|
+
realpathSync as realpathSync2,
|
|
2355
|
+
statSync,
|
|
2356
|
+
writeFileSync as writeFileSync3
|
|
2357
|
+
} from "node:fs";
|
|
2358
|
+
import { basename, isAbsolute, relative, resolve as resolve2, dirname as dirname3, join as join3, sep } from "node:path";
|
|
2341
2359
|
|
|
2342
2360
|
// src/diff.ts
|
|
2343
2361
|
var R = "\x1B[0m";
|
|
@@ -3026,9 +3044,10 @@ function decodeEntities(text) {
|
|
|
3026
3044
|
|
|
3027
3045
|
// src/mcp.ts
|
|
3028
3046
|
import { spawn as spawn3 } from "node:child_process";
|
|
3029
|
-
import {
|
|
3047
|
+
import { createHash } from "node:crypto";
|
|
3048
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, realpathSync, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
3030
3049
|
import { homedir as homedir2 } from "node:os";
|
|
3031
|
-
import { join as join2 } from "node:path";
|
|
3050
|
+
import { dirname as dirname2, join as join2, resolve } from "node:path";
|
|
3032
3051
|
var CONNECT_TIMEOUT_MS = 2e4;
|
|
3033
3052
|
var DEFAULT_CALL_TIMEOUT_MS = 12e4;
|
|
3034
3053
|
var PROTOCOL_VERSION = "2025-06-18";
|
|
@@ -3039,14 +3058,20 @@ var McpError = class extends Error {
|
|
|
3039
3058
|
function isHttpConfig(c) {
|
|
3040
3059
|
return c.url !== void 0;
|
|
3041
3060
|
}
|
|
3042
|
-
function
|
|
3043
|
-
|
|
3061
|
+
function mcpConfigFiles(root) {
|
|
3062
|
+
const personal = join2(homedir2(), ".clixad", "mcp.json");
|
|
3063
|
+
const files = [{ path: personal, scope: "user" }];
|
|
3064
|
+
for (const path of [join2(root, ".mcp.json"), join2(root, ".clixad", "mcp.json")]) {
|
|
3065
|
+
if (resolve(path) !== resolve(personal)) files.push({ path, scope: "project" });
|
|
3066
|
+
}
|
|
3067
|
+
return files;
|
|
3044
3068
|
}
|
|
3045
3069
|
function loadMcpConfig(root) {
|
|
3046
3070
|
const servers = {};
|
|
3071
|
+
const origins = {};
|
|
3047
3072
|
const sources = [];
|
|
3048
3073
|
const errors = [];
|
|
3049
|
-
for (const path of
|
|
3074
|
+
for (const { path, scope } of mcpConfigFiles(root)) {
|
|
3050
3075
|
if (!existsSync(path)) continue;
|
|
3051
3076
|
let parsed;
|
|
3052
3077
|
try {
|
|
@@ -3067,13 +3092,24 @@ function loadMcpConfig(root) {
|
|
|
3067
3092
|
sources.push(short(path));
|
|
3068
3093
|
for (const [name, raw] of Object.entries(declared)) {
|
|
3069
3094
|
const config = validateServer(name, raw);
|
|
3070
|
-
if (typeof config === "string")
|
|
3071
|
-
|
|
3095
|
+
if (typeof config === "string") {
|
|
3096
|
+
errors.push(`${short(path)}: ${config}`);
|
|
3097
|
+
continue;
|
|
3098
|
+
}
|
|
3099
|
+
servers[name] = config;
|
|
3100
|
+
origins[name] = {
|
|
3101
|
+
scope,
|
|
3102
|
+
file: short(path),
|
|
3103
|
+
fingerprint: fingerprint(raw),
|
|
3104
|
+
display: describeEntry(raw),
|
|
3105
|
+
variables: [...new Set(variablesIn(raw))].sort()
|
|
3106
|
+
};
|
|
3072
3107
|
}
|
|
3073
3108
|
}
|
|
3074
|
-
return { servers, sources, errors };
|
|
3109
|
+
return { servers, origins, sources, errors };
|
|
3075
3110
|
}
|
|
3076
3111
|
function validateServer(name, raw) {
|
|
3112
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) return `server ${JSON.stringify(name)} has control characters in its name`;
|
|
3077
3113
|
if (typeof raw !== "object" || raw === null) return `server "${name}" is not an object`;
|
|
3078
3114
|
const c = raw;
|
|
3079
3115
|
if (typeof c.url === "string") {
|
|
@@ -3114,6 +3150,109 @@ function expandMap(map) {
|
|
|
3114
3150
|
function short(path) {
|
|
3115
3151
|
return path.replace(homedir2(), "~");
|
|
3116
3152
|
}
|
|
3153
|
+
function canonicalJson(value) {
|
|
3154
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
3155
|
+
if (value && typeof value === "object") {
|
|
3156
|
+
const entries = Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`);
|
|
3157
|
+
return `{${entries.join(",")}}`;
|
|
3158
|
+
}
|
|
3159
|
+
return JSON.stringify(value) ?? "null";
|
|
3160
|
+
}
|
|
3161
|
+
function fingerprint(raw) {
|
|
3162
|
+
return createHash("sha256").update(canonicalJson(raw)).digest("hex");
|
|
3163
|
+
}
|
|
3164
|
+
function variablesIn(value) {
|
|
3165
|
+
if (typeof value === "string") return [...value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
|
|
3166
|
+
if (Array.isArray(value)) return value.flatMap(variablesIn);
|
|
3167
|
+
if (value && typeof value === "object") return Object.values(value).flatMap(variablesIn);
|
|
3168
|
+
return [];
|
|
3169
|
+
}
|
|
3170
|
+
var MAX_DISPLAY_CHARS = 2e3;
|
|
3171
|
+
function shellWord(word) {
|
|
3172
|
+
if (/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(word)) return word;
|
|
3173
|
+
return JSON.stringify(word).replace(/[^\x20-\x7e]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
3174
|
+
}
|
|
3175
|
+
function describeEntry(raw) {
|
|
3176
|
+
const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
3177
|
+
const pairs = (v) => isStringMap(v) ? Object.entries(v) : [];
|
|
3178
|
+
const lines = [];
|
|
3179
|
+
if (typeof raw.url === "string") {
|
|
3180
|
+
lines.push(`${raw.type === "sse" ? "sse" : "http"} ${shellWord(raw.url)}`);
|
|
3181
|
+
for (const [k, v] of pairs(raw.headers)) lines.push(`header ${shellWord(k)}: ${shellWord(v)}`);
|
|
3182
|
+
} else {
|
|
3183
|
+
lines.push([String(raw.command ?? ""), ...strings(raw.args)].map(shellWord).join(" "));
|
|
3184
|
+
for (const [k, v] of pairs(raw.env)) lines.push(`env ${shellWord(k)}=${shellWord(v)}`);
|
|
3185
|
+
if (typeof raw.cwd === "string") lines.push(`cwd ${shellWord(raw.cwd)}`);
|
|
3186
|
+
}
|
|
3187
|
+
const text = lines.join("\n");
|
|
3188
|
+
return text.length > MAX_DISPLAY_CHARS ? `${text.slice(0, MAX_DISPLAY_CHARS)}\u2026 (${text.length - MAX_DISPLAY_CHARS} more characters; read the file before approving)` : text;
|
|
3189
|
+
}
|
|
3190
|
+
var INHERITED_ENV = {
|
|
3191
|
+
posix: ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TZ"],
|
|
3192
|
+
win32: [
|
|
3193
|
+
"APPDATA",
|
|
3194
|
+
"COMSPEC",
|
|
3195
|
+
"HOMEDRIVE",
|
|
3196
|
+
"HOMEPATH",
|
|
3197
|
+
"LOCALAPPDATA",
|
|
3198
|
+
"PATH",
|
|
3199
|
+
"PATHEXT",
|
|
3200
|
+
"PROCESSOR_ARCHITECTURE",
|
|
3201
|
+
"PROGRAMFILES",
|
|
3202
|
+
"SYSTEMDRIVE",
|
|
3203
|
+
"SYSTEMROOT",
|
|
3204
|
+
"TEMP",
|
|
3205
|
+
"TMP",
|
|
3206
|
+
"USERNAME",
|
|
3207
|
+
"USERPROFILE",
|
|
3208
|
+
"WINDIR"
|
|
3209
|
+
]
|
|
3210
|
+
};
|
|
3211
|
+
function mcpServerEnv(own = {}, source = process.env, platform2 = process.platform) {
|
|
3212
|
+
const env = {};
|
|
3213
|
+
const wanted = INHERITED_ENV[platform2 === "win32" ? "win32" : "posix"];
|
|
3214
|
+
const byName = new Map(Object.keys(source).map((k) => [platform2 === "win32" ? k.toUpperCase() : k, k]));
|
|
3215
|
+
for (const name of wanted) {
|
|
3216
|
+
const key = byName.get(name);
|
|
3217
|
+
const value = key === void 0 ? void 0 : source[key];
|
|
3218
|
+
if (value !== void 0 && !value.startsWith("()")) env[name] = value;
|
|
3219
|
+
}
|
|
3220
|
+
return { ...env, ...own };
|
|
3221
|
+
}
|
|
3222
|
+
function mcpApprovalsPath() {
|
|
3223
|
+
return join2(homedir2(), ".clixad", "mcp-approvals.json");
|
|
3224
|
+
}
|
|
3225
|
+
function workspaceKey(root) {
|
|
3226
|
+
try {
|
|
3227
|
+
return realpathSync.native(root);
|
|
3228
|
+
} catch {
|
|
3229
|
+
return resolve(root);
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
function readApprovals() {
|
|
3233
|
+
try {
|
|
3234
|
+
const parsed = JSON.parse(readFileSync2(mcpApprovalsPath(), "utf8"));
|
|
3235
|
+
if (parsed?.version === 1 && parsed.projects && typeof parsed.projects === "object") return parsed;
|
|
3236
|
+
} catch {
|
|
3237
|
+
}
|
|
3238
|
+
return { version: 1, projects: {} };
|
|
3239
|
+
}
|
|
3240
|
+
function mcpDecision(root, name, fingerprint2) {
|
|
3241
|
+
const entry = readApprovals().projects[workspaceKey(root)]?.[name];
|
|
3242
|
+
return entry && entry.fingerprint === fingerprint2 ? entry.decision : void 0;
|
|
3243
|
+
}
|
|
3244
|
+
function recordMcpDecision(root, servers, decision, at = /* @__PURE__ */ new Date()) {
|
|
3245
|
+
const file = readApprovals();
|
|
3246
|
+
const key = workspaceKey(root);
|
|
3247
|
+
const project = file.projects[key] ??= {};
|
|
3248
|
+
for (const server of servers) project[server.name] = { fingerprint: server.fingerprint, decision, at: at.toISOString() };
|
|
3249
|
+
const path = mcpApprovalsPath();
|
|
3250
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
3251
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
3252
|
+
writeFileSync2(tmp, `${JSON.stringify(file, null, 2)}
|
|
3253
|
+
`, { encoding: "utf8", mode: 384 });
|
|
3254
|
+
renameSync2(tmp, path);
|
|
3255
|
+
}
|
|
3117
3256
|
var StdioTransport = class {
|
|
3118
3257
|
child;
|
|
3119
3258
|
buffer = "";
|
|
@@ -3124,10 +3263,13 @@ var StdioTransport = class {
|
|
|
3124
3263
|
constructor(config, root) {
|
|
3125
3264
|
this.child = spawn3(config.command, config.args ?? [], {
|
|
3126
3265
|
cwd: config.cwd ?? root,
|
|
3127
|
-
//
|
|
3128
|
-
//
|
|
3129
|
-
//
|
|
3130
|
-
|
|
3266
|
+
// What a program needs to run plus what the entry names, and never the
|
|
3267
|
+
// rest of ours: this process's environment holds whatever the user
|
|
3268
|
+
// exported, API keys included, and a server is somebody else's program.
|
|
3269
|
+
// `shell: false` on purpose: the command and its args are already
|
|
3270
|
+
// separate, so there is nothing for a shell to re-split, and a path with
|
|
3271
|
+
// a space in it would be split if there were.
|
|
3272
|
+
env: mcpServerEnv(config.env),
|
|
3131
3273
|
stdio: ["pipe", "pipe", "pipe"],
|
|
3132
3274
|
windowsHide: true,
|
|
3133
3275
|
// npx and friends are .cmd shims on Windows, which CreateProcess cannot
|
|
@@ -3177,7 +3319,7 @@ var StdioTransport = class {
|
|
|
3177
3319
|
if (this.exited) return Promise.reject(new McpError(this.exited));
|
|
3178
3320
|
const id = ++this.seq;
|
|
3179
3321
|
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
|
3180
|
-
return new Promise((
|
|
3322
|
+
return new Promise((resolve6, reject) => {
|
|
3181
3323
|
const settle = (fn) => {
|
|
3182
3324
|
clearTimeout(timer);
|
|
3183
3325
|
signal?.removeEventListener("abort", onAbort);
|
|
@@ -3191,7 +3333,7 @@ var StdioTransport = class {
|
|
|
3191
3333
|
const onAbort = () => settle(() => reject(new McpError("cancelled")));
|
|
3192
3334
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3193
3335
|
this.pending.set(id, {
|
|
3194
|
-
resolve: (v) => settle(() =>
|
|
3336
|
+
resolve: (v) => settle(() => resolve6(v)),
|
|
3195
3337
|
reject: (e) => settle(() => reject(e))
|
|
3196
3338
|
});
|
|
3197
3339
|
this.child.stdin?.write(payload, (err) => {
|
|
@@ -3315,23 +3457,42 @@ async function readSseResponse(res, id) {
|
|
|
3315
3457
|
return null;
|
|
3316
3458
|
}
|
|
3317
3459
|
var McpServerConnection = class {
|
|
3318
|
-
constructor(name, config, root) {
|
|
3460
|
+
constructor(name, config, root, origin, held) {
|
|
3319
3461
|
this.name = name;
|
|
3320
3462
|
this.config = config;
|
|
3321
|
-
this.
|
|
3463
|
+
this.root = root;
|
|
3464
|
+
this.origin = origin;
|
|
3465
|
+
this.state = held ?? "connecting";
|
|
3322
3466
|
}
|
|
3323
3467
|
name;
|
|
3324
3468
|
config;
|
|
3325
|
-
|
|
3469
|
+
root;
|
|
3470
|
+
origin;
|
|
3471
|
+
state;
|
|
3326
3472
|
error;
|
|
3327
3473
|
tools = [];
|
|
3474
|
+
/**
|
|
3475
|
+
* Created by `connect()` and nowhere else, because creating a stdio
|
|
3476
|
+
* transport *is* starting the program. A held connection never has one.
|
|
3477
|
+
*/
|
|
3328
3478
|
transport;
|
|
3329
3479
|
get kind() {
|
|
3330
3480
|
return isHttpConfig(this.config) ? "http" : "stdio";
|
|
3331
3481
|
}
|
|
3482
|
+
/** Lift the hold, after the user approved. Starts nothing by itself. */
|
|
3483
|
+
release() {
|
|
3484
|
+
if (this.state === "unapproved" || this.state === "declined") this.state = "connecting";
|
|
3485
|
+
}
|
|
3486
|
+
/** Remember a refusal, so `/mcp` can say why it is not running. */
|
|
3487
|
+
decline() {
|
|
3488
|
+
if (this.state === "unapproved") this.state = "declined";
|
|
3489
|
+
}
|
|
3332
3490
|
async connect(timeoutMs = CONNECT_TIMEOUT_MS) {
|
|
3491
|
+
if (this.state !== "connecting" || this.transport) return;
|
|
3492
|
+
const transport = isHttpConfig(this.config) ? new HttpTransport(this.config) : new StdioTransport(this.config, this.root);
|
|
3493
|
+
this.transport = transport;
|
|
3333
3494
|
try {
|
|
3334
|
-
await
|
|
3495
|
+
await transport.request(
|
|
3335
3496
|
"initialize",
|
|
3336
3497
|
{
|
|
3337
3498
|
protocolVersion: PROTOCOL_VERSION,
|
|
@@ -3340,8 +3501,8 @@ var McpServerConnection = class {
|
|
|
3340
3501
|
},
|
|
3341
3502
|
timeoutMs
|
|
3342
3503
|
);
|
|
3343
|
-
await
|
|
3344
|
-
const listed = await
|
|
3504
|
+
await transport.notify("notifications/initialized", {});
|
|
3505
|
+
const listed = await transport.request("tools/list", {}, timeoutMs);
|
|
3345
3506
|
this.tools = (listed?.tools ?? []).filter(
|
|
3346
3507
|
(t) => typeof t?.name === "string" && t.name.length > 0
|
|
3347
3508
|
).map((t) => ({
|
|
@@ -3352,17 +3513,19 @@ var McpServerConnection = class {
|
|
|
3352
3513
|
...typeof t.description === "string" ? { description: t.description } : {},
|
|
3353
3514
|
...t.inputSchema && typeof t.inputSchema === "object" ? { inputSchema: t.inputSchema } : {}
|
|
3354
3515
|
}));
|
|
3516
|
+
if (this.state !== "connecting") return;
|
|
3355
3517
|
this.state = "ready";
|
|
3356
3518
|
} catch (err) {
|
|
3519
|
+
if (this.state !== "connecting") return;
|
|
3357
3520
|
this.state = "failed";
|
|
3358
|
-
const diagnostics =
|
|
3521
|
+
const diagnostics = transport.diagnostics();
|
|
3359
3522
|
this.error = err.message + (diagnostics ? `
|
|
3360
3523
|
${diagnostics.split("\n").slice(-3).join("\n ")}` : "");
|
|
3361
|
-
await
|
|
3524
|
+
await transport.close().catch(() => void 0);
|
|
3362
3525
|
}
|
|
3363
3526
|
}
|
|
3364
3527
|
async call(tool, args, timeoutMs, signal) {
|
|
3365
|
-
if (this.state !== "ready") throw new McpError(`server "${this.name}" is ${this.state}`);
|
|
3528
|
+
if (this.state !== "ready" || !this.transport) throw new McpError(`server "${this.name}" is ${this.state}`);
|
|
3366
3529
|
const raw = await this.transport.request(
|
|
3367
3530
|
"tools/call",
|
|
3368
3531
|
{ name: tool, arguments: args ?? {} },
|
|
@@ -3375,7 +3538,7 @@ var McpServerConnection = class {
|
|
|
3375
3538
|
}
|
|
3376
3539
|
async close() {
|
|
3377
3540
|
this.state = "closed";
|
|
3378
|
-
await this.transport
|
|
3541
|
+
await this.transport?.close().catch(() => void 0);
|
|
3379
3542
|
}
|
|
3380
3543
|
};
|
|
3381
3544
|
function renderContent(content, structured) {
|
|
@@ -3418,34 +3581,118 @@ function isMcpToolName(name) {
|
|
|
3418
3581
|
return name.startsWith("mcp__");
|
|
3419
3582
|
}
|
|
3420
3583
|
var McpHub = class _McpHub {
|
|
3421
|
-
constructor(servers) {
|
|
3584
|
+
constructor(servers, root) {
|
|
3422
3585
|
this.servers = servers;
|
|
3586
|
+
this.root = root;
|
|
3423
3587
|
}
|
|
3424
3588
|
servers;
|
|
3589
|
+
root;
|
|
3425
3590
|
/**
|
|
3426
|
-
* Connect to every
|
|
3591
|
+
* Connect to every server given, in parallel, and never reject.
|
|
3427
3592
|
*
|
|
3593
|
+
* For servers the caller has already decided may run; a config read from
|
|
3594
|
+
* disk goes through `fromConfig`, which holds the repository's servers.
|
|
3428
3595
|
* Parallel because a slow server must not delay the others, and one that
|
|
3429
3596
|
* cannot start must not delay anything: the whole point is that the REPL opens
|
|
3430
3597
|
* whether or not somebody's Docker daemon is running today.
|
|
3431
3598
|
*/
|
|
3432
3599
|
static async connect(servers, opts = { root: process.cwd() }) {
|
|
3433
3600
|
const connections = Object.entries(servers).map(([name, config]) => new McpServerConnection(name, config, opts.root));
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3601
|
+
const hub = new _McpHub(connections, opts.root);
|
|
3602
|
+
await hub.startReleased(opts.timeoutMs);
|
|
3603
|
+
return hub;
|
|
3604
|
+
}
|
|
3605
|
+
/**
|
|
3606
|
+
* A hub for the config on disk, with nothing started yet.
|
|
3607
|
+
*
|
|
3608
|
+
* A server from `~/.clixad/mcp.json` is ready to start. A server from the
|
|
3609
|
+
* workspace is held unless the user approved this exact entry for this
|
|
3610
|
+
* workspace before, and stays held if they declined it. `startReleased`
|
|
3611
|
+
* then starts what may start; `approve` releases the rest.
|
|
3612
|
+
*/
|
|
3613
|
+
static fromConfig(loaded, opts) {
|
|
3614
|
+
const connections = Object.entries(loaded.servers).map(([name, config]) => {
|
|
3615
|
+
const origin = loaded.origins[name];
|
|
3616
|
+
let held;
|
|
3617
|
+
if (!origin || origin.scope !== "user") {
|
|
3618
|
+
const decision = origin ? mcpDecision(opts.root, name, origin.fingerprint) : void 0;
|
|
3619
|
+
held = decision === "approved" ? void 0 : decision === "declined" ? "declined" : "unapproved";
|
|
3620
|
+
}
|
|
3621
|
+
return new McpServerConnection(name, config, opts.root, origin, held);
|
|
3622
|
+
});
|
|
3623
|
+
return new _McpHub(connections, opts.root);
|
|
3624
|
+
}
|
|
3625
|
+
/** An empty hub — what a run with no config, or a scripted run, gets. */
|
|
3626
|
+
static empty() {
|
|
3627
|
+
return new _McpHub([], process.cwd());
|
|
3628
|
+
}
|
|
3629
|
+
/**
|
|
3630
|
+
* Start every server that may start and has not been, in parallel. Returns
|
|
3631
|
+
* the ones it started, so the caller can say how each one went.
|
|
3632
|
+
*/
|
|
3633
|
+
async startReleased(timeoutMs) {
|
|
3634
|
+
const starting = this.servers.filter((s) => s.state === "connecting");
|
|
3635
|
+
await Promise.all(starting.map((c) => c.connect(timeoutMs)));
|
|
3636
|
+
this.assignNames();
|
|
3637
|
+
const names = new Set(starting.map((s) => s.name));
|
|
3638
|
+
return this.status().filter((s) => names.has(s.name));
|
|
3639
|
+
}
|
|
3640
|
+
/**
|
|
3641
|
+
* Give every listed tool its model-facing name. Names already given keep
|
|
3642
|
+
* theirs, so a server approved later cannot rename a tool the model has
|
|
3643
|
+
* already been told about. A collision (two servers with the same tool, or
|
|
3644
|
+
* two long names that truncate together) is broken deterministically.
|
|
3645
|
+
*/
|
|
3646
|
+
assignNames() {
|
|
3647
|
+
const taken = new Set(this.servers.flatMap((s) => s.tools.map((t) => t.qualified)).filter(Boolean));
|
|
3648
|
+
for (const conn of this.servers) {
|
|
3437
3649
|
for (const tool of conn.tools) {
|
|
3650
|
+
if (tool.qualified) continue;
|
|
3438
3651
|
let name = qualifiedToolName(conn.name, tool.name);
|
|
3439
3652
|
for (let i = 2; taken.has(name); i++) name = `${name.slice(0, 61)}_${i}`;
|
|
3440
3653
|
taken.add(name);
|
|
3441
3654
|
tool.qualified = name;
|
|
3442
3655
|
}
|
|
3443
3656
|
}
|
|
3444
|
-
return new _McpHub(connections);
|
|
3445
3657
|
}
|
|
3446
|
-
/**
|
|
3447
|
-
|
|
3448
|
-
return
|
|
3658
|
+
/** The servers waiting for the user, with what the prompt shows. */
|
|
3659
|
+
pending(opts = {}) {
|
|
3660
|
+
return this.servers.filter((s) => s.state === "unapproved" || opts.includeDeclined && s.state === "declined").map((s) => ({
|
|
3661
|
+
name: s.name,
|
|
3662
|
+
file: s.origin?.file ?? "",
|
|
3663
|
+
display: s.origin?.display ?? "",
|
|
3664
|
+
variables: s.origin?.variables ?? [],
|
|
3665
|
+
fingerprint: s.origin?.fingerprint ?? ""
|
|
3666
|
+
}));
|
|
3667
|
+
}
|
|
3668
|
+
/**
|
|
3669
|
+
* The user approved these held servers: remember it (bound to each entry as
|
|
3670
|
+
* written) when asked to, and start them. Names that are not held are
|
|
3671
|
+
* ignored. Returns how each one started.
|
|
3672
|
+
*/
|
|
3673
|
+
async approve(names, opts) {
|
|
3674
|
+
const chosen = this.held(names);
|
|
3675
|
+
if (opts.remember) this.remember(chosen, "approved");
|
|
3676
|
+
for (const s of chosen) s.release();
|
|
3677
|
+
return this.startReleased(opts.timeoutMs);
|
|
3678
|
+
}
|
|
3679
|
+
/** The user declined these held servers: remember it, so they are not asked about again until the entry changes. */
|
|
3680
|
+
decline(names) {
|
|
3681
|
+
const chosen = this.held(names).filter((s) => s.state === "unapproved");
|
|
3682
|
+
this.remember(chosen, "declined");
|
|
3683
|
+
for (const s of chosen) s.decline();
|
|
3684
|
+
}
|
|
3685
|
+
held(names) {
|
|
3686
|
+
const wanted = new Set(names);
|
|
3687
|
+
return this.servers.filter((s) => wanted.has(s.name) && (s.state === "unapproved" || s.state === "declined"));
|
|
3688
|
+
}
|
|
3689
|
+
remember(servers, decision) {
|
|
3690
|
+
const entries = servers.filter((s) => s.origin).map((s) => ({ name: s.name, fingerprint: s.origin.fingerprint }));
|
|
3691
|
+
if (!entries.length) return;
|
|
3692
|
+
try {
|
|
3693
|
+
recordMcpDecision(this.root, entries, decision);
|
|
3694
|
+
} catch {
|
|
3695
|
+
}
|
|
3449
3696
|
}
|
|
3450
3697
|
tools() {
|
|
3451
3698
|
return this.servers.filter((s) => s.state === "ready").flatMap((s) => s.tools);
|
|
@@ -3509,13 +3756,60 @@ var PermissionDeniedError = class extends ToolError {
|
|
|
3509
3756
|
}
|
|
3510
3757
|
};
|
|
3511
3758
|
function safeResolve(root, p) {
|
|
3512
|
-
const abs = isAbsolute(p) ? p :
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3759
|
+
const abs = isAbsolute(p) ? p : resolve2(root, p);
|
|
3760
|
+
if (escapes(root, abs)) throw new ToolError(`path escapes workspace: ${p}`);
|
|
3761
|
+
let real;
|
|
3762
|
+
let realRoot;
|
|
3763
|
+
try {
|
|
3764
|
+
real = realLocation(abs);
|
|
3765
|
+
realRoot = realLocation(resolve2(root));
|
|
3766
|
+
} catch (err) {
|
|
3767
|
+
const code = err.code;
|
|
3768
|
+
throw new ToolError(code === "ELOOP" ? `${p}: the path goes through a symlink loop` : `cannot resolve ${p}`);
|
|
3769
|
+
}
|
|
3770
|
+
if (escapes(realRoot, real)) {
|
|
3771
|
+
throw new ToolError(`path escapes workspace: ${p} is a symlink (or inside one) that points outside the workspace`);
|
|
3516
3772
|
}
|
|
3517
3773
|
return abs;
|
|
3518
3774
|
}
|
|
3775
|
+
function escapes(root, abs) {
|
|
3776
|
+
const rel2 = relative(root, abs);
|
|
3777
|
+
return rel2 === ".." || rel2.startsWith(".." + sep) || isAbsolute(rel2);
|
|
3778
|
+
}
|
|
3779
|
+
var MAX_LINK_HOPS = 40;
|
|
3780
|
+
function realLocation(abs, hops = 0) {
|
|
3781
|
+
try {
|
|
3782
|
+
return realpathSync2.native(abs);
|
|
3783
|
+
} catch (err) {
|
|
3784
|
+
const code = err.code;
|
|
3785
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw err;
|
|
3786
|
+
}
|
|
3787
|
+
const parent = dirname3(abs);
|
|
3788
|
+
if (parent === abs) return abs;
|
|
3789
|
+
let link;
|
|
3790
|
+
try {
|
|
3791
|
+
if (lstatSync(abs).isSymbolicLink()) link = readlinkSync(abs);
|
|
3792
|
+
} catch {
|
|
3793
|
+
}
|
|
3794
|
+
const realParent = realLocation(parent, hops);
|
|
3795
|
+
if (link === void 0) return join3(realParent, basename(abs));
|
|
3796
|
+
if (hops >= MAX_LINK_HOPS) throw Object.assign(new Error("too many symlinks"), { code: "ELOOP" });
|
|
3797
|
+
return realLocation(resolve2(realParent, link), hops + 1);
|
|
3798
|
+
}
|
|
3799
|
+
function realRootOf(root) {
|
|
3800
|
+
try {
|
|
3801
|
+
return realLocation(resolve2(root));
|
|
3802
|
+
} catch {
|
|
3803
|
+
return resolve2(root);
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
function linkStaysInside(abs, realRoot) {
|
|
3807
|
+
try {
|
|
3808
|
+
return !escapes(realRoot, realpathSync2.native(abs));
|
|
3809
|
+
} catch {
|
|
3810
|
+
return false;
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3519
3813
|
function rel(root, abs) {
|
|
3520
3814
|
return relative(root, abs).replace(/\\/g, "/") || ".";
|
|
3521
3815
|
}
|
|
@@ -3589,10 +3883,11 @@ function describeValue(value) {
|
|
|
3589
3883
|
if (typeof value === "number") return "an unusable number";
|
|
3590
3884
|
return typeof value === "object" ? "an object" : `a ${typeof value}`;
|
|
3591
3885
|
}
|
|
3592
|
-
function entryKind(entry, abs) {
|
|
3886
|
+
function entryKind(entry, abs, realRoot) {
|
|
3593
3887
|
if (entry.isDirectory()) return "dir";
|
|
3594
3888
|
if (entry.isFile()) return "file";
|
|
3595
3889
|
if (!entry.isSymbolicLink()) return "other";
|
|
3890
|
+
if (!linkStaysInside(abs, realRoot)) return "other";
|
|
3596
3891
|
try {
|
|
3597
3892
|
const stat = statSync(abs);
|
|
3598
3893
|
return stat.isDirectory() ? "dir" : stat.isFile() ? "file" : "other";
|
|
@@ -3730,9 +4025,10 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
|
|
|
3730
4025
|
if (!fsCall(`cannot list ${name}`, () => statSync(abs)).isDirectory()) {
|
|
3731
4026
|
throw new ToolError(`${path} is a file \u2014 use read_file`);
|
|
3732
4027
|
}
|
|
4028
|
+
const realRoot = realRootOf(ctx.root);
|
|
3733
4029
|
const entries = fsCall(`cannot list ${name}`, () => readdirSync(abs, { withFileTypes: true })).map(
|
|
3734
4030
|
(entry) => {
|
|
3735
|
-
const isDir = entryKind(entry,
|
|
4031
|
+
const isDir = entryKind(entry, resolve2(abs, entry.name), realRoot) === "dir";
|
|
3736
4032
|
return { name: isDir ? `${entry.name}/` : entry.name, isDir };
|
|
3737
4033
|
}
|
|
3738
4034
|
);
|
|
@@ -3812,9 +4108,10 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
|
|
|
3812
4108
|
path: name,
|
|
3813
4109
|
root: ctx.root
|
|
3814
4110
|
});
|
|
4111
|
+
safeResolve(ctx.root, abs);
|
|
3815
4112
|
fsCall(`cannot write ${name}`, () => {
|
|
3816
|
-
|
|
3817
|
-
|
|
4113
|
+
mkdirSync3(dirname3(abs), { recursive: true });
|
|
4114
|
+
writeFileSync3(abs, content, "utf8");
|
|
3818
4115
|
});
|
|
3819
4116
|
ctx.present?.(view);
|
|
3820
4117
|
return `wrote ${content.length} bytes to ${name}`;
|
|
@@ -3856,7 +4153,8 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
|
|
|
3856
4153
|
path: name,
|
|
3857
4154
|
root: ctx.root
|
|
3858
4155
|
});
|
|
3859
|
-
|
|
4156
|
+
safeResolve(ctx.root, abs);
|
|
4157
|
+
fsCall(`cannot edit ${name}`, () => writeFileSync3(abs, after, "utf8"));
|
|
3860
4158
|
ctx.present?.(view);
|
|
3861
4159
|
return `edited ${name} (${count > 1 ? `${count} occurrences` : "1 occurrence"})`;
|
|
3862
4160
|
},
|
|
@@ -4262,6 +4560,7 @@ function walk(dir, root, visit) {
|
|
|
4262
4560
|
visit(dir);
|
|
4263
4561
|
return;
|
|
4264
4562
|
}
|
|
4563
|
+
const realRoot = realRootOf(root);
|
|
4265
4564
|
let seen = 0;
|
|
4266
4565
|
const stack = [dir];
|
|
4267
4566
|
while (stack.length > 0) {
|
|
@@ -4274,7 +4573,7 @@ function walk(dir, root, visit) {
|
|
|
4274
4573
|
}
|
|
4275
4574
|
for (const entry of entries) {
|
|
4276
4575
|
const abs = join3(current, entry.name);
|
|
4277
|
-
const kind = entryKind(entry, abs);
|
|
4576
|
+
const kind = entryKind(entry, abs, realRoot);
|
|
4278
4577
|
if (kind === "dir") {
|
|
4279
4578
|
if (entry.isSymbolicLink() || SKIP_DIRS.has(entry.name)) continue;
|
|
4280
4579
|
stack.push(abs);
|
|
@@ -4285,7 +4584,6 @@ function walk(dir, root, visit) {
|
|
|
4285
4584
|
if (!visit(abs)) return;
|
|
4286
4585
|
}
|
|
4287
4586
|
}
|
|
4288
|
-
void root;
|
|
4289
4587
|
}
|
|
4290
4588
|
function globToRegExp(pattern) {
|
|
4291
4589
|
const p = requireString(pattern, "glob", "pattern").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -4590,7 +4888,7 @@ var TOOL_SCHEMA = [
|
|
|
4590
4888
|
|
|
4591
4889
|
// src/auto-mode.ts
|
|
4592
4890
|
import { homedir as homedir3 } from "node:os";
|
|
4593
|
-
import { isAbsolute as isAbsolute2, relative as relative2, resolve as
|
|
4891
|
+
import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
|
|
4594
4892
|
function parseCommand(src) {
|
|
4595
4893
|
const pipelines = [];
|
|
4596
4894
|
let pipeline = [];
|
|
@@ -4763,9 +5061,9 @@ function expandHome(word, home) {
|
|
|
4763
5061
|
function resolveWord(word, ctx) {
|
|
4764
5062
|
const w = expandHome(word, ctx.home);
|
|
4765
5063
|
if (w === null) return null;
|
|
4766
|
-
if (isAbsolute2(w)) return
|
|
5064
|
+
if (isAbsolute2(w)) return resolve3(w);
|
|
4767
5065
|
if (ctx.cwd === null) return null;
|
|
4768
|
-
return
|
|
5066
|
+
return resolve3(ctx.cwd, w);
|
|
4769
5067
|
}
|
|
4770
5068
|
function within(parent, child) {
|
|
4771
5069
|
const rel2 = relative2(parent, child);
|
|
@@ -5299,7 +5597,7 @@ function classify(command, root, home, depth) {
|
|
|
5299
5597
|
if (FORK_BOMB.test(command)) return { kind: "dangerous", reason: "it is a fork bomb" };
|
|
5300
5598
|
if (FETCH_AND_RUN.some((re) => re.test(command))) return { kind: "dangerous", reason: "it downloads code and runs it" };
|
|
5301
5599
|
const parsed = parseCommand(command);
|
|
5302
|
-
const ctx = { root:
|
|
5600
|
+
const ctx = { root: resolve3(root), home: resolve3(home), cwd: resolve3(root) };
|
|
5303
5601
|
for (const pipeline of parsed.pipelines) {
|
|
5304
5602
|
let fetched = false;
|
|
5305
5603
|
for (const cmd of pipeline) {
|
|
@@ -5816,7 +6114,7 @@ async function dispatch(ctx, name, args) {
|
|
|
5816
6114
|
import { spawnSync } from "node:child_process";
|
|
5817
6115
|
import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
|
|
5818
6116
|
import { homedir as homedir4, platform } from "node:os";
|
|
5819
|
-
import { join as join4, parse, resolve as
|
|
6117
|
+
import { join as join4, parse, resolve as resolve4 } from "node:path";
|
|
5820
6118
|
var MAX_CONTEXT_FILE_CHARS = 2e4;
|
|
5821
6119
|
function contextFileCandidates(root) {
|
|
5822
6120
|
return [join4(homedir4(), ".clixad", "CLIXAD.md"), join4(root, "AGENTS.md"), join4(root, "CLIXAD.md")];
|
|
@@ -5824,10 +6122,12 @@ function contextFileCandidates(root) {
|
|
|
5824
6122
|
function collectContext(root, base = SYSTEM_PROMPT) {
|
|
5825
6123
|
const parts = [base, environmentBlock(root)];
|
|
5826
6124
|
const files = [];
|
|
6125
|
+
const personal = join4(homedir4(), ".clixad", "CLIXAD.md");
|
|
5827
6126
|
for (const path of contextFileCandidates(root)) {
|
|
5828
6127
|
if (!existsSync3(path)) continue;
|
|
5829
6128
|
let text;
|
|
5830
6129
|
try {
|
|
6130
|
+
if (path !== personal) safeResolve(root, path);
|
|
5831
6131
|
text = readFileSync4(path, "utf8").trim();
|
|
5832
6132
|
} catch {
|
|
5833
6133
|
continue;
|
|
@@ -5882,8 +6182,8 @@ var INIT_PROMPT = "Create a CLIXAD.md file in the workspace root that tells a fu
|
|
|
5882
6182
|
var INIT_HINT = "No CLIXAD.md in this project yet. /init writes one, and every later session here starts out knowing the layout.";
|
|
5883
6183
|
var PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "Gemfile"];
|
|
5884
6184
|
function shouldSuggestInit(root) {
|
|
5885
|
-
const dir =
|
|
5886
|
-
if (dir ===
|
|
6185
|
+
const dir = resolve4(root);
|
|
6186
|
+
if (dir === resolve4(homedir4()) || dir === parse(dir).root) return false;
|
|
5887
6187
|
if (existsSync3(join4(dir, "CLIXAD.md")) || existsSync3(join4(dir, "AGENTS.md"))) return false;
|
|
5888
6188
|
return PROJECT_MARKERS.some((name) => existsSync3(join4(dir, name)));
|
|
5889
6189
|
}
|
|
@@ -6055,17 +6355,17 @@ var denyAll = async (req) => ({
|
|
|
6055
6355
|
import {
|
|
6056
6356
|
closeSync as closeSync2,
|
|
6057
6357
|
existsSync as existsSync4,
|
|
6058
|
-
mkdirSync as
|
|
6358
|
+
mkdirSync as mkdirSync4,
|
|
6059
6359
|
openSync as openSync2,
|
|
6060
6360
|
readdirSync as readdirSync3,
|
|
6061
6361
|
readFileSync as readFileSync5,
|
|
6062
6362
|
readSync,
|
|
6063
6363
|
rmSync,
|
|
6064
6364
|
statSync as statSync2,
|
|
6065
|
-
writeFileSync as
|
|
6365
|
+
writeFileSync as writeFileSync4
|
|
6066
6366
|
} from "node:fs";
|
|
6067
6367
|
import { homedir as homedir5 } from "node:os";
|
|
6068
|
-
import { join as join5, resolve as
|
|
6368
|
+
import { join as join5, resolve as resolve5 } from "node:path";
|
|
6069
6369
|
var ROOT = join5(homedir5(), ".clixad");
|
|
6070
6370
|
var SESSION_DIR = join5(ROOT, "sessions");
|
|
6071
6371
|
var HISTORY_FILE = join5(ROOT, "history");
|
|
@@ -6076,7 +6376,7 @@ function isSessionId(id) {
|
|
|
6076
6376
|
return SESSION_ID.test(id);
|
|
6077
6377
|
}
|
|
6078
6378
|
function sameDirectory(a, b) {
|
|
6079
|
-
const norm = (p) => process.platform === "win32" ?
|
|
6379
|
+
const norm = (p) => process.platform === "win32" ? resolve5(p).toLowerCase() : resolve5(p);
|
|
6080
6380
|
return norm(a) === norm(b);
|
|
6081
6381
|
}
|
|
6082
6382
|
function newSessionId() {
|
|
@@ -6085,9 +6385,9 @@ function newSessionId() {
|
|
|
6085
6385
|
}
|
|
6086
6386
|
function saveSession(session) {
|
|
6087
6387
|
try {
|
|
6088
|
-
|
|
6388
|
+
mkdirSync4(SESSION_DIR, { recursive: true });
|
|
6089
6389
|
const stored = { ...session, messages: withoutImages(session.messages) };
|
|
6090
|
-
|
|
6390
|
+
writeFileSync4(join5(SESSION_DIR, `${session.id}.json`), JSON.stringify(stored), "utf8");
|
|
6091
6391
|
prune();
|
|
6092
6392
|
} catch {
|
|
6093
6393
|
}
|
|
@@ -6175,17 +6475,17 @@ function loadHistory() {
|
|
|
6175
6475
|
}
|
|
6176
6476
|
function saveHistory(entries) {
|
|
6177
6477
|
try {
|
|
6178
|
-
|
|
6179
|
-
|
|
6478
|
+
mkdirSync4(ROOT, { recursive: true });
|
|
6479
|
+
writeFileSync4(HISTORY_FILE, entries.slice(0, MAX_HISTORY).reverse().join("\n") + "\n", "utf8");
|
|
6180
6480
|
} catch {
|
|
6181
6481
|
}
|
|
6182
6482
|
}
|
|
6183
6483
|
|
|
6184
6484
|
// src/kimi.ts
|
|
6185
6485
|
import { spawn as spawn5, spawnSync as spawnSync2 } from "node:child_process";
|
|
6186
|
-
import { existsSync as existsSync5, mkdirSync as
|
|
6486
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
6187
6487
|
import { homedir as homedir6 } from "node:os";
|
|
6188
|
-
import { dirname as
|
|
6488
|
+
import { dirname as dirname4, join as join6 } from "node:path";
|
|
6189
6489
|
var CONTEXT_TOKENS = {
|
|
6190
6490
|
"gpt-5-nano": 4e5,
|
|
6191
6491
|
"gemini-2.5-flash-lite": 1048576,
|
|
@@ -6265,8 +6565,8 @@ function runKimiCode(config, task, opts = {}) {
|
|
|
6265
6565
|
return Promise.resolve(1);
|
|
6266
6566
|
}
|
|
6267
6567
|
const path = kimiConfigPath();
|
|
6268
|
-
|
|
6269
|
-
|
|
6568
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
6569
|
+
writeFileSync5(path, buildKimiConfig({ gatewayUrl: config.gatewayUrl, token: config.token, modelId: config.model }), "utf8");
|
|
6270
6570
|
const bin = process.env.CLIXAD_KIMI_BIN || "kimi";
|
|
6271
6571
|
if (!binExists(bin)) {
|
|
6272
6572
|
console.log("\n" + INSTALL_HELP);
|
|
@@ -6280,14 +6580,14 @@ function runKimiCode(config, task, opts = {}) {
|
|
|
6280
6580
|
OPENAI_BASE_URL: `${config.gatewayUrl.replace(/\/+$/, "")}/v1`,
|
|
6281
6581
|
OPENAI_API_KEY: config.token
|
|
6282
6582
|
};
|
|
6283
|
-
return new Promise((
|
|
6583
|
+
return new Promise((resolve6) => {
|
|
6284
6584
|
const child = process.platform === "win32" ? spawn5([bin, ...args].map(quoteWin).join(" "), { stdio: "inherit", env, shell: true }) : spawn5(bin, args, { stdio: "inherit", env });
|
|
6285
6585
|
child.on("error", (err) => {
|
|
6286
6586
|
if (err.code === "ENOENT") console.log("\n" + INSTALL_HELP);
|
|
6287
6587
|
else console.error(`failed to launch Kimi CLI: ${err.message}`);
|
|
6288
|
-
|
|
6588
|
+
resolve6(127);
|
|
6289
6589
|
});
|
|
6290
|
-
child.on("exit", (code) =>
|
|
6590
|
+
child.on("exit", (code) => resolve6(code ?? 0));
|
|
6291
6591
|
});
|
|
6292
6592
|
}
|
|
6293
6593
|
function quoteWin(s) {
|
|
@@ -6538,6 +6838,24 @@ var COMPACT_THRESHOLD = 0.75;
|
|
|
6538
6838
|
function shouldCompact(messages, contextTokens, threshold = COMPACT_THRESHOLD) {
|
|
6539
6839
|
return conversationTokens(messages) > contextTokens * threshold;
|
|
6540
6840
|
}
|
|
6841
|
+
function compactBudget(contextTokens) {
|
|
6842
|
+
return Math.floor(contextTokens * COMPACT_THRESHOLD);
|
|
6843
|
+
}
|
|
6844
|
+
function messageWeight(message) {
|
|
6845
|
+
let images = 0;
|
|
6846
|
+
const content = Array.isArray(message.content) ? message.content.map((part) => {
|
|
6847
|
+
if (part.type !== "image_url") return part;
|
|
6848
|
+
images++;
|
|
6849
|
+
return { ...part, image_url: { url: "" } };
|
|
6850
|
+
}) : message.content;
|
|
6851
|
+
const bytes = Buffer.byteLength(JSON.stringify({ ...message, content }));
|
|
6852
|
+
return Math.ceil(bytes / 4) + images * IMAGE_TOKEN_ESTIMATE;
|
|
6853
|
+
}
|
|
6854
|
+
function requestTokens(messages) {
|
|
6855
|
+
let total = 0;
|
|
6856
|
+
for (const m of messages) total += messageWeight(m);
|
|
6857
|
+
return total;
|
|
6858
|
+
}
|
|
6541
6859
|
function cutPoint(messages, keepTurns = 2) {
|
|
6542
6860
|
let seen = 0;
|
|
6543
6861
|
for (let i = messages.length - 1; i > 0; i--) {
|
|
@@ -6547,37 +6865,187 @@ function cutPoint(messages, keepTurns = 2) {
|
|
|
6547
6865
|
}
|
|
6548
6866
|
return 0;
|
|
6549
6867
|
}
|
|
6868
|
+
function safeCuts(messages) {
|
|
6869
|
+
const open = /* @__PURE__ */ new Set();
|
|
6870
|
+
const cuts = [];
|
|
6871
|
+
for (let i = 1; i <= messages.length; i++) {
|
|
6872
|
+
const prev = messages[i - 1];
|
|
6873
|
+
if (prev.role === "assistant") for (const call of prev.tool_calls ?? []) open.add(call.id);
|
|
6874
|
+
if (prev.role === "tool" && prev.tool_call_id) open.delete(prev.tool_call_id);
|
|
6875
|
+
if (open.size === 0 && (i === messages.length || messages[i].role !== "tool")) cuts.push(i);
|
|
6876
|
+
}
|
|
6877
|
+
return cuts;
|
|
6878
|
+
}
|
|
6550
6879
|
var SUMMARY_LEAD = "Summary of the earlier conversation:\n\n";
|
|
6551
6880
|
var SUMMARY_ACK = "Understood \u2014 continuing from that summary.";
|
|
6552
|
-
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6881
|
+
function withSummary(summary, rest) {
|
|
6882
|
+
const lead = { role: "user", content: `${SUMMARY_LEAD}${summary}` };
|
|
6883
|
+
return rest[0]?.role === "assistant" ? [lead, ...rest] : [lead, { role: "assistant", content: SUMMARY_ACK }, ...rest];
|
|
6884
|
+
}
|
|
6885
|
+
function summaryReserve(budget) {
|
|
6886
|
+
return Math.min(1e4, Math.max(1e3, Math.floor(budget / 6)));
|
|
6887
|
+
}
|
|
6888
|
+
function summaryPrompt(words) {
|
|
6889
|
+
return `Summarise the conversation above for your future self, which will continue the work without seeing it. Cover: what the user asked for, what was already changed (files and the reasoning), what was learned about the codebase, and what is still open. Be specific about file paths and names. If it opens with a summary of earlier messages, carry what that summary says forward. At most ${words.toLocaleString("en-US")} words. No preamble.`;
|
|
6890
|
+
}
|
|
6891
|
+
function planCompaction(messages, contextTokens, keepTurns = 2) {
|
|
6892
|
+
if (messages.length < 2) return void 0;
|
|
6893
|
+
const budget = compactBudget(contextTokens);
|
|
6894
|
+
const suffix = new Array(messages.length + 1).fill(0);
|
|
6895
|
+
for (let i = messages.length - 1; i >= 0; i--) suffix[i] = suffix[i + 1] + messageWeight(messages[i]);
|
|
6896
|
+
const total = suffix[0];
|
|
6897
|
+
const tail = (from) => suffix[from];
|
|
6898
|
+
const fixed = total - suffix[1] + summaryReserve(budget) + messageWeight({ role: "assistant", content: SUMMARY_ACK });
|
|
6899
|
+
const preferred = cutPoint(messages, keepTurns);
|
|
6900
|
+
const over = total > budget;
|
|
6901
|
+
if (!over) return preferred > 1 ? { cut: preferred, fits: fixed + tail(preferred) <= budget, over } : void 0;
|
|
6902
|
+
const cuts = safeCuts(messages).filter((c) => c >= Math.max(preferred, 2));
|
|
6903
|
+
if (cuts.length === 0) return void 0;
|
|
6904
|
+
const keep = Math.min(budget - fixed, Math.floor(budget / 2));
|
|
6905
|
+
const cut2 = cuts.find((c) => tail(c) <= keep) ?? cuts.at(-1);
|
|
6906
|
+
return { cut: cut2, fits: fixed + tail(cut2) <= budget, over };
|
|
6907
|
+
}
|
|
6908
|
+
function cutNote(left) {
|
|
6909
|
+
return `
|
|
6910
|
+
\u2026 [${left.toLocaleString("en-US")} more characters left out here]`;
|
|
6911
|
+
}
|
|
6912
|
+
function capText(text, limit) {
|
|
6913
|
+
return text.length > limit ? `${text.slice(0, limit)}${cutNote(text.length - limit)}` : text;
|
|
6914
|
+
}
|
|
6915
|
+
function capArguments(json, limit) {
|
|
6916
|
+
const walk2 = (v) => {
|
|
6917
|
+
if (typeof v === "string") return capText(v, limit);
|
|
6918
|
+
if (Array.isArray(v)) return v.map(walk2);
|
|
6919
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk2(x)]));
|
|
6920
|
+
return v;
|
|
6921
|
+
};
|
|
6922
|
+
try {
|
|
6923
|
+
return JSON.stringify(walk2(JSON.parse(json)));
|
|
6924
|
+
} catch {
|
|
6925
|
+
return json.length > limit ? JSON.stringify({ arguments: capText(json, limit) }) : json;
|
|
6926
|
+
}
|
|
6927
|
+
}
|
|
6928
|
+
function capMessage(message, limit) {
|
|
6929
|
+
const content = typeof message.content === "string" ? capText(message.content, limit) : Array.isArray(message.content) ? message.content.map((part) => part.type === "text" ? { ...part, text: capText(part.text, limit) } : part) : message.content;
|
|
6930
|
+
const out = { ...message, content };
|
|
6931
|
+
if (message.tool_calls) {
|
|
6932
|
+
out.tool_calls = message.tool_calls.map((c) => ({
|
|
6933
|
+
...c,
|
|
6934
|
+
function: { ...c.function, arguments: capArguments(c.function.arguments, limit) }
|
|
6935
|
+
}));
|
|
6936
|
+
}
|
|
6937
|
+
return out;
|
|
6938
|
+
}
|
|
6939
|
+
function withoutImages2(message) {
|
|
6940
|
+
if (!Array.isArray(message.content)) return message;
|
|
6941
|
+
return {
|
|
6942
|
+
...message,
|
|
6943
|
+
content: message.content.map(
|
|
6944
|
+
(part) => part.type === "image_url" ? { type: "text", text: "[an image, not shown here]" } : part
|
|
6945
|
+
)
|
|
6946
|
+
};
|
|
6947
|
+
}
|
|
6948
|
+
function longestText(messages) {
|
|
6949
|
+
let longest = 0;
|
|
6950
|
+
for (const m of messages) {
|
|
6951
|
+
longest = Math.max(longest, messageText(m).length);
|
|
6952
|
+
for (const c of m.tool_calls ?? []) longest = Math.max(longest, c.function.arguments.length);
|
|
6953
|
+
}
|
|
6954
|
+
return longest;
|
|
6955
|
+
}
|
|
6956
|
+
function shrinkToFit(messages, room) {
|
|
6957
|
+
if (requestTokens(messages) <= room) return messages;
|
|
6958
|
+
const plain2 = messages.map(withoutImages2);
|
|
6959
|
+
if (requestTokens(plain2) <= room) return plain2;
|
|
6960
|
+
const capped = (limit) => plain2.map((m) => capMessage(m, limit));
|
|
6961
|
+
let lo = 0;
|
|
6962
|
+
let hi = longestText(plain2);
|
|
6963
|
+
while (lo < hi) {
|
|
6964
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
6965
|
+
if (requestTokens(capped(mid)) <= room) lo = mid;
|
|
6966
|
+
else hi = mid - 1;
|
|
6967
|
+
}
|
|
6968
|
+
return capped(lo);
|
|
6969
|
+
}
|
|
6970
|
+
async function compact(client, model, messages, contextTokens, opts = {}) {
|
|
6971
|
+
const plan = planCompaction(messages, contextTokens, opts.keepTurns ?? 2);
|
|
6972
|
+
if (!plan) return void 0;
|
|
6973
|
+
const { cut: cut2 } = plan;
|
|
6974
|
+
const budget = compactBudget(contextTokens);
|
|
6975
|
+
const system = messages[0];
|
|
6976
|
+
const prompt = { role: "user", content: summaryPrompt(Math.floor(summaryReserve(budget) / 2)) };
|
|
6977
|
+
const bounds = [1, ...safeCuts(messages).filter((c) => c > 1 && c < cut2), cut2];
|
|
6978
|
+
const rounds = [];
|
|
6979
|
+
for (let i = 0; i + 1 < bounds.length; i++) rounds.push(messages.slice(bounds[i], bounds[i + 1]));
|
|
6556
6980
|
const before = conversationTokens(messages);
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
)
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6981
|
+
let summary;
|
|
6982
|
+
let creditsCharged = 0;
|
|
6983
|
+
let balance = 0;
|
|
6984
|
+
let spendable;
|
|
6985
|
+
let calls = 0;
|
|
6986
|
+
let next = 0;
|
|
6987
|
+
while (next < rounds.length) {
|
|
6988
|
+
const room = Math.max(
|
|
6989
|
+
1e3,
|
|
6990
|
+
budget - requestTokens([system, prompt]) - (summary === void 0 ? 0 : requestTokens(withSummary(summary, [])))
|
|
6991
|
+
);
|
|
6992
|
+
let chunk = shrinkToFit(rounds[next++], room);
|
|
6993
|
+
while (next < rounds.length) {
|
|
6994
|
+
const round = rounds[next];
|
|
6995
|
+
const left = room - requestTokens(chunk);
|
|
6996
|
+
if (requestTokens(round) <= left) {
|
|
6997
|
+
chunk = [...chunk, ...round];
|
|
6998
|
+
next++;
|
|
6999
|
+
continue;
|
|
7000
|
+
}
|
|
7001
|
+
if (requestTokens(round) > room && left >= room / 2) {
|
|
7002
|
+
chunk = [...chunk, ...shrinkToFit(round, left)];
|
|
7003
|
+
next++;
|
|
7004
|
+
}
|
|
7005
|
+
break;
|
|
7006
|
+
}
|
|
7007
|
+
const request = [system, ...summary === void 0 ? chunk : withSummary(summary, chunk), prompt];
|
|
7008
|
+
let text = "";
|
|
7009
|
+
for (let attempt = 0; attempt < 2 && !text; attempt++) {
|
|
7010
|
+
const turn = await client.chatStream(request, model, { signal: opts.signal, effort: opts.effort });
|
|
7011
|
+
calls++;
|
|
7012
|
+
creditsCharged += turn.creditsCharged;
|
|
7013
|
+
balance = turn.balance;
|
|
7014
|
+
spendable = turn.spendable;
|
|
7015
|
+
text = messageText(turn.message).trim();
|
|
7016
|
+
}
|
|
7017
|
+
if (!text) throw new Error("the model returned an empty summary, so the conversation was left as it was");
|
|
7018
|
+
summary = text;
|
|
7019
|
+
}
|
|
7020
|
+
const tailMessages = messages.slice(cut2);
|
|
7021
|
+
let final = summary ?? "";
|
|
7022
|
+
const excess = requestTokens([system, ...withSummary(final, tailMessages)]) - budget;
|
|
7023
|
+
if (excess > 0) {
|
|
7024
|
+
const lead = { role: "user", content: final };
|
|
7025
|
+
final = messageText(shrinkToFit([lead], Math.max(0, messageWeight(lead) - excess - 1))[0]);
|
|
7026
|
+
}
|
|
7027
|
+
const compacted = [system, ...withSummary(final, tailMessages)];
|
|
6571
7028
|
return {
|
|
6572
7029
|
messages: compacted,
|
|
6573
|
-
summary,
|
|
6574
|
-
creditsCharged
|
|
6575
|
-
balance
|
|
6576
|
-
...
|
|
7030
|
+
summary: final,
|
|
7031
|
+
creditsCharged,
|
|
7032
|
+
balance,
|
|
7033
|
+
...spendable ? { spendable } : {},
|
|
6577
7034
|
before,
|
|
6578
|
-
after: conversationTokens(compacted)
|
|
7035
|
+
after: conversationTokens(compacted),
|
|
7036
|
+
calls
|
|
6579
7037
|
};
|
|
6580
7038
|
}
|
|
7039
|
+
var COMPACT_ADVICE = "The work done so far is kept. Run /compact to summarise the conversation, this task's steps included, then send a message to carry on. /clear starts over, and /model switches to a model that takes longer conversations.";
|
|
7040
|
+
function withCompactAdvice(refusal) {
|
|
7041
|
+
const lines = refusal.split("\n");
|
|
7042
|
+
if (lines.length === 3) return [lines[0], COMPACT_ADVICE, lines[2]].join("\n");
|
|
7043
|
+
return [...lines, COMPACT_ADVICE].join("\n");
|
|
7044
|
+
}
|
|
7045
|
+
function compactionHelps(messages, contextTokens) {
|
|
7046
|
+
const plan = planCompaction(messages, contextTokens);
|
|
7047
|
+
return plan !== void 0 && plan.over && plan.fits;
|
|
7048
|
+
}
|
|
6581
7049
|
|
|
6582
7050
|
// src/version.ts
|
|
6583
7051
|
import { readFileSync as readFileSync6 } from "node:fs";
|
|
@@ -6687,6 +7155,7 @@ export {
|
|
|
6687
7155
|
PremiumLockedError,
|
|
6688
7156
|
LimitRefusalError,
|
|
6689
7157
|
MaintenanceError,
|
|
7158
|
+
PromptTooLongError,
|
|
6690
7159
|
shortfallHeadline,
|
|
6691
7160
|
AuthError,
|
|
6692
7161
|
GatewayClient,
|
|
@@ -6771,6 +7240,8 @@ export {
|
|
|
6771
7240
|
SUMMARY_LEAD,
|
|
6772
7241
|
SUMMARY_ACK,
|
|
6773
7242
|
compact,
|
|
7243
|
+
withCompactAdvice,
|
|
7244
|
+
compactionHelps,
|
|
6774
7245
|
VERSION,
|
|
6775
7246
|
VERSION_LABEL,
|
|
6776
7247
|
isNewerVersion,
|
package/dist/clixad.mjs
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
takeConfigNotices,
|
|
55
55
|
toolSchemaFor,
|
|
56
56
|
usd
|
|
57
|
-
} from "./chunk-
|
|
57
|
+
} from "./chunk-E7SAKAVQ.mjs";
|
|
58
58
|
|
|
59
59
|
// src/tui/load.ts
|
|
60
60
|
import * as nodeModule from "node:module";
|
|
@@ -133,7 +133,7 @@ async function withProductionReact(load) {
|
|
|
133
133
|
}
|
|
134
134
|
function loadTui() {
|
|
135
135
|
boundInkCaches();
|
|
136
|
-
return withProductionReact(() => import("./tui-
|
|
136
|
+
return withProductionReact(() => import("./tui-2H2XRALC.mjs"));
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
// src/title.ts
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
NO_SEARCH_INSTRUCTION,
|
|
19
19
|
PaywallError,
|
|
20
20
|
PremiumLockedError,
|
|
21
|
+
PromptTooLongError,
|
|
21
22
|
SPINNER_TICKS_PER_FRAME,
|
|
22
23
|
SUMMARY_ACK,
|
|
23
24
|
SUMMARY_LEAD,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
colorEnabled,
|
|
36
37
|
compact,
|
|
37
38
|
compactBanner,
|
|
39
|
+
compactionHelps,
|
|
38
40
|
contextTokensFor,
|
|
39
41
|
conversationTokens,
|
|
40
42
|
createPermit,
|
|
@@ -104,8 +106,9 @@ import {
|
|
|
104
106
|
usd,
|
|
105
107
|
visibleLength,
|
|
106
108
|
webSearchCostLine,
|
|
107
|
-
welcomeBanner
|
|
108
|
-
|
|
109
|
+
welcomeBanner,
|
|
110
|
+
withCompactAdvice
|
|
111
|
+
} from "./chunk-E7SAKAVQ.mjs";
|
|
109
112
|
|
|
110
113
|
// src/tui/index.tsx
|
|
111
114
|
import "react";
|
|
@@ -486,7 +489,7 @@ var COMMANDS = [
|
|
|
486
489
|
// enter complete the command instead of running it, and listing is what both
|
|
487
490
|
// are for. `/bg kill <id>` still works when typed in full.
|
|
488
491
|
{ name: "bg", desc: "background commands the agent started" },
|
|
489
|
-
{ name: "mcp", desc: "
|
|
492
|
+
{ name: "mcp", desc: "MCP servers and their tools (approve: start one from this project)" },
|
|
490
493
|
{ name: "exit", desc: "quit clixad" }
|
|
491
494
|
];
|
|
492
495
|
function commandLabel(c) {
|
|
@@ -2271,17 +2274,10 @@ function App({
|
|
|
2271
2274
|
}
|
|
2272
2275
|
}).catch(() => void 0);
|
|
2273
2276
|
}, []);
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
if (names.length === 0) return;
|
|
2279
|
-
let cancelled = false;
|
|
2280
|
-
void McpHub.connect(servers, { root }).then((hub) => {
|
|
2281
|
-
if (cancelled) return void hub.close();
|
|
2282
|
-
mcpRef.current = hub;
|
|
2283
|
-
const ready = hub.status().filter((s) => s.state === "ready");
|
|
2284
|
-
const failed2 = hub.status().filter((s) => s.state === "failed");
|
|
2277
|
+
const reportMcpStarted = useCallback(
|
|
2278
|
+
(started) => {
|
|
2279
|
+
const ready = started.filter((s) => s.state === "ready");
|
|
2280
|
+
const failed2 = started.filter((s) => s.state === "failed");
|
|
2285
2281
|
if (ready.length) {
|
|
2286
2282
|
push({
|
|
2287
2283
|
kind: "notice",
|
|
@@ -2291,10 +2287,63 @@ function App({
|
|
|
2291
2287
|
for (const server of failed2) {
|
|
2292
2288
|
push({ kind: "notice", tone: "warn", text: ` mcp: ${server.name} did not start \u2014 ${server.error}` });
|
|
2293
2289
|
}
|
|
2290
|
+
},
|
|
2291
|
+
[push]
|
|
2292
|
+
);
|
|
2293
|
+
const askMcpApproval = useCallback(
|
|
2294
|
+
(hub, pending) => {
|
|
2295
|
+
if (!pending.length) return;
|
|
2296
|
+
const files = [...new Set(pending.map((p) => p.file))].join(" and ");
|
|
2297
|
+
push({
|
|
2298
|
+
kind: "notice",
|
|
2299
|
+
tone: "warn",
|
|
2300
|
+
text: ` ${files} in this project ${pending.length === 1 ? "wants to start an MCP server" : `wants to start ${pending.length} MCP servers`}. A server is a program that runs on this machine; nothing has been started.
|
|
2301
|
+
` + pending.map(
|
|
2302
|
+
(p) => ` ${p.name}
|
|
2303
|
+
` + p.display.split("\n").map((line2) => ` ${line2}`).join("\n") + `
|
|
2304
|
+
environment it gets: ${p.variables.length ? p.variables.join(", ") : "none beyond PATH, HOME and the locale"}`
|
|
2305
|
+
).join("\n")
|
|
2306
|
+
});
|
|
2307
|
+
const names = pending.map((p) => p.name);
|
|
2308
|
+
setPickerSel(1);
|
|
2309
|
+
setPicker({
|
|
2310
|
+
title: pending.length === 1 ? `Start the MCP server "${names[0]}"?` : `Start these ${pending.length} MCP servers?`,
|
|
2311
|
+
subtitle: "Only if you trust this project \xB7 esc: not now",
|
|
2312
|
+
items: [
|
|
2313
|
+
{ value: "approve", label: "Yes, start", hint: "and remember it for this project until the config changes" },
|
|
2314
|
+
{ value: "later", label: "Not now", hint: "ask again next time \xB7 /mcp approve starts them" },
|
|
2315
|
+
{ value: "decline", label: "No", hint: "and don't ask again until the config changes" }
|
|
2316
|
+
],
|
|
2317
|
+
onPick: (choice) => {
|
|
2318
|
+
if (choice.value === "approve") {
|
|
2319
|
+
push({ kind: "notice", text: ` mcp: starting ${names.join(", ")}` });
|
|
2320
|
+
void hub.approve(names, { remember: true }).then(reportMcpStarted);
|
|
2321
|
+
} else if (choice.value === "decline") {
|
|
2322
|
+
hub.decline(names);
|
|
2323
|
+
push({ kind: "notice", text: ` mcp: not starting ${names.join(", ")} \xB7 /mcp approve starts them` });
|
|
2324
|
+
} else {
|
|
2325
|
+
push({ kind: "notice", text: " mcp: not started \xB7 /mcp approve starts them" });
|
|
2326
|
+
}
|
|
2327
|
+
},
|
|
2328
|
+
onCancel: () => push({ kind: "notice", text: " mcp: not started \xB7 /mcp approve starts them" })
|
|
2329
|
+
});
|
|
2330
|
+
},
|
|
2331
|
+
[push, reportMcpStarted]
|
|
2332
|
+
);
|
|
2333
|
+
useEffect(() => {
|
|
2334
|
+
const loaded = loadMcpConfig(root);
|
|
2335
|
+
for (const problem of loaded.errors) push({ kind: "notice", tone: "warn", text: ` mcp config \u2014 ${problem}` });
|
|
2336
|
+
if (Object.keys(loaded.servers).length === 0) return;
|
|
2337
|
+
const hub = McpHub.fromConfig(loaded, { root });
|
|
2338
|
+
mcpRef.current = hub;
|
|
2339
|
+
let cancelled = false;
|
|
2340
|
+
void hub.startReleased().then((started) => {
|
|
2341
|
+
if (!cancelled) reportMcpStarted(started);
|
|
2294
2342
|
});
|
|
2343
|
+
askMcpApproval(hub, hub.pending());
|
|
2295
2344
|
return () => {
|
|
2296
2345
|
cancelled = true;
|
|
2297
|
-
void
|
|
2346
|
+
void hub.close();
|
|
2298
2347
|
};
|
|
2299
2348
|
}, []);
|
|
2300
2349
|
useEffect(() => {
|
|
@@ -2692,10 +2741,13 @@ function App({
|
|
|
2692
2741
|
try {
|
|
2693
2742
|
const window = contextWindow(model2);
|
|
2694
2743
|
if (shouldCompact([{ role: "system", content: contextRef.current.systemPrompt }, ...history], window)) {
|
|
2695
|
-
const res = await compact(
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2744
|
+
const res = await compact(
|
|
2745
|
+
client,
|
|
2746
|
+
model2,
|
|
2747
|
+
[{ role: "system", content: contextRef.current.systemPrompt }, ...history],
|
|
2748
|
+
window,
|
|
2749
|
+
{ signal: ac.signal, effort: savedEffort(config, model2) }
|
|
2750
|
+
);
|
|
2699
2751
|
if (res) {
|
|
2700
2752
|
history = res.messages.slice(1);
|
|
2701
2753
|
setMessages(history);
|
|
@@ -2853,6 +2905,7 @@ ${preferencesText}` : ""),
|
|
|
2853
2905
|
setMessages(kept);
|
|
2854
2906
|
persist(kept);
|
|
2855
2907
|
}
|
|
2908
|
+
const compactAdvised = err instanceof PromptTooLongError && kept !== void 0 && compactionHelps([{ role: "system", content: contextRef.current.systemPrompt }, ...kept], contextWindow(model2));
|
|
2856
2909
|
if ((err instanceof LimitRefusalError || err instanceof PremiumLockedError) && err.spendable) {
|
|
2857
2910
|
balanceSeqRef.current++;
|
|
2858
2911
|
takeSpendable(err.spendable.figures, err.spendable.balance);
|
|
@@ -2909,10 +2962,12 @@ ${preferencesText}` : ""),
|
|
|
2909
2962
|
push({ kind: "notice", tone: "error", text: NOT_SIGNED_IN });
|
|
2910
2963
|
} else if (err instanceof MaintenanceError) {
|
|
2911
2964
|
push({ kind: "notice", tone: "warn", text: ` ${errorNotice(err.message, void 0, " ")}` });
|
|
2965
|
+
} else if (compactAdvised) {
|
|
2966
|
+
push({ kind: "notice", tone: "error", text: ` error: ${errorNotice(withCompactAdvice(err.message), void 0, " ")}` });
|
|
2912
2967
|
} else {
|
|
2913
2968
|
push({ kind: "notice", tone: "error", text: ` error: ${errorNotice(err.message, void 0, " ")}` });
|
|
2914
2969
|
}
|
|
2915
|
-
if (kept && !(err instanceof PaywallError) && !(err instanceof PremiumLockedError)) {
|
|
2970
|
+
if (kept && !(err instanceof PaywallError) && !(err instanceof PremiumLockedError) && !compactAdvised) {
|
|
2916
2971
|
push({ kind: "notice", text: KEPT_AFTER_ERROR });
|
|
2917
2972
|
}
|
|
2918
2973
|
} finally {
|
|
@@ -3353,15 +3408,17 @@ ${preferencesText}` : ""),
|
|
|
3353
3408
|
push({ kind: "notice", text: ` mode \u2192 ${MODE_LABEL[permRef.current.mode]}` });
|
|
3354
3409
|
return;
|
|
3355
3410
|
}
|
|
3356
|
-
//
|
|
3357
|
-
//
|
|
3358
|
-
//
|
|
3411
|
+
// Model calls like any other, so esc has to stop them like any other.
|
|
3412
|
+
// Stopping leaves the conversation as it was, since the summary only
|
|
3413
|
+
// replaces it once every call has come back whole; a summary that took
|
|
3414
|
+
// several calls has been charged for the ones already finished.
|
|
3359
3415
|
case "compact":
|
|
3360
3416
|
await runBusy(COMPACTING, async (signal) => {
|
|
3361
3417
|
const res = await compact(
|
|
3362
3418
|
client,
|
|
3363
3419
|
model,
|
|
3364
3420
|
[{ role: "system", content: contextRef.current.systemPrompt }, ...messagesRef.current],
|
|
3421
|
+
contextWindow(model),
|
|
3365
3422
|
{ signal, effort: savedEffort(config, model) }
|
|
3366
3423
|
);
|
|
3367
3424
|
if (!res) return void push({ kind: "notice", text: " nothing to compact yet" });
|
|
@@ -3644,7 +3701,27 @@ ${preferencesText}` : ""),
|
|
|
3644
3701
|
return;
|
|
3645
3702
|
}
|
|
3646
3703
|
case "mcp": {
|
|
3647
|
-
const
|
|
3704
|
+
const hub = mcpRef.current;
|
|
3705
|
+
const [verb, ...targets] = arg.split(/\s+/).filter(Boolean);
|
|
3706
|
+
if (verb === "approve") {
|
|
3707
|
+
const held = hub.pending({ includeDeclined: true });
|
|
3708
|
+
if (!held.length) {
|
|
3709
|
+
push({ kind: "notice", text: " no MCP servers waiting for approval" });
|
|
3710
|
+
return;
|
|
3711
|
+
}
|
|
3712
|
+
if (!targets.length || targets.includes("all")) return askMcpApproval(hub, held);
|
|
3713
|
+
const unknown = targets.filter((t) => !held.some((h) => h.name === t));
|
|
3714
|
+
if (unknown.length) {
|
|
3715
|
+
push({
|
|
3716
|
+
kind: "notice",
|
|
3717
|
+
tone: "warn",
|
|
3718
|
+
text: ` not waiting for approval: ${unknown.join(", ")} \xB7 waiting: ${held.map((h) => h.name).join(", ")}`
|
|
3719
|
+
});
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
return askMcpApproval(hub, held.filter((h) => targets.includes(h.name)));
|
|
3723
|
+
}
|
|
3724
|
+
const status = hub.status();
|
|
3648
3725
|
if (!status.length) {
|
|
3649
3726
|
push({
|
|
3650
3727
|
kind: "notice",
|
|
@@ -3652,14 +3729,16 @@ ${preferencesText}` : ""),
|
|
|
3652
3729
|
});
|
|
3653
3730
|
return;
|
|
3654
3731
|
}
|
|
3732
|
+
const label = { unapproved: "not approved", declined: "declined" };
|
|
3733
|
+
const anyHeld = status.some((s) => s.state === "unapproved" || s.state === "declined");
|
|
3655
3734
|
push({
|
|
3656
3735
|
kind: "notice",
|
|
3657
3736
|
text: status.map((s) => {
|
|
3658
|
-
const head = ` ${s.name.padEnd(16)} ${s.state.padEnd(
|
|
3737
|
+
const head = ` ${s.name.padEnd(16)} ${(label[s.state] ?? s.state).padEnd(12)} ${s.transport}`;
|
|
3659
3738
|
if (s.state === "ready") return `${head} ${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}`;
|
|
3660
3739
|
return s.error ? `${head}
|
|
3661
3740
|
${s.error.split("\n").join("\n ")}` : head;
|
|
3662
|
-
}).join("\n")
|
|
3741
|
+
}).join("\n") + (anyHeld ? "\n /mcp approve shows what a server runs and starts it" : "")
|
|
3663
3742
|
});
|
|
3664
3743
|
return;
|
|
3665
3744
|
}
|
|
@@ -3702,7 +3781,7 @@ ${preferencesText}` : ""),
|
|
|
3702
3781
|
push({ kind: "notice", tone: "warn", text: ` unknown command: /${cmd} \u2014 try /help` });
|
|
3703
3782
|
}
|
|
3704
3783
|
},
|
|
3705
|
-
[client, cols, config, cycleMode, exit, model, modelTaskHints, premium, push, replayConversation, root, runAdWall, runBusy, runBuy, runLogin, runTurn, setPremium, writeToStdout]
|
|
3784
|
+
[askMcpApproval, client, cols, config, cycleMode, exit, model, modelTaskHints, premium, push, replayConversation, root, runAdWall, runBusy, runBuy, runLogin, runTurn, setPremium, writeToStdout]
|
|
3706
3785
|
);
|
|
3707
3786
|
const runShell = useCallback(
|
|
3708
3787
|
async (command2) => {
|