clixad 0.0.1-beta.46 → 0.0.1-beta.48

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
 
@@ -13,6 +13,10 @@ function isAdFormat(value) {
13
13
  // src/sponsor/direct.ts
14
14
  var DIRECT_ASK_TIMEOUT_MS = 1500;
15
15
  var MAX_TEXT = 200;
16
+ function parseMeasure(body) {
17
+ const raw = body?.measure;
18
+ return typeof raw === "string" && raw.length > 0 && raw.length <= 2e3 ? raw : void 0;
19
+ }
16
20
  function sanitizeAdText(raw) {
17
21
  return raw.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "").replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/[‪-‮⁦-⁩‎‏؜]/g, "").replace(/[​-‍⁠­]/g, "").replace(/\s+/g, " ").trim();
18
22
  }
@@ -62,7 +66,8 @@ function readServedLine(e) {
62
66
  } catch {
63
67
  return null;
64
68
  }
65
- return { format: e.format, text, serveToken: e.serve_token, clickUrl };
69
+ const shortUrl = typeof e.short_url === "string" && shortLinkText(e.short_url) ? e.short_url : void 0;
70
+ return { format: e.format, text, serveToken: e.serve_token, clickUrl, ...shortUrl ? { shortUrl } : {} };
66
71
  }
67
72
  var MAX_LINK_TEXT = 40;
