clixad 0.0.1-beta.13 → 0.0.1-beta.15

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.
Files changed (2) hide show
  1. package/dist/clixad.mjs +275 -102
  2. package/package.json +1 -1
package/dist/clixad.mjs CHANGED
@@ -604,7 +604,12 @@ var init_client = __esm({
604
604
  const calls = /* @__PURE__ */ new Map();
605
605
  for await (const evt of parseSSE(res.body)) {
606
606
  if (evt === "[DONE]") break;
607
- const json = JSON.parse(evt);
607
+ let json;
608
+ try {
609
+ json = JSON.parse(evt);
610
+ } catch {
611
+ continue;
612
+ }
608
613
  const choice = json.choices?.[0];
609
614
  const delta = choice?.delta?.content;
610
615
  if (delta) {
@@ -1239,12 +1244,13 @@ function installExitHook(registry) {
1239
1244
  });
1240
1245
  }
1241
1246
  }
1242
- var MAX_SHELL_BUFFER, MAX_SHELLS, ShellError, ShellRegistry, shells, hookInstalled;
1247
+ var MAX_SHELL_BUFFER, MAX_SHELLS, MAX_FINISHED_SHELLS, ShellError, ShellRegistry, shells, hookInstalled;
1243
1248
  var init_shells = __esm({
1244
1249
  "src/shells.ts"() {
1245
1250
  "use strict";
1246
1251
  MAX_SHELL_BUFFER = 2e5;
1247
1252
  MAX_SHELLS = 8;
1253
+ MAX_FINISHED_SHELLS = 8;
1248
1254
  ShellError = class extends Error {
1249
1255
  };
1250
1256
  ShellRegistry = class {
@@ -1252,21 +1258,27 @@ var init_shells = __esm({
1252
1258
  seq = 0;
1253
1259
  /** Start a command in the background and return it before it has finished. */
1254
1260
  start(root, command, now = Date.now()) {
1261
+ this.pruneFinished();
1255
1262
  const live = [...this.entries.values()].filter((e) => e.status === "running");
1256
1263
  if (live.length >= MAX_SHELLS) {
1257
1264
  throw new ShellError(
1258
1265
  `${MAX_SHELLS} background commands are already running (${live.map((e) => e.id).join(", ")}) \u2014 stop one with kill_shell before starting another`
1259
1266
  );
1260
1267
  }
1261
- const child = spawn2(command, {
1262
- cwd: root,
1263
- shell: true,
1264
- windowsHide: true,
1265
- detached: process.platform !== "win32",
1266
- // Nothing types at a background command, and leaving stdin inherited lets
1267
- // it steal the keystrokes meant for the REPL.
1268
- stdio: ["ignore", "pipe", "pipe"]
1269
- });
1268
+ let child;
1269
+ try {
1270
+ child = spawn2(command, {
1271
+ cwd: root,
1272
+ shell: true,
1273
+ windowsHide: true,
1274
+ detached: process.platform !== "win32",
1275
+ // Nothing types at a background command, and leaving stdin inherited
1276
+ // lets it steal the keystrokes meant for the REPL.
1277
+ stdio: ["ignore", "pipe", "pipe"]
1278
+ });
1279
+ } catch (err) {
1280
+ throw new ShellError(`could not start that command: ${err.message}`);
1281
+ }
1270
1282
  const entry = {
1271
1283
  id: `bash_${++this.seq}`,
1272
1284
  command,
@@ -1339,10 +1351,23 @@ var init_shells = __esm({
1339
1351
  list(now = Date.now()) {
1340
1352
  return [...this.entries.values()].map((e) => snapshot(e, now));
1341
1353
  }
1342
- /** Forget a finished shell. Running ones are kept — see `killAll`. */
1343
- forget(id) {
1344
- const entry = this.entries.get(id);
1345
- if (entry && entry.status !== "running") this.entries.delete(id);
1354
+ /**
1355
+ * Forget all but the most recent `MAX_FINISHED_SHELLS` finished commands.
1356
+ *
1357
+ * Run when a new one starts, which is the only moment the registry grows.
1358
+ * Running commands are never touched — those are `killAll`'s and
1359
+ * `kill`'s — and the Map's insertion order is the start order, so "oldest"
1360
+ * needs no timestamp.
1361
+ *
1362
+ * This replaces a `forget(id)` that nothing ever called: an explicit
1363
+ * single-entry drop is only useful to a caller who knows a shell is finished
1364
+ * with, and neither the tools nor the REPL is in a position to know that.
1365
+ */
1366
+ pruneFinished() {
1367
+ const finished = [...this.entries.values()].filter((e) => e.status !== "running");
1368
+ for (const entry of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_SHELLS))) {
1369
+ this.entries.delete(entry.id);
1370
+ }
1346
1371
  }
1347
1372
  /** Stop everything. Called on exit; safe to call more than once. */
1348
1373
  killAll() {
@@ -2189,6 +2214,64 @@ function passesThrough(err) {
2189
2214
  if (err instanceof PaywallError) return true;
2190
2215
  return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
2191
2216
  }
2217
+ function fsCall(what, run) {
2218
+ try {
2219
+ return run();
2220
+ } catch (err) {
2221
+ if (passesThrough(err)) throw err;
2222
+ const code = err.code ?? "";
2223
+ throw new ToolError(`${what}: ${FS_REASON[code] ?? err.message}`);
2224
+ }
2225
+ }
2226
+ function countArg(raw, fallback) {
2227
+ const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : Number.NaN;
2228
+ return Number.isFinite(n) ? Math.max(1, Math.floor(n)) : fallback;
2229
+ }
2230
+ function requireString(value, tool, field) {
2231
+ const text = readString(value);
2232
+ if (text === void 0) {
2233
+ throw new ToolError(`${tool}: ${field} must be a string, got ${describeValue(value)}`);
2234
+ }
2235
+ return text;
2236
+ }
2237
+ function optionalString(value, tool, field) {
2238
+ if (value === void 0 || value === null) return void 0;
2239
+ return requireString(value, tool, field);
2240
+ }
2241
+ function readString(value) {
2242
+ if (typeof value === "string") return value;
2243
+ return typeof value === "number" && Number.isFinite(value) ? String(value) : void 0;
2244
+ }
2245
+ function describeValue(value) {
2246
+ if (value === null) return "null";
2247
+ if (value === void 0) return "nothing";
2248
+ if (Array.isArray(value)) return "an array";
2249
+ if (typeof value === "number") return "an unusable number";
2250
+ return typeof value === "object" ? "an object" : `a ${typeof value}`;
2251
+ }
2252
+ function entryKind(entry, abs) {
2253
+ if (entry.isDirectory()) return "dir";
2254
+ if (entry.isFile()) return "file";
2255
+ if (!entry.isSymbolicLink()) return "other";
2256
+ try {
2257
+ const stat = statSync(abs);
2258
+ return stat.isDirectory() ? "dir" : stat.isFile() ? "file" : "other";
2259
+ } catch {
2260
+ return "other";
2261
+ }
2262
+ }
2263
+ function isFilePath(abs) {
2264
+ try {
2265
+ return statSync(abs).isFile();
2266
+ } catch {
2267
+ return false;
2268
+ }
2269
+ }
2270
+ function searchBase(ctx, path) {
2271
+ const abs = safeResolve(ctx.root, path ?? ".");
2272
+ if (!existsSync2(abs)) throw new ToolError(`not found: ${path ?? "."}`);
2273
+ return abs;
2274
+ }
2192
2275
  function commandSignature(command) {
2193
2276
  return `run:${command.replace(/\s+/g, " ").trim()}`;
2194
2277
  }
@@ -2299,13 +2382,19 @@ async function runMcpTool(ctx, name, args) {
2299
2382
  }
2300
2383
  function execute(ctx, command, timeout) {
2301
2384
  return new Promise((resolvePromise) => {
2302
- const child = spawn4(command, {
2303
- cwd: ctx.root,
2304
- shell: true,
2305
- windowsHide: true,
2306
- // Own process group on POSIX so killTree can take the children with it.
2307
- detached: process.platform !== "win32"
2308
- });
2385
+ let child;
2386
+ try {
2387
+ child = spawn4(command, {
2388
+ cwd: ctx.root,
2389
+ shell: true,
2390
+ windowsHide: true,
2391
+ // Own process group on POSIX so killTree can take the children with it.
2392
+ detached: process.platform !== "win32"
2393
+ });
2394
+ } catch (err) {
2395
+ return resolvePromise(`command failed to start: ${err.message}
2396
+ (no output)`);
2397
+ }
2309
2398
  let out = "";
2310
2399
  let total = 0;
2311
2400
  let timedOut = false;
@@ -2352,6 +2441,10 @@ function listWorkspaceFiles(root, limit = 5e3) {
2352
2441
  return files.sort();
2353
2442
  }
2354
2443
  function walk(dir, root, visit) {
2444
+ if (isFilePath(dir)) {
2445
+ visit(dir);
2446
+ return;
2447
+ }
2355
2448
  let seen = 0;
2356
2449
  const stack = [dir];
2357
2450
  while (stack.length > 0) {
@@ -2364,12 +2457,13 @@ function walk(dir, root, visit) {
2364
2457
  }
2365
2458
  for (const entry of entries) {
2366
2459
  const abs = join3(current, entry.name);
2367
- if (entry.isDirectory()) {
2368
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
2460
+ const kind = entryKind(entry, abs);
2461
+ if (kind === "dir") {
2462
+ if (entry.isSymbolicLink() || SKIP_DIRS.has(entry.name)) continue;
2369
2463
  stack.push(abs);
2370
2464
  continue;
2371
2465
  }
2372
- if (!entry.isFile()) continue;
2466
+ if (kind !== "file") continue;
2373
2467
  if (++seen > MAX_WALK_FILES) return;
2374
2468
  if (!visit(abs)) return;
2375
2469
  }
@@ -2377,8 +2471,9 @@ function walk(dir, root, visit) {
2377
2471
  void root;
2378
2472
  }
2379
2473
  function globToRegExp(pattern) {
2380
- const p = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
2474
+ const p = requireString(pattern, "glob", "pattern").replace(/\\/g, "/").replace(/^\.\//, "");
2381
2475
  let re = "";
2476
+ let depth = 0;
2382
2477
  for (let i = 0; i < p.length; i++) {
2383
2478
  const ch = p[i];
2384
2479
  if (ch === "*") {
@@ -2394,14 +2489,23 @@ function globToRegExp(pattern) {
2394
2489
  re += "[^/]*";
2395
2490
  }
2396
2491
  } else if (ch === "?") re += "[^/]";
2397
- else if (ch === "{") re += "(?:";
2398
- else if (ch === "}") re += ")";
2399
- else if (ch === ",") re += "|";
2400
- else if (".+^$()[]|\\".includes(ch)) re += "\\" + ch;
2492
+ else if (ch === "{") {
2493
+ re += "(?:";
2494
+ depth++;
2495
+ } else if (ch === "}" && depth > 0) {
2496
+ re += ")";
2497
+ depth--;
2498
+ } else if (ch === "," && depth > 0) re += "|";
2499
+ else if (".+^$()[]|\\{}".includes(ch)) re += "\\" + ch;
2401
2500
  else re += ch;
2402
2501
  }
2502
+ re += ")".repeat(depth);
2403
2503
  const anchored = p.includes("/") ? `^${re}$` : `^(?:.*/)?${re}$`;
2404
- return new RegExp(anchored);
2504
+ try {
2505
+ return new RegExp(anchored);
2506
+ } catch (err) {
2507
+ throw new ToolError(`not a usable glob pattern: ${pattern} \u2014 ${err.message}`);
2508
+ }
2405
2509
  }
2406
2510
  function toolSchemaFor(mode, mcpSchemas = [], available = {}) {
2407
2511
  const usable = (t) => t.function.name !== "web_search" || available.webSearch === true;
@@ -2416,7 +2520,7 @@ function subagentToolSchema(available = {}) {
2416
2520
  (t) => SUBAGENT_TOOLS.includes(t.function.name) && (t.function.name !== "web_search" || available.webSearch === true)
2417
2521
  );
2418
2522
  }
2419
- var ToolError, PermissionDeniedError, MAX_FILE_BYTES, MAX_READ_LINES, MAX_READ_LINE_CHARS, MAX_OUTPUT_CHARS, MAX_GLOB_RESULTS, MAX_GREP_MATCHES, MAX_WALK_FILES, DEFAULT_COMMAND_TIMEOUT, MAX_IMAGE_BYTES, DEFAULT_SEARCH_RESULTS, MAX_SEARCH_RESULTS, MAX_ASK_OPTIONS, BACKGROUND_SETTLE_MS, SKIP_DIRS, TOOLS, READ_ONLY_TOOLS, EXIT_PLAN_MODE_SCHEMA, SUBAGENT_TOOLS, TOOL_SCHEMA;
2523
+ var ToolError, PermissionDeniedError, FS_REASON, MAX_FILE_BYTES, MAX_READ_LINES, MAX_READ_LINE_CHARS, MAX_OUTPUT_CHARS, MAX_GLOB_RESULTS, MAX_GREP_MATCHES, MAX_WALK_FILES, DEFAULT_COMMAND_TIMEOUT, MAX_IMAGE_BYTES, DEFAULT_SEARCH_RESULTS, MAX_SEARCH_RESULTS, MAX_ASK_OPTIONS, BACKGROUND_SETTLE_MS, SKIP_DIRS, TOOLS, READ_ONLY_TOOLS, EXIT_PLAN_MODE_SCHEMA, SUBAGENT_TOOLS, TOOL_SCHEMA;
2420
2524
  var init_tools = __esm({
2421
2525
  "src/tools.ts"() {
2422
2526
  "use strict";
@@ -2436,6 +2540,19 @@ var init_tools = __esm({
2436
2540
  this.notice = notice;
2437
2541
  }
2438
2542
  };
2543
+ FS_REASON = {
2544
+ ENOTDIR: "it is a file, not a directory \u2014 use read_file",
2545
+ EISDIR: "it is a directory, not a file \u2014 use list_dir, or edit a file inside it",
2546
+ ENOENT: "it does not exist (a symlink pointing at nothing looks like this too)",
2547
+ EACCES: "permission denied",
2548
+ EPERM: "permission denied",
2549
+ ELOOP: "the path goes through a symlink loop",
2550
+ ENAMETOOLONG: "the path is too long",
2551
+ EMFILE: "this process has too many files open \u2014 try again",
2552
+ ENFILE: "the system has too many files open \u2014 try again",
2553
+ ENOSPC: "the disk is full",
2554
+ EROFS: "the filesystem is read-only"
2555
+ };
2439
2556
  MAX_FILE_BYTES = 4e5;
2440
2557
  MAX_READ_LINES = 2e3;
2441
2558
  MAX_READ_LINE_CHARS = 2e3;
@@ -2450,16 +2567,37 @@ var init_tools = __esm({
2450
2567
  MAX_ASK_OPTIONS = 6;
2451
2568
  BACKGROUND_SETTLE_MS = 500;
2452
2569
  SKIP_DIRS = /* @__PURE__ */ new Set([
2570
+ // Version control.
2453
2571
  ".git",
2572
+ ".hg",
2573
+ ".svn",
2574
+ // Dependencies and their caches.
2454
2575
  "node_modules",
2576
+ ".yarn",
2577
+ ".pnpm-store",
2578
+ ".venv",
2579
+ ".tox",
2580
+ // Build output.
2455
2581
  "dist",
2456
2582
  "build",
2457
2583
  "coverage",
2458
2584
  ".next",
2585
+ ".nuxt",
2586
+ ".svelte-kit",
2587
+ ".output",
2588
+ ".angular",
2589
+ ".dart_tool",
2590
+ // Tool caches.
2459
2591
  ".turbo",
2592
+ ".nx",
2460
2593
  ".cache",
2594
+ ".parcel-cache",
2595
+ ".gradle",
2461
2596
  "__pycache__",
2462
- ".venv"
2597
+ ".mypy_cache",
2598
+ ".pytest_cache",
2599
+ ".ruff_cache",
2600
+ ".terraform"
2463
2601
  ]);
2464
2602
  TOOLS = {
2465
2603
  /**
@@ -2478,11 +2616,14 @@ var init_tools = __esm({
2478
2616
  * is told precisely what went wrong instead of guessing.
2479
2617
  */
2480
2618
  read_file(ctx, args) {
2481
- const abs = safeResolve(ctx.root, args.path);
2482
- if (!existsSync2(abs)) throw new ToolError(`not found: ${args.path}`);
2483
- if (statSync(abs).isDirectory()) throw new ToolError(`${args.path} is a directory \u2014 use list_dir`);
2484
- const bytes = readFileSync3(abs);
2619
+ const path = requireString(args.path, "read_file", "path");
2620
+ const abs = safeResolve(ctx.root, path);
2621
+ if (!existsSync2(abs)) throw new ToolError(`not found: ${path}`);
2485
2622
  const name = rel(ctx.root, abs);
2623
+ if (fsCall(`cannot read ${name}`, () => statSync(abs)).isDirectory()) {
2624
+ throw new ToolError(`${path} is a directory \u2014 use list_dir`);
2625
+ }
2626
+ const bytes = fsCall(`cannot read ${name}`, () => readFileSync3(abs));
2486
2627
  const image = imageType(bytes);
2487
2628
  if (image) return readImage(ctx, bytes, name, image);
2488
2629
  if (looksLikePdf(bytes)) {
@@ -2503,11 +2644,11 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2503
2644
  const raw = bytes.toString("utf8");
2504
2645
  const all = raw.split("\n");
2505
2646
  const total = all.length > 1 && all[all.length - 1] === "" ? all.length - 1 : all.length;
2506
- const first = Math.max(1, Math.floor(args.offset ?? 1));
2507
- const want = Math.max(1, Math.floor(args.limit ?? MAX_READ_LINES));
2647
+ const first = countArg(args.offset, 1);
2648
+ const want = countArg(args.limit, MAX_READ_LINES);
2508
2649
  const last = Math.min(total, first + want - 1);
2509
2650
  if (first > total) {
2510
- return `${args.path} has ${total} lines; offset ${first} is past the end`;
2651
+ return `${path} has ${total} lines; offset ${first} is past the end`;
2511
2652
  }
2512
2653
  const body = all.slice(first - 1, last).map((line2, i) => {
2513
2654
  const n = String(first + i).padStart(5, " ");
@@ -2523,27 +2664,35 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2523
2664
  return body + note;
2524
2665
  },
2525
2666
  list_dir(ctx, args) {
2526
- const abs = safeResolve(ctx.root, args.path ?? ".");
2527
- if (!existsSync2(abs)) throw new ToolError(`not found: ${args.path ?? "."}`);
2528
- const entries = readdirSync(abs).map((name) => {
2529
- const isDir = statSync(resolve(abs, name)).isDirectory();
2530
- return { name: isDir ? `${name}/` : name, isDir };
2531
- });
2667
+ const path = optionalString(args.path, "list_dir", "path") ?? ".";
2668
+ const abs = safeResolve(ctx.root, path);
2669
+ if (!existsSync2(abs)) throw new ToolError(`not found: ${path}`);
2670
+ const name = rel(ctx.root, abs);
2671
+ if (!fsCall(`cannot list ${name}`, () => statSync(abs)).isDirectory()) {
2672
+ throw new ToolError(`${path} is a file \u2014 use read_file`);
2673
+ }
2674
+ const entries = fsCall(`cannot list ${name}`, () => readdirSync(abs, { withFileTypes: true })).map(
2675
+ (entry) => {
2676
+ const isDir = entryKind(entry, resolve(abs, entry.name)) === "dir";
2677
+ return { name: isDir ? `${entry.name}/` : entry.name, isDir };
2678
+ }
2679
+ );
2532
2680
  entries.sort((a, b) => Number(b.isDir) - Number(a.isDir) || a.name.localeCompare(b.name));
2533
2681
  return entries.map((e) => e.name).join("\n") || "(empty)";
2534
2682
  },
2535
2683
  /** Find files by glob, e.g. `src/**\/*.ts`. Cheap orientation before reading. */
2536
2684
  glob(ctx, args) {
2537
- if (!args.pattern) throw new ToolError("glob needs a pattern");
2538
- const base = safeResolve(ctx.root, args.path ?? ".");
2539
- const re = globToRegExp(args.pattern);
2685
+ const pattern = optionalString(args.pattern, "glob", "pattern");
2686
+ if (!pattern) throw new ToolError("glob needs a pattern");
2687
+ const base = searchBase(ctx, optionalString(args.path, "glob", "path"));
2688
+ const re = globToRegExp(pattern);
2540
2689
  const hits = [];
2541
2690
  walk(base, ctx.root, (abs) => {
2542
2691
  const r = rel(ctx.root, abs);
2543
2692
  if (re.test(r)) hits.push(r);
2544
2693
  return hits.length < MAX_GLOB_RESULTS;
2545
2694
  });
2546
- if (hits.length === 0) return `no files match ${args.pattern}`;
2695
+ if (hits.length === 0) return `no files match ${pattern}`;
2547
2696
  hits.sort();
2548
2697
  const more = hits.length >= MAX_GLOB_RESULTS ? `
2549
2698
  \u2026 [capped at ${MAX_GLOB_RESULTS}]` : "";
@@ -2551,15 +2700,17 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2551
2700
  },
2552
2701
  /** Search file contents with a regular expression; returns path:line: text. */
2553
2702
  grep(ctx, args) {
2554
- if (!args.pattern) throw new ToolError("grep needs a pattern");
2703
+ const pattern = optionalString(args.pattern, "grep", "pattern");
2704
+ if (!pattern) throw new ToolError("grep needs a pattern");
2555
2705
  let re;
2556
2706
  try {
2557
- re = new RegExp(args.pattern);
2707
+ re = new RegExp(pattern);
2558
2708
  } catch (err) {
2559
2709
  throw new ToolError(`invalid regular expression: ${err.message}`);
2560
2710
  }
2561
- const base = safeResolve(ctx.root, args.path ?? ".");
2562
- const filter = args.glob ? globToRegExp(args.glob) : void 0;
2711
+ const base = searchBase(ctx, optionalString(args.path, "grep", "path"));
2712
+ const globArg = optionalString(args.glob, "grep", "glob");
2713
+ const filter = globArg ? globToRegExp(globArg) : void 0;
2563
2714
  const out = [];
2564
2715
  walk(base, ctx.root, (abs) => {
2565
2716
  const r = rel(ctx.root, abs);
@@ -2580,24 +2731,28 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2580
2731
  }
2581
2732
  return true;
2582
2733
  });
2583
- if (out.length === 0) return `no matches for ${args.pattern}`;
2734
+ if (out.length === 0) return `no matches for ${pattern}`;
2584
2735
  const more = out.length >= MAX_GREP_MATCHES ? `
2585
2736
  \u2026 [capped at ${MAX_GREP_MATCHES} matches]` : "";
2586
2737
  return out.join("\n") + more;
2587
2738
  },
2588
2739
  async write_file(ctx, args) {
2589
- const abs = safeResolve(ctx.root, args.path);
2590
- const before = existsSync2(abs) ? readFileSync3(abs, "utf8") : "";
2591
- const preview = renderDiff(before, args.content) || "(no change)";
2740
+ const abs = safeResolve(ctx.root, requireString(args.path, "write_file", "path"));
2741
+ const content = requireString(args.content, "write_file", "content");
2742
+ const name = rel(ctx.root, abs);
2743
+ const before = existsSync2(abs) ? fsCall(`cannot write ${name}`, () => readFileSync3(abs, "utf8")) : "";
2744
+ const preview = renderDiff(before, content) || "(no change)";
2592
2745
  await gate(ctx, {
2593
2746
  tool: "write_file",
2594
- signature: `write:${rel(ctx.root, abs)}`,
2595
- summary: `${before ? "overwrite" : "create"} ${rel(ctx.root, abs)}`,
2747
+ signature: `write:${name}`,
2748
+ summary: `${before ? "overwrite" : "create"} ${name}`,
2596
2749
  preview
2597
2750
  });
2598
- mkdirSync2(dirname2(abs), { recursive: true });
2599
- writeFileSync2(abs, args.content, "utf8");
2600
- return `wrote ${args.content.length} bytes to ${rel(ctx.root, abs)}`;
2751
+ fsCall(`cannot write ${name}`, () => {
2752
+ mkdirSync2(dirname2(abs), { recursive: true });
2753
+ writeFileSync2(abs, content, "utf8");
2754
+ });
2755
+ return `wrote ${content.length} bytes to ${name}`;
2601
2756
  },
2602
2757
  /**
2603
2758
  * Replace an exact snippet in a file. Preferred over write_file: it keeps the
@@ -2605,41 +2760,42 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2605
2760
  * An ambiguous match is an error rather than a guess.
2606
2761
  */
2607
2762
  async edit_file(ctx, args) {
2608
- const abs = safeResolve(ctx.root, args.path);
2609
- if (!existsSync2(abs)) throw new ToolError(`not found: ${args.path}`);
2610
- if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
2611
- throw new ToolError("edit_file needs old_string and new_string");
2612
- }
2613
- if (args.old_string === args.new_string) throw new ToolError("old_string and new_string are identical");
2614
- const before = readFileSync3(abs, "utf8");
2615
- const count = occurrences(before, args.old_string);
2763
+ const path = requireString(args.path, "edit_file", "path");
2764
+ const abs = safeResolve(ctx.root, path);
2765
+ if (!existsSync2(abs)) throw new ToolError(`not found: ${path}`);
2766
+ const oldString = requireString(args.old_string, "edit_file", "old_string");
2767
+ const newString = requireString(args.new_string, "edit_file", "new_string");
2768
+ if (oldString === newString) throw new ToolError("old_string and new_string are identical");
2769
+ const name = rel(ctx.root, abs);
2770
+ const before = fsCall(`cannot edit ${name}`, () => readFileSync3(abs, "utf8"));
2771
+ const count = occurrences(before, oldString);
2616
2772
  if (count === 0) {
2617
2773
  throw new ToolError(
2618
- `old_string not found in ${args.path} \u2014 read the file and copy the exact text, without the line numbers read_file puts in front of each line`
2774
+ `old_string not found in ${path} \u2014 read the file and copy the exact text, without the line numbers read_file puts in front of each line`
2619
2775
  );
2620
2776
  }
2621
2777
  if (count > 1 && !args.replace_all) {
2622
2778
  throw new ToolError(
2623
- `old_string appears ${count} times in ${args.path} \u2014 add more surrounding context, or pass replace_all: true`
2779
+ `old_string appears ${count} times in ${path} \u2014 add more surrounding context, or pass replace_all: true`
2624
2780
  );
2625
2781
  }
2626
- const after = before.split(args.old_string).join(args.new_string);
2782
+ const after = before.split(oldString).join(newString);
2627
2783
  const preview = renderDiff(before, after) || "(no change)";
2628
2784
  await gate(ctx, {
2629
2785
  tool: "edit_file",
2630
- signature: `write:${rel(ctx.root, abs)}`,
2631
- summary: `edit ${rel(ctx.root, abs)}`,
2786
+ signature: `write:${name}`,
2787
+ summary: `edit ${name}`,
2632
2788
  preview
2633
2789
  });
2634
- writeFileSync2(abs, after, "utf8");
2635
- return `edited ${rel(ctx.root, abs)} (${count > 1 ? `${count} occurrences` : "1 occurrence"})`;
2790
+ fsCall(`cannot edit ${name}`, () => writeFileSync2(abs, after, "utf8"));
2791
+ return `edited ${name} (${count > 1 ? `${count} occurrences` : "1 occurrence"})`;
2636
2792
  },
2637
2793
  /**
2638
2794
  * Run a shell command in the workspace. Streams output to `ctx.onOutput` as it
2639
2795
  * arrives and dies with `ctx.signal`, so a runaway build can be interrupted.
2640
2796
  */
2641
2797
  async run_command(ctx, args) {
2642
- const command = (args.command ?? "").trim();
2798
+ const command = (optionalString(args.command, "run_command", "command") ?? "").trim();
2643
2799
  if (!command) throw new ToolError("run_command needs a command");
2644
2800
  const background = args.background === true;
2645
2801
  await gate(ctx, {
@@ -2651,7 +2807,7 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2651
2807
  summary: `${background ? "run in the background" : "run"} ${command.length > 60 ? command.slice(0, 59) + "\u2026" : command}`,
2652
2808
  preview: command
2653
2809
  });
2654
- if (!background) return execute(ctx, command, args.timeout ?? DEFAULT_COMMAND_TIMEOUT);
2810
+ if (!background) return execute(ctx, command, countArg(args.timeout, DEFAULT_COMMAND_TIMEOUT));
2655
2811
  const registry = ctx.shells ?? shells;
2656
2812
  let started;
2657
2813
  try {
@@ -2671,7 +2827,7 @@ ${first.output.trim().slice(0, 4e3)}` : "");
2671
2827
  /** Whatever a background command has printed since it was last read. */
2672
2828
  read_output(ctx, args) {
2673
2829
  const registry = ctx.shells ?? shells;
2674
- const id = (args.shell_id ?? "").trim();
2830
+ const id = (optionalString(args.shell_id, "read_output", "shell_id") ?? "").trim();
2675
2831
  if (!id) {
2676
2832
  const running = registry.list();
2677
2833
  throw new ToolError(
@@ -2680,7 +2836,8 @@ ${first.output.trim().slice(0, 4e3)}` : "");
2680
2836
  }
2681
2837
  let read;
2682
2838
  try {
2683
- read = registry.read(id, args.filter ? { filter: args.filter } : {});
2839
+ const filter = optionalString(args.filter, "read_output", "filter");
2840
+ read = registry.read(id, filter ? { filter } : {});
2684
2841
  } catch (err) {
2685
2842
  asToolError(err);
2686
2843
  }
@@ -2696,7 +2853,7 @@ ${body || "(nothing new since the last read)"}`;
2696
2853
  },
2697
2854
  async kill_shell(ctx, args) {
2698
2855
  const registry = ctx.shells ?? shells;
2699
- const id = (args.shell_id ?? "").trim();
2856
+ const id = (optionalString(args.shell_id, "kill_shell", "shell_id") ?? "").trim();
2700
2857
  if (!id) throw new ToolError("kill_shell needs a shell_id");
2701
2858
  await gate(ctx, {
2702
2859
  tool: "kill_shell",
@@ -2719,14 +2876,14 @@ ${body || "(nothing new since the last read)"}`;
2719
2876
  * a context that is billed by the token.
2720
2877
  */
2721
2878
  async web_search(ctx, args) {
2722
- const query = (args.query ?? "").trim();
2879
+ const query = (optionalString(args.query, "web_search", "query") ?? "").trim();
2723
2880
  if (!query) throw new ToolError("web_search needs a query");
2724
2881
  if (!ctx.webSearch) {
2725
2882
  throw new ToolError(
2726
2883
  "web search is not available in this run \u2014 use web_fetch on a URL you already know instead"
2727
2884
  );
2728
2885
  }
2729
- const count = Math.max(1, Math.min(MAX_SEARCH_RESULTS, Math.floor(args.count ?? DEFAULT_SEARCH_RESULTS)));
2886
+ const count = Math.min(MAX_SEARCH_RESULTS, countArg(args.count, DEFAULT_SEARCH_RESULTS));
2730
2887
  let results;
2731
2888
  try {
2732
2889
  results = await ctx.webSearch(query, { count, ...ctx.signal ? { signal: ctx.signal } : {} });
@@ -2754,9 +2911,9 @@ ${body || "(nothing new since the last read)"}`;
2754
2911
  async web_fetch(ctx, args) {
2755
2912
  let page;
2756
2913
  try {
2757
- page = await fetchPage(args.url ?? "", {
2914
+ page = await fetchPage(optionalString(args.url, "web_fetch", "url") ?? "", {
2758
2915
  ...ctx.signal ? { signal: ctx.signal } : {},
2759
- ...typeof args.max_chars === "number" ? { maxChars: Math.max(1e3, Math.floor(args.max_chars)) } : {}
2916
+ ...args.max_chars === void 0 ? {} : { maxChars: Math.max(1e3, countArg(args.max_chars, MAX_FILE_BYTES)) }
2760
2917
  });
2761
2918
  } catch (err) {
2762
2919
  networkToolError(err);
@@ -2783,8 +2940,8 @@ ${page.text}`;
2783
2940
  * the credits it saves do not pay for that.
2784
2941
  */
2785
2942
  async task(ctx, args) {
2786
- const prompt = (args.prompt ?? "").trim();
2787
- const description = (args.description ?? "").trim() || "subagent";
2943
+ const prompt = (optionalString(args.prompt, "task", "prompt") ?? "").trim();
2944
+ const description = (optionalString(args.description, "task", "description") ?? "").trim() || "subagent";
2788
2945
  if (!prompt) throw new ToolError("task needs a prompt describing what to find out");
2789
2946
  if (!ctx.spawn) {
2790
2947
  throw new ToolError(
@@ -2821,7 +2978,7 @@ ${page.text}`;
2821
2978
  * be told something that was not on the list.
2822
2979
  */
2823
2980
  async ask_user(ctx, args) {
2824
- const question = (args.question ?? "").trim();
2981
+ const question = (optionalString(args.question, "ask_user", "question") ?? "").trim();
2825
2982
  if (!question) throw new ToolError("ask_user needs a question");
2826
2983
  if (!ctx.askUser) {
2827
2984
  throw new ToolError(
@@ -2846,7 +3003,7 @@ ${page.text}`;
2846
3003
  * able to grant itself write access.
2847
3004
  */
2848
3005
  async exit_plan_mode(ctx, args) {
2849
- const plan = (args.plan ?? "").trim();
3006
+ const plan = (optionalString(args.plan, "exit_plan_mode", "plan") ?? "").trim();
2850
3007
  if (!plan) throw new ToolError("exit_plan_mode needs a plan");
2851
3008
  if (!ctx.approvePlan) {
2852
3009
  throw new ToolError(
@@ -3337,8 +3494,6 @@ var init_agent = __esm({
3337
3494
  "use strict";
3338
3495
  init_tools();
3339
3496
  init_mcp();
3340
- init_shells();
3341
- init_web();
3342
3497
  init_client();
3343
3498
  SUBAGENT_MAX_STEPS = 12;
3344
3499
  SYSTEM_PROMPT = "You are Clixad, a terminal coding agent. You can read, search, write and edit files and run shell commands, all confined to the user's workspace. Find your way around with glob and grep before reading whole files. Prefer edit_file over write_file for existing files. Make minimal correct edits, verify them when a test or build command is available, and stop when the task is complete. Keep your final message short: say what you did and why.\n\nNot every message is a task. If the user asks a question, or asks you to say something, answer in plain text and call no tools at all. Reach for a file or a command only when the request is actually about one, and when a message is ambiguous, prefer the reading of it that changes nothing.\n\nA tool call that was refused did not happen. Never describe it as done, and do not repeat it on a later message unless the user asks for it again.\n\nReach outside the workspace when the answer is outside it: web_search for current documentation, releases and error messages, then web_fetch to read the page. Do not guess at an API you could look up.\n\nStart anything that does not exit on its own \u2014 dev servers, watchers, tailed logs \u2014 with run_command background: true, and read it with read_output. A foreground command is killed after its timeout.\n\nWhen finding something out means opening many files, give it to task instead: the sub-agent reads them in its own context and reports back, which keeps this conversation short. Use todo_write for work with several distinct steps, and keep it current as you go.";
@@ -4831,7 +4986,9 @@ function App({ client, config, wallet, session, initialTask, sponsorServe }) {
4831
4986
  const busyAbortRef = useRef(null);
4832
4987
  const filesRef = useRef(null);
4833
4988
  const ctrlCRef = useRef(0);
4834
- const sessionRef = useRef(session ?? { id: newSessionId(), started: (/* @__PURE__ */ new Date()).toISOString() });
4989
+ const sessionRef = useRef(
4990
+ session ?? { id: newSessionId(), started: (/* @__PURE__ */ new Date()).toISOString() }
4991
+ );
4835
4992
  const runStartedAtRef = useRef(Date.now());
4836
4993
  const contextRef = useRef(collectContext(root));
4837
4994
  const catalogRef = useRef([]);
@@ -5185,16 +5342,14 @@ ${NO_SEARCH_INSTRUCTION}`),
5185
5342
  });
5186
5343
  const next = result.messages.slice(1);
5187
5344
  setMessages(next);
5345
+ sessionRef.current.title ??= (task.split("\n")[0] || task).slice(0, 80);
5188
5346
  saveSession({
5189
5347
  id: sessionRef.current.id,
5190
5348
  started: sessionRef.current.started,
5191
5349
  updated: (/* @__PURE__ */ new Date()).toISOString(),
5192
5350
  cwd: root,
5193
5351
  model,
5194
- // The first line only: an `@path` message carries the whole file after
5195
- // a blank line, and a session titled with a file's contents is not a
5196
- // title. What the user typed is always the first line.
5197
- title: (session?.title || task.split("\n")[0] || task).slice(0, 80),
5352
+ title: sessionRef.current.title,
5198
5353
  messages: next
5199
5354
  });
5200
5355
  if (result.stopped === "aborted") {
@@ -5248,8 +5403,7 @@ ${NO_SEARCH_INSTRUCTION}`),
5248
5403
  nextSponsor,
5249
5404
  permit,
5250
5405
  push,
5251
- root,
5252
- session?.title
5406
+ root
5253
5407
  ]
5254
5408
  );
5255
5409
  const stopCurrent = useCallback(() => {
@@ -5515,7 +5669,7 @@ ${NO_SEARCH_INSTRUCTION}`),
5515
5669
  return;
5516
5670
  }
5517
5671
  setMessages(found.messages);
5518
- sessionRef.current = { id: found.id, started: found.started };
5672
+ sessionRef.current = { id: found.id, started: found.started, title: found.title };
5519
5673
  push({
5520
5674
  kind: "notice",
5521
5675
  tone: "good",
@@ -5722,9 +5876,27 @@ ${output}` }
5722
5876
  setEditor((state) => insert(state, text2));
5723
5877
  setHistIdx(-1);
5724
5878
  });
5879
+ const dismissModals = useCallback(() => {
5880
+ if (plan) {
5881
+ setPlan(null);
5882
+ plan.resolve("keepPlanning");
5883
+ }
5884
+ if (ask2) {
5885
+ setAsk(null);
5886
+ ask2.resolve("deny");
5887
+ }
5888
+ if (picker) {
5889
+ const cancel = picker.onCancel;
5890
+ setPicker(null);
5891
+ cancel?.();
5892
+ }
5893
+ }, [ask2, picker, plan]);
5725
5894
  useInput((ch, key) => {
5726
5895
  if (key.ctrl && ch === "c") {
5727
- if (busy) return stopCurrent();
5896
+ if (busy) {
5897
+ dismissModals();
5898
+ return stopCurrent();
5899
+ }
5728
5900
  if (!isEmpty(editor)) return setEditor(EMPTY);
5729
5901
  if (Date.now() - ctrlCRef.current < 2e3) return exit();
5730
5902
  ctrlCRef.current = Date.now();
@@ -6419,6 +6591,7 @@ function logout(config) {
6419
6591
  delete config.token;
6420
6592
  delete config.userId;
6421
6593
  delete config.email;
6594
+ delete config.login;
6422
6595
  saveConfig(config);
6423
6596
  console.log(c.green("\u2713 logged out"));
6424
6597
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clixad",
3
- "version": "0.0.1-beta.13",
3
+ "version": "0.0.1-beta.15",
4
4
  "description": "Free AI coding agent in your terminal, funded by rewarded ads.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",