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 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 privately, through "Report a vulnerability" on the repository's
99
- Security tab, rather than in a public issue.
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 === "prompt_too_long" && typeof body.message === "string") return new Error(body.message);
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((resolve5) => {
653
+ return new Promise((resolve6) => {
647
654
  if (!signal) {
648
- setTimeout(resolve5, ms);
655
+ setTimeout(resolve6, ms);
649
656
  return;
650
657
  }
651
- if (signal.aborted) return resolve5();
658
+ if (signal.aborted) return resolve6();
652
659
  const done = () => {
653
660
  clearTimeout(timer);
654
661
  signal.removeEventListener("abort", done);
655
- resolve5();
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((resolve5) => {
2043
+ return new Promise((resolve6) => {
2036
2044
  if (!signal) {
2037
- setTimeout(resolve5, ms);
2045
+ setTimeout(resolve6, ms);
2038
2046
  return;
2039
2047
  }
2040
- if (signal.aborted) return resolve5();
2048
+ if (signal.aborted) return resolve6();
2041
2049
  const done = () => {
2042
2050
  clearTimeout(timer);
2043
2051
  signal.removeEventListener("abort", done);
2044
- resolve5();
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 { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync2 } from "node:fs";
2340
- import { isAbsolute, relative, resolve, dirname as dirname2, join as join3, sep } from "node:path";
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 { existsSync, readFileSync as readFileSync2 } from "node:fs";
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 mcpConfigPaths(root) {
3043
- return [join2(homedir2(), ".clixad", "mcp.json"), join2(root, ".mcp.json"), join2(root, ".clixad", "mcp.json")];
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 mcpConfigPaths(root)) {
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") errors.push(`${short(path)}: ${config}`);
3071
- else servers[name] = config;
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
- // The server's own env plus ours. `shell: false` on purpose: the command
3128
- // and its args are already separate, so there is nothing for a shell to
3129
- // re-split, and a path with a space in it would be split if there were.
3130
- env: { ...process.env, ...config.env ?? {} },
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((resolve5, reject) => {
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(() => resolve5(v)),
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.transport = isHttpConfig(config) ? new HttpTransport(config) : new StdioTransport(config, root);
3463
+ this.root = root;
3464
+ this.origin = origin;
3465
+ this.state = held ?? "connecting";
3322
3466
  }
3323
3467
  name;
3324
3468
  config;
3325
- state = "connecting";
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 this.transport.request(
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 this.transport.notify("notifications/initialized", {});
3344
- const listed = await this.transport.request("tools/list", {}, timeoutMs);
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 = this.transport.diagnostics();
3521
+ const diagnostics = transport.diagnostics();
3359
3522
  this.error = err.message + (diagnostics ? `
3360
3523
  ${diagnostics.split("\n").slice(-3).join("\n ")}` : "");
3361
- await this.transport.close().catch(() => void 0);
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.close().catch(() => void 0);
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 configured server, in parallel, and never reject.
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
- await Promise.all(connections.map((c) => c.connect(opts.timeoutMs)));
3435
- const taken = /* @__PURE__ */ new Set();
3436
- for (const conn of connections) {
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
- /** An empty hub — what a run with no config, or a scripted run, gets. */
3447
- static empty() {
3448
- return new _McpHub([]);
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 : resolve(root, p);
3513
- const rel2 = relative(root, abs);
3514
- if (rel2 === ".." || rel2.startsWith(".." + sep) || isAbsolute(rel2)) {
3515
- throw new ToolError(`path escapes workspace: ${p}`);
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, resolve(abs, entry.name)) === "dir";
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
- mkdirSync2(dirname2(abs), { recursive: true });
3817
- writeFileSync2(abs, content, "utf8");
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
- fsCall(`cannot edit ${name}`, () => writeFileSync2(abs, after, "utf8"));
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 resolve2, sep as sep2 } from "node:path";
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 resolve2(w);
5064
+ if (isAbsolute2(w)) return resolve3(w);
4767
5065
  if (ctx.cwd === null) return null;
4768
- return resolve2(ctx.cwd, w);
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: resolve2(root), home: resolve2(home), cwd: resolve2(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 resolve3 } from "node:path";
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 = resolve3(root);
5886
- if (dir === resolve3(homedir4()) || dir === parse(dir).root) return false;
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 mkdirSync3,
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 writeFileSync3
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 resolve4 } from "node:path";
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" ? resolve4(p).toLowerCase() : resolve4(p);
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
- mkdirSync3(SESSION_DIR, { recursive: true });
6388
+ mkdirSync4(SESSION_DIR, { recursive: true });
6089
6389
  const stored = { ...session, messages: withoutImages(session.messages) };
6090
- writeFileSync3(join5(SESSION_DIR, `${session.id}.json`), JSON.stringify(stored), "utf8");
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
- mkdirSync3(ROOT, { recursive: true });
6179
- writeFileSync3(HISTORY_FILE, entries.slice(0, MAX_HISTORY).reverse().join("\n") + "\n", "utf8");
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 mkdirSync4, writeFileSync as writeFileSync4 } from "node:fs";
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 dirname3, join as join6 } from "node:path";
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
- mkdirSync4(dirname3(path), { recursive: true });
6269
- writeFileSync4(path, buildKimiConfig({ gatewayUrl: config.gatewayUrl, token: config.token, modelId: config.model }), "utf8");
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((resolve5) => {
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
- resolve5(127);
6588
+ resolve6(127);
6289
6589
  });
6290
- child.on("exit", (code) => resolve5(code ?? 0));
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
- var SUMMARY_PROMPT = "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. No preamble.";
6553
- async function compact(client, model, messages, opts = {}) {
6554
- const cut2 = cutPoint(messages, opts.keepTurns ?? 2);
6555
- if (cut2 <= 1) return void 0;
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
- const turn = await client.chatStream(
6558
- [...messages.slice(0, cut2), { role: "user", content: SUMMARY_PROMPT }],
6559
- model,
6560
- // The `/effort` setting applies to every call on the model, a summary
6561
- // included: somebody who turned it down to save credits saves them here too.
6562
- { signal: opts.signal, effort: opts.effort }
6563
- );
6564
- const summary = messageText(turn.message).trim() || "(no summary returned)";
6565
- const compacted = [
6566
- messages[0],
6567
- { role: "user", content: `${SUMMARY_LEAD}${summary}` },
6568
- { role: "assistant", content: SUMMARY_ACK },
6569
- ...messages.slice(cut2)
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: turn.creditsCharged,
6575
- balance: turn.balance,
6576
- ...turn.spendable ? { spendable: turn.spendable } : {},
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-TKGMAY4C.mjs";
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-US36BPP3.mjs"));
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
- } from "./chunk-TKGMAY4C.mjs";
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: "connected MCP servers and their tools" },
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
- useEffect(() => {
2275
- const { servers, errors } = loadMcpConfig(root);
2276
- for (const problem of errors) push({ kind: "notice", tone: "warn", text: ` mcp config \u2014 ${problem}` });
2277
- const names = Object.keys(servers);
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 mcpRef.current.close();
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(client, model2, [
2696
- { role: "system", content: contextRef.current.systemPrompt },
2697
- ...history
2698
- ], { signal: ac.signal, effort: savedEffort(config, model2) });
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
- // A model call like any other, so esc has to stop it like any other —
3357
- // and stopping it costs nothing, since the summary only replaces the
3358
- // conversation once the call has come back whole.
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 status = mcpRef.current.status();
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(10)} ${s.transport}`;
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clixad",
3
- "version": "0.0.1-beta.47",
3
+ "version": "0.0.1-beta.49",
4
4
  "description": "Free AI coding agent in your terminal, funded by rewarded ads.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",