68
73
  function shortLinkText(clickUrl) {
@@ -638,16 +643,16 @@ async function httpFailure(res, what) {
638
643
  return detail ? `${what} failed: ${res.status} \u2014 ${detail}` : `${what} failed: ${res.status}`;
639
644
  }
640
645
  function sleep(ms, signal) {
641
- return new Promise((resolve5) => {
646
+ return new Promise((resolve6) => {
642
647
  if (!signal) {
643
- setTimeout(resolve5, ms);
648
+ setTimeout(resolve6, ms);
644
649
  return;
645
650
  }
646
- if (signal.aborted) return resolve5();
651
+ if (signal.aborted) return resolve6();
647
652
  const done = () => {
648
653
  clearTimeout(timer);
649
654
  signal.removeEventListener("abort", done);
650
- resolve5();
655
+ resolve6();
651
656
  };
652
657
  const timer = setTimeout(done, ms);
653
658
  signal.addEventListener("abort", done, { once: true });
@@ -1140,7 +1145,13 @@ var GatewayClient = class {
1140
1145
  if (!res.ok) return { supported: true, lines: [], failed: true };
1141
1146
  const body = await res.json();
1142
1147
  const fallbacks = parseFallbackLines(body);
1143
- return { supported: true, lines: parseDirectLines(body), ...fallbacks.length > 0 ? { fallbacks } : {} };
1148
+ const measure = parseMeasure(body);
1149
+ return {
1150
+ supported: true,
1151
+ lines: parseDirectLines(body),
1152
+ ...fallbacks.length > 0 ? { fallbacks } : {},
1153
+ ...measure ? { measure } : {}
1154
+ };
1144
1155
  } catch {
1145
1156
  return { supported: true, lines: [], failed: true };
1146
1157
  }
@@ -1157,7 +1168,14 @@ var GatewayClient = class {
1157
1168
  method: "POST",
1158
1169
  headers: this.headers(),
1159
1170
  ...signal ? { signal } : {},
1160
- body: JSON.stringify({ impressions: impressions.map((i) => ({ format: i.format, serve_token: i.serveToken })) })
1171
+ body: JSON.stringify({
1172
+ impressions: impressions.map((i) => ({
1173
+ format: i.format,
1174
+ serve_token: i.serveToken,
1175
+ ...i.visibleMs !== void 0 ? { visible_ms: Math.round(i.visibleMs) } : {},
1176
+ ...i.link ? { link: i.link } : {}
1177
+ }))
1178
+ })
1161
1179
  });
1162
1180
  if (!res.ok) return 0;
1163
1181
  const body = await res.json();
@@ -1166,6 +1184,27 @@ var GatewayClient = class {
1166
1184
  return 0;
1167
1185
  }
1168
1186
  }
1187
+ /**
1188
+ * Report how long each place a turn asked about stayed on screen with a
1189
+ * line in it, with the turn's measure token: what a booked week is
1190
+ * forecast on. Never throws; answers how many places counted.
1191
+ */
1192
+ async reportSponsorViews(measure, places, signal) {
1193
+ if (places.length === 0) return 0;
1194
+ try {
1195
+ const res = await gatewayFetch(`${this.config.gatewayUrl}/v1/sponsor/direct/views`, {
1196
+ method: "POST",
1197
+ headers: this.headers(),
1198
+ ...signal ? { signal } : {},
1199
+ body: JSON.stringify({ measure, places: places.map((p) => ({ format: p.format, visible_ms: Math.round(p.visibleMs) })) })
1200
+ });
1201
+ if (!res.ok) return 0;
1202
+ const body = await res.json();
1203
+ return typeof body.counted === "number" ? body.counted : 0;
1204
+ } catch {
1205
+ return 0;
1206
+ }
1207
+ }
1169
1208
  /**
1170
1209
  * Report the fallback lines a turn drew, in one call, after the turn is
1171
1210
  * over. They are counted and earn nothing, because nobody pays for them being
@@ -1993,16 +2032,16 @@ async function devLogin(client, config, email, invite) {
1993
2032
  };
1994
2033
  }
1995
2034
  function sleep2(ms, signal) {
1996
- return new Promise((resolve5) => {
2035
+ return new Promise((resolve6) => {
1997
2036
  if (!signal) {
1998
- setTimeout(resolve5, ms);
2037
+ setTimeout(resolve6, ms);
1999
2038
  return;
2000
2039
  }
2001
- if (signal.aborted) return resolve5();
2040
+ if (signal.aborted) return resolve6();
2002
2041
  const done = () => {
2003
2042
  clearTimeout(timer);
2004
2043
  signal.removeEventListener("abort", done);
2005
- resolve5();
2044
+ resolve6();
2006
2045
  };
2007
2046
  const timer = setTimeout(done, ms);
2008
2047
  signal.addEventListener("abort", done, { once: true });
@@ -2297,8 +2336,18 @@ ${renderTodos(items)}${warning}`;
2297
2336
 
2298
2337
  // src/tools.ts
2299
2338
  import { spawn as spawn4 } from "node:child_process";
2300
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync2 } from "node:fs";
2301
- import { isAbsolute, relative, resolve, dirname as dirname2, join as join3, sep } from "node:path";
2339
+ import {
2340
+ existsSync as existsSync2,
2341
+ lstatSync,
2342
+ mkdirSync as mkdirSync3,
2343
+ readdirSync,
2344
+ readFileSync as readFileSync3,
2345
+ readlinkSync,
2346
+ realpathSync as realpathSync2,
2347
+ statSync,
2348
+ writeFileSync as writeFileSync3
2349
+ } from "node:fs";
2350
+ import { basename, isAbsolute, relative, resolve as resolve2, dirname as dirname3, join as join3, sep } from "node:path";
2302
2351
 
2303
2352
  // src/diff.ts
2304
2353
  var R = "\x1B[0m";
@@ -2987,9 +3036,10 @@ function decodeEntities(text) {
2987
3036
 
2988
3037
  // src/mcp.ts
2989
3038
  import { spawn as spawn3 } from "node:child_process";
2990
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
3039
+ import { createHash } from "node:crypto";
3040
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, realpathSync, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
2991
3041
  import { homedir as homedir2 } from "node:os";
2992
- import { join as join2 } from "node:path";
3042
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
2993
3043
  var CONNECT_TIMEOUT_MS = 2e4;
2994
3044
  var DEFAULT_CALL_TIMEOUT_MS = 12e4;
2995
3045
  var PROTOCOL_VERSION = "2025-06-18";
@@ -3000,14 +3050,20 @@ var McpError = class extends Error {
3000
3050
  function isHttpConfig(c) {
3001
3051
  return c.url !== void 0;
3002
3052
  }
3003
- function mcpConfigPaths(root) {
3004
- return [join2(homedir2(), ".clixad", "mcp.json"), join2(root, ".mcp.json"), join2(root, ".clixad", "mcp.json")];
3053
+ function mcpConfigFiles(root) {
3054
+ const personal = join2(homedir2(), ".clixad", "mcp.json");
3055
+ const files = [{ path: personal, scope: "user" }];
3056
+ for (const path of [join2(root, ".mcp.json"), join2(root, ".clixad", "mcp.json")]) {
3057
+ if (resolve(path) !== resolve(personal)) files.push({ path, scope: "project" });
3058
+ }
3059
+ return files;
3005
3060
  }
3006
3061
  function loadMcpConfig(root) {
3007
3062
  const servers = {};
3063
+ const origins = {};
3008
3064
  const sources = [];
3009
3065
  const errors = [];
3010
- for (const path of mcpConfigPaths(root)) {
3066
+ for (const { path, scope } of mcpConfigFiles(root)) {
3011
3067
  if (!existsSync(path)) continue;
3012
3068
  let parsed;
3013
3069
  try {
@@ -3028,13 +3084,24 @@ function loadMcpConfig(root) {
3028
3084
  sources.push(short(path));
3029
3085
  for (const [name, raw] of Object.entries(declared)) {
3030
3086
  const config = validateServer(name, raw);
3031
- if (typeof config === "string") errors.push(`${short(path)}: ${config}`);
3032
- else servers[name] = config;
3087
+ if (typeof config === "string") {
3088
+ errors.push(`${short(path)}: ${config}`);
3089
+ continue;
3090
+ }
3091
+ servers[name] = config;
3092
+ origins[name] = {
3093
+ scope,
3094
+ file: short(path),
3095
+ fingerprint: fingerprint(raw),
3096
+ display: describeEntry(raw),
3097
+ variables: [...new Set(variablesIn(raw))].sort()
3098
+ };
3033
3099
  }
3034
3100
  }
3035
- return { servers, sources, errors };
3101
+ return { servers, origins, sources, errors };
3036
3102
  }
3037
3103
  function validateServer(name, raw) {
3104
+ if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) return `server ${JSON.stringify(name)} has control characters in its name`;
3038
3105
  if (typeof raw !== "object" || raw === null) return `server "${name}" is not an object`;
3039
3106
  const c = raw;
3040
3107
  if (typeof c.url === "string") {
@@ -3075,6 +3142,109 @@ function expandMap(map) {
3075
3142
  function short(path) {
3076
3143
  return path.replace(homedir2(), "~");
3077
3144
  }
3145
+ function canonicalJson(value) {
3146
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
3147
+ if (value && typeof value === "object") {
3148
+ const entries = Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`);
3149
+ return `{${entries.join(",")}}`;
3150
+ }
3151
+ return JSON.stringify(value) ?? "null";
3152
+ }
3153
+ function fingerprint(raw) {
3154
+ return createHash("sha256").update(canonicalJson(raw)).digest("hex");
3155
+ }
3156
+ function variablesIn(value) {
3157
+ if (typeof value === "string") return [...value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
3158
+ if (Array.isArray(value)) return value.flatMap(variablesIn);
3159
+ if (value && typeof value === "object") return Object.values(value).flatMap(variablesIn);
3160
+ return [];
3161
+ }
3162
+ var MAX_DISPLAY_CHARS = 2e3;
3163
+ function shellWord(word) {
3164
+ if (/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(word)) return word;
3165
+ return JSON.stringify(word).replace(/[^\x20-\x7e]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
3166
+ }
3167
+ function describeEntry(raw) {
3168
+ const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
3169
+ const pairs = (v) => isStringMap(v) ? Object.entries(v) : [];
3170
+ const lines = [];
3171
+ if (typeof raw.url === "string") {
3172
+ lines.push(`${raw.type === "sse" ? "sse" : "http"} ${shellWord(raw.url)}`);
3173
+ for (const [k, v] of pairs(raw.headers)) lines.push(`header ${shellWord(k)}: ${shellWord(v)}`);
3174
+ } else {
3175
+ lines.push([String(raw.command ?? ""), ...strings(raw.args)].map(shellWord).join(" "));
3176
+ for (const [k, v] of pairs(raw.env)) lines.push(`env ${shellWord(k)}=${shellWord(v)}`);
3177
+ if (typeof raw.cwd === "string") lines.push(`cwd ${shellWord(raw.cwd)}`);
3178
+ }
3179
+ const text = lines.join("\n");
3180
+ 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;
3181
+ }
3182
+ var INHERITED_ENV = {
3183
+ posix: ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TZ"],
3184
+ win32: [
3185
+ "APPDATA",
3186
+ "COMSPEC",
3187
+ "HOMEDRIVE",
3188
+ "HOMEPATH",
3189
+ "LOCALAPPDATA",
3190
+ "PATH",
3191
+ "PATHEXT",
3192
+ "PROCESSOR_ARCHITECTURE",
3193
+ "PROGRAMFILES",
3194
+ "SYSTEMDRIVE",
3195
+ "SYSTEMROOT",
3196
+ "TEMP",
3197
+ "TMP",
3198
+ "USERNAME",
3199
+ "USERPROFILE",
3200
+ "WINDIR"
3201
+ ]
3202
+ };
3203
+ function mcpServerEnv(own = {}, source = process.env, platform2 = process.platform) {
3204
+ const env = {};
3205
+ const wanted = INHERITED_ENV[platform2 === "win32" ? "win32" : "posix"];
3206
+ const byName = new Map(Object.keys(source).map((k) => [platform2 === "win32" ? k.toUpperCase() : k, k]));
3207
+ for (const name of wanted) {
3208
+ const key = byName.get(name);
3209
+ const value = key === void 0 ? void 0 : source[key];
3210
+ if (value !== void 0 && !value.startsWith("()")) env[name] = value;
3211
+ }
3212
+ return { ...env, ...own };
3213
+ }
3214
+ function mcpApprovalsPath() {
3215
+ return join2(homedir2(), ".clixad", "mcp-approvals.json");
3216
+ }
3217
+ function workspaceKey(root) {
3218
+ try {
3219
+ return realpathSync.native(root);
3220
+ } catch {
3221
+ return resolve(root);
3222
+ }
3223
+ }
3224
+ function readApprovals() {
3225
+ try {
3226
+ const parsed = JSON.parse(readFileSync2(mcpApprovalsPath(), "utf8"));
3227
+ if (parsed?.version === 1 && parsed.projects && typeof parsed.projects === "object") return parsed;
3228
+ } catch {
3229
+ }
3230
+ return { version: 1, projects: {} };
3231
+ }
3232
+ function mcpDecision(root, name, fingerprint2) {
3233
+ const entry = readApprovals().projects[workspaceKey(root)]?.[name];
3234
+ return entry && entry.fingerprint === fingerprint2 ? entry.decision : void 0;
3235
+ }
3236
+ function recordMcpDecision(root, servers, decision, at = /* @__PURE__ */ new Date()) {
3237
+ const file = readApprovals();
3238
+ const key = workspaceKey(root);
3239
+ const project = file.projects[key] ??= {};
3240
+ for (const server of servers) project[server.name] = { fingerprint: server.fingerprint, decision, at: at.toISOString() };
3241
+ const path = mcpApprovalsPath();
3242
+ mkdirSync2(dirname2(path), { recursive: true });
3243
+ const tmp = `${path}.${process.pid}.tmp`;
3244
+ writeFileSync2(tmp, `${JSON.stringify(file, null, 2)}
3245
+ `, { encoding: "utf8", mode: 384 });
3246
+ renameSync2(tmp, path);
3247
+ }
3078
3248
  var StdioTransport = class {
3079
3249
  child;
3080
3250
  buffer = "";
@@ -3085,10 +3255,13 @@ var StdioTransport = class {
3085
3255
  constructor(config, root) {
3086
3256
  this.child = spawn3(config.command, config.args ?? [], {
3087
3257
  cwd: config.cwd ?? root,
3088
- // The server's own env plus ours. `shell: false` on purpose: the command
3089
- // and its args are already separate, so there is nothing for a shell to
3090
- // re-split, and a path with a space in it would be split if there were.
3091
- env: { ...process.env, ...config.env ?? {} },
3258
+ // What a program needs to run plus what the entry names, and never the
3259
+ // rest of ours: this process's environment holds whatever the user
3260
+ // exported, API keys included, and a server is somebody else's program.
3261
+ // `shell: false` on purpose: the command and its args are already
3262
+ // separate, so there is nothing for a shell to re-split, and a path with
3263
+ // a space in it would be split if there were.
3264
+ env: mcpServerEnv(config.env),
3092
3265
  stdio: ["pipe", "pipe", "pipe"],
3093
3266
  windowsHide: true,
3094
3267
  // npx and friends are .cmd shims on Windows, which CreateProcess cannot
@@ -3138,7 +3311,7 @@ var StdioTransport = class {
3138
3311
  if (this.exited) return Promise.reject(new McpError(this.exited));
3139
3312
  const id = ++this.seq;
3140
3313
  const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
3141
- return new Promise((resolve5, reject) => {
3314
+ return new Promise((resolve6, reject) => {
3142
3315
  const settle = (fn) => {
3143
3316
  clearTimeout(timer);
3144
3317
  signal?.removeEventListener("abort", onAbort);
@@ -3152,7 +3325,7 @@ var StdioTransport = class {
3152
3325
  const onAbort = () => settle(() => reject(new McpError("cancelled")));
3153
3326
  signal?.addEventListener("abort", onAbort, { once: true });
3154
3327
  this.pending.set(id, {
3155
- resolve: (v) => settle(() => resolve5(v)),
3328
+ resolve: (v) => settle(() => resolve6(v)),
3156
3329
  reject: (e) => settle(() => reject(e))
3157
3330
  });
3158
3331
  this.child.stdin?.write(payload, (err) => {
@@ -3276,23 +3449,42 @@ async function readSseResponse(res, id) {
3276
3449
  return null;
3277
3450
  }
3278
3451
  var McpServerConnection = class {
3279
- constructor(name, config, root) {
3452
+ constructor(name, config, root, origin, held) {
3280
3453
  this.name = name;
3281
3454
  this.config = config;
3282
- this.transport = isHttpConfig(config) ? new HttpTransport(config) : new StdioTransport(config, root);
3455
+ this.root = root;
3456
+ this.origin = origin;
3457
+ this.state = held ?? "connecting";
3283
3458
  }
3284
3459
  name;
3285
3460
  config;
3286
- state = "connecting";
3461
+ root;
3462
+ origin;
3463
+ state;
3287
3464
  error;
3288
3465
  tools = [];
3466
+ /**
3467
+ * Created by `connect()` and nowhere else, because creating a stdio
3468
+ * transport *is* starting the program. A held connection never has one.
3469
+ */
3289
3470
  transport;
3290
3471
  get kind() {
3291
3472
  return isHttpConfig(this.config) ? "http" : "stdio";
3292
3473
  }
3474
+ /** Lift the hold, after the user approved. Starts nothing by itself. */
3475
+ release() {
3476
+ if (this.state === "unapproved" || this.state === "declined") this.state = "connecting";
3477
+ }
3478
+ /** Remember a refusal, so `/mcp` can say why it is not running. */
3479
+ decline() {
3480
+ if (this.state === "unapproved") this.state = "declined";
3481
+ }
3293
3482
  async connect(timeoutMs = CONNECT_TIMEOUT_MS) {
3483
+ if (this.state !== "connecting" || this.transport) return;
3484
+ const transport = isHttpConfig(this.config) ? new HttpTransport(this.config) : new StdioTransport(this.config, this.root);
3485
+ this.transport = transport;
3294
3486
  try {
3295
- await this.transport.request(
3487
+ await transport.request(
3296
3488
  "initialize",
3297
3489
  {
3298
3490
  protocolVersion: PROTOCOL_VERSION,
@@ -3301,8 +3493,8 @@ var McpServerConnection = class {
3301
3493
  },
3302
3494
  timeoutMs
3303
3495
  );
3304
- await this.transport.notify("notifications/initialized", {});
3305
- const listed = await this.transport.request("tools/list", {}, timeoutMs);
3496
+ await transport.notify("notifications/initialized", {});
3497
+ const listed = await transport.request("tools/list", {}, timeoutMs);
3306
3498
  this.tools = (listed?.tools ?? []).filter(
3307
3499
  (t) => typeof t?.name === "string" && t.name.length > 0
3308
3500
  ).map((t) => ({
@@ -3313,17 +3505,19 @@ var McpServerConnection = class {
3313
3505
  ...typeof t.description === "string" ? { description: t.description } : {},
3314
3506
  ...t.inputSchema && typeof t.inputSchema === "object" ? { inputSchema: t.inputSchema } : {}
3315
3507
  }));
3508
+ if (this.state !== "connecting") return;
3316
3509
  this.state = "ready";
3317
3510
  } catch (err) {
3511
+ if (this.state !== "connecting") return;
3318
3512
  this.state = "failed";
3319
- const diagnostics = this.transport.diagnostics();
3513
+ const diagnostics = transport.diagnostics();
3320
3514
  this.error = err.message + (diagnostics ? `
3321
3515
  ${diagnostics.split("\n").slice(-3).join("\n ")}` : "");
3322
- await this.transport.close().catch(() => void 0);
3516
+ await transport.close().catch(() => void 0);
3323
3517
  }
3324
3518
  }
3325
3519
  async call(tool, args, timeoutMs, signal) {
3326
- if (this.state !== "ready") throw new McpError(`server "${this.name}" is ${this.state}`);
3520
+ if (this.state !== "ready" || !this.transport) throw new McpError(`server "${this.name}" is ${this.state}`);
3327
3521
  const raw = await this.transport.request(
3328
3522
  "tools/call",
3329
3523
  { name: tool, arguments: args ?? {} },
@@ -3336,7 +3530,7 @@ var McpServerConnection = class {
3336
3530
  }
3337
3531
  async close() {
3338
3532
  this.state = "closed";
3339
- await this.transport.close().catch(() => void 0);
3533
+ await this.transport?.close().catch(() => void 0);
3340
3534
  }
3341
3535
  };
3342
3536
  function renderContent(content, structured) {
@@ -3379,34 +3573,118 @@ function isMcpToolName(name) {
3379
3573
  return name.startsWith("mcp__");
3380
3574
  }
3381
3575
  var McpHub = class _McpHub {
3382
- constructor(servers) {
3576
+ constructor(servers, root) {
3383
3577
  this.servers = servers;
3578
+ this.root = root;
3384
3579
  }
3385
3580
  servers;
3581
+ root;
3386
3582
  /**
3387
- * Connect to every configured server, in parallel, and never reject.
3583
+ * Connect to every server given, in parallel, and never reject.
3388
3584
  *
3585
+ * For servers the caller has already decided may run; a config read from
3586
+ * disk goes through `fromConfig`, which holds the repository's servers.
3389
3587
  * Parallel because a slow server must not delay the others, and one that
3390
3588
  * cannot start must not delay anything: the whole point is that the REPL opens
3391
3589
  * whether or not somebody's Docker daemon is running today.
3392
3590
  */
3393
3591
  static async connect(servers, opts = { root: process.cwd() }) {
3394
3592
  const connections = Object.entries(servers).map(([name, config]) => new McpServerConnection(name, config, opts.root));
3395
- await Promise.all(connections.map((c) => c.connect(opts.timeoutMs)));
3396
- const taken = /* @__PURE__ */ new Set();
3397
- for (const conn of connections) {
3593
+ const hub = new _McpHub(connections, opts.root);
3594
+ await hub.startReleased(opts.timeoutMs);
3595
+ return hub;
3596
+ }
3597
+ /**
3598
+ * A hub for the config on disk, with nothing started yet.
3599
+ *
3600
+ * A server from `~/.clixad/mcp.json` is ready to start. A server from the
3601
+ * workspace is held unless the user approved this exact entry for this
3602
+ * workspace before, and stays held if they declined it. `startReleased`
3603
+ * then starts what may start; `approve` releases the rest.
3604
+ */
3605
+ static fromConfig(loaded, opts) {
3606
+ const connections = Object.entries(loaded.servers).map(([name, config]) => {
3607
+ const origin = loaded.origins[name];
3608
+ let held;
3609
+ if (!origin || origin.scope !== "user") {
3610
+ const decision = origin ? mcpDecision(opts.root, name, origin.fingerprint) : void 0;
3611
+ held = decision === "approved" ? void 0 : decision === "declined" ? "declined" : "unapproved";
3612
+ }
3613
+ return new McpServerConnection(name, config, opts.root, origin, held);
3614
+ });
3615
+ return new _McpHub(connections, opts.root);
3616
+ }
3617
+ /** An empty hub — what a run with no config, or a scripted run, gets. */
3618
+ static empty() {
3619
+ return new _McpHub([], process.cwd());
3620
+ }
3621
+ /**
3622
+ * Start every server that may start and has not been, in parallel. Returns
3623
+ * the ones it started, so the caller can say how each one went.
3624
+ */
3625
+ async startReleased(timeoutMs) {
3626
+ const starting = this.servers.filter((s) => s.state === "connecting");
3627
+ await Promise.all(starting.map((c) => c.connect(timeoutMs)));
3628
+ this.assignNames();
3629
+ const names = new Set(starting.map((s) => s.name));
3630
+ return this.status().filter((s) => names.has(s.name));
3631
+ }
3632
+ /**
3633
+ * Give every listed tool its model-facing name. Names already given keep
3634
+ * theirs, so a server approved later cannot rename a tool the model has
3635
+ * already been told about. A collision (two servers with the same tool, or
3636
+ * two long names that truncate together) is broken deterministically.
3637
+ */
3638
+ assignNames() {
3639
+ const taken = new Set(this.servers.flatMap((s) => s.tools.map((t) => t.qualified)).filter(Boolean));
3640
+ for (const conn of this.servers) {
3398
3641
  for (const tool of conn.tools) {
3642
+ if (tool.qualified) continue;
3399
3643
  let name = qualifiedToolName(conn.name, tool.name);
3400
3644
  for (let i = 2; taken.has(name); i++) name = `${name.slice(0, 61)}_${i}`;
3401
3645
  taken.add(name);
3402
3646
  tool.qualified = name;
3403
3647
  }
3404
3648
  }
3405
- return new _McpHub(connections);
3406
3649
  }
3407
- /** An empty hub — what a run with no config, or a scripted run, gets. */
3408
- static empty() {
3409
- return new _McpHub([]);
3650
+ /** The servers waiting for the user, with what the prompt shows. */
3651
+ pending(opts = {}) {
3652
+ return this.servers.filter((s) => s.state === "unapproved" || opts.includeDeclined && s.state === "declined").map((s) => ({
3653
+ name: s.name,
3654
+ file: s.origin?.file ?? "",
3655
+ display: s.origin?.display ?? "",
3656
+ variables: s.origin?.variables ?? [],
3657
+ fingerprint: s.origin?.fingerprint ?? ""
3658
+ }));
3659
+ }
3660
+ /**
3661
+ * The user approved these held servers: remember it (bound to each entry as
3662
+ * written) when asked to, and start them. Names that are not held are
3663
+ * ignored. Returns how each one started.
3664
+ */
3665
+ async approve(names, opts) {
3666
+ const chosen = this.held(names);
3667
+ if (opts.remember) this.remember(chosen, "approved");
3668
+ for (const s of chosen) s.release();
3669
+ return this.startReleased(opts.timeoutMs);
3670
+ }
3671
+ /** The user declined these held servers: remember it, so they are not asked about again until the entry changes. */
3672
+ decline(names) {
3673
+ const chosen = this.held(names).filter((s) => s.state === "unapproved");
3674
+ this.remember(chosen, "declined");
3675
+ for (const s of chosen) s.decline();
3676
+ }
3677
+ held(names) {
3678
+ const wanted = new Set(names);
3679
+ return this.servers.filter((s) => wanted.has(s.name) && (s.state === "unapproved" || s.state === "declined"));
3680
+ }
3681
+ remember(servers, decision) {
3682
+ const entries = servers.filter((s) => s.origin).map((s) => ({ name: s.name, fingerprint: s.origin.fingerprint }));
3683
+ if (!entries.length) return;
3684
+ try {
3685
+ recordMcpDecision(this.root, entries, decision);
3686
+ } catch {
3687
+ }
3410
3688
  }
3411
3689
  tools() {
3412
3690
  return this.servers.filter((s) => s.state === "ready").flatMap((s) => s.tools);
@@ -3470,13 +3748,60 @@ var PermissionDeniedError = class extends ToolError {
3470
3748
  }
3471
3749
  };
3472
3750
  function safeResolve(root, p) {
3473
- const abs = isAbsolute(p) ? p : resolve(root, p);
3474
- const rel2 = relative(root, abs);
3475
- if (rel2 === ".." || rel2.startsWith(".." + sep) || isAbsolute(rel2)) {
3476
- throw new ToolError(`path escapes workspace: ${p}`);
3751
+ const abs = isAbsolute(p) ? p : resolve2(root, p);
3752
+ if (escapes(root, abs)) throw new ToolError(`path escapes workspace: ${p}`);
3753
+ let real;
3754
+ let realRoot;
3755
+ try {
3756
+ real = realLocation(abs);
3757
+ realRoot = realLocation(resolve2(root));
3758
+ } catch (err) {
3759
+ const code = err.code;
3760
+ throw new ToolError(code === "ELOOP" ? `${p}: the path goes through a symlink loop` : `cannot resolve ${p}`);
3761
+ }
3762
+ if (escapes(realRoot, real)) {
3763
+ throw new ToolError(`path escapes workspace: ${p} is a symlink (or inside one) that points outside the workspace`);
3477
3764
  }
3478
3765
  return abs;
3479
3766
  }
3767
+ function escapes(root, abs) {
3768
+ const rel2 = relative(root, abs);
3769
+ return rel2 === ".." || rel2.startsWith(".." + sep) || isAbsolute(rel2);
3770
+ }
3771
+ var MAX_LINK_HOPS = 40;
3772
+ function realLocation(abs, hops = 0) {
3773
+ try {
3774
+ return realpathSync2.native(abs);
3775
+ } catch (err) {
3776
+ const code = err.code;
3777
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw err;
3778
+ }
3779
+ const parent = dirname3(abs);
3780
+ if (parent === abs) return abs;
3781
+ let link;
3782
+ try {
3783
+ if (lstatSync(abs).isSymbolicLink()) link = readlinkSync(abs);
3784
+ } catch {
3785
+ }
3786
+ const realParent = realLocation(parent, hops);
3787
+ if (link === void 0) return join3(realParent, basename(abs));
3788
+ if (hops >= MAX_LINK_HOPS) throw Object.assign(new Error("too many symlinks"), { code: "ELOOP" });
3789
+ return realLocation(resolve2(realParent, link), hops + 1);
3790
+ }
3791
+ function realRootOf(root) {
3792
+ try {
3793
+ return realLocation(resolve2(root));
3794
+ } catch {
3795
+ return resolve2(root);
3796
+ }
3797
+ }
3798
+ function linkStaysInside(abs, realRoot) {
3799
+ try {
3800
+ return !escapes(realRoot, realpathSync2.native(abs));
3801
+ } catch {
3802
+ return false;
3803
+ }
3804
+ }
3480
3805
  function rel(root, abs) {
3481
3806
  return relative(root, abs).replace(/\\/g, "/") || ".";
3482
3807
  }
@@ -3550,10 +3875,11 @@ function describeValue(value) {
3550
3875
  if (typeof value === "number") return "an unusable number";
3551
3876
  return typeof value === "object" ? "an object" : `a ${typeof value}`;
3552
3877
  }
3553
- function entryKind(entry, abs) {
3878
+ function entryKind(entry, abs, realRoot) {
3554
3879
  if (entry.isDirectory()) return "dir";
3555
3880
  if (entry.isFile()) return "file";
3556
3881
  if (!entry.isSymbolicLink()) return "other";
3882
+ if (!linkStaysInside(abs, realRoot)) return "other";
3557
3883
  try {
3558
3884
  const stat = statSync(abs);
3559
3885
  return stat.isDirectory() ? "dir" : stat.isFile() ? "file" : "other";
@@ -3691,9 +4017,10 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
3691
4017
  if (!fsCall(`cannot list ${name}`, () => statSync(abs)).isDirectory()) {
3692
4018
  throw new ToolError(`${path} is a file \u2014 use read_file`);
3693
4019
  }
4020
+ const realRoot = realRootOf(ctx.root);
3694
4021
  const entries = fsCall(`cannot list ${name}`, () => readdirSync(abs, { withFileTypes: true })).map(
3695
4022
  (entry) => {
3696
- const isDir = entryKind(entry, resolve(abs, entry.name)) === "dir";
4023
+ const isDir = entryKind(entry, resolve2(abs, entry.name), realRoot) === "dir";
3697
4024
  return { name: isDir ? `${entry.name}/` : entry.name, isDir };
3698
4025
  }
3699
4026
  );
@@ -3773,9 +4100,10 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
3773
4100
  path: name,
3774
4101
  root: ctx.root
3775
4102
  });
4103
+ safeResolve(ctx.root, abs);
3776
4104
  fsCall(`cannot write ${name}`, () => {
3777
- mkdirSync2(dirname2(abs), { recursive: true });
3778
- writeFileSync2(abs, content, "utf8");
4105
+ mkdirSync3(dirname3(abs), { recursive: true });
4106
+ writeFileSync3(abs, content, "utf8");
3779
4107
  });
3780
4108
  ctx.present?.(view);
3781
4109
  return `wrote ${content.length} bytes to ${name}`;
@@ -3817,7 +4145,8 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
3817
4145
  path: name,
3818
4146
  root: ctx.root
3819
4147
  });
3820
- fsCall(`cannot edit ${name}`, () => writeFileSync2(abs, after, "utf8"));
4148
+ safeResolve(ctx.root, abs);
4149
+ fsCall(`cannot edit ${name}`, () => writeFileSync3(abs, after, "utf8"));
3821
4150
  ctx.present?.(view);
3822
4151
  return `edited ${name} (${count > 1 ? `${count} occurrences` : "1 occurrence"})`;
3823
4152
  },
@@ -4223,6 +4552,7 @@ function walk(dir, root, visit) {
4223
4552
  visit(dir);
4224
4553
  return;
4225
4554
  }
4555
+ const realRoot = realRootOf(root);
4226
4556
  let seen = 0;
4227
4557
  const stack = [dir];
4228
4558
  while (stack.length > 0) {
@@ -4235,7 +4565,7 @@ function walk(dir, root, visit) {
4235
4565
  }
4236
4566
  for (const entry of entries) {
4237
4567
  const abs = join3(current, entry.name);
4238
- const kind = entryKind(entry, abs);
4568
+ const kind = entryKind(entry, abs, realRoot);
4239
4569
  if (kind === "dir") {
4240
4570
  if (entry.isSymbolicLink() || SKIP_DIRS.has(entry.name)) continue;
4241
4571
  stack.push(abs);
@@ -4246,7 +4576,6 @@ function walk(dir, root, visit) {
4246
4576
  if (!visit(abs)) return;
4247
4577
  }
4248
4578
  }
4249
- void root;
4250
4579
  }
4251
4580
  function globToRegExp(pattern) {
4252
4581
  const p = requireString(pattern, "glob", "pattern").replace(/\\/g, "/").replace(/^\.\//, "");
@@ -4551,7 +4880,7 @@ var TOOL_SCHEMA = [
4551
4880
 
4552
4881
  // src/auto-mode.ts
4553
4882
  import { homedir as homedir3 } from "node:os";
4554
- import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
4883
+ import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
4555
4884
  function parseCommand(src) {
4556
4885
  const pipelines = [];
4557
4886
  let pipeline = [];
@@ -4724,9 +5053,9 @@ function expandHome(word, home) {
4724
5053
  function resolveWord(word, ctx) {
4725
5054
  const w = expandHome(word, ctx.home);
4726
5055
  if (w === null) return null;
4727
- if (isAbsolute2(w)) return resolve2(w);
5056
+ if (isAbsolute2(w)) return resolve3(w);
4728
5057
  if (ctx.cwd === null) return null;
4729
- return resolve2(ctx.cwd, w);
5058
+ return resolve3(ctx.cwd, w);
4730
5059
  }
4731
5060
  function within(parent, child) {
4732
5061
  const rel2 = relative2(parent, child);
@@ -5260,7 +5589,7 @@ function classify(command, root, home, depth) {
5260
5589
  if (FORK_BOMB.test(command)) return { kind: "dangerous", reason: "it is a fork bomb" };
5261
5590
  if (FETCH_AND_RUN.some((re) => re.test(command))) return { kind: "dangerous", reason: "it downloads code and runs it" };
5262
5591
  const parsed = parseCommand(command);
5263
- const ctx = { root: resolve2(root), home: resolve2(home), cwd: resolve2(root) };
5592
+ const ctx = { root: resolve3(root), home: resolve3(home), cwd: resolve3(root) };
5264
5593
  for (const pipeline of parsed.pipelines) {
5265
5594
  let fetched = false;
5266
5595
  for (const cmd of pipeline) {
@@ -5777,7 +6106,7 @@ async function dispatch(ctx, name, args) {
5777
6106
  import { spawnSync } from "node:child_process";
5778
6107
  import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
5779
6108
  import { homedir as homedir4, platform } from "node:os";
5780
- import { join as join4, parse, resolve as resolve3 } from "node:path";
6109
+ import { join as join4, parse, resolve as resolve4 } from "node:path";
5781
6110
  var MAX_CONTEXT_FILE_CHARS = 2e4;
5782
6111
  function contextFileCandidates(root) {
5783
6112
  return [join4(homedir4(), ".clixad", "CLIXAD.md"), join4(root, "AGENTS.md"), join4(root, "CLIXAD.md")];
@@ -5785,10 +6114,12 @@ function contextFileCandidates(root) {
5785
6114
  function collectContext(root, base = SYSTEM_PROMPT) {
5786
6115
  const parts = [base, environmentBlock(root)];
5787
6116
  const files = [];
6117
+ const personal = join4(homedir4(), ".clixad", "CLIXAD.md");
5788
6118
  for (const path of contextFileCandidates(root)) {
5789
6119
  if (!existsSync3(path)) continue;
5790
6120
  let text;
5791
6121
  try {
6122
+ if (path !== personal) safeResolve(root, path);
5792
6123
  text = readFileSync4(path, "utf8").trim();
5793
6124
  } catch {
5794
6125
  continue;
@@ -5843,8 +6174,8 @@ var INIT_PROMPT = "Create a CLIXAD.md file in the workspace root that tells a fu
5843
6174
  var INIT_HINT = "No CLIXAD.md in this project yet. /init writes one, and every later session here starts out knowing the layout.";
5844
6175
  var PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "Gemfile"];
5845
6176
  function shouldSuggestInit(root) {
5846
- const dir = resolve3(root);
5847
- if (dir === resolve3(homedir4()) || dir === parse(dir).root) return false;
6177
+ const dir = resolve4(root);
6178
+ if (dir === resolve4(homedir4()) || dir === parse(dir).root) return false;
5848
6179
  if (existsSync3(join4(dir, "CLIXAD.md")) || existsSync3(join4(dir, "AGENTS.md"))) return false;
5849
6180
  return PROJECT_MARKERS.some((name) => existsSync3(join4(dir, name)));
5850
6181
  }
@@ -6016,17 +6347,17 @@ var denyAll = async (req) => ({
6016
6347
  import {
6017
6348
  closeSync as closeSync2,
6018
6349
  existsSync as existsSync4,
6019
- mkdirSync as mkdirSync3,
6350
+ mkdirSync as mkdirSync4,
6020
6351
  openSync as openSync2,
6021
6352
  readdirSync as readdirSync3,
6022
6353
  readFileSync as readFileSync5,
6023
6354
  readSync,
6024
6355
  rmSync,
6025
6356
  statSync as statSync2,
6026
- writeFileSync as writeFileSync3
6357
+ writeFileSync as writeFileSync4
6027
6358
  } from "node:fs";
6028
6359
  import { homedir as homedir5 } from "node:os";
6029
- import { join as join5, resolve as resolve4 } from "node:path";
6360
+ import { join as join5, resolve as resolve5 } from "node:path";
6030
6361
  var ROOT = join5(homedir5(), ".clixad");
6031
6362
  var SESSION_DIR = join5(ROOT, "sessions");
6032
6363
  var HISTORY_FILE = join5(ROOT, "history");
@@ -6037,7 +6368,7 @@ function isSessionId(id) {
6037
6368
  return SESSION_ID.test(id);
6038
6369
  }
6039
6370
  function sameDirectory(a, b) {
6040
- const norm = (p) => process.platform === "win32" ? resolve4(p).toLowerCase() : resolve4(p);
6371
+ const norm = (p) => process.platform === "win32" ? resolve5(p).toLowerCase() : resolve5(p);
6041
6372
  return norm(a) === norm(b);
6042
6373
  }
6043
6374
  function newSessionId() {
@@ -6046,9 +6377,9 @@ function newSessionId() {
6046
6377
  }
6047
6378
  function saveSession(session) {
6048
6379
  try {
6049
- mkdirSync3(SESSION_DIR, { recursive: true });
6380
+ mkdirSync4(SESSION_DIR, { recursive: true });
6050
6381
  const stored = { ...session, messages: withoutImages(session.messages) };
6051
- writeFileSync3(join5(SESSION_DIR, `${session.id}.json`), JSON.stringify(stored), "utf8");
6382
+ writeFileSync4(join5(SESSION_DIR, `${session.id}.json`), JSON.stringify(stored), "utf8");
6052
6383
  prune();
6053
6384
  } catch {
6054
6385
  }
@@ -6136,17 +6467,17 @@ function loadHistory() {
6136
6467
  }
6137
6468
  function saveHistory(entries) {
6138
6469
  try {
6139
- mkdirSync3(ROOT, { recursive: true });
6140
- writeFileSync3(HISTORY_FILE, entries.slice(0, MAX_HISTORY).reverse().join("\n") + "\n", "utf8");
6470
+ mkdirSync4(ROOT, { recursive: true });
6471
+ writeFileSync4(HISTORY_FILE, entries.slice(0, MAX_HISTORY).reverse().join("\n") + "\n", "utf8");
6141
6472
  } catch {
6142
6473
  }
6143
6474
  }
6144
6475
 
6145
6476
  // src/kimi.ts
6146
6477
  import { spawn as spawn5, spawnSync as spawnSync2 } from "node:child_process";
6147
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "node:fs";
6478
+ import { existsSync as existsSync5, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
6148
6479
  import { homedir as homedir6 } from "node:os";
6149
- import { dirname as dirname3, join as join6 } from "node:path";
6480
+ import { dirname as dirname4, join as join6 } from "node:path";
6150
6481
  var CONTEXT_TOKENS = {
6151
6482
  "gpt-5-nano": 4e5,
6152
6483
  "gemini-2.5-flash-lite": 1048576,
@@ -6226,8 +6557,8 @@ function runKimiCode(config, task, opts = {}) {
6226
6557
  return Promise.resolve(1);
6227
6558
  }
6228
6559
  const path = kimiConfigPath();
6229
- mkdirSync4(dirname3(path), { recursive: true });
6230
- writeFileSync4(path, buildKimiConfig({ gatewayUrl: config.gatewayUrl, token: config.token, modelId: config.model }), "utf8");
6560
+ mkdirSync5(dirname4(path), { recursive: true });
6561
+ writeFileSync5(path, buildKimiConfig({ gatewayUrl: config.gatewayUrl, token: config.token, modelId: config.model }), "utf8");
6231
6562
  const bin = process.env.CLIXAD_KIMI_BIN || "kimi";
6232
6563
  if (!binExists(bin)) {
6233
6564
  console.log("\n" + INSTALL_HELP);
@@ -6241,14 +6572,14 @@ function runKimiCode(config, task, opts = {}) {
6241
6572
  OPENAI_BASE_URL: `${config.gatewayUrl.replace(/\/+$/, "")}/v1`,
6242
6573
  OPENAI_API_KEY: config.token
6243
6574
  };
6244
- return new Promise((resolve5) => {
6575
+ return new Promise((resolve6) => {
6245
6576
  const child = process.platform === "win32" ? spawn5([bin, ...args].map(quoteWin).join(" "), { stdio: "inherit", env, shell: true }) : spawn5(bin, args, { stdio: "inherit", env });
6246
6577
  child.on("error", (err) => {
6247
6578
  if (err.code === "ENOENT") console.log("\n" + INSTALL_HELP);
6248
6579
  else console.error(`failed to launch Kimi CLI: ${err.message}`);
6249
- resolve5(127);
6580
+ resolve6(127);
6250
6581
  });
6251
- child.on("exit", (code) => resolve5(code ?? 0));
6582
+ child.on("exit", (code) => resolve6(code ?? 0));
6252
6583
  });
6253
6584
  }
6254
6585
  function quoteWin(s) {
package/dist/clixad.mjs CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  takeConfigNotices,
55
55
  toolSchemaFor,
56
56
  usd
57
- } from "./chunk-5GX5GUR4.mjs";
57
+ } from "./chunk-CNLFGTYS.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-2AEK7HJV.mjs"));
136
+ return withProductionReact(() => import("./tui-M5SJRCHM.mjs"));
137
137
  }
138
138
 
139
139
  // src/title.ts
@@ -105,7 +105,7 @@ import {
105
105
  visibleLength,
106
106
  webSearchCostLine,
107
107
  welcomeBanner
108
- } from "./chunk-5GX5GUR4.mjs";
108
+ } from "./chunk-CNLFGTYS.mjs";
109
109
 
110
110
  // src/tui/index.tsx
111
111
  import "react";
@@ -287,19 +287,25 @@ function sidebarCard(line2, cols, linkText) {
287
287
  const link = linkText && visibleLength(label) + visibleLength(LINK_SEPARATOR) + visibleLength(linkText) <= SIDEBAR_BODY_COLS ? linkText : null;
288
288
  return { rule: SIDEBAR_RULE, label, link, body };
289
289
  }
290
- function sponsorRow(line2, cols, origin, linkText) {
290
+ function sponsorRow(line2, cols, origin, linkText, linkRequired = false) {
291
291
  const marker = origin === "house" || cols >= AD_FULL_PREFIX_MIN_COLS ? HOUSE_MARKER_ROW : AD_MARKER_ROW;
292
292
  const label = origin === "network" ? LABEL_ROW : null;
293
293
  if (origin === "network" && !adFitsWidth(cols)) return null;
294
294
  const room = cols - visibleLength(marker) - (label ? visibleLength(label) : 0);
295
295
  if (room < MIN_BODY_COLS) return null;
296
296
  if (origin === "network" && linkText) {
297
- const whole = visibleLength(line2) + visibleLength(LINK_SEPARATOR) + visibleLength(linkText);
297
+ const linkCols = visibleLength(LINK_SEPARATOR) + visibleLength(linkText);
298
+ const whole = visibleLength(line2) + linkCols;
298
299
  if (whole <= room) return { marker, label, body: line2, link: linkText, origin };
300
+ if (linkRequired && room - linkCols >= MIN_LINKED_COPY_COLS) {
301
+ const copy = room - linkCols;
302
+ return { marker, label, body: `${sliceColumns(line2, copy - 1).trimEnd()}\u2026`, link: linkText, origin };
303
+ }
299
304
  }
300
305
  const body = visibleLength(line2) <= room ? line2 : `${sliceColumns(line2, room - 1).trimEnd()}\u2026`;
301
306
  return { marker, label, body, link: null, origin };
302
307
  }
308
+ var MIN_LINKED_COPY_COLS = 12;
303
309
  function adColorEnabled({ isTTY, env }) {
304
310
  if (!colorEnabled({ isTTY, env })) return false;
305
311
  if (env.CI !== void 0 && env.CI !== "") return false;
@@ -356,6 +362,46 @@ function createSponsorSlot(config, format = "top_bar") {
356
362
  };
357
363
  }
358
364
 
365
+ // src/sponsor/visibility.ts
366
+ var VisibilityClock = class {
367
+ tracks = /* @__PURE__ */ new Map();
368
+ /** The keys on screen as of `now`. Every key not in `onScreen` is off screen from `now`. */
369
+ observe(onScreen, now) {
370
+ const visible = new Set(onScreen);
371
+ for (const [key, track] of this.tracks) {
372
+ if (track.since !== null && !visible.has(key)) {
373
+ track.done.push([track.since, now]);
374
+ track.since = null;
375
+ }
376
+ }
377
+ for (const key of visible) {
378
+ const track = this.tracks.get(key) ?? { since: null, done: [] };
379
+ if (track.since === null) track.since = now;
380
+ this.tracks.set(key, track);
381
+ }
382
+ }
383
+ /**
384
+ * The longest unbroken stretch `key` was on screen up to `now`, counting
385
+ * only time from `from` on (a place is measured from the moment a booked
386
+ * line could first have been drawn in it). Zero for a key never seen.
387
+ */
388
+ longest(key, now, from = Number.NEGATIVE_INFINITY) {
389
+ const track = this.tracks.get(key);
390
+ if (!track) return 0;
391
+ const stretches = track.since === null ? track.done : [...track.done, [track.since, now]];
392
+ let best = 0;
393
+ for (const [start, end] of stretches) best = Math.max(best, Math.min(end, now) - Math.max(start, from));
394
+ return Math.max(0, best);
395
+ }
396
+ /** Forget everything: a new turn measures from nothing. */
397
+ reset() {
398
+ this.tracks.clear();
399
+ }
400
+ };
401
+ function placeKey(format) {
402
+ return `place:${format}`;
403
+ }
404
+
359
405
  // src/sponsor/hyperlink.ts
360
406
  function version(v) {
361
407
  const m = /^(\d+)(?:\.(\d+))?/.exec(v ?? "");
@@ -440,7 +486,7 @@ var COMMANDS = [
440
486
  // enter complete the command instead of running it, and listing is what both
441
487
  // are for. `/bg kill <id>` still works when typed in full.
442
488
  { name: "bg", desc: "background commands the agent started" },
443
- { name: "mcp", desc: "connected MCP servers and their tools" },
489
+ { name: "mcp", desc: "MCP servers and their tools (approve: start one from this project)" },
444
490
  { name: "exit", desc: "quit clixad" }
445
491
  ];
446
492
  function commandLabel(c) {
@@ -2018,6 +2064,9 @@ function App({
2018
2064
  const directSupportedRef = useRef(true);
2019
2065
  const knownBookedRef = useRef(/* @__PURE__ */ new Set());
2020
2066
  const drawnRef = useRef(/* @__PURE__ */ new Set());
2067
+ const visibilityRef = useRef(new VisibilityClock());
2068
+ const linkModeRef = useRef(/* @__PURE__ */ new Map());
2069
+ const measureRef = useRef(null);
2021
2070
  const sponsorSlotRef = useRef(
2022
2071
  /* @__PURE__ */ new Map()
2023
2072
  );
@@ -2222,17 +2271,10 @@ function App({
2222
2271
  }
2223
2272
  }).catch(() => void 0);
2224
2273
  }, []);
2225
- useEffect(() => {
2226
- const { servers, errors } = loadMcpConfig(root);
2227
- for (const problem of errors) push({ kind: "notice", tone: "warn", text: ` mcp config \u2014 ${problem}` });
2228
- const names = Object.keys(servers);
2229
- if (names.length === 0) return;
2230
- let cancelled = false;
2231
- void McpHub.connect(servers, { root }).then((hub) => {
2232
- if (cancelled) return void hub.close();
2233
- mcpRef.current = hub;
2234
- const ready = hub.status().filter((s) => s.state === "ready");
2235
- const failed2 = hub.status().filter((s) => s.state === "failed");
2274
+ const reportMcpStarted = useCallback(
2275
+ (started) => {
2276
+ const ready = started.filter((s) => s.state === "ready");
2277
+ const failed2 = started.filter((s) => s.state === "failed");
2236
2278
  if (ready.length) {
2237
2279
  push({
2238
2280
  kind: "notice",
@@ -2242,10 +2284,63 @@ function App({
2242
2284
  for (const server of failed2) {
2243
2285
  push({ kind: "notice", tone: "warn", text: ` mcp: ${server.name} did not start \u2014 ${server.error}` });
2244
2286
  }
2287
+ },
2288
+ [push]
2289
+ );
2290
+ const askMcpApproval = useCallback(
2291
+ (hub, pending) => {
2292
+ if (!pending.length) return;
2293
+ const files = [...new Set(pending.map((p) => p.file))].join(" and ");
2294
+ push({
2295
+ kind: "notice",
2296
+ tone: "warn",
2297
+ 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.
2298
+ ` + pending.map(
2299
+ (p) => ` ${p.name}
2300
+ ` + p.display.split("\n").map((line2) => ` ${line2}`).join("\n") + `
2301
+ environment it gets: ${p.variables.length ? p.variables.join(", ") : "none beyond PATH, HOME and the locale"}`
2302
+ ).join("\n")
2303
+ });
2304
+ const names = pending.map((p) => p.name);
2305
+ setPickerSel(1);
2306
+ setPicker({
2307
+ title: pending.length === 1 ? `Start the MCP server "${names[0]}"?` : `Start these ${pending.length} MCP servers?`,
2308
+ subtitle: "Only if you trust this project \xB7 esc: not now",
2309
+ items: [
2310
+ { value: "approve", label: "Yes, start", hint: "and remember it for this project until the config changes" },
2311
+ { value: "later", label: "Not now", hint: "ask again next time \xB7 /mcp approve starts them" },
2312
+ { value: "decline", label: "No", hint: "and don't ask again until the config changes" }
2313
+ ],
2314
+ onPick: (choice) => {
2315
+ if (choice.value === "approve") {
2316
+ push({ kind: "notice", text: ` mcp: starting ${names.join(", ")}` });
2317
+ void hub.approve(names, { remember: true }).then(reportMcpStarted);
2318
+ } else if (choice.value === "decline") {
2319
+ hub.decline(names);
2320
+ push({ kind: "notice", text: ` mcp: not starting ${names.join(", ")} \xB7 /mcp approve starts them` });
2321
+ } else {
2322
+ push({ kind: "notice", text: " mcp: not started \xB7 /mcp approve starts them" });
2323
+ }
2324
+ },
2325
+ onCancel: () => push({ kind: "notice", text: " mcp: not started \xB7 /mcp approve starts them" })
2326
+ });
2327
+ },
2328
+ [push, reportMcpStarted]
2329
+ );
2330
+ useEffect(() => {
2331
+ const loaded = loadMcpConfig(root);
2332
+ for (const problem of loaded.errors) push({ kind: "notice", tone: "warn", text: ` mcp config \u2014 ${problem}` });
2333
+ if (Object.keys(loaded.servers).length === 0) return;
2334
+ const hub = McpHub.fromConfig(loaded, { root });
2335
+ mcpRef.current = hub;
2336
+ let cancelled = false;
2337
+ void hub.startReleased().then((started) => {
2338
+ if (!cancelled) reportMcpStarted(started);
2245
2339
  });
2340
+ askMcpApproval(hub, hub.pending());
2246
2341
  return () => {
2247
2342
  cancelled = true;
2248
- void mcpRef.current.close();
2343
+ void hub.close();
2249
2344
  };
2250
2345
  }, []);
2251
2346
  useEffect(() => {
@@ -2260,6 +2355,9 @@ function App({
2260
2355
  const nextSponsor = useCallback(
2261
2356
  (modelId) => {
2262
2357
  const call = ++sponsorCallRef.current;
2358
+ visibilityRef.current.reset();
2359
+ linkModeRef.current = /* @__PURE__ */ new Map();
2360
+ measureRef.current = null;
2263
2361
  if (!sponsorEnabled({
2264
2362
  isTTY: stdout.isTTY,
2265
2363
  env: process.env,
@@ -2312,11 +2410,14 @@ function App({
2312
2410
  if (format !== "top_bar" && modalOpenRef.current) return;
2313
2411
  const drawnId = `direct:${call}:${format}`;
2314
2412
  const link = line2.clickUrl;
2413
+ const linkable2 = hyperlinksEnabled({ isTTY: stdout.isTTY, env: process.env });
2414
+ const linkText = !linkable2 && line2.shortUrl ? shortLinkText(line2.shortUrl) : void 0;
2415
+ const extra = linkText ? { link, linkText, linkRequired: true } : { link };
2315
2416
  if (format === "top_bar") {
2316
- if (!sponsorRow(line2.text, colsRef.current, "network")) return;
2317
- setSponsor({ text: line2.text, origin: "network", impressionId: drawnId, link });
2417
+ if (!sponsorRow(line2.text, colsRef.current, "network", linkText, true)) return;
2418
+ setSponsor({ text: line2.text, origin: "network", impressionId: drawnId, ...extra });
2318
2419
  } else {
2319
- setAdSlots((slots) => ({ ...slots, [format]: { text: line2.text, impressionId: drawnId, link } }));
2420
+ setAdSlots((slots) => ({ ...slots, [format]: { text: line2.text, impressionId: drawnId, ...extra } }));
2320
2421
  }
2321
2422
  pendingDirectRef.current.set(format, { call, serveToken: line2.serveToken, drawnId });
2322
2423
  };
@@ -2354,6 +2455,9 @@ function App({
2354
2455
  failed2 = answer.failed === true;
2355
2456
  direct = answer.lines;
2356
2457
  fallbacks = answer.fallbacks ?? [];
2458
+ if (answer.measure && call === sponsorCallRef.current) {
2459
+ measureRef.current = { call, token: answer.measure, answeredAt: Date.now(), formats: [...fitting] };
2460
+ }
2357
2461
  if (!failed2) knownBookedRef.current = new Set(direct.map((line2) => line2.format));
2358
2462
  }
2359
2463
  if (call !== sponsorCallRef.current) return;
@@ -2374,12 +2478,29 @@ function App({
2374
2478
  const pendingDirect = [...pendingDirectRef.current.entries()];
2375
2479
  const pendingFallback = [...pendingFallbackRef.current.entries()];
2376
2480
  const drawn = drawnRef.current;
2481
+ const clock = visibilityRef.current;
2482
+ const linkModes = linkModeRef.current;
2483
+ const measure = measureRef.current;
2484
+ const at = Date.now();
2377
2485
  pendingImpressionRef.current = /* @__PURE__ */ new Map();
2378
2486
  pendingDirectRef.current = /* @__PURE__ */ new Map();
2379
2487
  pendingFallbackRef.current = /* @__PURE__ */ new Map();
2380
2488
  drawnRef.current = /* @__PURE__ */ new Set();
2381
- const directReports = pendingDirect.filter(([, p]) => p.call === sponsorCallRef.current && drawn.has(p.drawnId)).map(([format, p]) => ({ format, serveToken: p.serveToken }));
2489
+ measureRef.current = null;
2490
+ const directReports = pendingDirect.filter(([, p]) => p.call === sponsorCallRef.current && drawn.has(p.drawnId)).map(([format, p]) => ({
2491
+ format,
2492
+ serveToken: p.serveToken,
2493
+ visibleMs: clock.longest(p.drawnId, at),
2494
+ link: linkModes.get(p.drawnId) ?? "none"
2495
+ }));
2382
2496
  if (directReports.length > 0) void client.reportDirectImpressions(directReports);
2497
+ if (measure && measure.call === sponsorCallRef.current) {
2498
+ const places = measure.formats.map((format) => ({
2499
+ format,
2500
+ visibleMs: clock.longest(placeKey(format), at, measure.answeredAt)
2501
+ }));
2502
+ void client.reportSponsorViews(measure.token, places);
2503
+ }
2383
2504
  const fallbackReports = pendingFallback.filter(([, p]) => p.call === sponsorCallRef.current && drawn.has(p.drawnId)).map(([format, p]) => ({ format, serveToken: p.serveToken }));
2384
2505
  if (fallbackReports.length > 0) void client.reportAffiliateImpressions(fallbackReports);
2385
2506
  const reports = pending.filter((p) => p.call === sponsorCallRef.current).filter((p) => drawn.has(p.impression.impressionId)).map((p) => p.impression);
@@ -3569,7 +3690,27 @@ ${preferencesText}` : ""),
3569
3690
  return;
3570
3691
  }
3571
3692
  case "mcp": {
3572
- const status = mcpRef.current.status();
3693
+ const hub = mcpRef.current;
3694
+ const [verb, ...targets] = arg.split(/\s+/).filter(Boolean);
3695
+ if (verb === "approve") {
3696
+ const held = hub.pending({ includeDeclined: true });
3697
+ if (!held.length) {
3698
+ push({ kind: "notice", text: " no MCP servers waiting for approval" });
3699
+ return;
3700
+ }
3701
+ if (!targets.length || targets.includes("all")) return askMcpApproval(hub, held);
3702
+ const unknown = targets.filter((t) => !held.some((h) => h.name === t));
3703
+ if (unknown.length) {
3704
+ push({
3705
+ kind: "notice",
3706
+ tone: "warn",
3707
+ text: ` not waiting for approval: ${unknown.join(", ")} \xB7 waiting: ${held.map((h) => h.name).join(", ")}`
3708
+ });
3709
+ return;
3710
+ }
3711
+ return askMcpApproval(hub, held.filter((h) => targets.includes(h.name)));
3712
+ }
3713
+ const status = hub.status();
3573
3714
  if (!status.length) {
3574
3715
  push({
3575
3716
  kind: "notice",
@@ -3577,14 +3718,16 @@ ${preferencesText}` : ""),
3577
3718
  });
3578
3719
  return;
3579
3720
  }
3721
+ const label = { unapproved: "not approved", declined: "declined" };
3722
+ const anyHeld = status.some((s) => s.state === "unapproved" || s.state === "declined");
3580
3723
  push({
3581
3724
  kind: "notice",
3582
3725
  text: status.map((s) => {
3583
- const head = ` ${s.name.padEnd(16)} ${s.state.padEnd(10)} ${s.transport}`;
3726
+ const head = ` ${s.name.padEnd(16)} ${(label[s.state] ?? s.state).padEnd(12)} ${s.transport}`;
3584
3727
  if (s.state === "ready") return `${head} ${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}`;
3585
3728
  return s.error ? `${head}
3586
3729
  ${s.error.split("\n").join("\n ")}` : head;
3587
- }).join("\n")
3730
+ }).join("\n") + (anyHeld ? "\n /mcp approve shows what a server runs and starts it" : "")
3588
3731
  });
3589
3732
  return;
3590
3733
  }
@@ -3627,7 +3770,7 @@ ${preferencesText}` : ""),
3627
3770
  push({ kind: "notice", tone: "warn", text: ` unknown command: /${cmd} \u2014 try /help` });
3628
3771
  }
3629
3772
  },
3630
- [client, cols, config, cycleMode, exit, model, modelTaskHints, premium, push, replayConversation, root, runAdWall, runBusy, runBuy, runLogin, runTurn, setPremium, writeToStdout]
3773
+ [askMcpApproval, client, cols, config, cycleMode, exit, model, modelTaskHints, premium, push, replayConversation, root, runAdWall, runBusy, runBuy, runLogin, runTurn, setPremium, writeToStdout]
3631
3774
  );
3632
3775
  const runShell = useCallback(
3633
3776
  async (command2) => {
@@ -4023,13 +4166,13 @@ ${dropped.map((l) => ` \xB7 ${l}`).join("\n")}`
4023
4166
  });
4024
4167
  const elapsed = busy && startedAt ? Math.floor((Date.now() - startedAt) / 1e3) : 0;
4025
4168
  const spinner = spinnerRef.current[Math.floor(tick / SPINNER_TICKS_PER_FRAME) % spinnerRef.current.length];
4026
- const sponsorLine = busy && sponsor ? sponsorRow(sponsor.text, cols, sponsor.origin, sponsor.linkText) : null;
4169
+ const sponsorLine = busy && sponsor ? sponsorRow(sponsor.text, cols, sponsor.origin, sponsor.linkText, sponsor.linkRequired === true) : null;
4027
4170
  const adColor = adColorEnabled({ isTTY: stdout.isTTY, env: process.env });
4028
4171
  const linkable = hyperlinksEnabled({ isTTY: stdout.isTTY, env: process.env });
4029
4172
  const modalOpen = Boolean(picker || ask || plan || buy);
4030
4173
  modalOpenRef.current = modalOpen;
4031
4174
  const card = busy && !modalOpen && adSlots.sidebar_card ? sidebarCard(adSlots.sidebar_card.text, cols, adSlots.sidebar_card.linkText) : null;
4032
- const statusBarLine = busy && !modalOpen && adSlots.status_bar ? sponsorRow(adSlots.status_bar.text, cols, "network", adSlots.status_bar.linkText) : null;
4175
+ const statusBarLine = busy && !modalOpen && adSlots.status_bar ? sponsorRow(adSlots.status_bar.text, cols, "network", adSlots.status_bar.linkText, adSlots.status_bar.linkRequired === true) : null;
4033
4176
  const viewport = Math.max(1, rows - 1);
4034
4177
  const inputRows = Math.max(1, Math.min(MAX_INPUT_ROWS, editor.lines.length));
4035
4178
  const inputFrom = windowStart(editor.row, editor.lines.length, inputRows);
@@ -4060,9 +4203,24 @@ ${dropped.map((l) => ` \xB7 ${l}`).join("\n")}`
4060
4203
  showCard ? adSlots.sidebar_card?.impressionId : void 0,
4061
4204
  showStatusBar ? adSlots.status_bar?.impressionId : void 0
4062
4205
  ].filter(Boolean).join(" ");
4206
+ const placesKey = [
4207
+ showSponsor && sponsorLine ? placeKey("top_bar") : void 0,
4208
+ showCard ? placeKey("sidebar_card") : void 0,
4209
+ showStatusBar ? placeKey("status_bar") : void 0
4210
+ ].filter(Boolean).join(" ");
4211
+ if (showSponsor && sponsorLine && sponsor?.impressionId?.startsWith("direct:")) {
4212
+ linkModeRef.current.set(sponsor.impressionId, linkable ? "link" : sponsorLine.link ? "address" : "none");
4213
+ }
4214
+ if (showCard && card && adSlots.sidebar_card?.impressionId.startsWith("direct:")) {
4215
+ linkModeRef.current.set(adSlots.sidebar_card.impressionId, linkable ? "link" : card.link ? "address" : "none");
4216
+ }
4217
+ if (showStatusBar && statusBarLine && adSlots.status_bar?.impressionId.startsWith("direct:")) {
4218
+ linkModeRef.current.set(adSlots.status_bar.impressionId, linkable ? "link" : statusBarLine.link ? "address" : "none");
4219
+ }
4063
4220
  useEffect(() => {
4064
4221
  for (const id of drawnKey.split(" ")) if (id) drawnRef.current.add(id);
4065
- }, [drawnKey]);
4222
+ visibilityRef.current.observe([...drawnKey.split(" "), ...placesKey.split(" ")].filter(Boolean), Date.now());
4223
+ }, [drawnKey, placesKey]);
4066
4224
  let budget = Math.max(0, viewport - chromeHeight - menu.length - busyHeight);
4067
4225
  const pickerCompact = Boolean(picker) && budget < PICKER_CHROME + 1;
4068
4226
  const pickerChrome = pickerCompact ? PICKER_CHROME_COMPACT : PICKER_CHROME;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clixad",
3
- "version": "0.0.1-beta.46",
3
+ "version": "0.0.1-beta.48",
4
4
  "description": "Free AI coding agent in your terminal, funded by rewarded ads.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",